Files
Genarrative/apps/admin-web/src/components/AdminUserDetailDialog.tsx
T
kdletters 8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
后台公共组件收口到共享组件库并统一列表实现
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出
- AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表)
- AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序
- AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它
- AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间
- 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列
- 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable
- 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心
- 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染
- docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑
- 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
2026-09-29 22:39:41 +08:00

552 lines
17 KiB
TypeScript

import {
AdminAlert,
AdminButton,
AdminDialog,
AdminField,
AdminInfoList,
AdminListPanel,
AdminPanelHeading,
AdminStatusPill,
AdminTag,
AdminTagList,
} 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 {
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;
};
}, []);
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(
<AdminDialog
backdropClassName="admin-user-detail-backdrop"
closeOnBackdrop={
!isSavingRestriction && !isReconcilingConsumption && !isConfirming
}
closeOnEscape={
!isSavingRestriction && !isReconcilingConsumption && !isConfirming
}
description={detail?.publicUserCode || publicUserCode || userId || '-'}
panelClassName="admin-user-detail-panel"
showClose={false}
title="用户详情"
titleProps={{ id: 'admin-user-detail-title' }}
onClose={onClose}
extra={
<div className="admin-detail-actions">
<AdminButton
variant="ghost"
aria-label="刷新用户信息"
disabled={isLoading || isReconcilingConsumption}
title="刷新"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
</AdminButton>
<AdminButton
variant="ghost"
ref={closeButtonRef}
aria-label="关闭用户详情"
disabled={isSavingRestriction || isReconcilingConsumption}
title="关闭"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</AdminButton>
</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">
<AdminAlert>{errorMessage}</AdminAlert>
<AdminButton
variant="secondary"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>重试</span>
</AdminButton>
</div>
) : detail ? (
<>
<UserIdentityHeader detail={detail} />
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : 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">
<AdminPanelHeading
title="人工冻结"
count={detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'}
/>
{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 ? (
<AdminAlert tone="warning">
<ShieldAlert size={17} aria-hidden="true" />
<span>解除人工冻结后,退款欠账限制仍会保留。</span>
</AdminAlert>
) : null}
<div className="admin-user-restriction-actions">
<AdminField label="操作原因" fill>
<input
aria-label="人工冻结操作原因"
disabled={isSavingRestriction || isReconcilingConsumption}
value={restrictionReason}
onChange={(event) => setRestrictionReason(event.target.value)}
/>
</AdminField>
<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">
<AdminPanelHeading
title="充值订单"
count={<>{detail.rechargeOrders.length} 笔</>}
/>
<AdminListPanel
columns={[
{
key: 'orderId',
label: '订单',
sortable: true,
sortDescription: '订单创建时间',
sortValue: (order) => 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) => (
<tr key={order.orderId}>
<td>
<span className="admin-mono-value">{order.orderId}</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>{order.productTitle || order.productId}</td>
<td>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付'}
</td>
<td>{order.pointsDelta} 泥点</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small>欠账 {order.unrecoveredPoints} 泥点</small>
</td>
<td>{formatOrderStatus(order.status)}</td>
</tr>
)}
rows={detail.rechargeOrders}
surface="plain"
tableClassName="admin-user-recharge-table"
/>
</section>
</>
) : null}
{confirmDialog}
</AdminDialog>,
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>
<AdminInfoList
items={[
{
label: <>内部 ID</>,
value: <>{detail.userId}</>,
},
{
label: <>脱敏手机号</>,
value: <>{detail.phoneNumberMasked || '未绑定'}</>,
},
{
label: <>登录方式</>,
value: <>{detail.loginMethod || '-'}</>,
},
{
label: <>累计充值</>,
value: (
<>
{typeof detail.cumulativeRechargedCents === 'number'
? formatMoney(detail.cumulativeRechargedCents)
: '未知'}
</>
),
},
{
label: <>绑定状态</>,
value: (
<>
{detail.bindingStatus || '-'} / 手机
{detail.phoneBound ? '已绑定' : '未绑定'} / 微信
{detail.wechatBound ? '已绑定' : '未绑定'}
</>
),
},
]}
className="admin-user-identity-list"
/>
</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">
<AdminPanelHeading
title="钱包"
extra={
<div className="admin-detail-actions">
{canReconcileConsumption ? (
<AdminButton
variant="ghost"
aria-label="手动对账历史花费"
disabled={isBusy}
type="button"
onClick={onReconcile}
className="admin-user-wallet-reconcile-button"
>
<RefreshCcw size={15} aria-hidden="true" />
<span>{isReconciling ? '对账中' : '手动对账'}</span>
</AdminButton>
) : null}
<AdminTagList as="div">
{wallet.manualFrozen ? <AdminTag>人工冻结</AdminTag> : null}
{wallet.refundDebtFrozen ? (
<AdminTag>退款欠账限制</AdminTag>
) : null}
{!wallet.walletFrozen ? (
<AdminStatusPill tone="ok">正常</AdminStatusPill>
) : null}
</AdminTagList>
</div>
}
/>
{reconcileMessage ? <AdminAlert>{reconcileMessage}</AdminAlert> : 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;
}