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

View File

@@ -0,0 +1,222 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import type { Provider } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ENABLED_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",
};
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
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",
});
});
});
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",
};
function createProviderFetchMock() {
let providers = [TEST_PROVIDER];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/providers" && call.method === "POST") {
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const created: Provider = {
...TEST_PROVIDER,
...data,
createdAt: "2024-01-02T00:00:00.000Z",
id: "pv-new",
updatedAt: "2024-01-02T00:00:00.000Z",
};
providers = [created, ...providers];
return jsonResponse({ provider: created }, { status: 201 });
}
if (/^\/api\/providers\/[^/]+$/.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 = providers.find((p) => p.id === id) ?? TEST_PROVIDER;
const updated = { ...existing, ...(data as Partial<Provider>) };
providers = providers.map((p) => (p.id === id ? updated : p));
return jsonResponse({ provider: updated });
}
if (/^\/api\/providers\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
const id = url.pathname.split("/").pop()!;
providers = providers.filter((p) => p.id !== id);
return new Response(null, { status: 204 });
}
if (url.pathname === "/api/providers" && call.method === "GET") {
const keyword = url.searchParams.get("keyword") ?? "";
const items = keyword ? providers.filter((p) => p.name.includes(keyword)) : providers;
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
if (/\/api\/providers\/[^/]+\/test$/.exec(url.pathname) && call.method === "POST") {
return jsonResponse({ message: "连接成功", ok: true });
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
describe("ProviderListPage", () => {
test("渲染供应商列表页并请求供应商数据", async () => {
const calls = createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => {
expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0);
});
expect(screen.getByPlaceholderText("搜索供应商名称")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull();
expect(calls.some((call) => call.url.includes("/api/providers"))).toBe(true);
}, 15000);
test("搜索供应商更新请求参数", async () => {
const calls = createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
fireEvent.change(screen.getByPlaceholderText("搜索供应商名称"), { target: { value: "Open" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
}, 15000);
test("新建供应商弹窗可以打开", async () => {
createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建供应商/ }));
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
}, 15000);
});