合并最新master并融合生成链路改造
保留master的多产物持久化、图集拆分与任务告警能力 融合BGFilter并发重试、处理阶段投影和手动去背景统一链路 按master现有字段顺序追加SpacetimeDB phase并同步生成绑定与文档
This commit is contained in:
@@ -104,6 +104,8 @@ WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo"
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
|
||||
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT="https://api.weixin.qq.com/xpay/query_order"
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT="https://api.weixin.qq.com/xpay/notify_provide_goods"
|
||||
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
|
||||
WECHAT_STATE_TTL_MINUTES="15"
|
||||
WECHAT_MOCK_USER_ID="wx-mock-user"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
executeAdminRechargeRefund,
|
||||
getAdminUserDetail,
|
||||
listAdminRechargeOrders,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
} from './adminApiClient';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('充值订单查询按后台契约序列化筛选参数', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ entries: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listAdminRechargeOrders('token-1', {
|
||||
orderId: 'order 1',
|
||||
userId: 'user-1',
|
||||
providerTransactionId: 'wx-1',
|
||||
paymentChannel: 'wechat_native',
|
||||
status: 'paid',
|
||||
createdAfter: '2026-07-01T00:00:00.000Z',
|
||||
createdBefore: '2026-07-13T23:59:00.000Z',
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
|
||||
const parsed = new URL(requestUrl, 'http://admin.local');
|
||||
expect(parsed.pathname).toBe('/admin/api/profile/recharge-orders');
|
||||
expect(Object.fromEntries(parsed.searchParams)).toEqual({
|
||||
orderId: 'order 1',
|
||||
providerTransactionId: 'wx-1',
|
||||
userId: 'user-1',
|
||||
paymentChannel: 'wechat_native',
|
||||
status: 'paid',
|
||||
createdAfter: '2026-07-01T00:00:00.000Z',
|
||||
createdBefore: '2026-07-13T23:59:00.000Z',
|
||||
limit: '50',
|
||||
});
|
||||
});
|
||||
|
||||
test('用户详情只发送实际提供的用户定位字段', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' });
|
||||
|
||||
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
|
||||
const parsed = new URL(requestUrl, 'http://admin.local');
|
||||
expect(parsed.pathname).toBe('/admin/api/profile/users/detail');
|
||||
expect(Object.fromEntries(parsed.searchParams)).toEqual({
|
||||
publicUserCode: 'TN1001',
|
||||
});
|
||||
});
|
||||
|
||||
test('退款执行使用独立 execute 管理员路由', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await executeAdminRechargeRefund('token-1', {
|
||||
orderId: 'order-1',
|
||||
refundAmountCents: 300,
|
||||
requestId: 'request-1',
|
||||
reason: '用户申请',
|
||||
});
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
||||
'/admin/api/profile/recharge-refunds/execute',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderId: 'order-1',
|
||||
refundAmountCents: 300,
|
||||
requestId: 'request-1',
|
||||
reason: '用户申请',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('退款人工复核使用独立 resolve 管理员路由', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await resolveAdminRechargeRefundManualReview('token-1', {
|
||||
outRefundNo: 'refund-1',
|
||||
reason: '已核对微信商户平台原始账单',
|
||||
expectedErrorCode: 'provider_transaction_id_mismatch',
|
||||
});
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
||||
'/admin/api/profile/recharge-refunds/manual-review/resolve',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
outRefundNo: 'refund-1',
|
||||
reason: '已核对微信商户平台原始账单',
|
||||
expectedErrorCode: 'provider_transaction_id_mismatch',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -26,6 +26,14 @@ import type {
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminRechargeOrderListQuery,
|
||||
AdminRechargeOrderListResponse,
|
||||
AdminRechargeRefundActionResponse,
|
||||
AdminRechargeRefundExecuteRequest,
|
||||
AdminRechargeRefundManualReviewResolveRequest,
|
||||
AdminRechargeRefundPreviewRequest,
|
||||
AdminRechargeRefundPreviewResponse,
|
||||
AdminRechargeRefundRegisterRequest,
|
||||
AdminTrackingEventListQuery,
|
||||
AdminTrackingEventKeyListResponse,
|
||||
AdminTrackingEventListResponse,
|
||||
@@ -41,6 +49,10 @@ import type {
|
||||
AdminUpsertProfileWalletConfigRequest,
|
||||
AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
AdminUserDetailQuery,
|
||||
AdminUserDetailResponse,
|
||||
AdminWalletRestrictionRequest,
|
||||
AdminWalletRestrictionResponse,
|
||||
ApiErrorEnvelope,
|
||||
ApiMeta,
|
||||
ApiSuccessEnvelope,
|
||||
@@ -580,6 +592,76 @@ export function upsertProfileRechargeProduct(
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminRechargeOrders(
|
||||
token: string,
|
||||
query: AdminRechargeOrderListQuery = {},
|
||||
) {
|
||||
return request<AdminRechargeOrderListResponse>(
|
||||
`/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`,
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminUserDetail(
|
||||
token: string,
|
||||
query: AdminUserDetailQuery,
|
||||
) {
|
||||
return request<AdminUserDetailResponse>(
|
||||
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function previewAdminRechargeRefund(
|
||||
token: string,
|
||||
payload: AdminRechargeRefundPreviewRequest,
|
||||
) {
|
||||
return request<AdminRechargeRefundPreviewResponse>(
|
||||
'/admin/api/profile/recharge-refunds/preview',
|
||||
{method: 'POST', token, body: payload},
|
||||
);
|
||||
}
|
||||
|
||||
export function executeAdminRechargeRefund(
|
||||
token: string,
|
||||
payload: AdminRechargeRefundExecuteRequest,
|
||||
) {
|
||||
return request<AdminRechargeRefundActionResponse>(
|
||||
'/admin/api/profile/recharge-refunds/execute',
|
||||
{method: 'POST', token, body: payload},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerAdminRechargeRefund(
|
||||
token: string,
|
||||
payload: AdminRechargeRefundRegisterRequest,
|
||||
) {
|
||||
return request<AdminRechargeRefundActionResponse>(
|
||||
'/admin/api/profile/recharge-refunds/register',
|
||||
{method: 'POST', token, body: payload},
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAdminRechargeRefundManualReview(
|
||||
token: string,
|
||||
payload: AdminRechargeRefundManualReviewResolveRequest,
|
||||
) {
|
||||
return request<AdminRechargeRefundActionResponse>(
|
||||
'/admin/api/profile/recharge-refunds/manual-review/resolve',
|
||||
{method: 'POST', token, body: payload},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAdminWalletRestriction(
|
||||
token: string,
|
||||
payload: AdminWalletRestrictionRequest,
|
||||
) {
|
||||
return request<AdminWalletRestrictionResponse>(
|
||||
'/admin/api/profile/wallet-restriction',
|
||||
{method: 'POST', token, body: payload},
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
@@ -747,6 +829,35 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'orderId', query.orderId);
|
||||
appendQueryParam(
|
||||
params,
|
||||
'providerTransactionId',
|
||||
query.providerTransactionId,
|
||||
);
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
|
||||
appendQueryParam(params, 'paymentChannel', query.paymentChannel);
|
||||
appendQueryParam(params, 'status', query.status);
|
||||
appendQueryParam(params, 'createdAfter', query.createdAfter);
|
||||
appendQueryParam(params, 'createdBefore', query.createdBefore);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildAdminUserDetailQuery(query: AdminUserDetailQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildDashboardQuery(query: AdminDashboardQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'granularity', query.granularity);
|
||||
@@ -764,6 +875,11 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
if (typeof query.page === 'number' && Number.isFinite(query.page)) {
|
||||
params.set('page', String(query.page));
|
||||
}
|
||||
appendQueryParam(params, 'sortColumn', query.sortColumn);
|
||||
appendQueryParam(params, 'sortDirection', query.sortDirection);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
@@ -150,8 +150,11 @@ export interface AdminDatabaseTableListResponse {
|
||||
|
||||
export interface AdminDatabaseTableRowsQuery {
|
||||
limit?: number;
|
||||
page?: number;
|
||||
search?: string;
|
||||
filters?: string;
|
||||
sortColumn?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableRowPayload {
|
||||
@@ -165,6 +168,11 @@ export interface AdminDatabaseTableRowsResponse {
|
||||
rows: AdminDatabaseTableRowPayload[];
|
||||
totalReturned: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
totalMatched: number;
|
||||
scannedCount: number;
|
||||
scanLimit: number;
|
||||
scanLimitReached: boolean;
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableStatPayload {
|
||||
@@ -545,6 +553,8 @@ export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
allowedPublicUserCodes: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileInviteCodeRequest {
|
||||
@@ -606,6 +616,8 @@ export interface ProfileRedeemCodeAdminResponse {
|
||||
globalUsedCount: number;
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -726,3 +738,188 @@ export interface AdminTrackingEventKeyPayload {
|
||||
export interface AdminTrackingEventKeyListResponse {
|
||||
eventKeys: AdminTrackingEventKeyPayload[];
|
||||
}
|
||||
|
||||
export interface AdminRechargeOrderListQuery {
|
||||
orderId?: string;
|
||||
providerTransactionId?: string;
|
||||
userId?: string;
|
||||
publicUserCode?: string;
|
||||
paymentChannel?: string;
|
||||
status?: string;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminUserSummaryPayload {
|
||||
userId: string;
|
||||
publicUserCode: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminWalletManualRestrictionPayload {
|
||||
frozen: boolean;
|
||||
reason: string;
|
||||
createdByAdminUserId: string;
|
||||
createdAtMicros: number;
|
||||
updatedByAdminUserId: string;
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminProfileWalletPayload {
|
||||
userId: string;
|
||||
totalBalance: number;
|
||||
spendableBalance: number;
|
||||
dailyFreePoints: number;
|
||||
membershipLimitedPoints: number;
|
||||
permanentPoints: number;
|
||||
heldPoints: number;
|
||||
refundDebtPoints: number;
|
||||
manualFrozen: boolean;
|
||||
refundDebtFrozen: boolean;
|
||||
walletFrozen: boolean;
|
||||
manualRestriction?: AdminWalletManualRestrictionPayload | null;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundPayload {
|
||||
outRefundNo: string;
|
||||
providerRefundId: string;
|
||||
providerTransactionId: string;
|
||||
providerStatus: string;
|
||||
totalCents: number;
|
||||
refundCents: number;
|
||||
payerRefundCents: number;
|
||||
successAtMicros?: number | null;
|
||||
firstObservedAtMicros: number;
|
||||
updatedAtMicros: number;
|
||||
observationSource: string;
|
||||
targetRecoveryPoints: number;
|
||||
recoveredPoints: number;
|
||||
unrecoveredPoints: number;
|
||||
recoveryStatus: string;
|
||||
lastErrorCode?: string | null;
|
||||
manualReviewResolvedByAdminUserId?: string | null;
|
||||
manualReviewResolutionReason?: string | null;
|
||||
manualReviewResolvedAtMicros?: number | null;
|
||||
manualReviewResolvedErrorCode?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundHoldPayload {
|
||||
outRefundNo: string;
|
||||
refundCents: number;
|
||||
heldPoints: number;
|
||||
status: string;
|
||||
adminUserId: string;
|
||||
reason: string;
|
||||
createdAtMicros: number;
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminRechargeOrderEntryPayload {
|
||||
orderId: string;
|
||||
userId: string;
|
||||
user?: AdminUserSummaryPayload | null;
|
||||
productId: string;
|
||||
productTitle: string;
|
||||
productKind: string;
|
||||
amountCents: number;
|
||||
status: string;
|
||||
paymentChannel: string;
|
||||
paidAtMicros?: number | null;
|
||||
providerTransactionId?: string | null;
|
||||
createdAtMicros: number;
|
||||
pointsDelta: number;
|
||||
cumulativeSuccessRefundCents: number;
|
||||
targetRecoveryPoints: number;
|
||||
recoveredPoints: number;
|
||||
unrecoveredPoints: number;
|
||||
recoveryStatus?: string | null;
|
||||
wallet: AdminProfileWalletPayload;
|
||||
refunds: AdminRechargeRefundPayload[];
|
||||
activeHold?: AdminRechargeRefundHoldPayload | null;
|
||||
remainingRefundableCents: number;
|
||||
refundEligible: boolean;
|
||||
refundBlockReasonCode?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminRechargeOrderListResponse {
|
||||
entries: AdminRechargeOrderEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminUserDetailQuery {
|
||||
userId?: string;
|
||||
publicUserCode?: string;
|
||||
}
|
||||
|
||||
export interface AdminUserDetailResponse {
|
||||
userId: string;
|
||||
publicUserCode: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
phoneNumberMasked?: string | null;
|
||||
loginMethod: string;
|
||||
bindingStatus: string;
|
||||
phoneBound: boolean;
|
||||
wechatBound: boolean;
|
||||
wallet: AdminProfileWalletPayload;
|
||||
rechargeOrders: AdminRechargeOrderEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundPreviewRequest {
|
||||
orderId: string;
|
||||
refundAmountCents: number;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundExecuteRequest {
|
||||
orderId: string;
|
||||
refundAmountCents: number;
|
||||
requestId: string;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundRegisterRequest {
|
||||
outRefundNo: string;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundManualReviewResolveRequest {
|
||||
outRefundNo: string;
|
||||
reason: string;
|
||||
expectedErrorCode: string;
|
||||
}
|
||||
|
||||
export interface AdminWalletRestrictionRequest {
|
||||
userId: string;
|
||||
frozen: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AdminWechatPaymentCheckPayload {
|
||||
verified: boolean;
|
||||
tradeState: string;
|
||||
transactionId?: string | null;
|
||||
amountTotalCents?: number | null;
|
||||
knownRefundsRefreshed: number;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundPreviewResponse {
|
||||
order: AdminRechargeOrderEntryPayload;
|
||||
paymentCheck: AdminWechatPaymentCheckPayload;
|
||||
refundAmountCents: number;
|
||||
incrementalRecoveryPoints: number;
|
||||
remainingRefundableCents: number;
|
||||
canSubmit: boolean;
|
||||
blockReasonCode?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundActionResponse {
|
||||
outRefundNo: string;
|
||||
providerStatus: string;
|
||||
resultCode: string;
|
||||
providerStatusUnknown: boolean;
|
||||
order: AdminRechargeOrderEntryPayload;
|
||||
}
|
||||
|
||||
export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage';
|
||||
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
|
||||
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
|
||||
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
|
||||
@@ -248,6 +249,12 @@ export function AdminApp() {
|
||||
onResultChange={setRechargeProductResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'recharge-orders' ? (
|
||||
<AdminRechargeOrderPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'editor-generation-pricing' ? (
|
||||
<AdminEditorGenerationPricingPage
|
||||
token={token}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Table2,
|
||||
TicketCheck,
|
||||
TicketPercent,
|
||||
ReceiptText,
|
||||
} from 'lucide-react';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
@@ -45,6 +46,7 @@ const routeIcons = {
|
||||
'profile-wallet': WalletCards,
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'recharge-orders': ReceiptText,
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'editor-assets': Images,
|
||||
|
||||
@@ -69,3 +69,13 @@ test('后台精选审核路由可通过导航和 hash 访问', () => {
|
||||
expect(resolveAdminRoute('#editor-showcase')).toBe('editor-showcase');
|
||||
expect(routeHash('editor-showcase')).toBe('#editor-showcase');
|
||||
});
|
||||
|
||||
test('后台充值管理路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'recharge-orders',
|
||||
label: '充值管理',
|
||||
hash: '#recharge-orders',
|
||||
});
|
||||
expect(resolveAdminRoute('#recharge-orders')).toBe('recharge-orders');
|
||||
expect(routeHash('recharge-orders')).toBe('#recharge-orders');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AdminRouteId =
|
||||
| 'profile-wallet'
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'recharge-orders'
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'editor-assets'
|
||||
@@ -37,6 +38,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'},
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
|
||||
{id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders'},
|
||||
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
|
||||
{id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'},
|
||||
{id: 'editor-assets', label: '素材查询', hash: '#editor-assets'},
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminUserDetail,
|
||||
updateAdminWalletRestriction,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminProfileWalletPayload,
|
||||
AdminUserDetailResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import {AdminUserReferenceButton} from './AdminUserReferenceButton';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminUserDetail: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
updateAdminWalletRestriction: vi.fn(),
|
||||
}));
|
||||
|
||||
const wallet: AdminProfileWalletPayload = {
|
||||
userId: 'user-1',
|
||||
totalBalance: 96,
|
||||
spendableBalance: 40,
|
||||
dailyFreePoints: 6,
|
||||
membershipLimitedPoints: 20,
|
||||
permanentPoints: 70,
|
||||
heldPoints: 5,
|
||||
refundDebtPoints: 25,
|
||||
manualFrozen: false,
|
||||
refundDebtFrozen: true,
|
||||
walletFrozen: true,
|
||||
manualRestriction: null,
|
||||
};
|
||||
|
||||
const detail: AdminUserDetailResponse = {
|
||||
userId: 'user-1',
|
||||
publicUserCode: 'TN1001',
|
||||
displayName: '陶泥用户',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
phoneNumberMasked: '138****5678',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'bound',
|
||||
phoneBound: true,
|
||||
wechatBound: true,
|
||||
wallet,
|
||||
rechargeOrders: [
|
||||
{
|
||||
orderId: 'order-1',
|
||||
userId: 'user-1',
|
||||
user: null,
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
productKind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'wechat_native',
|
||||
paidAtMicros: 1_720_000_000_000_000,
|
||||
providerTransactionId: 'wx-1',
|
||||
createdAtMicros: 1_720_000_000_000_000,
|
||||
pointsDelta: 60,
|
||||
cumulativeSuccessRefundCents: 300,
|
||||
targetRecoveryPoints: 30,
|
||||
recoveredPoints: 5,
|
||||
unrecoveredPoints: 25,
|
||||
recoveryStatus: 'shortfall',
|
||||
wallet,
|
||||
refunds: [],
|
||||
activeHold: null,
|
||||
remainingRefundableCents: 300,
|
||||
refundEligible: false,
|
||||
refundBlockReasonCode: 'refund_reconciliation_pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminUserDetail).mockResolvedValue(detail);
|
||||
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet});
|
||||
});
|
||||
|
||||
test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退款限制', async () => {
|
||||
const user = userEvent.setup();
|
||||
const parentClick = vi.fn();
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', {name: '查看用户信息'});
|
||||
await user.click(trigger);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole('dialog', {name: '用户详情'})).toBeTruthy();
|
||||
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
|
||||
userId: 'user-1',
|
||||
publicUserCode: undefined,
|
||||
});
|
||||
expect(screen.getByText('陶泥用户')).toBeTruthy();
|
||||
expect(screen.getAllByText('TN1001').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('138****5678')).toBeTruthy();
|
||||
expect(screen.getByText('退款欠账限制')).toBeTruthy();
|
||||
expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy();
|
||||
expect(screen.getByText('order-1')).toBeTruthy();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', {name: '用户详情'})).toBeNull());
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
});
|
||||
|
||||
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
publicUserCode="TN1001"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
|
||||
await screen.findByText('陶泥用户');
|
||||
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
|
||||
userId: undefined,
|
||||
publicUserCode: 'TN1001',
|
||||
});
|
||||
});
|
||||
|
||||
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
|
||||
const user = userEvent.setup();
|
||||
const manuallyFrozenWallet: AdminProfileWalletPayload = {
|
||||
...wallet,
|
||||
manualFrozen: true,
|
||||
walletFrozen: true,
|
||||
manualRestriction: {
|
||||
frozen: true,
|
||||
reason: '风险核查',
|
||||
createdByAdminUserId: 'admin:root',
|
||||
createdAtMicros: 1_720_000_000_000_000,
|
||||
updatedByAdminUserId: 'admin:root',
|
||||
updatedAtMicros: 1_720_000_000_000_000,
|
||||
},
|
||||
};
|
||||
vi.mocked(updateAdminWalletRestriction)
|
||||
.mockResolvedValueOnce({wallet: manuallyFrozenWallet})
|
||||
.mockResolvedValueOnce({wallet: {...wallet, manualFrozen: false}});
|
||||
render(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
|
||||
await screen.findByText('陶泥用户');
|
||||
|
||||
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '异常登录');
|
||||
await user.click(screen.getByRole('button', {name: '人工冻结钱包'}));
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
await waitFor(() => {
|
||||
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(1, 'admin-token', {
|
||||
userId: 'user-1',
|
||||
frozen: true,
|
||||
reason: '异常登录',
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy();
|
||||
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成');
|
||||
await user.click(screen.getByRole('button', {name: '解除人工冻结'}));
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
await waitFor(() => {
|
||||
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(2, 'admin-token', {
|
||||
userId: 'user-1',
|
||||
frozen: false,
|
||||
reason: '核查完成',
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('退款欠账限制')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('用户详情读取失败后可以重试', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getAdminUserDetail)
|
||||
.mockRejectedValueOnce(new Error('读取失败'))
|
||||
.mockResolvedValueOnce(detail);
|
||||
render(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
|
||||
expect(await screen.findByText('读取失败')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', {name: '重试'}));
|
||||
expect(await screen.findByText('陶泥用户')).toBeTruthy();
|
||||
expect(getAdminUserDetail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
import {RefreshCcw, ShieldAlert, UserRound, X} from 'lucide-react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
getAdminUserDetail,
|
||||
isAdminApiError,
|
||||
updateAdminWalletRestriction,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminProfileWalletPayload,
|
||||
AdminUserDetailResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import {useAdminWriteConfirm} from './useAdminWriteConfirm';
|
||||
|
||||
interface AdminUserDetailDialogProps {
|
||||
token: string;
|
||||
userId?: string | null;
|
||||
publicUserCode?: string | null;
|
||||
onClose: () => void;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
export function AdminUserDetailDialog({
|
||||
token,
|
||||
userId,
|
||||
publicUserCode,
|
||||
onClose,
|
||||
onUnauthorized,
|
||||
}: AdminUserDetailDialogProps) {
|
||||
const [detail, setDetail] = useState<AdminUserDetailResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [restrictionReason, setRestrictionReason] = useState('');
|
||||
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const requestVersionRef = useRef(0);
|
||||
const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetail();
|
||||
return () => {
|
||||
requestVersionRef.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, userId, publicUserCode]);
|
||||
|
||||
useEffect(() => {
|
||||
closeButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isConfirming, isSavingRestriction, onClose]);
|
||||
|
||||
async function loadDetail() {
|
||||
const requestVersion = requestVersionRef.current + 1;
|
||||
requestVersionRef.current = requestVersion;
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminUserDetail(token, {
|
||||
userId: userId?.trim() || undefined,
|
||||
publicUserCode: userId?.trim()
|
||||
? undefined
|
||||
: publicUserCode?.trim() || undefined,
|
||||
});
|
||||
if (requestVersionRef.current === requestVersion) {
|
||||
setDetail(response);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (requestVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
if (isAdminApiError(error) && error.status === 401) {
|
||||
onUnauthorized('登录状态已失效');
|
||||
return;
|
||||
}
|
||||
setErrorMessage(formatAdminApiError(error));
|
||||
} finally {
|
||||
if (requestVersionRef.current === requestVersion) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestrictionChange() {
|
||||
if (!detail || isSavingRestriction) {
|
||||
return;
|
||||
}
|
||||
const reason = restrictionReason.trim();
|
||||
if (!reason) {
|
||||
setErrorMessage('请填写人工冻结操作原因');
|
||||
return;
|
||||
}
|
||||
const nextFrozen = !detail.wallet.manualFrozen;
|
||||
const action = nextFrozen ? '人工冻结钱包' : '解除人工冻结';
|
||||
const confirmed = await confirmWrite({
|
||||
action,
|
||||
target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingRestriction(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await updateAdminWalletRestriction(token, {
|
||||
userId: detail.userId,
|
||||
frozen: nextFrozen,
|
||||
reason,
|
||||
});
|
||||
setDetail((current) =>
|
||||
current ? {...current, wallet: response.wallet} : current,
|
||||
);
|
||||
setRestrictionReason('');
|
||||
} catch (error: unknown) {
|
||||
if (isAdminApiError(error) && error.status === 401) {
|
||||
onUnauthorized('登录状态已失效');
|
||||
} else {
|
||||
setErrorMessage(formatAdminApiError(error));
|
||||
}
|
||||
} finally {
|
||||
setIsSavingRestriction(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-modal="true"
|
||||
className="admin-confirm-backdrop admin-user-detail-backdrop"
|
||||
role="dialog"
|
||||
aria-labelledby="admin-user-detail-title"
|
||||
onMouseDown={(event) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
!isSavingRestriction &&
|
||||
!isConfirming
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section className="admin-detail-panel admin-user-detail-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h3 id="admin-user-detail-title">用户详情</h3>
|
||||
<span>{detail?.publicUserCode || publicUserCode || userId || '-'}</span>
|
||||
</div>
|
||||
<div className="admin-detail-actions">
|
||||
<button
|
||||
aria-label="刷新用户信息"
|
||||
className="admin-ghost-button"
|
||||
disabled={isLoading}
|
||||
title="刷新"
|
||||
type="button"
|
||||
onClick={() => void loadDetail()}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
aria-label="关闭用户详情"
|
||||
className="admin-ghost-button"
|
||||
disabled={isSavingRestriction}
|
||||
title="关闭"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="admin-user-detail-loading" role="status">
|
||||
<div className="admin-loading-mark" />
|
||||
<span>正在读取用户信息</span>
|
||||
</div>
|
||||
) : errorMessage && !detail ? (
|
||||
<div className="admin-user-detail-error">
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={() => void loadDetail()}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>重试</span>
|
||||
</button>
|
||||
</div>
|
||||
) : detail ? (
|
||||
<>
|
||||
<UserIdentityHeader detail={detail} />
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<WalletSection wallet={detail.wallet} />
|
||||
|
||||
<section className="admin-user-restriction-section">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>人工冻结</h3>
|
||||
<span>
|
||||
{detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'}
|
||||
</span>
|
||||
</div>
|
||||
{detail.wallet.manualRestriction ? (
|
||||
<div className="admin-user-restriction-record">
|
||||
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
|
||||
<small>
|
||||
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
|
||||
{detail.wallet.manualRestriction.updatedByAdminUserId}
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.wallet.manualFrozen && detail.wallet.refundDebtFrozen ? (
|
||||
<div className="admin-alert admin-alert-warning" role="status">
|
||||
<ShieldAlert size={17} aria-hidden="true" />
|
||||
<span>解除人工冻结后,退款欠账限制仍会保留。</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-user-restriction-actions">
|
||||
<label className="admin-field admin-field-fill">
|
||||
<span>操作原因</span>
|
||||
<input
|
||||
aria-label="人工冻结操作原因"
|
||||
disabled={isSavingRestriction}
|
||||
value={restrictionReason}
|
||||
onChange={(event) => setRestrictionReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className={
|
||||
detail.wallet.manualFrozen
|
||||
? 'admin-secondary-button'
|
||||
: 'admin-danger-button'
|
||||
}
|
||||
disabled={isSavingRestriction || !restrictionReason.trim()}
|
||||
type="button"
|
||||
onClick={() => void handleRestrictionChange()}
|
||||
>
|
||||
<ShieldAlert size={17} aria-hidden="true" />
|
||||
<span>
|
||||
{isSavingRestriction
|
||||
? '处理中'
|
||||
: detail.wallet.manualFrozen
|
||||
? '解除人工冻结'
|
||||
: '人工冻结钱包'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-user-recharge-section">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>充值订单</h3>
|
||||
<span>{detail.rechargeOrders.length} 笔</span>
|
||||
</div>
|
||||
{detail.rechargeOrders.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-user-recharge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单</th>
|
||||
<th>商品</th>
|
||||
<th>实付</th>
|
||||
<th>退款</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.rechargeOrders.map((order) => (
|
||||
<tr key={order.orderId}>
|
||||
<td>
|
||||
<span className="admin-mono-value">{order.orderId}</span>
|
||||
<small>{formatMicros(order.createdAtMicros)}</small>
|
||||
</td>
|
||||
<td>
|
||||
{order.productTitle || order.productId}
|
||||
<small>发放 {order.pointsDelta} 泥点</small>
|
||||
</td>
|
||||
<td>{formatMoney(order.amountCents)}</td>
|
||||
<td>
|
||||
{formatMoney(order.cumulativeSuccessRefundCents)}
|
||||
<small>欠账 {order.unrecoveredPoints} 泥点</small>
|
||||
</td>
|
||||
<td>{formatOrderStatus(order.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">暂无充值订单</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
{confirmDialog}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
|
||||
return (
|
||||
<section className="admin-user-identity">
|
||||
<div className="admin-user-avatar">
|
||||
{detail.avatarUrl ? (
|
||||
<img alt={`${detail.displayName || detail.publicUserCode}头像`} src={detail.avatarUrl} />
|
||||
) : (
|
||||
<UserRound size={30} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-user-identity-primary">
|
||||
<strong>{detail.displayName || '未设置昵称'}</strong>
|
||||
<span>{detail.publicUserCode || '未分配陶泥号'}</span>
|
||||
</div>
|
||||
<dl className="admin-info-list admin-user-identity-list">
|
||||
<div>
|
||||
<dt>内部 ID</dt>
|
||||
<dd>{detail.userId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>脱敏手机号</dt>
|
||||
<dd>{detail.phoneNumberMasked || '未绑定'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>登录方式</dt>
|
||||
<dd>{detail.loginMethod || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>绑定状态</dt>
|
||||
<dd>
|
||||
{detail.bindingStatus || '-'} / 手机{detail.phoneBound ? '已绑定' : '未绑定'} / 微信
|
||||
{detail.wechatBound ? '已绑定' : '未绑定'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
|
||||
const metrics = [
|
||||
['总余额', wallet.totalBalance],
|
||||
['可消费', wallet.spendableBalance],
|
||||
['永久泥点', wallet.permanentPoints],
|
||||
['每日免费', wallet.dailyFreePoints],
|
||||
['会员限时', wallet.membershipLimitedPoints],
|
||||
['退款占用', wallet.heldPoints],
|
||||
['退款欠账', wallet.refundDebtPoints],
|
||||
] as const;
|
||||
return (
|
||||
<section className="admin-user-wallet-section">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>钱包</h3>
|
||||
<div className="admin-tag-list">
|
||||
{wallet.manualFrozen ? <span className="admin-tag">人工冻结</span> : null}
|
||||
{wallet.refundDebtFrozen ? (
|
||||
<span className="admin-tag">退款欠账限制</span>
|
||||
) : null}
|
||||
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok">正常</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-user-wallet-grid">
|
||||
{metrics.map(([label, value]) => (
|
||||
<div className="admin-recharge-metric" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMoney(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatMicros(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '-';
|
||||
}
|
||||
return new Date(Math.floor(value / 1000)).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatOrderStatus(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
refunded: '已退款',
|
||||
closed: '已关闭',
|
||||
};
|
||||
return labels[status.toLowerCase()] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {UserRoundSearch} from 'lucide-react';
|
||||
import {MouseEvent, useRef, useState} from 'react';
|
||||
|
||||
import {AdminUserDetailDialog} from './AdminUserDetailDialog';
|
||||
|
||||
interface AdminUserReferenceButtonProps {
|
||||
token: string;
|
||||
userId?: string | null;
|
||||
publicUserCode?: string | null;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
export function AdminUserReferenceButton({
|
||||
token,
|
||||
userId,
|
||||
publicUserCode,
|
||||
onUnauthorized,
|
||||
}: AdminUserReferenceButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const normalizedUserId = normalizeUserReference(userId);
|
||||
const normalizedPublicUserCode = normalizeUserReference(publicUserCode);
|
||||
const lookup = normalizedUserId
|
||||
? {userId: normalizedUserId}
|
||||
: normalizedPublicUserCode
|
||||
? {publicUserCode: normalizedPublicUserCode}
|
||||
: null;
|
||||
|
||||
if (!lookup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function openDialog(event: MouseEvent<HTMLButtonElement>) {
|
||||
event.stopPropagation();
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
aria-label="查看用户信息"
|
||||
className="admin-ghost-button admin-user-reference-button"
|
||||
title="查看用户信息"
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
>
|
||||
<UserRoundSearch size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<AdminUserDetailDialog
|
||||
token={token}
|
||||
{...lookup}
|
||||
onClose={closeDialog}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUserReference(value?: string | null) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (!normalized || normalized.toLowerCase().startsWith('admin:')) {
|
||||
return '';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -101,5 +101,9 @@ export function useAdminWriteConfirm() {
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return {confirmWrite, confirmDialog};
|
||||
return {
|
||||
confirmWrite,
|
||||
confirmDialog,
|
||||
isConfirming: pendingConfirm !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
getAdminDatabaseTableRows,
|
||||
getAdminDatabaseTables,
|
||||
} from '../api/adminApiClient';
|
||||
import { AdminDatabaseTablesPage } from './AdminDatabaseTablesPage';
|
||||
import {
|
||||
AdminDatabaseTablesPage,
|
||||
resolveAdminDatabaseUserReference,
|
||||
} from './AdminDatabaseTablesPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
@@ -19,6 +22,51 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AdminUserReferenceButton', () => ({
|
||||
AdminUserReferenceButton: ({ userId, publicUserCode }: {
|
||||
userId?: string;
|
||||
publicUserCode?: string;
|
||||
}) => (
|
||||
<button
|
||||
aria-label={`查看用户 ${userId || publicUserCode}`}
|
||||
data-public-user-code={publicUserCode}
|
||||
data-user-id={userId}
|
||||
type="button"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const referralRows = [
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-02T00:00:00Z',
|
||||
invitee_user_id: 'u-b',
|
||||
invite_code: 'INV-1001',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-01T00:00:00Z',
|
||||
invitee_user_id: 'u-a',
|
||||
invite_code: 'INV-1002',
|
||||
inviter_user_id: 'u-c',
|
||||
},
|
||||
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-03T00:00:00Z',
|
||||
invitee_user_id: 'u-c',
|
||||
invite_code: 'INV-1003',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
window.location.hash = '#tables?table=profile_referral_relation';
|
||||
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
|
||||
@@ -28,41 +76,72 @@ beforeEach(() => {
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
|
||||
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||
limit: 100,
|
||||
rows: [
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-02T00:00:00Z',
|
||||
invitee_user_id: 'u-b',
|
||||
invite_code: 'INV-1001',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-01T00:00:00Z',
|
||||
invitee_user_id: 'u-a',
|
||||
invite_code: 'INV-1002',
|
||||
inviter_user_id: 'u-c',
|
||||
},
|
||||
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-03T00:00:00Z',
|
||||
invitee_user_id: 'u-c',
|
||||
invite_code: 'INV-1003',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
|
||||
},
|
||||
],
|
||||
rows: referralRows,
|
||||
page: 1,
|
||||
scannedCount: 3,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: false,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 3,
|
||||
totalReturned: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
|
||||
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
|
||||
columns: ['invitee_user_id'],
|
||||
limit: 100,
|
||||
page: 1,
|
||||
rows: [
|
||||
{
|
||||
cells: { invitee_user_id: 'u-b' },
|
||||
raw: ['u-b'],
|
||||
},
|
||||
],
|
||||
scannedCount: 50000,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: true,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 250,
|
||||
totalReturned: 1,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('第 1 / 3 页,共 250 条')).toBeTruthy();
|
||||
const pagination = screen.getByRole('navigation', { name: '表查询分页' });
|
||||
expect(pagination.classList.contains('admin-database-pagination')).toBe(true);
|
||||
expect(pagination.closest('.admin-panel')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.admin-database-tables-page')?.lastElementChild,
|
||||
).toBe(pagination);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'当前表超过 50000 条扫描与浏览上限,本次已扫描 50000 条;当前分页结果和匹配总数可能不完整。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
|
||||
await user.click(screen.getByRole('button', { name: '下一页' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
'profile_referral_relation',
|
||||
expect.objectContaining({
|
||||
filters: '',
|
||||
limit: 100,
|
||||
page: 2,
|
||||
search: '',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
@@ -89,17 +168,95 @@ test('后台表查询页支持宽表滚动容器和表头排序', async () => {
|
||||
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
|
||||
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
|
||||
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||
limit: 100,
|
||||
page: 1,
|
||||
rows: [referralRows[0]!, referralRows[2]!, referralRows[1]!],
|
||||
scannedCount: 3,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: false,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 3,
|
||||
totalReturned: 3,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
|
||||
await waitFor(() => {
|
||||
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
'profile_referral_relation',
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
sortColumn: 'inviter_user_id',
|
||||
sortDirection: 'asc',
|
||||
}),
|
||||
);
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
|
||||
});
|
||||
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
|
||||
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||
limit: 100,
|
||||
page: 1,
|
||||
rows: [referralRows[1]!, referralRows[0]!, referralRows[2]!],
|
||||
scannedCount: 3,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: false,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 3,
|
||||
totalReturned: 3,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
|
||||
await waitFor(() => {
|
||||
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
'profile_referral_relation',
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
sortColumn: 'inviter_user_id',
|
||||
sortDirection: 'desc',
|
||||
}),
|
||||
);
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
|
||||
});
|
||||
});
|
||||
|
||||
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const userButton = await screen.findByRole('button', { name: '查看用户 u-b' });
|
||||
await user.click(userButton);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
|
||||
expect(
|
||||
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
|
||||
).toEqual({ userId: 'u-1' });
|
||||
expect(
|
||||
resolveAdminDatabaseUserReference(
|
||||
'auth_store_projection',
|
||||
'public_user_code',
|
||||
'TN1001',
|
||||
),
|
||||
).toEqual({ publicUserCode: 'TN1001' });
|
||||
expect(
|
||||
resolveAdminDatabaseUserReference('audit_log', 'operator_user_id', 'u-1'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
function readFirstColumnValues(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('tbody tr')).map(
|
||||
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
import { Eye, FileText, RefreshCcw, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
isAdminApiError,
|
||||
listAdminEditorAssets,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
AdminEditorAssetListQuery,
|
||||
AdminEditorAssetPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminEditorAssetQueryPageProps {
|
||||
@@ -19,7 +21,11 @@ interface AdminEditorAssetQueryPageProps {
|
||||
}
|
||||
|
||||
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
|
||||
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
|
||||
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
|
||||
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
|
||||
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
|
||||
let adminAssetReadDispatchTail = Promise.resolve();
|
||||
|
||||
export function AdminEditorAssetQueryPage({
|
||||
token,
|
||||
@@ -180,8 +186,20 @@ export function AdminEditorAssetQueryPage({
|
||||
</td>
|
||||
<td>{formatDateTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{authorDisplayName(entry)}
|
||||
<small>
|
||||
{entry.authorPublicUserCode?.trim() || '-'}
|
||||
</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@@ -236,6 +254,7 @@ export function AdminEditorAssetQueryPage({
|
||||
<AdminAssetDetailDialog
|
||||
entry={detailEntry}
|
||||
token={token}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
onPreview={(entry) => setPreviewEntry(entry)}
|
||||
onPromptPreview={(entry, prompt) =>
|
||||
@@ -295,17 +314,27 @@ function AdminAssetThumbnail({
|
||||
token: string;
|
||||
}) {
|
||||
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
|
||||
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
|
||||
const imageSrc = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
thumbnailSource.src,
|
||||
thumbnailSource.objectKey,
|
||||
shouldLoad,
|
||||
);
|
||||
const alt = `素材:${entry.label || entry.assetId}`;
|
||||
|
||||
return imageSrc ? (
|
||||
<img alt={alt} className="admin-asset-query-thumb" src={imageSrc} />
|
||||
<img
|
||||
ref={observeElement}
|
||||
alt={alt}
|
||||
className="admin-asset-query-thumb"
|
||||
src={imageSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-thumb admin-asset-query-thumb-placeholder" />
|
||||
<div
|
||||
ref={observeElement}
|
||||
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,12 +346,52 @@ function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) {
|
||||
if (mediaKind === 'video') {
|
||||
return { src: entry.thumbnailSrc || '', objectKey: null };
|
||||
}
|
||||
if (entry.thumbnailSrc?.trim()) {
|
||||
return {
|
||||
src: entry.thumbnailSrc,
|
||||
objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc)
|
||||
? entry.objectKey
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
src: entry.thumbnailSrc || entry.imageSrc,
|
||||
src: entry.imageSrc,
|
||||
objectKey: entry.objectKey,
|
||||
};
|
||||
}
|
||||
|
||||
function useAdminAssetThumbnailVisibility() {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
const observeElement = useCallback((nextElement: HTMLElement | null) => {
|
||||
setElement(nextElement);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldLoad || !element) {
|
||||
return;
|
||||
}
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setShouldLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setShouldLoad(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [element, shouldLoad]);
|
||||
|
||||
return { observeElement, shouldLoad };
|
||||
}
|
||||
|
||||
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
|
||||
|
||||
function resolveAdminAssetMediaKind(
|
||||
@@ -387,9 +456,11 @@ function AdminAssetDetailDialog({
|
||||
onClose,
|
||||
onPreview,
|
||||
onPromptPreview,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
entry: AdminEditorAssetPayload;
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
onClose: () => void;
|
||||
onPreview: (entry: AdminEditorAssetPayload) => void;
|
||||
onPromptPreview: (entry: AdminEditorAssetPayload, prompt: string) => void;
|
||||
@@ -427,8 +498,18 @@ function AdminAssetDetailDialog({
|
||||
</button>
|
||||
<dl className="admin-info-list admin-detail-list">
|
||||
<AdminInfoItem label="作者">
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</AdminInfoItem>
|
||||
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
|
||||
<AdminInfoItem label="尺寸">
|
||||
@@ -604,6 +685,7 @@ function useAdminResolvedAssetUrl(
|
||||
token: string,
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
enabled = true,
|
||||
) {
|
||||
const normalizedImageSrc = imageSrc?.trim() ?? '';
|
||||
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
|
||||
@@ -625,38 +707,67 @@ function useAdminResolvedAssetUrl(
|
||||
setResolvedImageSrc(normalizedImageSrc);
|
||||
return;
|
||||
}
|
||||
if (!enabled) {
|
||||
setResolvedImageSrc('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retryIndex = 0;
|
||||
const dispatchController = new AbortController();
|
||||
setResolvedImageSrc('');
|
||||
|
||||
void getAdminAssetReadUrl(
|
||||
token,
|
||||
normalizedObjectKey
|
||||
? {
|
||||
objectKey: normalizedObjectKey,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
}
|
||||
: {
|
||||
legacyPublicPath: normalizedLegacyPublicPath,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
)
|
||||
.then(resolveAdminAssetReadSignedUrl)
|
||||
.then((signedUrl) => {
|
||||
if (!cancelled) {
|
||||
setResolvedImageSrc(signedUrl);
|
||||
const resolveReadUrl = async () => {
|
||||
try {
|
||||
await waitForAdminAssetReadDispatch(dispatchController.signal);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const response = await getAdminAssetReadUrl(
|
||||
token,
|
||||
normalizedObjectKey
|
||||
? {
|
||||
objectKey: normalizedObjectKey,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
}
|
||||
: {
|
||||
legacyPublicPath: normalizedLegacyPublicPath,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
);
|
||||
if (!cancelled) {
|
||||
setResolvedImageSrc('');
|
||||
setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response));
|
||||
}
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
|
||||
if (
|
||||
isAdminApiError(error) &&
|
||||
error.status === 429 &&
|
||||
typeof retryDelay === 'number'
|
||||
) {
|
||||
retryIndex += 1;
|
||||
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
|
||||
return;
|
||||
}
|
||||
setResolvedImageSrc('');
|
||||
}
|
||||
};
|
||||
|
||||
void resolveReadUrl();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
dispatchController.abort();
|
||||
if (retryTimer !== null) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
normalizedImageSrc,
|
||||
normalizedLegacyPublicPath,
|
||||
normalizedObjectKey,
|
||||
@@ -667,10 +778,45 @@ function useAdminResolvedAssetUrl(
|
||||
return resolvedImageSrc;
|
||||
}
|
||||
|
||||
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
|
||||
const dispatch = adminAssetReadDispatchTail.then(
|
||||
() => waitForAdminAssetReadDispatchSpacing(signal),
|
||||
() => waitForAdminAssetReadDispatchSpacing(signal),
|
||||
);
|
||||
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
|
||||
await dispatch;
|
||||
}
|
||||
|
||||
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
resolve();
|
||||
}, ADMIN_ASSET_READ_DISPATCH_SPACING_MS);
|
||||
|
||||
function handleAbort() {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAdminObjectKey(value: string | null | undefined) {
|
||||
return value?.trim().replace(/^\/+/u, '') ?? '';
|
||||
}
|
||||
|
||||
function adminAssetPathsMatch(left: string, right: string) {
|
||||
return (
|
||||
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
|
||||
right.trim().replace(/^\/+|[?#].*$/gu, '')
|
||||
);
|
||||
}
|
||||
|
||||
function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
}
|
||||
|
||||
@@ -31,6 +31,20 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
upsertAdminEditorShowcaseCampaign: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AdminUserReferenceButton', () => ({
|
||||
AdminUserReferenceButton: ({ userId, publicUserCode }: {
|
||||
userId?: string;
|
||||
publicUserCode?: string | null;
|
||||
}) => (
|
||||
<button
|
||||
aria-label="查看精选作者"
|
||||
data-public-user-code={publicUserCode ?? undefined}
|
||||
data-user-id={userId}
|
||||
type="button"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
|
||||
showcaseId: 'showcase-1',
|
||||
assetId: 'asset-1',
|
||||
@@ -156,6 +170,9 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
|
||||
|
||||
expect(await screen.findByText('作者昵称')).toBeTruthy();
|
||||
expect(screen.getByText('SY-00000042')).toBeTruthy();
|
||||
const userButton = screen.getByRole('button', { name: '查看精选作者' });
|
||||
expect(userButton.getAttribute('data-user-id')).toBe('user-1');
|
||||
expect(userButton.getAttribute('data-public-user-code')).toBe('SY-00000042');
|
||||
expect(screen.getAllByText('待审核').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('12 泥点')).toBeTruthy();
|
||||
expect(await screen.findByDisplayValue('活动卡')).toBeTruthy();
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
AdminEditorShowcaseCampaignPayload,
|
||||
AdminEditorShowcaseListQuery,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminEditorShowcaseReviewPageProps {
|
||||
@@ -351,8 +352,18 @@ export function AdminEditorShowcaseReviewPage({
|
||||
</td>
|
||||
<td>{formatDateTime(entry.submittedAt)}</td>
|
||||
<td>
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{entry.reviewStatus === 'approved' ? (
|
||||
@@ -598,6 +609,7 @@ export function AdminEditorShowcaseReviewPage({
|
||||
<AdminShowcaseDetailDialog
|
||||
entry={detailEntry}
|
||||
token={token}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
onPromptPreview={(entry, prompt) =>
|
||||
setPromptPreview({
|
||||
@@ -677,10 +689,12 @@ function AdminShowcaseDetailDialog({
|
||||
token,
|
||||
onClose,
|
||||
onPromptPreview,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
entry: AdminEditorShowcaseAssetPayload;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
onPromptPreview: (
|
||||
entry: AdminEditorShowcaseAssetPayload,
|
||||
prompt: string,
|
||||
@@ -712,8 +726,18 @@ function AdminShowcaseDetailDialog({
|
||||
<AdminShowcaseThumbnail entry={entry} token={token} />
|
||||
<dl className="admin-info-list admin-detail-list">
|
||||
<AdminInfoItem label="作者">
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</AdminInfoItem>
|
||||
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
|
||||
<AdminInfoItem label="素材 ID">{entry.assetId}</AdminInfoItem>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
disableProfileRedeemCode,
|
||||
listProfileRedeemCodes,
|
||||
upsertProfileRedeemCode,
|
||||
} from '../api/adminApiClient';
|
||||
import type { ProfileRedeemCodeAdminResponse } from '../api/adminApiTypes';
|
||||
import { AdminRedeemCodePage } from './AdminRedeemCodePage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
disableProfileRedeemCode: vi.fn(),
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
listProfileRedeemCodes: vi.fn(),
|
||||
upsertProfileRedeemCode: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseEntry: ProfileRedeemCodeAdminResponse = {
|
||||
code: 'LONG-LIVED',
|
||||
mode: 'public',
|
||||
rewardPoints: 100,
|
||||
maxUses: 1,
|
||||
globalUsedCount: 0,
|
||||
enabled: true,
|
||||
allowedUserIds: [],
|
||||
startsAt: null,
|
||||
expiresAt: null,
|
||||
createdBy: 'admin',
|
||||
createdAt: '2026-07-13T01:00:00Z',
|
||||
updatedAt: '2026-07-13T01:00:00Z',
|
||||
};
|
||||
|
||||
const entries: ProfileRedeemCodeAdminResponse[] = [
|
||||
baseEntry,
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'PENDING',
|
||||
startsAt: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'EXPIRED',
|
||||
expiresAt: '2000-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'ACTIVE',
|
||||
startsAt: '2000-01-01T00:00:00Z',
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'DISABLED',
|
||||
enabled: false,
|
||||
startsAt: '2000-01-01T00:00:00Z',
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listProfileRedeemCodes).mockResolvedValue({
|
||||
entries,
|
||||
operations: [],
|
||||
});
|
||||
vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry);
|
||||
vi.mocked(disableProfileRedeemCode).mockResolvedValue({
|
||||
...baseEntry,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('兑换码列表展示生效状态与日期范围', async () => {
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect((await rowForCode('LONG-LIVED')).textContent).toContain('长期有效');
|
||||
expect((await rowForCode('LONG-LIVED')).textContent).toContain('立即 / 长期');
|
||||
expect((await rowForCode('PENDING')).textContent).toContain('未生效');
|
||||
expect((await rowForCode('EXPIRED')).textContent).toContain('已过期');
|
||||
expect((await rowForCode('ACTIVE')).textContent).toContain('有效');
|
||||
expect((await rowForCode('DISABLED')).textContent).toContain('停用');
|
||||
});
|
||||
|
||||
test('点击兑换码回填本地日期输入', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'ACTIVE' }));
|
||||
|
||||
expect((screen.getByLabelText('开始时间') as HTMLInputElement).value).toBe(
|
||||
toLocalInputValue('2000-01-01T00:00:00Z'),
|
||||
);
|
||||
expect((screen.getByLabelText('截止时间') as HTMLInputElement).value).toBe(
|
||||
toLocalInputValue('2999-01-01T00:00:00Z'),
|
||||
);
|
||||
});
|
||||
|
||||
test('兑换码拒绝截止时间不晚于开始时间的配置', async () => {
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByRole('button', { name: 'LONG-LIVED' });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Code'), {
|
||||
target: { value: 'INVALID' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('开始时间'), {
|
||||
target: { value: '2026-07-13T10:00' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('截止时间'), {
|
||||
target: { value: '2026-07-13T10:00' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('截止时间必须晚于开始时间')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(upsertProfileRedeemCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('兑换码保存时把本地时间转换为 ISO 并保留空边界', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByRole('button', { name: 'LONG-LIVED' });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Code'), {
|
||||
target: { value: 'WINDOWED' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('开始时间'), {
|
||||
target: { value: '2026-07-13T10:30' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertProfileRedeemCode).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
code: 'WINDOWED',
|
||||
startsAt: new Date('2026-07-13T10:30').toISOString(),
|
||||
expiresAt: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function rowForCode(code: string) {
|
||||
const button = await screen.findByRole('button', { name: code });
|
||||
const row = button.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`未找到兑换码 ${code} 所在行`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function toLocalInputValue(value: string) {
|
||||
const date = new Date(value);
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export function AdminRedeemCodePage({
|
||||
const [rewardPoints, setRewardPoints] = useState('100');
|
||||
const [maxUses, setMaxUses] = useState('1');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [startsAt, setStartsAt] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [allowedUserIds, setAllowedUserIds] = useState('');
|
||||
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
@@ -73,6 +75,12 @@ export function AdminRedeemCodePage({
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
const validityError = validateValidityWindow(startsAt, expiresAt);
|
||||
if (validityError) {
|
||||
setErrorMessage(validityError);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmWrite({
|
||||
action: '保存兑换码',
|
||||
target: code.trim(),
|
||||
@@ -92,6 +100,8 @@ export function AdminRedeemCodePage({
|
||||
allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [],
|
||||
allowedPublicUserCodes:
|
||||
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
|
||||
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
|
||||
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
|
||||
});
|
||||
fillForm(response);
|
||||
await refreshRedeemCodes();
|
||||
@@ -137,11 +147,15 @@ export function AdminRedeemCodePage({
|
||||
setRewardPoints(String(entry.rewardPoints));
|
||||
setMaxUses(String(entry.maxUses));
|
||||
setEnabled(entry.enabled);
|
||||
setStartsAt(toDateTimeLocalValue(entry.startsAt));
|
||||
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
|
||||
setAllowedUserIds(entry.allowedUserIds.join('\n'));
|
||||
setAllowedPublicUserCodes('');
|
||||
setDisableCode(entry.code);
|
||||
}
|
||||
|
||||
const validityError = validateValidityWindow(startsAt, expiresAt);
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
@@ -222,6 +236,25 @@ export function AdminRedeemCodePage({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>开始时间</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startsAt}
|
||||
onChange={(event) => setStartsAt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>截止时间</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={expiresAt}
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === 'private' ? (
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
@@ -250,6 +283,11 @@ export function AdminRedeemCodePage({
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{validityError && validityError !== errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{validityError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
@@ -257,7 +295,8 @@ export function AdminRedeemCodePage({
|
||||
isSaving ||
|
||||
!code.trim() ||
|
||||
!parsePositiveInteger(rewardPoints) ||
|
||||
!parsePositiveInteger(maxUses)
|
||||
!parsePositiveInteger(maxUses) ||
|
||||
Boolean(validityError)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
@@ -280,6 +319,7 @@ export function AdminRedeemCodePage({
|
||||
<th>Code</th>
|
||||
<th>奖励</th>
|
||||
<th>状态</th>
|
||||
<th>有效期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -296,7 +336,16 @@ export function AdminRedeemCodePage({
|
||||
<small>{redeemModeLabel(entry.mode)}</small>
|
||||
</td>
|
||||
<td>{entry.rewardPoints}</td>
|
||||
<td>{entry.enabled ? '启用' : '停用'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-status ${redeemValidityClass(entry)}`}
|
||||
>
|
||||
{redeemValidityLabel(entry)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<small>{formatValidityWindow(entry)}</small>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -400,3 +449,76 @@ function formatDateTime(value: string) {
|
||||
}
|
||||
return date.toLocaleString('zh-CN', {hour12: false});
|
||||
}
|
||||
|
||||
function validateValidityWindow(startsAt: string, expiresAt: string) {
|
||||
if (!startsAt || !expiresAt) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startsAtTime = Date.parse(toIsoDateTime(startsAt));
|
||||
const expiresAtTime = Date.parse(toIsoDateTime(expiresAt));
|
||||
if (!Number.isFinite(startsAtTime) || !Number.isFinite(expiresAtTime)) {
|
||||
return '有效期时间无效';
|
||||
}
|
||||
|
||||
return startsAtTime < expiresAtTime ? '' : '截止时间必须晚于开始时间';
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: string) {
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time)) {
|
||||
throw new Error('有效期时间无效');
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value?: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function redeemValidityLabel(entry: ProfileRedeemCodeAdminResponse) {
|
||||
if (!entry.enabled) {
|
||||
return '停用';
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null;
|
||||
const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null;
|
||||
if (startsAtTime !== null && Number.isFinite(startsAtTime) && now < startsAtTime) {
|
||||
return '未生效';
|
||||
}
|
||||
if (expiresAtTime !== null && Number.isFinite(expiresAtTime) && now >= expiresAtTime) {
|
||||
return '已过期';
|
||||
}
|
||||
if (entry.startsAt || entry.expiresAt) {
|
||||
return '有效';
|
||||
}
|
||||
return '长期有效';
|
||||
}
|
||||
|
||||
function redeemValidityClass(entry: ProfileRedeemCodeAdminResponse) {
|
||||
const label = redeemValidityLabel(entry);
|
||||
if (label === '停用' || label === '已过期') {
|
||||
return 'admin-status-error';
|
||||
}
|
||||
if (label === '未生效') {
|
||||
return 'admin-status-pending';
|
||||
}
|
||||
return 'admin-status-ok';
|
||||
}
|
||||
|
||||
function formatValidityWindow(entry: ProfileRedeemCodeAdminResponse) {
|
||||
const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即';
|
||||
const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期';
|
||||
return `${startsAt} / ${expiresAt}`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
filterAdminTrackingEventDefinitions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminTrackingEventsPageProps {
|
||||
@@ -326,8 +327,26 @@ export function AdminTrackingEventsPage({
|
||||
<small>dayKey: {entry.dayKey}</small>
|
||||
</td>
|
||||
<td>
|
||||
{entry.userId || '-'}
|
||||
<small>owner: {entry.ownerUserId || '-'}</small>
|
||||
<div className="admin-tracking-user-reference">
|
||||
<span>{entry.userId || '-'}</span>
|
||||
{entry.userId ? (
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-tracking-user-reference">
|
||||
<small>owner: {entry.ownerUserId || '-'}</small>
|
||||
{entry.ownerUserId ? (
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{entry.profileId || '-'}
|
||||
@@ -364,6 +383,8 @@ export function AdminTrackingEventsPage({
|
||||
{detailEntry ? (
|
||||
<TrackingEventDetailPanel
|
||||
entry={detailEntry}
|
||||
token={token}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -373,9 +394,13 @@ export function AdminTrackingEventsPage({
|
||||
|
||||
function TrackingEventDetailPanel({
|
||||
entry,
|
||||
token,
|
||||
onUnauthorized,
|
||||
onClose,
|
||||
}: {
|
||||
entry: AdminTrackingEventEntryPayload;
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -405,6 +430,24 @@ function TrackingEventDetailPanel({
|
||||
<pre className="admin-code-block">
|
||||
{formatMetadataJson(entry.metadataJson)}
|
||||
</pre>
|
||||
) : column.key === 'userId' && entry.userId ? (
|
||||
<div className="admin-inline-identity">
|
||||
<span>{entry.userId}</span>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
) : column.key === 'ownerUserId' && entry.ownerUserId ? (
|
||||
<div className="admin-inline-identity">
|
||||
<span>{entry.ownerUserId}</span>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
formatExportCell(entry[column.key], column.key) || '-'
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateAdminWorkVisibility,
|
||||
} from '../api/adminApiClient';
|
||||
import type {AdminWorkVisibilityEntryPayload} from '../api/adminApiTypes';
|
||||
import {AdminUserReferenceButton} from '../components/AdminUserReferenceButton';
|
||||
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
|
||||
import {handlePageError} from './pageUtils';
|
||||
|
||||
@@ -177,8 +178,17 @@ export function AdminWorkVisibilityPage({
|
||||
<small>{entry.subtitle || entry.profileId}</small>
|
||||
</td>
|
||||
<td>
|
||||
{entry.authorDisplayName || '玩家'}
|
||||
<small>{entry.ownerUserId}</small>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{entry.authorDisplayName || '玩家'}
|
||||
<small>{entry.ownerUserId}</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-table-cell-ellipsis">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user