完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。 新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。 验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { AlertTriangle, RefreshCw, RotateCcw, Search } from 'lucide-react';
|
||
import type { BillingSettlement } from '@easyai-ai-gateway/contracts';
|
||
import { listBillingSettlements, retryBillingSettlement } from '../../api';
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
Checkbox,
|
||
ConfirmDialog,
|
||
Input,
|
||
Select,
|
||
Table,
|
||
TableCell,
|
||
TableHead,
|
||
TableRow,
|
||
} from '../../components/ui';
|
||
|
||
export function BillingSettlementsPanel(props: { token: string }) {
|
||
const [items, setItems] = useState<BillingSettlement[]>([]);
|
||
const [query, setQuery] = useState('');
|
||
const [status, setStatus] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [retrying, setRetrying] = useState('');
|
||
const [batchRetrying, setBatchRetrying] = useState(false);
|
||
const [confirming, setConfirming] = useState<BillingSettlement | null>(null);
|
||
const [batchConfirming, setBatchConfirming] = useState(false);
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||
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]);
|
||
|
||
const filteredItems = useMemo(() => {
|
||
const keyword = query.trim().toLowerCase();
|
||
if (!keyword) return items;
|
||
return items.filter((item) => [
|
||
item.id,
|
||
item.taskId,
|
||
item.action,
|
||
item.status,
|
||
item.lastErrorCode,
|
||
item.manualReviewReason,
|
||
].filter(Boolean).join(' ').toLowerCase().includes(keyword));
|
||
}, [items, query]);
|
||
const retryableItems = useMemo(() => items.filter(canRetry), [items]);
|
||
const visibleRetryableItems = useMemo(() => filteredItems.filter(canRetry), [filteredItems]);
|
||
const selectedItems = useMemo(
|
||
() => retryableItems.filter((item) => selectedIds.has(item.id)),
|
||
[retryableItems, selectedIds],
|
||
);
|
||
const allVisibleSelected = visibleRetryableItems.length > 0
|
||
&& visibleRetryableItems.every((item) => selectedIds.has(item.id));
|
||
const someVisibleSelected = visibleRetryableItems.some((item) => selectedIds.has(item.id));
|
||
const busy = batchRetrying || Boolean(retrying);
|
||
|
||
useEffect(() => {
|
||
const retryableIds = new Set(retryableItems.map((item) => item.id));
|
||
setSelectedIds((current) => {
|
||
const next = new Set(Array.from(current).filter((id) => retryableIds.has(id)));
|
||
return next.size === current.size ? current : next;
|
||
});
|
||
}, [retryableItems]);
|
||
|
||
async function retry(item: BillingSettlement) {
|
||
setRetrying(item.id);
|
||
setMessage('');
|
||
try {
|
||
await retryBillingSettlement(props.token, item.id);
|
||
await load();
|
||
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '结算重试失败');
|
||
} finally {
|
||
setRetrying('');
|
||
}
|
||
}
|
||
|
||
function toggleItem(itemId: string, checked: boolean) {
|
||
setSelectedIds((current) => {
|
||
const next = new Set(current);
|
||
if (checked) next.add(itemId);
|
||
else next.delete(itemId);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function toggleVisible(checked: boolean) {
|
||
setSelectedIds((current) => {
|
||
const next = new Set(current);
|
||
visibleRetryableItems.forEach((item) => {
|
||
if (checked) next.add(item.id);
|
||
else next.delete(item.id);
|
||
});
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function retrySelected() {
|
||
if (!selectedItems.length) return;
|
||
setBatchRetrying(true);
|
||
setMessage('');
|
||
const processingItems = [...selectedItems];
|
||
try {
|
||
const { failedIds, succeeded } = await retrySettlementBatch(
|
||
processingItems,
|
||
(item) => retryBillingSettlement(props.token, item.id),
|
||
);
|
||
setSelectedIds(new Set(failedIds));
|
||
await load();
|
||
setMessage(failedIds.length
|
||
? `批量结算完成:成功 ${succeeded} 条,失败 ${failedIds.length} 条;失败记录已保留选择。`
|
||
: `已批量结算 ${succeeded} 条记录。`);
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '批量结算处理失败');
|
||
} finally {
|
||
setBatchRetrying(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="pageStack">
|
||
<Card className="compactAdminTableCard">
|
||
<CardContent className="compactAdminTableContent">
|
||
<div className="compactAdminToolbar billingCompactToolbar">
|
||
<span className="platformSearchBox compactAdminSearch">
|
||
<Search size={15} />
|
||
<Input
|
||
aria-label="搜索计费结算"
|
||
value={query}
|
||
placeholder="任务 ID、结算 ID、动作或异常原因"
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
/>
|
||
</span>
|
||
<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>
|
||
<div className="compactAdminToolbarActions">
|
||
<Button type="button" size="sm" disabled={!selectedItems.length || busy} onClick={() => setBatchConfirming(true)}>
|
||
<RotateCcw size={14} />
|
||
{batchRetrying ? '结算中' : `批量结算${selectedItems.length ? `(${selectedItems.length})` : ''}`}
|
||
</Button>
|
||
<Button type="button" size="sm" variant="outline" disabled={loading || busy} onClick={() => void load()}>
|
||
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{message && <p className="formMessage">{message}</p>}
|
||
{filteredItems.length ? (
|
||
<div className="billingSettlementTableViewport">
|
||
<Table density="compact" className="identityDataTable billingSettlementTable">
|
||
<TableRow>
|
||
<TableHead>
|
||
<Checkbox
|
||
aria-label="选择当前筛选结果中的全部可处理结算"
|
||
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
|
||
disabled={!visibleRetryableItems.length || busy}
|
||
onCheckedChange={(checked) => toggleVisible(checked === true)}
|
||
/>
|
||
</TableHead>
|
||
<TableHead>任务 / 动作</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>金额</TableHead>
|
||
<TableHead>尝试</TableHead>
|
||
<TableHead>异常分类</TableHead>
|
||
<TableHead>操作</TableHead>
|
||
</TableRow>
|
||
{filteredItems.map((item) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell>
|
||
<Checkbox
|
||
aria-label={`选择结算 ${shortId(item.id)}`}
|
||
checked={selectedIds.has(item.id)}
|
||
disabled={!canRetry(item) || busy}
|
||
onCheckedChange={(checked) => toggleItem(item.id, checked === true)}
|
||
/>
|
||
</TableCell>
|
||
<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>
|
||
{canRetry(item) ? (
|
||
<Button type="button" size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(item)}>
|
||
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
|
||
</Button>
|
||
) : '-'}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</Table>
|
||
</div>
|
||
) : (
|
||
<div className="emptyState">
|
||
<AlertTriangle size={18} />
|
||
<strong>{loading ? '正在加载结算队列' : '暂无匹配的结算记录'}</strong>
|
||
<span>可调整搜索或状态筛选查看其他记录。</span>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
<ConfirmDialog
|
||
open={Boolean(confirming)}
|
||
loading={Boolean(confirming && retrying === confirming.id)}
|
||
title={confirming ? `${retryActionLabel(confirming)}这条计费记录?` : '处理计费记录?'}
|
||
description={confirming ? retryActionDescription(confirming) : ''}
|
||
confirmLabel={confirming ? retryActionLabel(confirming) : '确认'}
|
||
onCancel={() => setConfirming(null)}
|
||
onConfirm={() => {
|
||
if (!confirming) return;
|
||
const item = confirming;
|
||
void retry(item).finally(() => setConfirming(null));
|
||
}}
|
||
/>
|
||
<ConfirmDialog
|
||
open={batchConfirming}
|
||
loading={batchRetrying}
|
||
title={`批量结算 ${selectedItems.length} 条记录?`}
|
||
description="系统会按记录动作逐条执行结算或释放冻结,并复用现有审计流程;只处理等待重试或人工复核记录,不会重新调用上游。"
|
||
confirmLabel="确认批量结算"
|
||
onCancel={() => setBatchConfirming(false)}
|
||
onConfirm={() => void retrySelected().finally(() => setBatchConfirming(false))}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function canRetry(item: BillingSettlement) {
|
||
return item.status === 'retryable_failed' || item.status === 'manual_review';
|
||
}
|
||
|
||
export async function retrySettlementBatch<T extends { id: string }>(
|
||
items: T[],
|
||
retryOne: (item: T) => Promise<unknown>,
|
||
concurrency = 5,
|
||
) {
|
||
const failedIds: string[] = [];
|
||
let succeeded = 0;
|
||
const batchSize = Math.max(1, Math.floor(concurrency));
|
||
for (let index = 0; index < items.length; index += batchSize) {
|
||
const batch = items.slice(index, index + batchSize);
|
||
const results = await Promise.allSettled(batch.map(retryOne));
|
||
results.forEach((result, resultIndex) => {
|
||
if (result.status === 'fulfilled') succeeded += 1;
|
||
else failedIds.push(batch[resultIndex].id);
|
||
});
|
||
}
|
||
return { failedIds, succeeded };
|
||
}
|
||
|
||
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 retryActionLabel(item: BillingSettlement) {
|
||
if (item.status !== 'manual_review') return '重试';
|
||
if (item.manualReviewReason === 'upstream_submission_unknown' && item.action === 'release') return '确认释放';
|
||
if (item.manualReviewReason === 'historical_success_without_charge' && item.action === 'settle') return '确认结算';
|
||
return '重新入队';
|
||
}
|
||
|
||
function retryActionDescription(item: BillingSettlement) {
|
||
if (item.manualReviewReason === 'upstream_submission_unknown' && item.action === 'release') {
|
||
return '上游提交结果不明。确认后只会释放当前冻结,不会再次调用上游;请先完成外部核查。';
|
||
}
|
||
if (item.manualReviewReason === 'historical_success_without_charge' && item.action === 'settle') {
|
||
return `确认后将按历史快照结算 ${formatAmount(item.amount)} ${item.currency},不会重新调用上游。`;
|
||
}
|
||
return '确认后会将该记录重新放入结算队列,不会重新调用上游。';
|
||
}
|
||
|
||
function shortId(value: string) {
|
||
return value.length > 12 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
|
||
}
|