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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user