easyai-ai-gateway/apps/web/src/pages/WorkspacePage.tsx

1429 lines
59 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 { 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));
}