1429 lines
59 KiB
TypeScript
1429 lines
59 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||
import { Popover as AntPopover } from 'antd';
|
||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react';
|
||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||
import type { ConsoleData } from '../app-state';
|
||
import { EntityTable } from '../components/EntityTable';
|
||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
|
||
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
||
import { listTaskParamPreprocessing } from '../api';
|
||
|
||
const tabs = [
|
||
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
||
{ value: 'billing', label: '余额充值', icon: <CreditCard size={15} /> },
|
||
{ value: 'apiKeys', label: 'API Key', icon: <KeyRound size={15} /> },
|
||
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
||
{ value: 'transactions', label: '消费记录', icon: <ReceiptText size={15} /> },
|
||
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
|
||
|
||
const taskPageSizeOptions = [10, 20, 50];
|
||
|
||
export function WorkspacePage(props: {
|
||
apiKeyForm: ApiKeyForm;
|
||
apiKeySecret: string;
|
||
apiKeySecretsById: Record<string, string>;
|
||
apiKeyPolicyModels: PlatformModel[];
|
||
data: ConsoleData;
|
||
message: string;
|
||
section: WorkspaceSection;
|
||
state: LoadState;
|
||
token: string;
|
||
taskQuery: WorkspaceTaskQuery;
|
||
taskTotal: number;
|
||
transactionQuery: WorkspaceTransactionQuery;
|
||
transactionTotal: number;
|
||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||
onSectionChange: (value: WorkspaceSection) => void;
|
||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
||
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
|
||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="pageStack">
|
||
<div className="subPageLayout">
|
||
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
|
||
<div className="subPageContent">
|
||
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
|
||
{props.section === 'billing' && <BillingPanel walletAccounts={props.data.walletAccounts} />}
|
||
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
|
||
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} token={props.token} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
|
||
{props.section === 'transactions' && <ConsumptionPanel data={props.data} query={props.transactionQuery} total={props.transactionTotal} onQueryChange={props.onTransactionQueryChange} />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorkspaceOverview(props: { data: ConsoleData }) {
|
||
const owner = props.data.users[0];
|
||
return (
|
||
<section className="contentGrid two">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>个人中心总览</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="profileGrid">
|
||
<InfoItem label="账号" value={owner?.username ?? '-'} />
|
||
<InfoItem label="租户" value={owner?.tenantKey ?? 'default'} />
|
||
<InfoItem label="身份源" value={owner?.source ?? 'gateway'} />
|
||
<InfoItem label="API Key" value={String(props.data.apiKeys.length)} />
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>用户组策略</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<EntityTable
|
||
columns={['用户组', '优先级', '状态', '来源']}
|
||
empty="暂无用户组"
|
||
rows={props.data.userGroups.map((item) => [item.groupKey, item.priority, item.status, item.source])}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function BillingPanel(props: { walletAccounts: GatewayWalletAccount[] }) {
|
||
const primaryWallet = primaryWalletAccount(props.walletAccounts);
|
||
const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0;
|
||
return (
|
||
<section className="contentGrid two">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>余额</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="balanceCard">
|
||
<span>{primaryWallet?.currency ?? 'resource'}</span>
|
||
<strong>{formatMoney(primaryWallet?.balance ?? 0)}</strong>
|
||
<Badge variant={primaryWallet?.status === 'active' || !primaryWallet ? 'success' : 'secondary'}>{primaryWallet?.status ?? 'active'}</Badge>
|
||
<div className="walletMetricGrid">
|
||
<InfoItem label="可用余额" value={formatMoney(availableBalance)} />
|
||
<InfoItem label="冻结余额" value={formatMoney(primaryWallet?.frozenBalance ?? 0)} />
|
||
<InfoItem label="累计充值" value={formatMoney(primaryWallet?.totalRecharged ?? 0)} />
|
||
<InfoItem label="累计消费" value={formatMoney(primaryWallet?.totalSpent ?? 0)} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>充值</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="formGrid">
|
||
<Label>
|
||
金额
|
||
<Input value="100" readOnly />
|
||
</Label>
|
||
<Button type="button" variant="secondary">创建充值订单</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ConsumptionPanel(props: {
|
||
data: ConsoleData;
|
||
query: WorkspaceTransactionQuery;
|
||
total: number;
|
||
onQueryChange: (value: WorkspaceTransactionQuery) => void;
|
||
}) {
|
||
const transactions = props.data.walletTransactions;
|
||
const transactionQuery = props.query;
|
||
const [pageJump, setPageJump] = useState(String(transactionQuery.page));
|
||
const pageSizeOptions = useMemo(() => {
|
||
return Array.from(new Set([...taskPageSizeOptions, transactionQuery.pageSize])).sort((a, b) => a - b);
|
||
}, [transactionQuery.pageSize]);
|
||
const totalPages = Math.max(1, Math.ceil(props.total / transactionQuery.pageSize));
|
||
const currentPage = Math.min(transactionQuery.page, totalPages);
|
||
const pageStart = props.total ? Math.min((currentPage - 1) * transactionQuery.pageSize + 1, props.total) : 0;
|
||
const pageEnd = Math.min(currentPage * transactionQuery.pageSize, props.total);
|
||
const hasActiveFilters = Boolean(transactionQuery.query || transactionQuery.createdFrom || transactionQuery.createdTo);
|
||
|
||
useEffect(() => {
|
||
if (transactionQuery.page > totalPages) {
|
||
props.onQueryChange({ ...transactionQuery, page: totalPages });
|
||
}
|
||
}, [props.onQueryChange, transactionQuery, totalPages]);
|
||
|
||
useEffect(() => {
|
||
setPageJump(String(currentPage));
|
||
}, [currentPage]);
|
||
|
||
function updateQuery(value: string) {
|
||
props.onQueryChange({ ...transactionQuery, query: value, page: 1 });
|
||
}
|
||
|
||
function updateCreatedRange(value: { from: string; to: string }) {
|
||
props.onQueryChange({ ...transactionQuery, createdFrom: value.from, createdTo: value.to, page: 1 });
|
||
}
|
||
|
||
function updatePageSize(value: string) {
|
||
const nextPageSize = Number(value);
|
||
props.onQueryChange({ ...transactionQuery, page: 1, pageSize: Number.isFinite(nextPageSize) ? nextPageSize : 10 });
|
||
}
|
||
|
||
function updatePage(page: number) {
|
||
props.onQueryChange({ ...transactionQuery, page });
|
||
}
|
||
|
||
function submitPageJump() {
|
||
const parsed = Number(pageJump);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
setPageJump(String(currentPage));
|
||
return;
|
||
}
|
||
updatePage(Math.min(totalPages, Math.max(1, Math.floor(parsed))));
|
||
}
|
||
|
||
function resetFilters() {
|
||
props.onQueryChange({
|
||
query: '',
|
||
createdFrom: '',
|
||
createdTo: '',
|
||
page: 1,
|
||
pageSize: transactionQuery.pageSize,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Card className="shTableViewportCard walletTransactionViewport">
|
||
<CardContent className="shTableViewportPanel">
|
||
<TableViewportLayout>
|
||
<TableToolbar className="walletTransactionFilters">
|
||
<Label className="taskRecordSearchLabel">
|
||
搜索
|
||
<span className="taskRecordSearchBox">
|
||
<Search size={15} />
|
||
<Input
|
||
value={transactionQuery.query}
|
||
placeholder="搜索任务 / 模型 / 平台 / 类型 / API Key / RequestID"
|
||
onChange={(event) => updateQuery(event.target.value)}
|
||
/>
|
||
</span>
|
||
</Label>
|
||
<Label className="taskRecordRangeLabel">
|
||
消费时间
|
||
<DateTimeRangePicker
|
||
from={transactionQuery.createdFrom}
|
||
fromPlaceholder="开始日期"
|
||
to={transactionQuery.createdTo}
|
||
toPlaceholder="结束日期"
|
||
onChange={updateCreatedRange}
|
||
/>
|
||
</Label>
|
||
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
|
||
<RotateCcw size={14} />
|
||
重置
|
||
</Button>
|
||
</TableToolbar>
|
||
|
||
{transactions.length ? (
|
||
<Table className="shTableViewport walletTransactionTable" density="compact">
|
||
<TableRow className="shTableHeader">
|
||
<TableHead>消费时间</TableHead>
|
||
<TableHead>模型</TableHead>
|
||
<TableHead>平台</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>API Key</TableHead>
|
||
<TableHead>Token 消耗</TableHead>
|
||
<TableHead>扣费</TableHead>
|
||
<TableHead>余额变化</TableHead>
|
||
<TableHead>任务</TableHead>
|
||
</TableRow>
|
||
{transactions.map((transaction) => (
|
||
<WalletTransactionRecord key={transaction.id} transaction={transaction} />
|
||
))}
|
||
</Table>
|
||
) : (
|
||
<div className="emptyState">
|
||
<strong>暂无消费记录</strong>
|
||
<span>完成可计费任务后会生成消费流水。</span>
|
||
</div>
|
||
)}
|
||
<TableFooter>
|
||
<div className="shTableFooterGroup">
|
||
<Label>
|
||
每页
|
||
<Select size="sm" value={String(transactionQuery.pageSize)} onChange={(event) => updatePageSize(event.target.value)}>
|
||
{pageSizeOptions.map((option) => (
|
||
<option key={option} value={option}>{option} 条</option>
|
||
))}
|
||
</Select>
|
||
</Label>
|
||
<span>共 {props.total} 条 · {pageStart}-{pageEnd}</span>
|
||
</div>
|
||
<form
|
||
className="shTablePageJump"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
submitPageJump();
|
||
}}
|
||
>
|
||
<span>第 {currentPage} / {totalPages} 页</span>
|
||
<span>跳至</span>
|
||
<Input
|
||
aria-label="跳转页码"
|
||
inputMode="numeric"
|
||
min={1}
|
||
max={totalPages}
|
||
size="xs"
|
||
type="number"
|
||
value={pageJump}
|
||
onChange={(event) => setPageJump(event.target.value)}
|
||
/>
|
||
<span>页</span>
|
||
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}>跳转</Button>
|
||
</form>
|
||
<TablePageActions>
|
||
<Button type="button" variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => updatePage(Math.max(1, currentPage - 1))}>
|
||
<ChevronLeft size={14} />
|
||
上一页
|
||
</Button>
|
||
<Button type="button" variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => updatePage(Math.min(totalPages, currentPage + 1))}>
|
||
下一页
|
||
<ChevronRight size={14} />
|
||
</Button>
|
||
</TablePageActions>
|
||
</TableFooter>
|
||
</TableViewportLayout>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function WalletTransactionRecord(props: { transaction: GatewayWalletTransaction }) {
|
||
const transaction = props.transaction;
|
||
const metadata = transaction.metadata;
|
||
const referenceId = transaction.referenceId || metadataString(transaction.metadata, 'taskId');
|
||
const requestId = metadataString(metadata, 'requestId');
|
||
const model = metadataString(metadata, 'resolvedModel') || metadataString(metadata, 'platformModelAlias') || metadataString(metadata, 'providerModel') || metadataString(metadata, 'model');
|
||
const requestedModel = metadataString(metadata, 'requestedModel');
|
||
const platform = metadataString(metadata, 'platformName') || metadataString(metadata, 'platformKey') || metadataString(metadata, 'provider') || metadataString(metadata, 'clientId');
|
||
const provider = metadataString(metadata, 'provider');
|
||
const taskType = metadataString(metadata, 'modelType') || metadataString(metadata, 'kind');
|
||
const apiKey = metadataString(metadata, 'apiKeyName') || metadataString(metadata, 'apiKeyPrefix') || metadataString(metadata, 'apiKeyId');
|
||
const chargeAmount = transactionChargeAmount(transaction);
|
||
const chargeCurrency = transactionChargeCurrency(transaction);
|
||
return (
|
||
<TableRow>
|
||
<TableCell>{formatDateTime(transaction.createdAt)}</TableCell>
|
||
<TableCell className="walletTransactionModelCell">
|
||
<span className="walletTransactionPrimaryCell">
|
||
<strong>{model || '-'}</strong>
|
||
{requestedModel && requestedModel !== model && <small>{requestedModel}</small>}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="walletTransactionPlatformCell">
|
||
<span className="walletTransactionPrimaryCell">
|
||
<strong>{platform || '-'}</strong>
|
||
{provider && provider !== platform && <small>{provider}</small>}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="walletTransactionPrimaryCell">
|
||
<strong>{taskType || '-'}</strong>
|
||
<small>{transactionTypeLabel(transaction.transactionType)}</small>
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>{apiKey || '-'}</TableCell>
|
||
<TableCell className="walletTransactionTokenCell">{formatTokenUsage(metadataObject(metadata, 'usage'))}</TableCell>
|
||
<TableCell>
|
||
<span className="walletTransactionAmount" data-direction={transaction.direction}>
|
||
{transaction.direction === 'debit' ? '-' : '+'}{formatMoney(chargeAmount)} {chargeCurrency}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="walletBalanceChange">
|
||
<span>{formatMoney(transaction.balanceBefore)}</span>
|
||
<span>{formatMoney(transaction.balanceAfter)}</span>
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="walletTransactionRef">
|
||
{referenceId ? <code title={referenceId}>{referenceId}</code> : '-'}
|
||
{requestId && <small title={requestId}>RequestID {requestId}</small>}
|
||
</span>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|
||
|
||
function ApiKeyPanel(props: {
|
||
apiKeyForm: ApiKeyForm;
|
||
apiKeySecret: string;
|
||
apiKeySecretsById: Record<string, string>;
|
||
apiKeyPolicyModels: PlatformModel[];
|
||
data: ConsoleData;
|
||
message: string;
|
||
state: LoadState;
|
||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||
}) {
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [policyApiKeyId, setPolicyApiKeyId] = useState('');
|
||
const [pendingDelete, setPendingDelete] = useState<GatewayApiKey | null>(null);
|
||
const [localMessage, setLocalMessage] = useState('');
|
||
const selectedPolicyKey = useMemo(
|
||
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
||
[policyApiKeyId, props.data.apiKeys],
|
||
);
|
||
const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]);
|
||
|
||
async function copyApiKey(item: GatewayApiKey) {
|
||
const secret = apiKeySecretFor(item, props.apiKeySecretsById);
|
||
if (!secret) return;
|
||
await navigator.clipboard.writeText(secret);
|
||
setLocalMessage(`已复制 ${item.name || item.keyPrefix}`);
|
||
}
|
||
|
||
async function submitCreate(event: FormEvent<HTMLFormElement>) {
|
||
try {
|
||
await props.onSubmitApiKey(event);
|
||
setCreateOpen(false);
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
|
||
async function confirmDeleteApiKey() {
|
||
if (!pendingDelete) return;
|
||
await props.onDeleteApiKey(pendingDelete.id);
|
||
setPendingDelete(null);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Card>
|
||
<CardHeader>
|
||
<div>
|
||
<CardTitle>API Key</CardTitle>
|
||
<p className="mutedText">按 Key 维护调用凭证、最近使用时间和平台/模型权限策略。</p>
|
||
</div>
|
||
<Button type="button" size="sm" onClick={() => setCreateOpen(true)}>
|
||
<Plus size={15} />
|
||
创建 API Key
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{(localMessage || props.message) && <p className="formMessage">{localMessage || props.message}</p>}
|
||
<Table className="apiKeyTable">
|
||
<TableRow className="shTableHeader">
|
||
<TableHead>名称</TableHead>
|
||
<TableHead>API Key</TableHead>
|
||
<TableHead>权限策略</TableHead>
|
||
<TableHead>最近使用</TableHead>
|
||
<TableHead>有效期</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead>操作</TableHead>
|
||
</TableRow>
|
||
{props.data.apiKeys.length ? props.data.apiKeys.map((item) => {
|
||
const secret = apiKeySecretFor(item, props.apiKeySecretsById);
|
||
const summary = countAccessPermissionRules(props.data.accessRules, 'api_key', item.id);
|
||
return (
|
||
<TableRow key={item.id}>
|
||
<TableCell>
|
||
<span className="apiKeyNameCell">
|
||
<strong>{item.name}</strong>
|
||
<small>{item.keyPrefix}</small>
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="apiKeySecretCell">
|
||
<code>{secret ? maskApiKey(secret) : item.keyPrefix}</code>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
title={secret ? '复制 API Key' : '暂无可复制的完整 Key'}
|
||
disabled={!secret}
|
||
onClick={() => void copyApiKey(item)}
|
||
>
|
||
<Copy size={14} />
|
||
</Button>
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
|
||
<ShieldCheck size={14} />
|
||
<span>{permissionSummaryText(summary)}</span>
|
||
</button>
|
||
</TableCell>
|
||
<TableCell>{formatDateTime(item.lastUsedAt)}</TableCell>
|
||
<TableCell>{formatDateTime(item.expiresAt)}</TableCell>
|
||
<TableCell><Badge variant={item.status === 'active' ? 'success' : 'secondary'}>{item.status}</Badge></TableCell>
|
||
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
||
<TableCell>
|
||
<Button type="button" variant="ghost" size="icon" title="删除 API Key" onClick={() => setPendingDelete(item)}>
|
||
<Trash2 size={14} />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}) : (
|
||
<div className="emptyState">
|
||
<strong>暂无 API Key</strong>
|
||
<span>点击右上角创建调用凭证。</span>
|
||
</div>
|
||
)}
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<FormDialog
|
||
ariaLabel="创建 API Key"
|
||
bodyClassName="apiKeyCreateDialogBody"
|
||
footer={(
|
||
<>
|
||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => setCreateOpen(false)}>取消</Button>
|
||
<Button type="submit" size="sm" disabled={props.state === 'loading'}>
|
||
<KeyRound size={15} />
|
||
确认创建
|
||
</Button>
|
||
</>
|
||
)}
|
||
open={createOpen}
|
||
title="创建 API Key"
|
||
onClose={() => setCreateOpen(false)}
|
||
onSubmit={(event) => void submitCreate(event)}
|
||
>
|
||
<Label>
|
||
名称
|
||
<Input value={props.apiKeyForm.name} placeholder="例如:生产调用 Key" onChange={(event) => props.onApiKeyFormChange({ ...props.apiKeyForm, name: event.target.value })} />
|
||
</Label>
|
||
<Label>
|
||
有效期
|
||
<DateTimePicker
|
||
value={props.apiKeyForm.expiresAt}
|
||
placeholder="不设置则长期有效"
|
||
onChange={(expiresAt) => props.onApiKeyFormChange({ ...props.apiKeyForm, expiresAt })}
|
||
/>
|
||
</Label>
|
||
</FormDialog>
|
||
|
||
<FormDialog
|
||
ariaLabel="维护 API Key 权限策略"
|
||
bodyClassName="apiKeyPolicyDialogBody"
|
||
className="apiKeyPolicyDialog"
|
||
footer={<Button type="button" size="sm" onClick={() => setPolicyApiKeyId('')}>关闭</Button>}
|
||
open={Boolean(selectedPolicyKey)}
|
||
title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'}
|
||
onClose={() => setPolicyApiKeyId('')}
|
||
onSubmit={(event) => event.preventDefault()}
|
||
>
|
||
<AccessPermissionEditor
|
||
accessRules={props.data.accessRules}
|
||
metadataMode="api_key_permission_tree"
|
||
platformModels={props.apiKeyPolicyModels}
|
||
platforms={permissionPlatforms}
|
||
state={props.state}
|
||
subjectId={selectedPolicyKey?.id ?? ''}
|
||
subjectType="api_key"
|
||
onBatchAccessRules={props.onBatchAccessRules}
|
||
/>
|
||
</FormDialog>
|
||
|
||
<ConfirmDialog
|
||
confirmLabel="删除 API Key"
|
||
description="删除后该 Key 将无法继续调用,关联的平台/模型权限策略会同步删除。"
|
||
loading={props.state === 'loading'}
|
||
open={Boolean(pendingDelete)}
|
||
title={`确认删除 ${pendingDelete?.name ?? ''}?`}
|
||
onCancel={() => setPendingDelete(null)}
|
||
onConfirm={confirmDeleteApiKey}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function TaskPanel(props: {
|
||
data: ConsoleData;
|
||
query: WorkspaceTaskQuery;
|
||
token: string;
|
||
total: number;
|
||
onQueryChange: (value: WorkspaceTaskQuery) => void;
|
||
}) {
|
||
const [localMessage, setLocalMessage] = useState('');
|
||
const [jsonTask, setJsonTask] = useState<GatewayTask | null>(null);
|
||
const [pageJump, setPageJump] = useState(String(props.query.page));
|
||
const taskQuery = props.query;
|
||
const tasks = props.data.tasks;
|
||
const taskTypes = useMemo(() => {
|
||
const knownTypes = ['text_generate', 'image_generate', 'image_edit', 'video_generate', 'image_to_video'];
|
||
const values = [...knownTypes, taskQuery.modelType, ...tasks.map((task) => task.modelType)];
|
||
return Array.from(new Set(values.filter((value): value is string => Boolean(value)))).sort((a, b) => a.localeCompare(b));
|
||
}, [taskQuery.modelType, tasks]);
|
||
const pageSizeOptions = useMemo(() => {
|
||
return Array.from(new Set([...taskPageSizeOptions, taskQuery.pageSize])).sort((a, b) => a - b);
|
||
}, [taskQuery.pageSize]);
|
||
const totalPages = Math.max(1, Math.ceil(props.total / taskQuery.pageSize));
|
||
const currentPage = Math.min(taskQuery.page, totalPages);
|
||
const pageStart = props.total ? Math.min((currentPage - 1) * taskQuery.pageSize + 1, props.total) : 0;
|
||
const pageEnd = Math.min(currentPage * taskQuery.pageSize, props.total);
|
||
const hasActiveFilters = Boolean(taskQuery.query || taskQuery.createdFrom || taskQuery.createdTo || taskQuery.modelType);
|
||
|
||
useEffect(() => {
|
||
if (taskQuery.page > totalPages) {
|
||
props.onQueryChange({ ...taskQuery, page: totalPages });
|
||
}
|
||
}, [props.onQueryChange, taskQuery, totalPages]);
|
||
|
||
useEffect(() => {
|
||
setPageJump(String(currentPage));
|
||
}, [currentPage]);
|
||
|
||
async function copyTaskRequestId(task: GatewayTask) {
|
||
if (!task.requestId) return;
|
||
await navigator.clipboard.writeText(task.requestId);
|
||
setLocalMessage(`已复制 RequestID:${task.requestId}`);
|
||
}
|
||
|
||
function resetFilters() {
|
||
props.onQueryChange({
|
||
query: '',
|
||
modelType: '',
|
||
createdFrom: '',
|
||
createdTo: '',
|
||
page: 1,
|
||
pageSize: taskQuery.pageSize,
|
||
});
|
||
}
|
||
|
||
function updateQuery(value: string) {
|
||
props.onQueryChange({ ...taskQuery, query: value, page: 1 });
|
||
}
|
||
|
||
function updateTypeFilter(value: string) {
|
||
props.onQueryChange({ ...taskQuery, modelType: value === 'all' ? '' : value, page: 1 });
|
||
}
|
||
|
||
function updateCreatedRange(value: { from: string; to: string }) {
|
||
props.onQueryChange({ ...taskQuery, createdFrom: value.from, createdTo: value.to, page: 1 });
|
||
}
|
||
|
||
function updatePageSize(value: string) {
|
||
const nextPageSize = Number(value);
|
||
props.onQueryChange({ ...taskQuery, page: 1, pageSize: Number.isFinite(nextPageSize) ? nextPageSize : 10 });
|
||
}
|
||
|
||
function updatePage(page: number) {
|
||
props.onQueryChange({ ...taskQuery, page });
|
||
}
|
||
|
||
function submitPageJump() {
|
||
const parsed = Number(pageJump);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
setPageJump(String(currentPage));
|
||
return;
|
||
}
|
||
updatePage(Math.min(totalPages, Math.max(1, Math.floor(parsed))));
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Card className="shTableViewportCard taskRecordViewport">
|
||
<CardContent className="shTableViewportPanel">
|
||
{localMessage && <p className="formMessage">{localMessage}</p>}
|
||
<TableViewportLayout>
|
||
<TableToolbar className="taskRecordFilters">
|
||
<Label className="taskRecordSearchLabel">
|
||
搜索
|
||
<span className="taskRecordSearchBox">
|
||
<Search size={15} />
|
||
<Input
|
||
value={taskQuery.query}
|
||
placeholder="搜索 ID / RequestID / 模型 / API Key"
|
||
onChange={(event) => updateQuery(event.target.value)}
|
||
/>
|
||
</span>
|
||
</Label>
|
||
<Label>
|
||
类型
|
||
<Select value={taskQuery.modelType || 'all'} onChange={(event) => updateTypeFilter(event.target.value)}>
|
||
<option value="all">全部类型</option>
|
||
{taskTypes.map((type) => (
|
||
<option key={type} value={type}>{type}</option>
|
||
))}
|
||
</Select>
|
||
</Label>
|
||
<Label className="taskRecordRangeLabel">
|
||
创建时间
|
||
<DateTimeRangePicker
|
||
from={taskQuery.createdFrom}
|
||
fromPlaceholder="开始日期"
|
||
to={taskQuery.createdTo}
|
||
toPlaceholder="结束日期"
|
||
onChange={updateCreatedRange}
|
||
/>
|
||
</Label>
|
||
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
|
||
<RotateCcw size={14} />
|
||
重置
|
||
</Button>
|
||
</TableToolbar>
|
||
|
||
{tasks.length ? (
|
||
<Table className="shTableViewport taskRecordTable" density="compact">
|
||
<TableRow className="shTableHeader">
|
||
<TableHead>任务</TableHead>
|
||
<TableHead>RequestID</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>模型</TableHead>
|
||
<TableHead>尝试链路</TableHead>
|
||
<TableHead>参数转换</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>API Key</TableHead>
|
||
<TableHead>Token</TableHead>
|
||
<TableHead>扣费</TableHead>
|
||
<TableHead>耗时</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead>原始 JSON</TableHead>
|
||
</TableRow>
|
||
{tasks.map((task) => (
|
||
<TaskRecord key={task.id} task={task} token={props.token} onCopyRequestId={copyTaskRequestId} onOpenJson={setJsonTask} />
|
||
))}
|
||
</Table>
|
||
) : (
|
||
<div className="emptyState">
|
||
<strong>{hasActiveFilters ? '没有匹配的任务' : '暂无任务'}</strong>
|
||
{hasActiveFilters && <span>调整关键词、类型或创建时间后再试。</span>}
|
||
</div>
|
||
)}
|
||
|
||
<TableFooter>
|
||
<div className="shTableFooterGroup">
|
||
<Label>
|
||
每页
|
||
<Select size="sm" value={String(taskQuery.pageSize)} onChange={(event) => updatePageSize(event.target.value)}>
|
||
{pageSizeOptions.map((option) => (
|
||
<option key={option} value={option}>{option} 条</option>
|
||
))}
|
||
</Select>
|
||
</Label>
|
||
<span>共 {props.total} 条 · {pageStart}-{pageEnd}</span>
|
||
</div>
|
||
<form
|
||
className="shTablePageJump"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
submitPageJump();
|
||
}}
|
||
>
|
||
<span>第 {currentPage} / {totalPages} 页</span>
|
||
<span>跳至</span>
|
||
<Input
|
||
aria-label="跳转页码"
|
||
inputMode="numeric"
|
||
min={1}
|
||
max={totalPages}
|
||
size="xs"
|
||
type="number"
|
||
value={pageJump}
|
||
onChange={(event) => setPageJump(event.target.value)}
|
||
/>
|
||
<span>页</span>
|
||
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}>跳转</Button>
|
||
</form>
|
||
<TablePageActions>
|
||
<Button type="button" variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => updatePage(Math.max(1, currentPage - 1))}>
|
||
<ChevronLeft size={14} />
|
||
上一页
|
||
</Button>
|
||
<Button type="button" variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => updatePage(Math.min(totalPages, currentPage + 1))}>
|
||
下一页
|
||
<ChevronRight size={14} />
|
||
</Button>
|
||
</TablePageActions>
|
||
</TableFooter>
|
||
</TableViewportLayout>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<FormDialog
|
||
ariaLabel="查看任务原始 JSON"
|
||
bodyClassName="taskJsonDialogBody"
|
||
className="taskJsonDialog"
|
||
footer={<Button type="button" size="sm" onClick={() => setJsonTask(null)}>关闭</Button>}
|
||
open={Boolean(jsonTask)}
|
||
title="任务原始 JSON"
|
||
onClose={() => setJsonTask(null)}
|
||
onSubmit={(event) => event.preventDefault()}
|
||
>
|
||
<pre className="taskJsonPreview">{JSON.stringify(jsonTask, null, 2)}</pre>
|
||
</FormDialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId: (task: GatewayTask) => Promise<void>; onOpenJson: (task: GatewayTask) => void }) {
|
||
const usage = props.task.usage ?? {};
|
||
const tokenUsage = formatTokenUsage(usage);
|
||
const chargeText = props.task.finalChargeAmount !== undefined ? formatCellValue(props.task.finalChargeAmount) : '-';
|
||
const resolvedModel = props.task.resolvedModel || props.task.model;
|
||
const badgeVariant = props.task.status === 'succeeded' ? 'success' : props.task.status === 'failed' ? 'destructive' : 'secondary';
|
||
return (
|
||
<TableRow>
|
||
<TableCell>
|
||
<span className="taskRecordPrimaryCell taskRecordIdentityCell">
|
||
<strong>{props.task.kind}</strong>
|
||
<span className="taskRecordIdLine">
|
||
<span>ID</span>
|
||
<code title={props.task.id}>{props.task.id}</code>
|
||
</span>
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="taskRecordRequestLine">
|
||
<code title={props.task.requestId || '-'}>{props.task.requestId || '-'}</code>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
title={props.task.requestId ? '复制 RequestID' : '暂无 RequestID'}
|
||
disabled={!props.task.requestId}
|
||
onClick={() => void props.onCopyRequestId(props.task)}
|
||
>
|
||
<Copy size={14} />
|
||
</Button>
|
||
</span>
|
||
</TableCell>
|
||
<TableCell><Badge variant={badgeVariant}>{props.task.status}</Badge></TableCell>
|
||
<TableCell className="taskRecordModelCell">
|
||
<span className="taskRecordPrimaryCell">
|
||
<strong>{resolvedModel}</strong>
|
||
{props.task.requestedModel && props.task.requestedModel !== resolvedModel && <small>{props.task.requestedModel}</small>}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="taskRecordAttemptCell">
|
||
<TaskAttemptChain task={props.task} />
|
||
</TableCell>
|
||
<TableCell className="taskRecordParamCell">
|
||
<TaskParamConversionCell task={props.task} token={props.token} />
|
||
</TableCell>
|
||
<TableCell>{props.task.modelType || '-'}</TableCell>
|
||
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
|
||
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
|
||
<TableCell>{chargeText}</TableCell>
|
||
<TableCell>{formatDuration(props.task.responseDurationMs)}</TableCell>
|
||
<TableCell>{formatDateTime(props.task.createdAt)}</TableCell>
|
||
<TableCell>
|
||
<Button type="button" variant="ghost" size="sm" className="taskRecordJsonButton" title={taskErrorText(props.task) || '查看原始 JSON'} onClick={() => props.onOpenJson(props.task)}>
|
||
<Eye size={14} />
|
||
原始 JSON
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|
||
|
||
type TaskParamConversionSummary = {
|
||
changed: boolean;
|
||
changeCount: number;
|
||
actions: string[];
|
||
paths: string[];
|
||
capabilityPaths: string[];
|
||
};
|
||
|
||
function TaskParamConversionCell(props: { task: GatewayTask; token: string }) {
|
||
const summary = taskParamConversionSummary(props.task);
|
||
const [open, setOpen] = useState(false);
|
||
const [logs, setLogs] = useState<GatewayTaskParamPreprocessingLog[] | null>(null);
|
||
const [loadState, setLoadState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||
const [error, setError] = useState('');
|
||
const logsRef = useRef<GatewayTaskParamPreprocessingLog[] | null>(null);
|
||
const loadingRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
logsRef.current = null;
|
||
loadingRef.current = false;
|
||
setLogs(null);
|
||
setLoadState('idle');
|
||
setError('');
|
||
}, [props.task.id]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !summary.changed || logsRef.current || loadingRef.current || !props.token) return;
|
||
let cancelled = false;
|
||
loadingRef.current = true;
|
||
setLoadState('loading');
|
||
setError('');
|
||
listTaskParamPreprocessing(props.token, props.task.id)
|
||
.then((response) => {
|
||
if (cancelled) return;
|
||
const items = response.items ?? [];
|
||
logsRef.current = items;
|
||
setLogs(items);
|
||
setLoadState('ready');
|
||
})
|
||
.catch((err) => {
|
||
if (cancelled) return;
|
||
setError(err instanceof Error ? err.message : '参数转换明细加载失败');
|
||
setLoadState('error');
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) {
|
||
loadingRef.current = false;
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
loadingRef.current = false;
|
||
};
|
||
}, [open, props.task.id, props.token, summary.changed]);
|
||
|
||
if (!summary.changed) {
|
||
return <span className="taskParamConversionEmpty">无转换</span>;
|
||
}
|
||
|
||
return (
|
||
<AntPopover
|
||
align={{ offset: [0, 8] }}
|
||
content={<TaskParamConversionPopover error={error} loadState={loadState} logs={logs} summary={summary} />}
|
||
overlayClassName="taskParamConversionAntPopover"
|
||
placement="bottomLeft"
|
||
trigger={['hover', 'focus']}
|
||
onOpenChange={setOpen}
|
||
>
|
||
<button className="taskParamConversionTrigger" type="button" aria-label={`参数转换 ${summary.changeCount} 项`}>
|
||
<SlidersHorizontal size={14} />
|
||
<span>{summary.changeCount || '有'} 项</span>
|
||
</button>
|
||
</AntPopover>
|
||
);
|
||
}
|
||
|
||
function TaskParamConversionPopover(props: {
|
||
error: string;
|
||
loadState: 'idle' | 'loading' | 'ready' | 'error';
|
||
logs: GatewayTaskParamPreprocessingLog[] | null;
|
||
summary: TaskParamConversionSummary;
|
||
}) {
|
||
const logs = props.logs ?? [];
|
||
return (
|
||
<span className="taskParamConversionPopover" role="tooltip">
|
||
<span className="taskParamConversionPopoverHeader">
|
||
<strong>参数转换汇总</strong>
|
||
<small>{taskParamSummaryText(props.summary)}</small>
|
||
</span>
|
||
{props.loadState === 'loading' && <span className="taskParamConversionState">正在加载转换明细...</span>}
|
||
{props.loadState === 'error' && <span className="taskParamConversionState error">{props.error || '参数转换明细加载失败'}</span>}
|
||
{props.loadState === 'ready' && logs.length === 0 && <span className="taskParamConversionState">暂无转换明细。</span>}
|
||
{logs.map((log) => (
|
||
<span key={log.id} className="taskParamConversionLog">
|
||
<span className="taskParamConversionLogHeader">
|
||
<span className="taskParamConversionLogTitleBlock">
|
||
<strong>{taskParamLogTitle(log)}</strong>
|
||
<small title={log.clientId || undefined}>{taskParamLogSubtitle(log)}</small>
|
||
</span>
|
||
<Badge className="taskParamConversionCountBadge" variant={log.changed ? 'secondary' : 'outline'}>{log.changeCount} 项</Badge>
|
||
</span>
|
||
{(log.changes ?? []).slice(0, 8).map((change, index) => (
|
||
<span key={`${log.id}-change-${index}`} className="taskParamConversionChange">
|
||
<span className="taskParamConversionChangeTop">
|
||
<Badge variant="outline">{taskParamActionLabel(objectString(change, 'action'))}</Badge>
|
||
<strong>{objectString(change, 'path') || '-'}</strong>
|
||
</span>
|
||
<span>{objectString(change, 'reason') || '按模型能力配置调整参数。'}</span>
|
||
{objectString(change, 'capabilityPath') && <small>能力配置:{objectString(change, 'capabilityPath')}</small>}
|
||
<code>{taskParamChangePreview(change)}</code>
|
||
</span>
|
||
))}
|
||
{(log.changes?.length ?? 0) > 8 && <small>还有 {(log.changes?.length ?? 0) - 8} 项转换未展开。</small>}
|
||
</span>
|
||
))}
|
||
{props.loadState !== 'ready' && props.summary.capabilityPaths.length > 0 && (
|
||
<span className="taskParamConversionSummaryPaths">
|
||
{props.summary.capabilityPaths.slice(0, 4).map((path) => <code key={path}>{path}</code>)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function TaskAttemptChain(props: { task: GatewayTask }) {
|
||
const attempts = props.task.attempts ?? [];
|
||
if (!attempts.length) return <span>-</span>;
|
||
|
||
return (
|
||
<AntPopover
|
||
align={{ offset: [0, 8] }}
|
||
content={<TaskAttemptPopoverContent task={props.task} />}
|
||
overlayClassName="taskRecordAttemptAntPopover"
|
||
placement="bottomLeft"
|
||
trigger={['hover', 'focus']}
|
||
>
|
||
<button className="taskRecordAttemptCount" type="button" aria-label={attempts.map(taskAttemptTitle).join('\n')}>
|
||
{attempts.length} 次尝试
|
||
</button>
|
||
</AntPopover>
|
||
);
|
||
}
|
||
|
||
function TaskAttemptPopoverContent(props: { task: GatewayTask }) {
|
||
const attempts = props.task.attempts ?? [];
|
||
return (
|
||
<span className="taskRecordAttemptPopover" role="tooltip">
|
||
{attempts.map((attempt) => {
|
||
const trace = taskAttemptTrace(attempt);
|
||
const rateLimitText = taskAttemptRateLimitText(attempt);
|
||
return (
|
||
<span
|
||
key={attempt.id || `${props.task.id}-${attempt.attemptNo}`}
|
||
className={`taskRecordAttemptDetail ${attempt.status === 'failed' ? 'failed' : attempt.status === 'succeeded' ? 'succeeded' : ''}`}
|
||
>
|
||
<span className="taskRecordAttemptDetailHeader">
|
||
<strong>#{attempt.attemptNo} {taskAttemptTarget(attempt)}</strong>
|
||
<Badge variant={attempt.status === 'succeeded' ? 'success' : attempt.status === 'failed' ? 'destructive' : 'secondary'}>{taskAttemptStatusText(attempt.status)}</Badge>
|
||
</span>
|
||
<small>{taskAttemptMeta(attempt)}</small>
|
||
{attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>}
|
||
{(rateLimitText || trace.length > 0) && (
|
||
<span className="taskRecordAttemptTrace">
|
||
{rateLimitText && <span className="taskRecordAttemptTraceItem">{rateLimitText}</span>}
|
||
{trace.map((entry, index) => (
|
||
<span key={`${attempt.id || attempt.attemptNo}-trace-${index}`} className="taskRecordAttemptTraceItem">
|
||
{taskAttemptTraceText(entry)}
|
||
</span>
|
||
))}
|
||
</span>
|
||
)}
|
||
</span>
|
||
);
|
||
})}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function taskAttemptTitle(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
const parts = [
|
||
`#${attempt.attemptNo}`,
|
||
attempt.platformName || attempt.provider || attempt.clientId || '',
|
||
attempt.status,
|
||
attempt.errorMessage || attempt.errorCode || metadataString(attempt.metrics, 'error') || '',
|
||
].filter(Boolean);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function taskAttemptTarget(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
return attempt.platformName || attempt.provider || attempt.clientId || `尝试 ${attempt.attemptNo}`;
|
||
}
|
||
|
||
function taskAttemptStatusText(status: string) {
|
||
if (status === 'succeeded') return '成功';
|
||
if (status === 'failed') return '失败';
|
||
if (status === 'running') return '运行中';
|
||
return status || '-';
|
||
}
|
||
|
||
function taskAttemptMeta(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
const statusCode = taskAttemptStatusCode(attempt);
|
||
const values = [
|
||
attempt.providerModelName || attempt.modelName || attempt.modelAlias,
|
||
attempt.requestId ? `RequestID ${attempt.requestId}` : '',
|
||
statusCode ? `状态码 ${statusCode}` : '',
|
||
attempt.responseDurationMs ? formatDuration(attempt.responseDurationMs) : '',
|
||
].filter(Boolean);
|
||
return values.join(' · ') || attempt.clientId || '-';
|
||
}
|
||
|
||
function taskAttemptFailureReason(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
const detail = firstText(
|
||
attempt.errorMessage,
|
||
metadataString(attempt.metrics, 'error'),
|
||
metadataString(attempt.metrics, 'message'),
|
||
);
|
||
const code = firstText(attempt.errorCode, metadataString(attempt.metrics, 'errorCode'));
|
||
const statusCode = taskAttemptStatusCode(attempt);
|
||
const statusText = statusCode ? `状态码 ${statusCode}` : '';
|
||
const category = taskAttemptFailureCategory(attempt);
|
||
const categoryText = category ? `错误分类 ${category}` : '';
|
||
if (detail && code && detail !== code) return [detail, code, statusText, categoryText].filter(Boolean).join(' · ');
|
||
return [detail || code || '失败', statusText, categoryText].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
function taskAttemptStatusCode(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
const value = attempt.statusCode ?? metadataNumber(attempt.metrics, 'statusCode');
|
||
return value && Number.isFinite(value) ? Math.trunc(value) : null;
|
||
}
|
||
|
||
function taskAttemptTrace(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
const raw = attempt.metrics?.trace;
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item));
|
||
}
|
||
|
||
function taskAttemptRateLimitText(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
const detail = metadataObject(attempt.metrics, 'rateLimit');
|
||
if (!Object.keys(detail).length) return '';
|
||
const scopeName = objectString(detail, 'scopeName') || objectString(detail, 'scopeKey') || '限流对象';
|
||
const metric = objectString(detail, 'metric') || 'rate_limit';
|
||
const current = metadataNumber(detail, 'current');
|
||
const amount = metadataNumber(detail, 'amount');
|
||
const projected = metadataNumber(detail, 'projected');
|
||
const limit = metadataNumber(detail, 'limit');
|
||
const windowSeconds = metadataNumber(detail, 'windowSeconds');
|
||
const retryAfterMs = metadataNumber(detail, 'retryAfterMs');
|
||
const values = [
|
||
`限流 ${scopeName} · ${metric}`,
|
||
current !== null ? `当前 ${formatCellValue(current)}` : '',
|
||
amount !== null ? `本次 ${formatCellValue(amount)}` : '',
|
||
projected !== null ? `预计 ${formatCellValue(projected)}` : '',
|
||
limit !== null ? `限制 ${formatCellValue(limit)}` : '',
|
||
windowSeconds !== null ? `窗口 ${Math.trunc(windowSeconds)} 秒` : '',
|
||
retryAfterMs !== null ? `约 ${formatDuration(Math.trunc(retryAfterMs))} 后可重试` : '',
|
||
].filter(Boolean);
|
||
return values.join(' · ');
|
||
}
|
||
|
||
function taskAttemptTraceText(entry: Record<string, unknown>) {
|
||
const event = objectString(entry, 'event');
|
||
const action = objectString(entry, 'action');
|
||
const reason = objectString(entry, 'reason');
|
||
const statusCode = metadataNumber(entry, 'statusCode');
|
||
const errorCode = objectString(entry, 'errorCode');
|
||
const category = objectString(entry, 'category');
|
||
const policy = taskAttemptTracePolicy(entry);
|
||
const values = [
|
||
taskAttemptTraceEventLabel(event, action),
|
||
statusCode ? `状态码 ${Math.trunc(statusCode)}` : '',
|
||
errorCode ? `错误 ${errorCode}` : '',
|
||
category ? `错误分类 ${category}` : '',
|
||
policy,
|
||
reason ? `原因 ${taskAttemptTraceReasonLabel(reason)}` : '',
|
||
].filter(Boolean);
|
||
return values.join(' · ');
|
||
}
|
||
|
||
function taskAttemptFailureCategory(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||
const category = firstText(
|
||
metadataString(attempt.metrics, 'errorCategory'),
|
||
metadataString(attempt.metrics, 'category'),
|
||
);
|
||
if (category) return category;
|
||
for (const entry of taskAttemptTrace(attempt)) {
|
||
const traceCategory = objectString(entry, 'category');
|
||
if (traceCategory) return traceCategory;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function taskAttemptTracePolicy(entry: Record<string, unknown>) {
|
||
const source = objectString(entry, 'policySource');
|
||
const policy = objectString(entry, 'policy');
|
||
const rule = objectString(entry, 'policyRule');
|
||
const matchedValue = objectString(entry, 'matchedValue');
|
||
const sourceLabel = source || policy;
|
||
const policyPath = [sourceLabel, rule].filter(Boolean).join('.');
|
||
if (!policyPath) return '';
|
||
return matchedValue ? `策略 ${policyPath}=${matchedValue}` : `策略 ${policyPath}`;
|
||
}
|
||
|
||
function taskAttemptTraceEventLabel(event: string, action: string) {
|
||
if (event === 'failure') return '失败';
|
||
if (event === 'same_client_retry') return action === 'retry' ? '本平台重试' : '本平台停止重试';
|
||
if (event === 'failover_next') return '切换下个平台';
|
||
if (event === 'failover_stop') return '停止切换平台';
|
||
if (event === 'priority_demoted') return '优先级降级';
|
||
return event || action || '链路事件';
|
||
}
|
||
|
||
function taskAttemptTraceReasonLabel(reason: string) {
|
||
const labels: Record<string, string> = {
|
||
client_call_failed: '客户端调用失败',
|
||
retry_disabled: '模型重试关闭',
|
||
retry_deny_policy: '命中模型拒绝重试规则',
|
||
retry_allow_policy: '命中模型允许重试规则',
|
||
client_retryable: '客户端标记可重试',
|
||
client_non_retryable: '客户端标记不可重试',
|
||
same_client_max_attempts: '达到本平台最大尝试次数',
|
||
request_validation_failed: '请求校验失败',
|
||
candidate_selection_failed: '候选模型选择失败',
|
||
parameter_preprocessing_failed: '参数预处理失败',
|
||
wallet_balance_check_failed: '余额校验失败',
|
||
local_rate_limit_blocked: '本地限流拦截',
|
||
pre_provider_failed: '调用上游前失败',
|
||
local_rate_limit_wait_queue: '本地限流排队等待',
|
||
failover_time_budget_exceeded: '超过全局切换时间预算',
|
||
runner_policy_disabled: '全局调度策略停用',
|
||
hard_stop_policy: '命中硬拒绝规则',
|
||
failover_disabled: '平台切换关闭',
|
||
failover_deny_policy: '命中拒绝切换规则',
|
||
failover_allow_policy: '命中允许切换规则',
|
||
max_platforms_reached: '达到最大平台数',
|
||
no_next_platform: '没有更多候选平台',
|
||
priority_demote_policy: '命中优先级降级规则',
|
||
};
|
||
return labels[reason] ?? reason;
|
||
}
|
||
|
||
function taskParamConversionSummary(task: GatewayTask): TaskParamConversionSummary {
|
||
const summary: TaskParamConversionSummary = {
|
||
changed: false,
|
||
changeCount: 0,
|
||
actions: [],
|
||
paths: [],
|
||
capabilityPaths: [],
|
||
};
|
||
let mergedAttemptSummary = false;
|
||
for (const attempt of task.attempts ?? []) {
|
||
const attemptSummary = metadataObject(attempt.metrics, 'parameterPreprocessingSummary');
|
||
if (Object.keys(attemptSummary).length) {
|
||
mergedAttemptSummary = true;
|
||
mergeTaskParamSummary(summary, attemptSummary);
|
||
}
|
||
}
|
||
if (!mergedAttemptSummary) {
|
||
mergeTaskParamSummary(summary, metadataObject(task.metrics, 'parameterPreprocessingSummary'));
|
||
}
|
||
return summary;
|
||
}
|
||
|
||
function mergeTaskParamSummary(target: TaskParamConversionSummary, raw: Record<string, unknown>) {
|
||
if (!Object.keys(raw).length) return;
|
||
target.changed = target.changed || raw.changed === true;
|
||
const changeCount = metadataNumber(raw, 'changeCount');
|
||
if (changeCount) target.changeCount += Math.max(0, Math.trunc(changeCount));
|
||
for (const action of metadataStringList(raw, 'actions')) appendUniqueText(target.actions, action);
|
||
for (const path of metadataStringList(raw, 'paths')) appendUniqueText(target.paths, path);
|
||
for (const path of metadataStringList(raw, 'capabilityPaths')) appendUniqueText(target.capabilityPaths, path);
|
||
}
|
||
|
||
function taskParamSummaryText(summary: TaskParamConversionSummary) {
|
||
const actionText = summary.actions.map(taskParamActionLabel).join('、');
|
||
const parts = [
|
||
`${summary.changeCount || summary.paths.length || 1} 项转换`,
|
||
actionText ? `动作 ${actionText}` : '',
|
||
summary.capabilityPaths.length ? `涉及 ${summary.capabilityPaths.length} 项能力配置` : '',
|
||
].filter(Boolean);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function taskParamLogTitle(log: GatewayTaskParamPreprocessingLog) {
|
||
const parts = [
|
||
log.attemptNo ? `#${log.attemptNo}` : '',
|
||
log.modelType || '',
|
||
].filter(Boolean);
|
||
return parts.join(' · ') || '预处理记录';
|
||
}
|
||
|
||
function taskParamLogSubtitle(log: GatewayTaskParamPreprocessingLog) {
|
||
const model = log.model ?? {};
|
||
const modelLabel = firstNonEmptyText(
|
||
objectString(model, 'modelAlias'),
|
||
objectString(model, 'providerModelName'),
|
||
objectString(model, 'modelName'),
|
||
);
|
||
const provider = objectString(model, 'provider');
|
||
const parts = [
|
||
provider,
|
||
modelLabel,
|
||
].filter(Boolean);
|
||
if (parts.length) return parts.join(' · ');
|
||
return log.clientId || '候选模型';
|
||
}
|
||
|
||
function firstNonEmptyText(...values: string[]) {
|
||
return values.find((value) => value.trim()) ?? '';
|
||
}
|
||
|
||
function taskParamActionLabel(action: string) {
|
||
if (action === 'remove') return '移除';
|
||
if (action === 'adjust') return '调整';
|
||
if (action === 'set') return '补齐';
|
||
return action || '转换';
|
||
}
|
||
|
||
function taskParamChangePreview(change: Record<string, unknown>) {
|
||
const before = previewCompactValue(change.before);
|
||
const after = previewCompactValue(change.after);
|
||
const action = objectString(change, 'action');
|
||
if (action === 'remove') return `原值 ${before}`;
|
||
if (action === 'set') return `新值 ${after}`;
|
||
return `原值 ${before} -> 新值 ${after}`;
|
||
}
|
||
|
||
function previewCompactValue(value: unknown) {
|
||
if (value === undefined || value === null || value === '') return '-';
|
||
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||
if (!text) return '-';
|
||
return text.length > 150 ? `${text.slice(0, 150)}...` : text;
|
||
}
|
||
|
||
function formatCellValue(value: unknown) {
|
||
if (value === undefined || value === null || value === '') return '-';
|
||
return String(value);
|
||
}
|
||
|
||
function primaryWalletAccount(accounts: GatewayWalletAccount[]) {
|
||
return accounts.find((account) => account.currency === 'resource') ?? accounts[0];
|
||
}
|
||
|
||
function formatMoney(value: unknown) {
|
||
const numericValue = typeof value === 'number' ? value : Number(value);
|
||
if (!Number.isFinite(numericValue)) return '0.00';
|
||
return new Intl.NumberFormat(undefined, {
|
||
maximumFractionDigits: 2,
|
||
minimumFractionDigits: 2,
|
||
}).format(numericValue);
|
||
}
|
||
|
||
function transactionTypeLabel(value: string) {
|
||
if (value === 'task_billing') return '任务扣费';
|
||
if (value === 'admin_adjust') return '余额调整';
|
||
if (value === 'recharge') return '充值';
|
||
if (value === 'refund') return '退款';
|
||
if (value === 'reserve') return '冻结';
|
||
if (value === 'release') return '释放';
|
||
return value || '-';
|
||
}
|
||
|
||
function firstText(...values: Array<unknown>) {
|
||
for (const value of values) {
|
||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function metadataString(metadata: Record<string, unknown> | undefined, key: string) {
|
||
const value = metadata?.[key];
|
||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||
}
|
||
|
||
function metadataStringList(metadata: Record<string, unknown> | undefined, key: string) {
|
||
const value = metadata?.[key];
|
||
if (!Array.isArray(value)) return [];
|
||
return value.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map((item) => item.trim());
|
||
}
|
||
|
||
function metadataNumber(metadata: Record<string, unknown> | undefined, key: string) {
|
||
const value = metadata?.[key];
|
||
if (value === undefined || value === null || value === '') return null;
|
||
const numericValue = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(numericValue) ? numericValue : null;
|
||
}
|
||
|
||
function metadataObject(metadata: Record<string, unknown> | undefined, key: string): Record<string, unknown> {
|
||
const value = metadata?.[key];
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function objectString(value: Record<string, unknown>, key: string) {
|
||
const next = value[key];
|
||
return typeof next === 'string' && next.trim() ? next.trim() : '';
|
||
}
|
||
|
||
function appendUniqueText(values: string[], value: string) {
|
||
if (!value || values.includes(value)) return;
|
||
values.push(value);
|
||
}
|
||
|
||
function transactionChargeAmount(transaction: GatewayWalletTransaction) {
|
||
return metadataNumber(transaction.metadata, 'finalChargeAmount') ?? transaction.amount;
|
||
}
|
||
|
||
function transactionChargeCurrency(transaction: GatewayWalletTransaction) {
|
||
const summary = metadataObject(transaction.metadata, 'billingSummary');
|
||
const finalCharge = metadataObject(summary, 'finalCharge');
|
||
return objectString(finalCharge, 'currency') || objectString(summary, 'currency') || transaction.currency || 'resource';
|
||
}
|
||
|
||
function formatTokenUsage(usage: Record<string, unknown>) {
|
||
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
|
||
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
|
||
const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null));
|
||
if (input === null && output === null && total === null) return '-';
|
||
return (
|
||
<span className="taskRecordTokenUsage">
|
||
<span>输入:{formatCellValue(input)}/输出:{formatCellValue(output)}</span>
|
||
<span>总计:{formatCellValue(total)}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function tokenValue(value: unknown) {
|
||
if (value === undefined || value === null || value === '') return null;
|
||
const numericValue = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(numericValue) ? numericValue : null;
|
||
}
|
||
|
||
function formatDuration(value?: number) {
|
||
if (value === undefined || value === null) return '-';
|
||
const milliseconds = Math.max(0, Math.round(value));
|
||
if (milliseconds === 0) return '0秒';
|
||
if (milliseconds < 1000) return `${milliseconds}毫秒`;
|
||
const totalSeconds = Math.round(milliseconds / 1000);
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
if (hours > 0) return `${hours}小时${minutes}分${seconds}秒`;
|
||
if (minutes > 0) return `${minutes}分${seconds}秒`;
|
||
return `${seconds}秒`;
|
||
}
|
||
|
||
function taskErrorText(task: GatewayTask) {
|
||
return task.errorCode || task.errorMessage || task.error || '';
|
||
}
|
||
|
||
function InfoItem(props: { label: string; value: string }) {
|
||
return (
|
||
<div className="infoItem">
|
||
<span>{props.label}</span>
|
||
<strong>{props.value}</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function apiKeySecretFor(item: GatewayApiKey, secretsById: Record<string, string>) {
|
||
return secretsById[item.id] || item.secret || '';
|
||
}
|
||
|
||
function maskApiKey(secret: string) {
|
||
if (secret.length <= 18) return secret;
|
||
return `${secret.slice(0, 12)}...${secret.slice(-4)}`;
|
||
}
|
||
|
||
function permissionSummaryText(summary: ReturnType<typeof countAccessPermissionRules>) {
|
||
const allow = summary.allow.platforms + summary.allow.models;
|
||
const deny = summary.deny.platforms + summary.deny.models;
|
||
if (allow === 0 && deny === 0) return '未配置';
|
||
return `允许 ${allow} / 拒绝 ${deny}`;
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return '-';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return '-';
|
||
return date.toLocaleString();
|
||
}
|
||
|
||
function platformsForPermissionTree(models: PlatformModel[]): IntegrationPlatform[] {
|
||
const byPlatform = new Map<string, IntegrationPlatform>();
|
||
for (const model of models) {
|
||
if (byPlatform.has(model.platformId)) continue;
|
||
const name = model.platformName || model.provider || model.platformId;
|
||
byPlatform.set(model.platformId, {
|
||
id: model.platformId,
|
||
provider: model.provider || '',
|
||
platformKey: model.platformId,
|
||
name,
|
||
authType: '',
|
||
status: 'enabled',
|
||
priority: 100,
|
||
defaultPricingMode: 'inherit',
|
||
defaultDiscountFactor: 1,
|
||
createdAt: '',
|
||
updatedAt: '',
|
||
});
|
||
}
|
||
return Array.from(byPlatform.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|