Files
easyai-ai-gateway/apps/web/src/pages/admin/BillingSettlementsPanel.tsx
T
wangbo de10875439 feat(admin): 优化后台运维与任务管理
完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。

新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。

验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
2026-07-25 01:32:49 +08:00

316 lines
13 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 { 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;
}