b8309eb3ae
- shared AdminFormat 新增 formatAdminCurrencyCents(分 → ¥x.xx)并从 components 导出 - AdminUserDetailDialog / AdminMembershipPlanPage / AdminRechargeOrderPage / AdminRechargeProductPage 删除各自重复的 formatMoney/formatPrice - 补充 formatAdminCurrencyCents 单测,避免后台各页面金额格式漂移
604 lines
19 KiB
TypeScript
604 lines
19 KiB
TypeScript
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;
|
||
}
|