Files
Genarrative/apps/admin-web/src/components/AdminUserDetailDialog.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

547 lines
18 KiB
TypeScript

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<AdminUserDetailResponse | null>(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<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 &&
!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(
<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 &&
!isReconcilingConsumption &&
!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 || isReconcilingConsumption}
title="刷新"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
</button>
<button
ref={closeButtonRef}
aria-label="关闭用户详情"
className="admin-ghost-button"
disabled={isSavingRestriction || isReconcilingConsumption}
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}
historicalConsumedPoints={detail.historicalConsumedPoints}
canReconcileConsumption={detail.canReconcileConsumption}
isBusy={isReconcilingConsumption || isSavingRestriction}
isReconciling={isReconcilingConsumption}
reconcileMessage={reconcileMessage}
onReconcile={() => void handleConsumptionReconcile()}
/>
<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.updatedByAdminDisplayName}
</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 || isReconcilingConsumption}
value={restrictionReason}
onChange={(event) =>
setRestrictionReason(event.target.value)
}
/>
</label>
<button
className={
detail.wallet.manualFrozen
? 'admin-secondary-button'
: 'admin-danger-button'
}
disabled={
isSavingRestriction ||
isReconcilingConsumption ||
!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,
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 (
<section className="admin-user-wallet-section">
<div className="admin-panel-heading">
<h3>钱包</h3>
<div className="admin-detail-actions">
{canReconcileConsumption ? (
<button
aria-label="手动对账历史花费"
className="admin-ghost-button admin-user-wallet-reconcile-button"
disabled={isBusy}
type="button"
onClick={onReconcile}
>
<RefreshCcw size={15} aria-hidden="true" />
<span>{isReconciling ? '对账中' : '手动对账'}</span>
</button>
) : null}
<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>
{reconcileMessage ? (
<div className="admin-alert" role="status">
{reconcileMessage}
</div>
) : null}
<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;
}