Files
Genarrative/apps/admin-web/src/components/AdminUserDetailDialog.tsx
T
k88936 b8309eb3ae 重构(后台金额): 抽出统一的元金额格式化
- shared AdminFormat 新增 formatAdminCurrencyCents(分 → ¥x.xx)并从 components 导出
- AdminUserDetailDialog / AdminMembershipPlanPage / AdminRechargeOrderPage / AdminRechargeProductPage 删除各自重复的 formatMoney/formatPrice
- 补充 formatAdminCurrencyCents 单测,避免后台各页面金额格式漂移
2026-10-05 18:12:22 +08:00

604 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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()}
/>
<MembershipSection membership={detail.membership} />
<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
? formatAdminCurrencyCents(order.paidAmountCents)
: '未支付'}
</td>
<td>{order.pointsDelta} 泥点</td>
<td>
{formatAdminCurrencyCents(
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'
? formatAdminCurrencyCents(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.monthlyPoints],
['退款占用', 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 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 (
<section className="admin-user-wallet-section">
<AdminPanelHeading title="会员" />
<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 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;
}