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

388 lines
15 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 { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Copy, CreditCard, KeyRound, ListChecks, Plus, ShieldCheck, Trash2, UserRound } from 'lucide-react';
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, 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, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow, Tabs } from '../components/ui';
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
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} /> },
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
export function WorkspacePage(props: {
apiKeyForm: ApiKeyForm;
apiKeySecret: string;
apiKeySecretsById: Record<string, string>;
apiKeyPolicyModels: PlatformModel[];
data: ConsoleData;
message: string;
section: WorkspaceSection;
state: LoadState;
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>;
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 />}
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
{props.section === 'tasks' && <TaskPanel data={props.data} />}
</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() {
return (
<section className="contentGrid two">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="balanceCard">
<span>resource</span>
<strong>0.00</strong>
<Badge variant="secondary">local</Badge>
</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 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 }) {
const task = props.data.taskResult;
const usage = task?.usage ?? {};
const tokenText = usage.totalTokens ? `${usage.totalTokens}` : '-';
const chargeText = task?.finalChargeAmount ? `${task.finalChargeAmount}` : '-';
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{task ? (
<div className="taskPreview">
<Badge variant={task.status === 'succeeded' ? 'success' : 'secondary'}>{task.status}</Badge>
<strong>{task.kind}</strong>
<span>{task.model}</span>
<div className="infoGrid compact">
<InfoItem label="API Key" value={task.apiKeyName || task.apiKeyId || '-'} />
<InfoItem label="RequestID" value={task.requestId || '-'} />
<InfoItem label="实际模型" value={task.resolvedModel || task.model} />
<InfoItem label="Token" value={tokenText} />
<InfoItem label="扣费" value={chargeText} />
<InfoItem label="响应耗时" value={task.responseDurationMs ? `${task.responseDurationMs}ms` : '-'} />
</div>
<pre>{JSON.stringify({ result: task.result, usage: task.usage, billings: task.billings, billingSummary: task.billingSummary, metrics: task.metrics }, null, 2)}</pre>
</div>
) : (
<div className="emptyState">
<strong></strong>
</div>
)}
</CardContent>
</Card>
);
}
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));
}