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());
};