388 lines
15 KiB
TypeScript
388 lines
15 KiB
TypeScript
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));
|
||
}
|