feat(web): 增加计费结算异常管理界面

管理端支持按状态查看结算与释放记录,展示精确到九位的小数金额、错误分类和重试次数,并使用一次性 Idempotency-Key 发起审计化重试。
This commit is contained in:
2026-07-21 00:25:57 +08:00
parent 5b2b94b1bd
commit 62d25fcb11
4 changed files with 214 additions and 9 deletions
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useState } from 'react';
import { AlertTriangle, ReceiptText, RefreshCw, RotateCcw } from 'lucide-react';
import type { BillingSettlement } from '@easyai-ai-gateway/contracts';
import { listBillingSettlements, retryBillingSettlement } from '../../api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
export function BillingSettlementsPanel(props: { token: string }) {
const [items, setItems] = useState<BillingSettlement[]>([]);
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const [retrying, setRetrying] = useState('');
const [message, setMessage] = useState('');
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setMessage('');
try {
const result = await listBillingSettlements(props.token, { status, pageSize: 100, signal });
setItems(result.items);
} catch (error) {
if (signal?.aborted) return;
setMessage(error instanceof Error ? error.message : '结算队列加载失败');
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [props.token, status]);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
async function retry(item: BillingSettlement) {
setRetrying(item.id);
setMessage('');
try {
await retryBillingSettlement(props.token, item.id);
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
await load();
} catch (error) {
setMessage(error instanceof Error ? error.message : '结算重试失败');
} finally {
setRetrying('');
}
}
const exceptions = items.filter((item) => item.status === 'retryable_failed' || item.status === 'manual_review').length;
return (
<div className="pageStack">
<Card>
<CardHeader>
<div className="identityHeaderTitle">
<div className="iconBox"><ReceiptText size={17} /></div>
<div>
<CardTitle></CardTitle>
<p className="mutedText"></p>
</div>
<Badge variant={exceptions ? 'warning' : 'secondary'}>{exceptions ? `${exceptions} 条异常` : `${items.length}`}</Badge>
</div>
</CardHeader>
<CardContent>
<div className="tableActions">
<Select size="sm" aria-label="结算状态" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value=""></option>
<option value="pending"></option>
<option value="processing"></option>
<option value="retryable_failed"></option>
<option value="manual_review"></option>
<option value="completed"></option>
</Select>
<Button type="button" size="sm" variant="outline" disabled={loading} onClick={() => void load()}>
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
</Button>
</div>
{message && <p className="formMessage">{message}</p>}
</CardContent>
</Card>
{items.length ? (
<Table density="compact" className="identityDataTable">
<TableRow>
<TableHead> / </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell><span className="identityTableName"><strong>{shortId(item.taskId)}</strong><small>{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)}</small></span></TableCell>
<TableCell><Badge variant={statusVariant(item.status)}>{statusLabel(item.status)}</Badge></TableCell>
<TableCell>{formatAmount(item.amount)} {item.currency}</TableCell>
<TableCell>{item.attempts} / 20</TableCell>
<TableCell>{item.lastErrorCode || item.manualReviewReason || '-'}</TableCell>
<TableCell>
{(item.status === 'retryable_failed' || item.status === 'manual_review') ? (
<Button type="button" size="xs" variant="outline" disabled={retrying === item.id} onClick={() => void retry(item)}>
<RotateCcw size={13} />{retrying === item.id ? '提交中' : '重试'}
</Button>
) : '-'}
</TableCell>
</TableRow>
))}
</Table>
) : (
<Card><CardContent className="emptyState"><AlertTriangle size={18} /><strong>{loading ? '正在加载结算队列' : '暂无结算记录'}</strong><span></span></CardContent></Card>
)}
</div>
);
}
function statusLabel(status: string) {
return ({ pending: '待处理', processing: '处理中', retryable_failed: '等待重试', manual_review: '人工复核', completed: '已完成' } as Record<string, string>)[status] ?? status;
}
function statusVariant(status: string): 'secondary' | 'warning' | 'destructive' | 'success' | 'outline' {
if (status === 'completed') return 'success';
if (status === 'manual_review') return 'destructive';
if (status === 'retryable_failed') return 'warning';
if (status === 'processing') return 'outline';
return 'secondary';
}
function formatAmount(value: number) {
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 9 }).format(value);
}
function shortId(value: string) {
return value.length > 12 ? `${value.slice(0, 8)}${value.slice(-4)}` : value;
}