feat: 拆分模型/供应商为独立路由页面,侧边栏支持 SubMenu 分组

This commit is contained in:
2026-06-04 11:11:32 +08:00
parent f67cfa84ef
commit 61b479e2be
13 changed files with 689 additions and 353 deletions

View File

@@ -4,9 +4,9 @@ import { createElement } from "react";
import type { Model, Provider } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { ModelFormModal } from "../../../src/web/features/models/components/ModelFormModal";
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
import { renderWithProviders } from "../test-utils";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ENABLED_PROVIDER: Provider = {
apiKey: "sk-test",
@@ -45,107 +45,6 @@ function clickLatestConfirmButton() {
fireEvent.click(buttons[buttons.length - 1]!);
}
describe("ProviderFormModal", () => {
test("编辑供应商表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: ENABLED_PROVIDER,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: (args: unknown) => {
updateCalls.push(args);
return Promise.resolve();
},
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "New OpenAI" } });
clickLatestConfirmButton();
await waitFor(() => expect(updateCalls.length).toBe(1));
expect(updateCalls[0]).toEqual({ data: { name: "New OpenAI" }, id: "pv1" });
});
test("新建供应商默认使用 openai-compatible 类型", async () => {
const createCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: (data: unknown) => {
createCalls.push(data);
return Promise.resolve();
},
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
clickLatestConfirmButton();
await waitFor(() => expect(createCalls.length).toBe(1));
expect(createCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
test("供应商表单可使用当前表单配置测试连接", async () => {
const testCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: (data: unknown) => {
testCalls.push(data);
return Promise.resolve({ message: "连接成功", ok: true });
},
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await waitFor(() => expect(testCalls.length).toBe(1));
expect(testCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
});
describe("ModelFormModal", () => {
test("编辑模型表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
@@ -317,3 +216,112 @@ describe("ModelFormModal", () => {
await waitFor(() => expect(screen.getByRole("button", { name: "测试连接" })).not.toBeNull());
});
});
const TEST_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const TEST_MODEL: Model = {
capabilities: ["text"],
contextLength: 128000,
createdAt: "2024-01-01T00:00:00.000Z",
id: "m1",
maxOutputTokens: 4096,
modelId: "gpt-4o",
name: "GPT-4o",
providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function createModelFetchMock() {
let models = [TEST_MODEL];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/providers/options" && call.method === "GET") {
return jsonResponse({ items: [TEST_PROVIDER] });
}
if (url.pathname === "/api/models" && call.method === "POST") {
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const created: Model = {
...TEST_MODEL,
...data,
createdAt: "2024-01-02T00:00:00.000Z",
id: "m-new",
updatedAt: "2024-01-02T00:00:00.000Z",
};
models = [created, ...models];
return jsonResponse({ model: created }, { status: 201 });
}
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "PATCH") {
const id = url.pathname.split("/").pop()!;
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const existing = models.find((m) => m.id === id) ?? TEST_MODEL;
const updated = { ...existing, ...(data as Partial<Model>) };
models = models.map((m) => (m.id === id ? updated : m));
return jsonResponse({ model: updated });
}
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
const id = url.pathname.split("/").pop()!;
models = models.filter((m) => m.id !== id);
return new Response(null, { status: 204 });
}
if (url.pathname === "/api/models" && call.method === "GET") {
const keyword = url.searchParams.get("keyword") ?? "";
const items = keyword ? models.filter((m) => `${m.name}${m.modelId}`.includes(keyword)) : models;
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
describe("ModelListPage", () => {
test("渲染模型列表页并请求模型数据", async () => {
const calls = createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => {
expect(screen.getByText("GPT-4o")).not.toBeNull();
});
expect(screen.getByPlaceholderText("搜索模型名称或 ID")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull();
expect(calls.some((call) => call.url.includes("/api/models"))).toBe(true);
}, 15000);
test("搜索模型更新请求参数", async () => {
const calls = createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("搜索模型名称或 ID"), { target: { value: "gpt" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
}, 15000);
test("新建模型弹窗可以打开", async () => {
createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建模型/ }));
await waitFor(() => expect(screen.getByPlaceholderText("请输入模型名称")).not.toBeNull());
}, 15000);
});