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 { 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 [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; }; }, []); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === 'Escape' && !isSavingRestriction && !isReconcilingConsumption && !isConfirming ) { event.preventDefault(); onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [isConfirming, isReconcilingConsumption, isSavingRestriction, onClose]); 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(
{ if ( event.target === event.currentTarget && !isSavingRestriction && !isReconcilingConsumption && !isConfirming ) { onClose(); } }} >

用户详情

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

人工冻结

{detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'}
{detail.wallet.manualRestriction ? (
{detail.wallet.manualRestriction.reason || '未填写原因'} {formatMicros( detail.wallet.manualRestriction.updatedAtMicros, )}{' '} /{' '} {detail.wallet.manualRestriction.updatedByAdminDisplayName}
) : 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, 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.membershipLimitedPoints], ['退款占用', 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 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; }