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 全部通过
552 lines
17 KiB
TypeScript
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;
|
|
}
|