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:
@@ -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