feat: 下游 API 密钥管理 (#103)

* feat: downstream API key usage + proxy log attribution

* chore(schema): regenerate generated artifacts

* test: keep migration replay + platform detection tests stable

* feat: complete downstream api key management validation

* refactor: move downstream keys into console nav

* feat: upgrade downstream keys management console

* feat: harden downstream key management flows

* test: cover downstream key policy guards

* feat: add downstream key grouping and tags

* feat: polish downstream key search and log metadata

* test: fix sqlite migration bootstrap coverage

* perf: isolate vchart bundle loading

* fix: align downstream key drawer metrics

---------

Co-authored-by: DeliciousBuding <DeliciousBuding@users.noreply.github.com>
This commit is contained in:
Delicious
2026-03-15 22:00:02 +08:00
committed by GitHub
parent 251348f784
commit a64a51aa0c
44 changed files with 4162 additions and 183 deletions
+446
View File
@@ -0,0 +1,446 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import DownstreamKeys from './DownstreamKeys.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function buildSummaryItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
keyMasked: 'sk-s****0315',
enabled: true,
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
key: 'sk-smoke-0315',
keyMasked: 'sk-s****0315',
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] });
apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] });
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'team/default', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
{ startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 },
],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
describe('DownstreamKeys page', () => {
it('loads management data and renders merged row content', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeysSummary).toHaveBeenCalledWith({ range: '24h' });
expect(apiMock.getDownstreamApiKeys).toHaveBeenCalled();
expect(apiMock.getRoutesLite).toHaveBeenCalled();
const text = collectText(root!.root);
expect(text).toContain('下游密钥');
expect(text).toContain('smoke-key');
expect(text).toContain('sk-s****0315');
expect(text).toContain('默认群组');
expect(text).toContain('4.2K');
expect(text).toContain('主分组');
expect(text).toContain('移动端');
} finally {
root?.unmount();
}
});
it('filters rows locally by search and status', async () => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [
buildSummaryItem(),
buildSummaryItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', keyMasked: 'sk-b****0315' }),
],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [
buildRawItem(),
buildRawItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', key: 'sk-batch-0315', keyMasked: 'sk-b****0315', supportedModels: ['gpt-4o-mini'], allowedRouteIds: [] }),
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const input = root!.root.findAllByType('input').find((node) => node.props.placeholder === '搜索名称、备注、模型、主分组或标签');
await act(async () => {
input!.props.onChange({ target: { value: 'batch' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
const select = root!.root.findAllByType('select').find((node) => collectText(node).includes('仅禁用'));
await act(async () => {
select.props.onChange({ target: { value: 'disabled' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
} finally {
root?.unmount();
}
});
it('supports create flow and drawer trend loading', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'new-key' } });
keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'new-key',
key: 'sk-new-key-0315',
siteWeightMultipliers: {},
}));
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
await act(async () => {
row.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledWith(1);
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' });
expect(collectText(root!.root)).toContain('trend:2');
const range7d = root!.root.findAll((node) => node.type === 'button' && collectText(node) === '7d')[0];
await act(async () => {
range7d.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '7d' });
expect(collectText(root!.root)).toContain('12.4K');
expect(collectText(root!.root)).toContain('9');
expect(collectText(root!.root)).toContain('88.9%');
} finally {
root?.unmount();
}
});
it('uses backend batch api for selected rows', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0];
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1],
action: 'enable',
});
} finally {
root?.unmount();
}
});
it('supports group and tag editing plus batch metadata update', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
const groupInput = inputs.find((node) => node.props.placeholder === '例如:VIP / 内部项目 / A组');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'grouped-key' } });
keyInput!.props.onChange({ target: { value: 'sk-grouped-key-0315' } });
groupInput!.props.onChange({ target: { value: '项目B' } });
tagInput!.props.onChange({ target: { value: '灰度,高优' } });
});
await flushMicrotasks();
await act(async () => {
tagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
groupName: '项目B',
tags: ['灰度', '高优'],
}));
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const batchMetadataBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量归类/标签'))[0];
await act(async () => {
batchMetadataBtn.props.onClick();
});
await flushMicrotasks();
const groupModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('统一设为主分组'));
const tagModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('追加标签'));
await act(async () => {
groupModeSelect!.props.onChange({ target: { value: 'set' } });
tagModeSelect!.props.onChange({ target: { value: 'append' } });
});
await flushMicrotasks();
const batchGroupInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '例如:VIP / 内部项目');
const batchTagInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '批量追加标签');
await act(async () => {
batchGroupInput!.props.onChange({ target: { value: '统一项目组' } });
batchTagInput!.props.onChange({ target: { value: '批量标签' } });
});
await flushMicrotasks();
await act(async () => {
batchTagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
await flushMicrotasks();
const applyBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('应用到所选密钥'))[0];
await act(async () => {
applyBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith(expect.objectContaining({
ids: [1],
action: 'updateMetadata',
groupOperation: 'set',
groupName: '统一项目组',
tagOperation: 'append',
tags: ['批量标签'],
}));
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,9 @@ function buildListResponse(overrides?: Partial<{
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
},
],
total: 1,
@@ -102,6 +105,9 @@ describe('ProxyLogs server-driven page', () => {
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
billingDetails: {
breakdown: {
inputPerMillion: 1,
@@ -165,6 +171,7 @@ describe('ProxyLogs server-driven page', () => {
expect(text).toContain('全部 12');
expect(text).toContain('成功 8');
expect(text).toContain('失败 4');
expect(text).toContain('下游 Key: 移动端灰度');
} finally {
root?.unmount();
}
@@ -194,7 +201,7 @@ describe('ProxyLogs server-driven page', () => {
await flushMicrotasks();
const searchInput = root!.root.find((node) => (
node.type === 'input' && node.props.placeholder === '搜索模型名称...'
node.type === 'input' && node.props.placeholder === '搜索模型、下游 Key、主分组、标签...'
));
await act(async () => {
searchInput.props.onChange({ target: { value: 'mini' } });
+25 -2
View File
@@ -80,6 +80,15 @@ function formatBillingDetailSummary(log: ProxyLogRenderItem) {
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
}
function renderDownstreamKeySummary(log: ProxyLogRenderItem) {
const parts = [
log.downstreamKeyName ? `下游 Key: ${log.downstreamKeyName}` : null,
log.downstreamKeyGroupName ? `主分组: ${log.downstreamKeyGroupName}` : null,
Array.isArray(log.downstreamKeyTags) && log.downstreamKeyTags.length > 0 ? `标签: ${log.downstreamKeyTags.join(' / ')}` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(',') : null;
}
function buildBillingProcessLines(log: ProxyLogRenderItem) {
const detail = log.billingDetails;
if (!detail) return [];
@@ -476,7 +485,7 @@ export default function ProxyLogs() {
setSearchInput(e.target.value);
setPage(1);
}}
placeholder="搜索模型名称..."
placeholder="搜索模型、下游 Key、主分组、标签..."
/>
</div>
<button
@@ -585,6 +594,7 @@ export default function ProxyLogs() {
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
const isExpanded = expanded === log.id;
return (
@@ -599,6 +609,7 @@ export default function ProxyLogs() {
>
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={log.siteName || '-'} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
@@ -612,6 +623,7 @@ export default function ProxyLogs() {
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
{downstreamKeySummary && <div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>}
{billingProcessLines.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{billingProcessLines.map((line, index) => (
@@ -661,6 +673,7 @@ export default function ProxyLogs() {
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
return (
<React.Fragment key={log.id}>
@@ -686,7 +699,14 @@ export default function ProxyLogs() {
{formatDateTimeLocal(log.createdAt)}
</td>
<td>
<ModelBadge model={log.modelRequested} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<ModelBadge model={log.modelRequested} />
{downstreamKeySummary ? (
<div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)' }}>
{downstreamKeySummary}
</div>
) : null}
</div>
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{log.siteName || '-'}
@@ -765,6 +785,9 @@ export default function ProxyLogs() {
{billingDetailSummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
)}
{downstreamKeySummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>
)}
</div>
</div>
+8 -60
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
@@ -180,6 +181,7 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
}
export default function Settings() {
const navigate = useNavigate();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
@@ -1057,68 +1059,14 @@ export default function Settings() {
</div>
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游密钥管理入口已迁移</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.8 }}>
下游 API Key 的新增、编辑、模型白名单、群组限制、趋势与用量分析,现统一收口到「控制台 / 下游密钥」页面,设置页不再保留重复管理入口。
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button onClick={openCreateDownstreamModal} className="btn btn-primary">
+ 新增 API Key
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => navigate('/downstream-keys')} className="btn btn-primary">
打开下游密钥管理页
</button>
<button onClick={loadDownstreamKeys} disabled={downstreamLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{downstreamLoading ? '刷新中...' : '刷新列表'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{downstreamKeys.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无下游 API Key</div>
) : downstreamKeys.map((item) => {
const opLoading = !!downstreamOps[item.id];
const quotaText = `${item.usedRequests}${item.maxRequests !== null ? `/${item.maxRequests}` : ''}`;
const costText = `$${item.usedCost.toFixed(6)}${item.maxCost !== null ? `/$${item.maxCost}` : ''}`;
return (
<div key={item.id} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: 13 }}>{item.name}</strong>
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-secondary)' }}>{item.keyMasked}</code>
<span style={{
fontSize: 12,
padding: '2px 8px',
borderRadius: 999,
background: item.enabled ? 'var(--color-success-bg)' : 'var(--color-danger-bg)',
color: item.enabled ? 'var(--color-success)' : 'var(--color-danger)',
}}>
{item.enabled ? '启用' : '禁用'}
</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => beginEditDownstream(item)} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
编辑
</button>
<button onClick={() => toggleDownstreamEnabled(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{item.enabled ? '禁用' : '启用'}
</button>
<button onClick={() => resetDownstreamUsage(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
重置用量
</button>
<button onClick={() => deleteDownstreamKey(item)} disabled={opLoading} className="btn btn-link btn-link-warning">
删除
</button>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6, display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<span>请求用量: {quotaText}</span>
<span>费用用量: {costText}</span>
<span>过期: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : '永久'}</span>
<span>模型规则: {item.supportedModels.length || 0}</span>
<span>群组限制: {item.allowedRouteIds.length || 0}</span>
</div>
</div>
);
})}
</div>
</div>