完善 API Key 能力范围可视化和维护

This commit is contained in:
2026-06-07 19:01:32 +08:00
parent dc14866210
commit f47132a653
19 changed files with 1165 additions and 49 deletions
+5 -4
View File
@@ -15,7 +15,7 @@ import type {
PricingRuleSetUpsertRequest,
RuntimePolicySetUpsertRequest,
UserGroupUpsertRequest,
WalletBalanceAdjustmentRequest,
WalletRechargeRequest,
} from '@easyai-ai-gateway/contracts';
import type { ConsoleData, StatItem } from '../app-state';
import { EntityTable } from '../components/EntityTable';
@@ -82,7 +82,7 @@ export function AdminPage(props: {
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
onClearOperationMessage: () => void;
onSectionChange: (value: AdminSection) => void;
@@ -90,6 +90,7 @@ export function AdminPage(props: {
return (
<div className="pageStack">
<ScreenMessage
duration={props.state === 'error' ? 0 : undefined}
message={props.operationMessage}
variant={props.state === 'error' ? 'error' : 'success'}
onClose={props.onClearOperationMessage}
@@ -207,7 +208,7 @@ function identityPanelProps(props: {
onDeleteUserGroup: (groupId: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
}) {
return {
@@ -219,7 +220,7 @@ function identityPanelProps(props: {
onDeleteUserGroup: props.onDeleteUserGroup,
onSaveTenant: props.onSaveTenant,
onSaveUser: props.onSaveUser,
onSetUserWalletBalance: props.onSetUserWalletBalance,
onRechargeUserWalletBalance: props.onRechargeUserWalletBalance,
onSaveUserGroup: props.onSaveUserGroup,
};
}
+197 -4
View File
@@ -1,10 +1,10 @@
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 { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, 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 { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, 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';
@@ -19,6 +19,19 @@ const tabs = [
const taskPageSizeOptions = [10, 20, 50];
const apiKeyScopeOptions = [
{ value: 'chat', label: '对话', description: 'Chat Completions / Responses' },
{ value: 'embedding', label: '向量', description: 'Embeddings' },
{ value: 'rerank', label: '重排', description: 'Reranks' },
{ value: 'image', label: '图像', description: 'Images' },
{ value: 'video', label: '视频', description: 'Videos' },
{ value: 'music', label: '音乐生成', description: 'Song / Music' },
{ value: 'audio', label: '语音合成', description: 'Speech / TTS' },
{ value: 'all', label: '全部能力', description: '不按接口能力限制' },
] as const;
const knownApiKeyScopes = new Set(apiKeyScopeOptions.map((item) => item.value));
export function WorkspacePage(props: {
apiKeyForm: ApiKeyForm;
apiKeySecret: string;
@@ -37,6 +50,7 @@ export function WorkspacePage(props: {
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSectionChange: (value: WorkspaceSection) => void;
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
@@ -59,7 +73,8 @@ export function WorkspacePage(props: {
}
function WorkspaceOverview(props: { data: ConsoleData }) {
const owner = props.data.users[0];
const owner = props.data.currentUser;
const groupRows = currentUserGroupRows(owner, props.data.currentUserGroups);
return (
<section className="contentGrid two">
<Card>
@@ -81,7 +96,7 @@ function WorkspaceOverview(props: { data: ConsoleData }) {
<EntityTable
columns={['用户组', '优先级', '状态', '来源']}
empty="暂无用户组"
rows={props.data.userGroups.map((item) => [item.groupKey, item.priority, item.status, item.source])}
rows={groupRows}
/>
</CardContent>
</Card>
@@ -89,6 +104,23 @@ function WorkspaceOverview(props: { data: ConsoleData }) {
);
}
function currentUserGroupRows(owner: ConsoleData['currentUser'], groups: ConsoleData['userGroups']) {
if (!owner) return [];
const ownerGroupKeys = Array.from(new Set([
owner.userGroupKey,
...(owner.userGroupKeys ?? []),
].filter((value): value is string => Boolean(value?.trim()))));
const ownerGroupIds = new Set([
owner.userGroupId,
...(owner.userGroupKeys ?? []),
].filter((value): value is string => Boolean(value?.trim())));
const matchedGroups = groups.filter((group) => ownerGroupIds.has(group.id) || ownerGroupKeys.includes(group.groupKey));
if (matchedGroups.length) {
return matchedGroups.map((item) => [item.groupKey, item.priority, item.status, item.source]);
}
return ownerGroupKeys.map((groupKey) => [groupKey, '-', '-', owner.source ?? 'gateway']);
}
function BillingPanel(props: { walletAccounts: GatewayWalletAccount[] }) {
const primaryWallet = primaryWalletAccount(props.walletAccounts);
const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0;
@@ -364,17 +396,25 @@ function ApiKeyPanel(props: {
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
}) {
const [createOpen, setCreateOpen] = useState(false);
const [policyApiKeyId, setPolicyApiKeyId] = useState('');
const [scopeApiKeyId, setScopeApiKeyId] = useState('');
const [scopeDraft, setScopeDraft] = useState<string[]>([]);
const [scopeError, setScopeError] = 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 selectedScopeKey = useMemo(
() => props.data.apiKeys.find((item) => item.id === scopeApiKeyId),
[scopeApiKeyId, props.data.apiKeys],
);
const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]);
async function copyApiKey(item: GatewayApiKey) {
@@ -399,6 +439,34 @@ function ApiKeyPanel(props: {
setPendingDelete(null);
}
function openScopeDialog(item: GatewayApiKey) {
setScopeApiKeyId(item.id);
setScopeDraft(normalizeApiKeyScopes(item.scopes));
setScopeError('');
}
function closeScopeDialog() {
setScopeApiKeyId('');
setScopeDraft([]);
setScopeError('');
}
async function submitScopes(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedScopeKey) return;
const scopes = normalizeApiKeyScopes(scopeDraft);
if (!scopes.length) {
setScopeError('至少保留一个能力范围。');
return;
}
try {
await props.onSaveApiKeyScopes(selectedScopeKey.id, { scopes });
closeScopeDialog();
} catch {
return;
}
}
return (
<>
<Card>
@@ -418,6 +486,7 @@ function ApiKeyPanel(props: {
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>使</TableHead>
<TableHead></TableHead>
@@ -451,6 +520,14 @@ function ApiKeyPanel(props: {
</Button>
</span>
</TableCell>
<TableCell>
<span className="apiKeyScopeCell">
<ApiKeyScopeBadges scopes={item.scopes} />
<Button type="button" variant="ghost" size="icon" title="维护能力范围" onClick={() => openScopeDialog(item)}>
<SlidersHorizontal size={14} />
</Button>
</span>
</TableCell>
<TableCell>
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
<ShieldCheck size={14} />
@@ -509,6 +586,30 @@ function ApiKeyPanel(props: {
</Label>
</FormDialog>
<FormDialog
ariaLabel="维护 API Key 能力范围"
bodyClassName="apiKeyScopeDialogBody"
footer={(
<>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={closeScopeDialog}></Button>
<Button type="submit" size="sm" disabled={props.state === 'loading' || normalizeApiKeyScopes(scopeDraft).length === 0}>
<SlidersHorizontal size={15} />
</Button>
</>
)}
open={Boolean(selectedScopeKey)}
title={selectedScopeKey ? `能力范围:${selectedScopeKey.name}` : '能力范围'}
onClose={closeScopeDialog}
onSubmit={(event) => void submitScopes(event)}
>
{scopeError && <p className="formMessage error">{scopeError}</p>}
<APIKeyScopeEditor value={scopeDraft} onChange={(scopes) => {
setScopeDraft(scopes);
if (scopeError) setScopeError('');
}} />
</FormDialog>
<FormDialog
ariaLabel="维护 API Key 权限策略"
bodyClassName="apiKeyPolicyDialogBody"
@@ -544,6 +645,64 @@ function ApiKeyPanel(props: {
);
}
function APIKeyScopeEditor(props: {
value: string[];
onChange: (value: string[]) => void;
}) {
const scopes = normalizeApiKeyScopes(props.value);
const customScopes = scopes.filter((scope) => !knownApiKeyScopes.has(scope as (typeof apiKeyScopeOptions)[number]['value']));
const customValue = customScopes.join(', ');
function setKnownScope(scope: string, checked: boolean) {
const next = new Set(scopes);
if (checked) {
if (scope === 'all') {
for (const option of apiKeyScopeOptions) {
next.delete(option.value);
}
} else {
next.delete('all');
}
next.add(scope);
} else {
next.delete(scope);
}
props.onChange(Array.from(next));
}
function setCustomScopes(value: string) {
const known = scopes.filter((scope) => knownApiKeyScopes.has(scope as (typeof apiKeyScopeOptions)[number]['value']));
props.onChange(normalizeApiKeyScopes([...known, ...value.split(',')]));
}
return (
<div className="apiKeyScopeEditor">
<div className="apiKeyScopeOptionGrid">
{apiKeyScopeOptions.map((option) => (
<label key={option.value} className="apiKeyScopeOption">
<Checkbox
checked={scopes.includes(option.value)}
onCheckedChange={(checked) => setKnownScope(option.value, checked === true)}
/>
<span>
<strong>{option.label}</strong>
<small>{option.description}</small>
</span>
</label>
))}
</div>
<Label>
scope
<Input
value={customValue}
placeholder="例如:text_to_speech, audio_generate"
onChange={(event) => setCustomScopes(event.target.value)}
/>
</Label>
</div>
);
}
function TaskPanel(props: {
data: ConsoleData;
query: WorkspaceTaskQuery;
@@ -1429,6 +1588,40 @@ function apiKeySecretFor(item: GatewayApiKey, secretsById: Record<string, string
return secretsById[item.id] || item.secret || '';
}
function ApiKeyScopeBadges(props: { scopes?: string[] }) {
const scopes = normalizeApiKeyScopes(props.scopes);
if (!scopes.length) {
return <Badge variant="warning"></Badge>;
}
const visibleScopes = scopes.includes('all') || scopes.includes('*') ? ['all'] : scopes.slice(0, 3);
const hiddenCount = scopes.length - visibleScopes.length;
return (
<span className="apiKeyScopeBadges">
{visibleScopes.map((scope) => (
<Badge key={scope} variant={scope === 'all' ? 'success' : 'outline'}>{apiKeyScopeLabel(scope)}</Badge>
))}
{hiddenCount > 0 && <Badge variant="secondary">+{hiddenCount}</Badge>}
</span>
);
}
function normalizeApiKeyScopes(scopes?: string[]) {
const out: string[] = [];
const seen = new Set<string>();
for (const value of scopes ?? []) {
let scope = value.trim().toLowerCase();
if (scope === '*') scope = 'all';
if (!scope || seen.has(scope)) continue;
seen.add(scope);
out.push(scope);
}
return out;
}
function apiKeyScopeLabel(scope: string) {
return apiKeyScopeOptions.find((item) => item.value === scope)?.label ?? scope;
}
function maskApiKey(secret: string) {
if (secret.length <= 18) return secret;
return `${secret.slice(0, 12)}...${secret.slice(-4)}`;
@@ -62,6 +62,7 @@ export function AuditLogsPanel(props: { auditLogs: GatewayAuditLog[]; message?:
function actionLabel(action: string) {
if (action === 'wallet.balance.set') return '余额调整';
if (action === 'wallet.balance.recharge') return '余额充值';
return action;
}
@@ -8,7 +8,7 @@ import type {
GatewayWalletAccount,
UserGroup,
UserGroupUpsertRequest,
WalletBalanceAdjustmentRequest,
WalletRechargeRequest,
} from '@easyai-ai-gateway/contracts';
import {
Badge,
@@ -21,6 +21,7 @@ import {
FormDialog,
Input,
Label,
ScreenMessage,
Select,
Table,
TableCell,
@@ -68,7 +69,7 @@ type UserForm = {
type WalletForm = {
currency: string;
balance: string;
amount: string;
reason: string;
};
@@ -149,11 +150,12 @@ export function TenantsPanel(props: IdentityPanelProps) {
return (
<div className="pageStack">
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
<IdentityHeader
count={props.data.tenants.length}
description="租户可独立维护,也可承载 server-main 同步过来的租户标识。"
icon={<Building2 size={17} />}
message={localError || props.operationMessage}
message={identityHeaderMessage(props)}
title="租户管理"
actionLabel="新增租户"
onCreate={openCreateDialog}
@@ -256,8 +258,8 @@ export function UsersPanel(props: IdentityPanelProps) {
setWalletUser(user);
setWalletForm({
currency: wallet?.currency ?? 'resource',
balance: String(wallet?.balance ?? 0),
reason: '',
amount: '',
reason: defaultRechargeReason,
});
}
@@ -290,9 +292,9 @@ export function UsersPanel(props: IdentityPanelProps) {
event.preventDefault();
setLocalError('');
if (!walletUser) return;
const balance = Number(walletForm.balance);
if (!Number.isFinite(balance) || balance < 0) {
setLocalError('余额必须是非负数字');
const amount = Number(walletForm.amount);
if (!Number.isFinite(amount) || amount <= 0) {
setLocalError('充值金额必须大于 0');
return;
}
if (!walletForm.reason.trim()) {
@@ -300,15 +302,15 @@ export function UsersPanel(props: IdentityPanelProps) {
return;
}
try {
await props.onSetUserWalletBalance(walletUser.id, {
await props.onRechargeUserWalletBalance(walletUser.id, {
currency: walletForm.currency,
balance,
amount,
reason: walletForm.reason.trim(),
idempotencyKey: newIdempotencyKey(),
});
closeWalletDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '更新余额失败');
setLocalError(err instanceof Error ? err.message : '充值用户余额失败');
}
}
@@ -319,11 +321,12 @@ export function UsersPanel(props: IdentityPanelProps) {
return (
<div className="pageStack">
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
<IdentityHeader
count={props.data.users.length}
description="支持本地用户闭环,也保留 server-main 用户同步字段。"
icon={<UserRound size={17} />}
message={localError || props.operationMessage}
message={identityHeaderMessage(props)}
title="用户管理"
actionLabel="新增用户"
onCreate={openCreateDialog}
@@ -400,18 +403,19 @@ export function UsersPanel(props: IdentityPanelProps) {
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
/>
<FormDialog
ariaLabel="修改用户余额"
ariaLabel="充值用户余额"
className="identityDialog walletDialog"
eyebrow="Wallet Adjustment"
footer={<WalletDialogFooter loading={props.state === 'loading'} onCancel={closeWalletDialog} />}
open={Boolean(walletUser)}
title={`修改余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
title={`充值余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
onClose={closeWalletDialog}
onSubmit={submitWallet}
>
<Label><Input size="sm" value={walletForm.currency} onChange={(event) => setWalletForm({ ...walletForm, currency: event.target.value })} placeholder="resource" /></Label>
<Label><Input size="sm" value={walletForm.balance} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, balance: event.target.value })} /></Label>
<Label className="spanTwo"><Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
<Label><Input size="sm" value={walletForm.amount} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, amount: event.target.value })} placeholder="100" /></Label>
<Label className="spanTwo"><Input size="sm" value={walletUser ? walletSummary(walletUser) : '0 resource'} disabled /></Label>
<Label className="spanTwo"><Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
</FormDialog>
</div>
);
@@ -466,11 +470,12 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
return (
<div className="pageStack">
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
<IdentityHeader
count={props.data.userGroups.length}
description="用户组承载充值折扣、计费折扣、限流和额度策略。"
icon={<UsersRound size={17} />}
message={localError || props.operationMessage}
message={identityHeaderMessage(props)}
title="用户组管理"
actionLabel="新增用户组"
onCreate={openCreateDialog}
@@ -546,10 +551,22 @@ type IdentityPanelProps = {
onDeleteUserGroup: (groupId: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
};
const defaultRechargeReason = '手动充值';
function identityHeaderMessage(props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
return props.state === 'error' ? '' : props.operationMessage;
}
function identityLocalErrorMessage(localError: string, props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
if (!localError) return '';
if (props.state === 'error' && localError === props.operationMessage) return '';
return localError;
}
function IdentityHeader(props: {
actionLabel: string;
count: number;
@@ -621,7 +638,7 @@ function WalletDialogFooter(props: { loading: boolean; onCancel: () => void }) {
return (
<>
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} /></Button>
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} /></Button>
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} /></Button>
</>
);
}
@@ -715,8 +732,8 @@ function defaultUserForm(tenant?: GatewayTenant): UserForm {
function defaultWalletForm(): WalletForm {
return {
currency: 'resource',
balance: '0',
reason: '',
amount: '',
reason: defaultRechargeReason,
};
}