diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx index 546c0a1..d5bd40d 100644 --- a/src/web/pages/DownstreamKeys.test.tsx +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -569,6 +569,73 @@ describe('DownstreamKeys page', () => { } }); + it('defaults new keys to all exact models and all group routes before saving', async () => { + apiMock.getRoutesLite.mockResolvedValue([ + { id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true }, + { id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true }, + { id: 13, modelPattern: 're:^gemini-2\\..*$', displayName: 'Gemini 全家桶', enabled: true }, + { id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true }, + ]); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0]; + await act(async () => { + advancedBtn.props.onClick(); + }); + await flushMicrotasks(); + + const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel'); + const modelPanel = panels.find((node) => collectText(node).includes('模型白名单')); + const groupPanel = panels.find((node) => collectText(node).includes('群组范围')); + expect(modelPanel).toBeTruthy(); + expect(groupPanel).toBeTruthy(); + expect(collectText(modelPanel!)).toContain('已选 2 个模型'); + expect(collectText(groupPanel!)).toContain('已选 2 个群组'); + + 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: 'default-all-key' } }); + keyInput!.props.onChange({ target: { value: 'sk-default-all-key-0323' } }); + }); + 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: 'default-all-key', + key: 'sk-default-all-key-0323', + supportedModels: ['claude-opus-4-6', 'gpt-4.1-mini'], + allowedRouteIds: [11, 13], + })); + } finally { + root?.unmount(); + } + }); + it('uses backend batch api for selected rows', async () => { let root!: WebTestRenderer; try { diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index 1dd8ae7..a2f6a0f 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -85,6 +85,7 @@ type BatchMetadataForm = { tags: string[]; }; +type DefaultRouteSelections = Pick; function toDateTimeLocal(isoString: string | null | undefined): string { if (!isoString) return ''; const ts = Date.parse(isoString); @@ -138,6 +139,21 @@ function uniqIds(values: number[]): number[] { return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))]; } +function buildDefaultRouteSelections(routeOptions: RouteSelectorItem[]): DefaultRouteSelections { + return { + selectedModels: uniqStrings( + routeOptions + .filter((item) => isExactModelPattern(item.modelPattern)) + .map((item) => item.modelPattern), + ).sort((a, b) => a.localeCompare(b)), + selectedGroupRouteIds: uniqIds( + routeOptions + .filter(isGroupRouteOption) + .map((item) => item.id), + ), + }; +} + function parseTagText(value: string): string[] { return normalizeTags(value.split(/[\r\n,,]+/g)); } @@ -279,7 +295,21 @@ function DownstreamKeyCopyIconButton({ fullKey }: { fullKey: string | undefined ); } -function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm { +function buildEditorForm( + item?: ManagedItem | DownstreamApiKeyItem | null, + routeOptions: RouteSelectorItem[] = [], + selectAllByDefault = false, +): EditorForm { + const defaultSelections = selectAllByDefault + ? buildDefaultRouteSelections(routeOptions) + : { selectedModels: [], selectedGroupRouteIds: [] }; + const selectedModels = Array.isArray(item?.supportedModels) + ? item.supportedModels + : defaultSelections.selectedModels; + const selectedGroupRouteIds = Array.isArray(item?.allowedRouteIds) + ? item.allowedRouteIds + : defaultSelections.selectedGroupRouteIds; + return { name: item?.name || '', key: item?.key || '', @@ -290,8 +320,8 @@ function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): Edit maxRequests: item?.maxRequests === null || item?.maxRequests === undefined ? '' : String(item.maxRequests), expiresAt: toDateTimeLocal(item?.expiresAt), enabled: item?.enabled ?? true, - selectedModels: uniqStrings(Array.isArray(item?.supportedModels) ? item!.supportedModels : []), - selectedGroupRouteIds: uniqIds(Array.isArray(item?.allowedRouteIds) ? item!.allowedRouteIds : []), + selectedModels: uniqStrings(selectedModels), + selectedGroupRouteIds: uniqIds(selectedGroupRouteIds), siteWeightMultipliersText: JSON.stringify(item?.siteWeightMultipliers || {}, null, 2), }; } @@ -823,6 +853,7 @@ export default function DownstreamKeys() { const [editorOpen, setEditorOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [editorForm, setEditorForm] = useState(() => buildEditorForm()); + const [createDefaultsPending, setCreateDefaultsPending] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [selectedId, setSelectedId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); @@ -981,9 +1012,33 @@ export default function DownstreamKeys() { return acc; }, { tokens: 0, requests: 0, cost: 0, enabled: 0 }), [visibleItems]); + useEffect(() => { + if (!editorOpen || editingId !== null || !createDefaultsPending) { + return; + } + + const defaultSelections = buildDefaultRouteSelections(routeOptions); + if (defaultSelections.selectedModels.length === 0 && defaultSelections.selectedGroupRouteIds.length === 0) { + return; + } + + setEditorForm((prev) => { + if (prev.selectedModels.length > 0 || prev.selectedGroupRouteIds.length > 0) { + return prev; + } + return { + ...prev, + selectedModels: defaultSelections.selectedModels, + selectedGroupRouteIds: defaultSelections.selectedGroupRouteIds, + }; + }); + setCreateDefaultsPending(false); + }, [createDefaultsPending, editorOpen, editingId, routeOptions]); + const openCreate = () => { setEditingId(null); - setEditorForm(buildEditorForm()); + setEditorForm(buildEditorForm(null, routeOptions, true)); + setCreateDefaultsPending(true); setEditorOpen(true); }; @@ -998,6 +1053,7 @@ export default function DownstreamKeys() { const openEdit = (item: ManagedItem) => { setEditingId(item.id); + setCreateDefaultsPending(false); setEditorForm(buildEditorForm(rawItemMap.get(item.id) || item)); setEditorOpen(true); }; @@ -1005,6 +1061,7 @@ export default function DownstreamKeys() { const closeEditor = () => { setEditorOpen(false); setEditingId(null); + setCreateDefaultsPending(false); setEditorForm(buildEditorForm()); };