1
0
forked from boos/BoosAPI

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
+11
View File
@@ -10,4 +10,15 @@ describe('App sidebar config', () => {
expect(source).not.toContain("{ to: '/accounts', label: '账号'");
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
});
it('places downstream key navigation under 控制台 instead of 系统', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const consoleGroupIndex = source.indexOf("label: '控制台'");
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
const systemGroupIndex = source.indexOf("label: '系统'");
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
});
});
+3
View File
@@ -26,6 +26,7 @@ const CheckinLog = lazy(() => import('./pages/CheckinLog.js'));
const TokenRoutes = lazy(() => import('./pages/TokenRoutes.js'));
const ProxyLogs = lazy(() => import('./pages/ProxyLogs.js'));
const Settings = lazy(() => import('./pages/Settings.js'));
const DownstreamKeys = lazy(() => import('./pages/DownstreamKeys.js'));
const ImportExport = lazy(() => import('./pages/ImportExport.js'));
const NotificationSettings = lazy(() => import('./pages/NotificationSettings.js'));
const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
@@ -327,6 +328,7 @@ const sidebarGroups = [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
@@ -767,6 +769,7 @@ function AppShell() {
<Route path="/logs" element={<ProxyLogs />} />
<Route path="/monitor" element={<Monitors />} />
<Route path="/settings" element={<Settings />} />
<Route path="/downstream-keys" element={<DownstreamKeys />} />
<Route path="/events" element={<ProgramLogs />} />
<Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} />
+21
View File
@@ -204,6 +204,10 @@ export type ProxyLogListItem = {
siteName?: string | null;
siteUrl?: string | null;
errorMessage?: string | null;
downstreamKeyId?: number | null;
downstreamKeyName?: string | null;
downstreamKeyGroupName?: string | null;
downstreamKeyTags?: string[];
promptTokens?: number | null;
completionTokens?: number | null;
estimatedCost?: number | null;
@@ -401,9 +405,26 @@ export const api = {
deleteDownstreamApiKey: (id: number) => request(`/api/downstream-keys/${id}`, {
method: 'DELETE',
}),
batchDownstreamApiKeys: (data: {
ids: number[];
action: 'enable' | 'disable' | 'delete' | 'resetUsage' | 'updateMetadata';
groupOperation?: 'keep' | 'set' | 'clear';
groupName?: string;
tagOperation?: 'keep' | 'append';
tags?: string[];
}) =>
request('/api/downstream-keys/batch', {
method: 'POST',
body: JSON.stringify(data),
}),
resetDownstreamApiKeyUsage: (id: number) => request(`/api/downstream-keys/${id}/reset-usage`, {
method: 'POST',
}),
getDownstreamApiKeysSummary: (params?: { range?: '24h' | '7d' | 'all'; status?: 'all' | 'enabled' | 'disabled'; search?: string }) =>
request(`/api/downstream-keys/summary${buildQueryString(params)}`),
getDownstreamApiKeyOverview: (id: number) => request(`/api/downstream-keys/${id}/overview`),
getDownstreamApiKeyTrend: (id: number, params?: { range?: '24h' | '7d' | 'all' }) =>
request(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`),
exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') =>
request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`),
importBackup: (data: any) =>
@@ -0,0 +1,205 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
type Metric = 'tokens' | 'requests' | 'cost';
const METRIC_OPTIONS: Array<{ key: Metric; label: string }> = [
{ key: 'tokens', label: 'Tokens' },
{ key: 'requests', label: '请求数' },
{ key: 'cost', label: '成本' },
];
export type DownstreamKeyTrendBucket = {
startUtc: string | null;
totalRequests: number;
totalTokens: number;
totalCost: number;
successRate: number | null;
};
export default function DownstreamKeyTrendChart({
buckets,
loading,
height = 260,
}: {
buckets: DownstreamKeyTrendBucket[];
loading?: boolean;
height?: number;
}) {
const [metric, setMetric] = useState<Metric>('tokens');
const flatData = useMemo(() => {
if (!Array.isArray(buckets) || buckets.length === 0) return [];
return buckets
.map((bucket) => {
const date = bucket.startUtc || '';
const value = metric === 'tokens'
? Number(bucket.totalTokens || 0)
: (metric === 'requests'
? Number(bucket.totalRequests || 0)
: Number(bucket.totalCost || 0));
return { date, value };
})
.filter((row) => row.date.length > 0);
}, [buckets, metric]);
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
if (!flatData || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 32 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
</div>
</div>
);
}
const spec: Record<string, unknown> = {
type: 'area' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
area: {
style: {
curveType: 'monotone',
fillOpacity: 0.2,
},
},
line: {
style: {
curveType: 'monotone',
lineWidth: 2,
},
},
point: { visible: false },
axes: [
{
orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
tooltip: {
dimension: {
title: { value: (datum: Record<string, unknown>) => String(datum?.date || '') },
content: [
{
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
value: (datum: Record<string, unknown>) => {
const value = Number(datum?.value ?? 0);
if (metric === 'cost') return `$${value.toFixed(6)}`;
return value.toLocaleString();
},
},
],
},
},
color: ['var(--color-primary)'],
background: 'transparent',
animationAppear: {
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
},
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 16,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 12px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
+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>