合并最新master并融合生成链路改造

保留master的多产物持久化、图集拆分与任务告警能力
融合BGFilter并发重试、处理阶段投影和手动去背景统一链路
按master现有字段顺序追加SpacetimeDB phase并同步生成绑定与文档
This commit is contained in:
2026-07-14 12:31:52 +00:00
247 changed files with 31482 additions and 1783 deletions
+2
View File
@@ -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',
}),
}),
);
});
+116
View File
@@ -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}` : '';
}
+197
View File
@@ -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;
}
+7
View File
@@ -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}
+2
View File
@@ -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');
});
+2
View File
@@ -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