feat(admin): 优化后台运维与任务管理

完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。

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

验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
This commit is contained in:
2026-07-25 01:32:49 +08:00
parent b29d539d49
commit de10875439
38 changed files with 5015 additions and 425 deletions
@@ -1,15 +1,32 @@
import { useCallback, useEffect, useState } from 'react';
import { AlertTriangle, ReceiptText, RefreshCw, RotateCcw } from 'lucide-react';
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, CardHeader, CardTitle, ConfirmDialog, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
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) => {
@@ -32,13 +49,44 @@ export function BillingSettlementsPanel(props: { token: string }) {
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);
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
await load();
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
} catch (error) {
setMessage(error instanceof Error ? error.message : '结算重试失败');
} finally {
@@ -46,22 +94,62 @@ export function BillingSettlementsPanel(props: { token: string }) {
}
}
const exceptions = items.filter((item) => item.status === 'retryable_failed' || item.status === 'manual_review').length;
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 (
<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">
<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>
@@ -70,43 +158,76 @@ export function BillingSettlementsPanel(props: { token: string }) {
<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 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>
{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={() => setConfirming(item)}>
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
</Button>
) : '-'}
</TableCell>
</TableRow>
))}
</Table>
) : (
<Card><CardContent className="emptyState"><AlertTriangle size={18} /><strong>{loading ? '正在加载结算队列' : '暂无结算记录'}</strong><span></span></CardContent></Card>
)}
<ConfirmDialog
open={Boolean(confirming)}
loading={Boolean(confirming && retrying === confirming.id)}
@@ -120,10 +241,42 @@ export function BillingSettlementsPanel(props: { token: string }) {
void retry(item).finally(() => setConfirming(null));
}}
/>
</div>
<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;
}