import { AdminAlert, AdminButton, AdminDialog, AdminField, AdminInfoList, AdminListPanel, AdminPanelHeading, AdminStatusPill, AdminTag, AdminTagList, formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { RefreshCcw, ShieldAlert, UserRound, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { formatAdminApiError, getAdminUserDetail, isAdminApiError, reconcileAdminUserConsumption, updateAdminWalletRestriction, } from '../api/adminApiClient'; import type { AdminProfileMembershipPayload, AdminProfileWalletPayload, AdminUserDetailResponse, } from '../api/adminApiTypes'; import { formatMembershipCycleKind, formatMembershipPlan, formatMembershipStatus, } from '../config/membershipDisplay'; 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 [isReconcilingConsumption, setIsReconcilingConsumption] = useState(false); const [reconcileMessage, setReconcileMessage] = useState(''); 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; }; }, []); async function loadDetail() { const requestVersion = requestVersionRef.current + 1; requestVersionRef.current = requestVersion; setIsLoading(true); setErrorMessage(''); setReconcileMessage(''); 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); } } async function handleConsumptionReconcile() { if (!detail || isReconcilingConsumption) { return; } const confirmed = await confirmWrite({ action: '手动对账历史花费', target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`, }); if (!confirmed) { return; } setIsReconcilingConsumption(true); setErrorMessage(''); setReconcileMessage(''); try { const response = await reconcileAdminUserConsumption(token, { userId: detail.userId, }); setDetail((current) => current ? { ...current, historicalConsumedPoints: response.historicalConsumedPoints, } : current, ); setReconcileMessage( response.changed ? '对账完成,历史花费已校准' : '对账完成,数据一致', ); } catch (error: unknown) { if (isAdminApiError(error) && error.status === 401) { onUnauthorized('登录状态已失效'); } else { setErrorMessage(formatAdminApiError(error)); } } finally { setIsReconcilingConsumption(false); } } if (typeof document === 'undefined') { return null; } return createPortal( void loadDetail()} > } > {isLoading ? (
正在读取用户信息
) : errorMessage && !detail ? (
{errorMessage} void loadDetail()} >
) : detail ? ( <> {errorMessage ? {errorMessage} : null} void handleConsumptionReconcile()} />
{detail.wallet.manualRestriction ? (
{detail.wallet.manualRestriction.reason || '未填写原因'} {formatMicros( detail.wallet.manualRestriction.updatedAtMicros, )}{' '} / {detail.wallet.manualRestriction.updatedByAdminDisplayName}
) : null} {detail.wallet.manualFrozen && detail.wallet.refundDebtFrozen ? ( ) : null}
setRestrictionReason(event.target.value)} />
{detail.rechargeOrders.length} 笔} /> order.createdAtMicros, }, { key: 'product', label: '商品', sortable: true, sortValue: (order) => order.productTitle || order.productId, }, { key: 'paidAmount', label: '实付', sortable: true, sortValue: (order) => order.paidAmountCents, }, { key: 'pointsDelta', label: '发放泥点', sortable: true, sortValue: (order) => order.pointsDelta, }, { key: 'refund', label: '退款', sortable: true, sortValue: (order) => order.cumulativeSuccessRefundCents, }, { key: 'status', label: '状态', sortable: true }, ]} emptyText="暂无充值订单" renderRow={(order) => ( {order.orderId} {formatMicros(order.createdAtMicros)} {order.productTitle || order.productId} {order.paidAmountCents > 0 ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'} {order.pointsDelta} 泥点 {formatAdminCurrencyCents( order.cumulativeSuccessRefundCents, )} 欠账 {order.unrecoveredPoints} 泥点 {formatOrderStatus(order.status)} )} rows={detail.rechargeOrders} surface="plain" tableClassName="admin-user-recharge-table" />
) : null} {confirmDialog} , document.body, ); } function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) { return (
{detail.avatarUrl ? ( {`${detail.displayName ) : (
{detail.displayName || '未设置昵称'} {detail.publicUserCode || '未分配陶泥号'}
内部 ID, value: <>{detail.userId}, }, { label: <>脱敏手机号, value: <>{detail.phoneNumberMasked || '未绑定'}, }, { label: <>登录方式, value: <>{detail.loginMethod || '-'}, }, { label: <>累计充值, value: ( <> {typeof detail.cumulativeRechargedCents === 'number' ? formatAdminCurrencyCents(detail.cumulativeRechargedCents) : '未知'} ), }, { label: <>绑定状态, value: ( <> {detail.bindingStatus || '-'} / 手机 {detail.phoneBound ? '已绑定' : '未绑定'} / 微信 {detail.wechatBound ? '已绑定' : '未绑定'} ), }, ]} className="admin-user-identity-list" />
); } function WalletSection({ wallet, historicalConsumedPoints, canReconcileConsumption, isBusy, isReconciling, reconcileMessage, onReconcile, }: { wallet: AdminProfileWalletPayload; historicalConsumedPoints: number; canReconcileConsumption: boolean; isBusy: boolean; isReconciling: boolean; reconcileMessage: string; onReconcile: () => void; }) { const metrics = [ ['总余额', wallet.totalBalance], ['可消费', wallet.spendableBalance], ['永久泥点', wallet.permanentPoints], ['每日免费', wallet.dailyFreePoints], ['月度泥点', wallet.monthlyPoints], ['退款占用', wallet.heldPoints], ['退款欠账', wallet.refundDebtPoints], ['历史花费', historicalConsumedPoints], ] as const; return (
{canReconcileConsumption ? ( ) : null} {wallet.manualFrozen ? 人工冻结 : null} {wallet.refundDebtFrozen ? ( 退款欠账限制 ) : null} {!wallet.walletFrozen ? ( 正常 ) : null}
} /> {reconcileMessage ? {reconcileMessage} : null}
{metrics.map(([label, value]) => (
{label} {value}
))}
); } function MembershipSection({ membership, }: { membership: AdminProfileMembershipPayload; }) { const metrics: Array<[string, string]> = [ ['档位', `${formatMembershipPlan(membership.plan)}(${membership.plan})`], ['状态', formatMembershipStatus(membership.status) || '-'], [ '账期', // 非会员时后端投影返回占位账期(monthly / 1/1),不展示成“正在计费”。 membership.status === 'active' ? `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期` : '-', ], ['本账期发放', `${membership.cycleGrantedPoints} 泥点`], ['本账期剩余', `${membership.cycleRemainingPoints} 泥点`], [ '到期', membership.expiresAtMicros ? formatMicros(membership.expiresAtMicros) : '-', ], [ '账期重置', membership.cycleResetsAtMicros ? formatMicros(membership.cycleResetsAtMicros) : '-', ], ]; return (
{metrics.map(([label, value]) => (
{label} {value}
))}
); } 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; }