529 lines
17 KiB
TypeScript
529 lines
17 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;
|
|
}
|