diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts new file mode 100644 index 000000000..b2127d332 --- /dev/null +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -0,0 +1,117 @@ +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: '已核对微信商户平台原始账单', + }); + + 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: '已核对微信商户平台原始账单', + }), + }), + ); +}); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 5d17b64d6..89a9bf649 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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( + `/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`, + {token}, + ); +} + +export function getAdminUserDetail( + token: string, + query: AdminUserDetailQuery, +) { + return request( + `/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`, + {token}, + ); +} + +export function previewAdminRechargeRefund( + token: string, + payload: AdminRechargeRefundPreviewRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/preview', + {method: 'POST', token, body: payload}, + ); +} + +export function executeAdminRechargeRefund( + token: string, + payload: AdminRechargeRefundExecuteRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/execute', + {method: 'POST', token, body: payload}, + ); +} + +export function registerAdminRechargeRefund( + token: string, + payload: AdminRechargeRefundRegisterRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/register', + {method: 'POST', token, body: payload}, + ); +} + +export function resolveAdminRechargeRefundManualReview( + token: string, + payload: AdminRechargeRefundManualReviewResolveRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/manual-review/resolve', + {method: 'POST', token, body: payload}, + ); +} + +export function updateAdminWalletRestriction( + token: string, + payload: AdminWalletRestrictionRequest, +) { + return request( + '/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); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 0cc109130..1afe7f04f 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -738,3 +738,184 @@ 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; + providerStatus: string; + 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; +} + +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; +} + +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; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 68c9cf799..9a596528c 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( + + ) : null} {routeId === 'editor-generation-pricing' ? ( { 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'); +}); diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 569e5309d..550065503 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -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'}, diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx new file mode 100644 index 000000000..719cefca1 --- /dev/null +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -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( +
+ +
, + ); + + 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( + , + ); + + 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( + , + ); + 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( + , + ); + + 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); +}); diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx new file mode 100644 index 000000000..80e513349 --- /dev/null +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [restrictionReason, setRestrictionReason] = useState(''); + const [isSavingRestriction, setIsSavingRestriction] = useState(false); + const closeButtonRef = useRef(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( +
{ + if ( + event.target === event.currentTarget && + !isSavingRestriction && + !isConfirming + ) { + onClose(); + } + }} + > +
+
+
+

用户详情

+ {detail?.publicUserCode || publicUserCode || userId || '-'} +
+
+ + +
+
+ + {isLoading ? ( +
+
+ 正在读取用户信息 +
+ ) : errorMessage && !detail ? ( +
+
+ {errorMessage} +
+ +
+ ) : detail ? ( + <> + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + +
+
+

人工冻结

+ + {detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'} + +
+ {detail.wallet.manualRestriction ? ( +
+ {detail.wallet.manualRestriction.reason || '未填写原因'} + + {formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '} + {detail.wallet.manualRestriction.updatedByAdminUserId} + +
+ ) : null} + {detail.wallet.manualFrozen && detail.wallet.refundDebtFrozen ? ( +
+
+ ) : null} +
+ + +
+
+ +
+
+

充值订单

+ {detail.rechargeOrders.length} 笔 +
+ {detail.rechargeOrders.length ? ( +
+ + + + + + + + + + + + {detail.rechargeOrders.map((order) => ( + + + + + + + + ))} + +
订单商品实付退款状态
+ {order.orderId} + {formatMicros(order.createdAtMicros)} + + {order.productTitle || order.productId} + 发放 {order.pointsDelta} 泥点 + {formatMoney(order.amountCents)} + {formatMoney(order.cumulativeSuccessRefundCents)} + 欠账 {order.unrecoveredPoints} 泥点 + {formatOrderStatus(order.status)}
+
+ ) : ( +
暂无充值订单
+ )} +
+ + ) : null} +
+ {confirmDialog} +
, + document.body, + ); +} + +function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) { + return ( +
+
+ {detail.avatarUrl ? ( + {`${detail.displayName + ) : ( +
+
+ {detail.displayName || '未设置昵称'} + {detail.publicUserCode || '未分配陶泥号'} +
+
+
+
内部 ID
+
{detail.userId}
+
+
+
脱敏手机号
+
{detail.phoneNumberMasked || '未绑定'}
+
+
+
登录方式
+
{detail.loginMethod || '-'}
+
+
+
绑定状态
+
+ {detail.bindingStatus || '-'} / 手机{detail.phoneBound ? '已绑定' : '未绑定'} / 微信 + {detail.wechatBound ? '已绑定' : '未绑定'} +
+
+
+
+ ); +} + +function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) { + const metrics = [ + ['总余额', wallet.totalBalance], + ['可消费', wallet.spendableBalance], + ['永久泥点', wallet.permanentPoints], + ['每日免费', wallet.dailyFreePoints], + ['会员限时', wallet.membershipLimitedPoints], + ['退款占用', wallet.heldPoints], + ['退款欠账', wallet.refundDebtPoints], + ] as const; + return ( +
+
+

钱包

+
+ {wallet.manualFrozen ? 人工冻结 : null} + {wallet.refundDebtFrozen ? ( + 退款欠账限制 + ) : null} + {!wallet.walletFrozen ? 正常 : null} +
+
+
+ {metrics.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ); +} + +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 = { + pending: '待支付', + paid: '已支付', + refunded: '已退款', + closed: '已关闭', + }; + return labels[status.toLowerCase()] ?? status; +} diff --git a/apps/admin-web/src/components/AdminUserReferenceButton.tsx b/apps/admin-web/src/components/AdminUserReferenceButton.tsx new file mode 100644 index 000000000..5a93fdfbe --- /dev/null +++ b/apps/admin-web/src/components/AdminUserReferenceButton.tsx @@ -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(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) { + event.stopPropagation(); + setOpen(true); + } + + function closeDialog() { + setOpen(false); + window.requestAnimationFrame(() => triggerRef.current?.focus()); + } + + return ( + <> + + {open ? ( + + ) : null} + + ); +} + +function normalizeUserReference(value?: string | null) { + const normalized = value?.trim() ?? ''; + if (!normalized || normalized.toLowerCase().startsWith('admin:')) { + return ''; + } + return normalized; +} diff --git a/apps/admin-web/src/components/useAdminWriteConfirm.tsx b/apps/admin-web/src/components/useAdminWriteConfirm.tsx index 6f62abfe4..3aa01b6f0 100644 --- a/apps/admin-web/src/components/useAdminWriteConfirm.tsx +++ b/apps/admin-web/src/components/useAdminWriteConfirm.tsx @@ -101,5 +101,9 @@ export function useAdminWriteConfirm() { ) : null; - return {confirmWrite, confirmDialog}; + return { + confirmWrite, + confirmDialog, + isConfirming: pendingConfirm !== null, + }; } diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index f6ef68b2e..d6bf2e71f 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -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,21 @@ vi.mock('../api/adminApiClient', () => ({ isAdminApiError: vi.fn(() => false), })); +vi.mock('../components/AdminUserReferenceButton', () => ({ + AdminUserReferenceButton: ({ userId, publicUserCode }: { + userId?: string; + publicUserCode?: string; + }) => ( +
- {authorDisplayName(entry)} - {entry.authorPublicUserCode?.trim() || '-'} +
+
+ {authorDisplayName(entry)} + {entry.authorPublicUserCode?.trim() || '-'} +
+ +
{entry.ownerUserId} @@ -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 | 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((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()); } diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index 67bf65873..9ad15e18b 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -31,6 +31,20 @@ vi.mock('../api/adminApiClient', () => ({ upsertAdminEditorShowcaseCampaign: vi.fn(), })); +vi.mock('../components/AdminUserReferenceButton', () => ({ + AdminUserReferenceButton: ({ userId, publicUserCode }: { + userId?: string; + publicUserCode?: string | null; + }) => ( + + + + + + {visibleDebtOrder ? ( + + ) : lastAction ? ( + + ) : null} + {listError ? ( +
+ {listError} +
+ ) : null} + +
+
+ setFilters({ ...filters, orderId: value })} + /> + + setFilters({ ...filters, providerTransactionId: value }) + } + /> + + setFilters({ + ...filters, + userId: value, + publicUserCode: value.trim() ? '' : filters.publicUserCode, + }) + } + /> + + setFilters({ + ...filters, + publicUserCode: value, + userId: value.trim() ? '' : filters.userId, + }) + } + /> + + + + + +
+ + +
+
+
+ +
+
+

充值订单

+ {orders.length} 条 +
+ {isLoading && orders.length === 0 ? ( +
正在读取充值订单
+ ) : orders.length === 0 ? ( +
暂无匹配订单
+ ) : ( +
+ + + + + + + + + + + + + + + {orders.map((order) => ( + + ))} + +
用户订单支付金额 / 泥点退款与追回钱包状态操作
+
+ )} +
+ + {refundOrder ? ( +
{ + if (event.target === event.currentTarget) { + closeRefund(); + } + }} + > +
+
+
+

退款处理

+ {refundOrder.orderId} +
+ +
+ +
+ + + + + + +
+ +
+ + +
+ +
+ + +
+ + {providerStatusUnknown ? ( +
+
+ ) : null} + {refundError && !providerStatusUnknown ? ( +
+ {refundError} +
+ ) : null} + + {preview ? ( +
+
+

退款预检

+ {preview.canSubmit ? '可以提交' : '已阻止'} +
+
+
+
微信账单核验
+
+ {preview.paymentCheck.verified ? '已核验' : '未核验'} /{' '} + {preview.paymentCheck.tradeState || '-'} +
+
+
+
本次应追回
+
{preview.incrementalRecoveryPoints} 泥点
+
+
+
刷新退款单
+
{preview.paymentCheck.knownRefundsRefreshed} 笔
+
+
+
预检后剩余可退
+
{formatMoney(preview.remainingRefundableCents)}
+
+
+
+ ) : null} + +
+ + +
+
+
+ ) : null} + + {registerOpen ? ( +
{ + if (event.target === event.currentTarget && !isRegistering) { + setRegisterOpen(false); + } + }} + > +
+
+
+

登记商户平台退款

+ 填写退款详情中的 out_refund_no +
+ +
+ + {registerError ? ( +
+ {registerError} +
+ ) : null} +
+ + +
+
+
+ ) : null} + + {manualReviewTarget ? ( +
{ + if ( + event.target === event.currentTarget && + !isResolvingManualReview + ) { + setManualReviewTarget(null); + } + }} + > +
+
+
+

确认退款人工复核

+ {manualReviewTarget.refund.outRefundNo} +
+ +
+