refactor: 统一管理页面布局 — FilterToolbar + usePageSearchParams + parseListParams

This commit is contained in:
2026-06-04 17:25:36 +08:00
parent 61b479e2be
commit 6f547560d1
40 changed files with 1805 additions and 628 deletions

View File

@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import type { RuntimeMode } from "../../src/shared/api";
import { parseListParams } from "../../src/server/helpers/list-params";
const mode: RuntimeMode = "test";
function makeUrl(params: Record<string, string> = {}): URL {
const sp = new URLSearchParams(params);
return new URL(`http://localhost/api/test?${sp.toString()}`);
}
describe("parseListParams", () => {
test("returns defaults when no params provided", () => {
const url = makeUrl();
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
expect(result.keyword).toBeUndefined();
expect(result.sortBy).toBeUndefined();
expect(result.sortOrder).toBeUndefined();
});
test("parses valid pagination params", () => {
const url = makeUrl({ page: "2", pageSize: "50" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.page).toBe(2);
expect(result.pageSize).toBe(50);
});
test("parses keyword param", () => {
const url = makeUrl({ keyword: "test" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.keyword).toBe("test");
});
test("keyword empty string becomes undefined", () => {
const url = makeUrl({ keyword: "" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.keyword).toBeUndefined();
});
test("parses valid sort params", () => {
const url = makeUrl({ sortBy: "name", sortOrder: "asc" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("name");
expect(result.sortOrder).toBe("asc");
});
test("parses desc sortOrder", () => {
const url = makeUrl({ sortBy: "createdAt", sortOrder: "desc" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortOrder).toBe("desc");
});
test("sortBy without sortOrder returns undefined sortOrder", () => {
const url = makeUrl({ sortBy: "name" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("name");
expect(result.sortOrder).toBeUndefined();
});
test("rejects invalid page", () => {
const url = makeUrl({ page: "0" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(400);
});
test("rejects negative page", () => {
const url = makeUrl({ page: "-1" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects non-integer page", () => {
const url = makeUrl({ page: "1.5" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects pageSize over 200", () => {
const url = makeUrl({ pageSize: "201" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects invalid sortOrder", () => {
const url = makeUrl({ sortBy: "name", sortOrder: "invalid" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects sortBy not in whitelist", () => {
const url = makeUrl({ sortBy: "evil" });
const result = parseListParams(url, mode, { allowedSortBy: ["name", "createdAt"] });
expect(result).toBeInstanceOf(Response);
});
test("allows sortBy in whitelist", () => {
const url = makeUrl({ sortBy: "name" });
const result = parseListParams(url, mode, { allowedSortBy: ["name", "createdAt"] });
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("name");
});
test("allows sortBy when no whitelist provided", () => {
const url = makeUrl({ sortBy: "anything" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("anything");
});
test("parses all params together", () => {
const url = makeUrl({ keyword: "hello", page: "3", pageSize: "10", sortBy: "createdAt", sortOrder: "desc" });
const result = parseListParams(url, mode, { allowedSortBy: ["createdAt"] });
if (result instanceof Response) throw new Error("Should not return Response");
expect(result).toEqual({
keyword: "hello",
page: 3,
pageSize: 10,
sortBy: "createdAt",
sortOrder: "desc",
});
});
});

View File

@@ -130,6 +130,48 @@ describe("models API routes", () => {
});
});
test("GET /api/models sortBy + sortOrder", async () => {
await withRouteDb(async (db) => {
const p = seedProvider(db, "SortP");
createTestModel(db, "Beta", p);
createTestModel(db, "Alpha", p);
const req = new Request("http://localhost/api/models?page=1&pageSize=20&sortBy=name&sortOrder=asc");
const res = await listModelsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Model[] };
expect(body.items[0]!.name).toBe("Alpha");
expect(body.items[1]!.name).toBe("Beta");
});
});
test("GET /api/models filter by capabilities", async () => {
await withRouteDb(async (db) => {
const p = seedProvider(db, "CapP");
createModel(db, { capabilities: ["text"], modelId: "text-1", name: "TextModel", providerId: p }, LOG);
createModel(
db,
{ capabilities: ["reasoning"], modelId: "reasoning-1", name: "ReasoningModel", providerId: p },
LOG,
);
const req = new Request("http://localhost/api/models?page=1&pageSize=20&capabilities=text");
const res = await listModelsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Model[]; total: number };
expect(body.total).toBe(1);
expect(body.items[0]!.name).toBe("TextModel");
});
});
test("GET /api/models rejects invalid sortBy", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/models?page=1&pageSize=20&sortBy=evil");
const res = await listModelsViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("GET /api/models filter by providerId", async () => {
await withRouteDb(async (db) => {
const p1 = seedProvider(db, "P1");

View File

@@ -131,6 +131,55 @@ describe("供应商 API 路由", () => {
});
});
test("GET /api/providers sortBy + sortOrder", async () => {
await withRouteDb(async (db) => {
createTestProvider(db, "Beta");
createTestProvider(db, "Alpha");
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=name&sortOrder=asc");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Provider[] };
expect(body.items[0]!.name).toBe("Alpha");
expect(body.items[1]!.name).toBe("Beta");
});
});
test("GET /api/providers filter by type", async () => {
await withRouteDb(async (db) => {
createTestProvider(db, "OpenAI Provider");
const compatResult = createProvider(
db,
{ apiKey: "sk-test", baseUrl: "https://compat.test.com", name: "Compat", type: "openai-compatible" },
LOG,
);
if ("error" in compatResult) throw new Error(compatResult.error);
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&type=openai");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Provider[]; total: number };
expect(body.total).toBe(1);
expect(body.items[0]!.name).toBe("OpenAI Provider");
});
});
test("GET /api/providers rejects invalid sortBy", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=evil");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("GET /api/providers rejects invalid sortOrder", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=name&sortOrder=invalid");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("GET /api/providers/options 返回最小字段", async () => {
await withRouteDb(async (db) => {
createTestProvider(db, "选项供应商");

View File

@@ -0,0 +1,106 @@
import { fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "bun:test";
import { createElement } from "react";
import { FilterToolbar } from "../../src/web/shared/components/FilterToolbar";
import { renderWithProviders } from "./test-utils";
describe("FilterToolbar", () => {
it("renders filter Select with placeholder", () => {
const { getByText } = renderWithProviders(
createElement(FilterToolbar, {
filters: [
{
key: "status",
label: "状态",
onChange: () => {},
options: [
{ label: "进行中", value: "active" },
{ label: "已归档", value: "archived" },
],
placeholder: "选择状态",
},
],
}),
);
expect(getByText("选择状态")).toBeTruthy();
});
it("renders search input with placeholder", () => {
const { getByPlaceholderText } = renderWithProviders(
createElement(FilterToolbar, {
search: {
onReset: () => {},
onSearch: () => {},
placeholder: "搜索名称",
},
}),
);
expect(getByPlaceholderText("搜索名称")).toBeTruthy();
});
it("renders reset button", () => {
const { container } = renderWithProviders(
createElement(FilterToolbar, {
search: {
onReset: () => {},
onSearch: () => {},
placeholder: "搜索",
},
}),
);
const resetBtn = container.querySelector('button[title="重置"]');
expect(resetBtn).toBeTruthy();
});
it("renders action buttons", () => {
const { container } = renderWithProviders(
createElement(FilterToolbar, {
actions: createElement("button", null, "新建项目"),
search: {
onReset: () => {},
onSearch: () => {},
placeholder: "搜索",
},
}),
);
const buttons = container.querySelectorAll("button");
const hasAction = Array.from(buttons).some((btn) => btn.textContent?.includes("新建项目"));
expect(hasAction).toBe(true);
});
it("calls onSearch when search entered after typing", () => {
const onSearch = vi.fn();
const onReset = vi.fn();
const { getByPlaceholderText } = renderWithProviders(
createElement(FilterToolbar, {
search: {
keyword: "",
onReset,
onSearch,
placeholder: "搜索名称",
},
}),
);
const input = getByPlaceholderText("搜索名称");
fireEvent.change(input, { target: { value: "test" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSearch).toHaveBeenCalledWith("test");
});
it("calls onReset when reset button clicked", () => {
const onReset = vi.fn();
const { container } = renderWithProviders(
createElement(FilterToolbar, {
search: {
onReset,
onSearch: () => {},
placeholder: "搜索",
},
}),
);
const resetBtn = container.querySelector<HTMLButtonElement>('button[title="重置"]');
resetBtn?.click();
expect(onReset).toHaveBeenCalled();
});
});

View File

@@ -74,9 +74,9 @@ function renderModelTable(overrides?: Record<string, unknown>) {
createElement(ModelTable, {
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
loading: false,
onChange: () => undefined,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
onPageChange: () => undefined,
page: 1,
pageSize: 20,
providers: [OPENAI_PROVIDER_OPTION, DEEPSEEK_PROVIDER_OPTION],
@@ -90,9 +90,9 @@ function renderProviderTable(overrides?: Record<string, unknown>) {
createElement(ProviderTable, {
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
loading: false,
onChange: () => undefined,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
onPageChange: () => undefined,
page: 1,
pageSize: 20,
...overrides,

25
tests/web/format.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "bun:test";
import { formatDatetime } from "../../src/web/shared/utils/format";
describe("formatDatetime", () => {
it("formats ISO date string to YYYY-MM-DD HH:mm:ss", () => {
expect(formatDatetime("2024-06-15T14:30:45.123Z")).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it("pads single-digit month, day, hour, minute, second", () => {
const result = formatDatetime("2024-01-05T09:08:07.000Z");
expect(result).toMatch(/2024-0[1-9]-0[1-9] 0[0-9]:0[0-9]:0[0-9]/);
});
it("handles end-of-year date", () => {
const result = formatDatetime("2024-12-31T23:59:59.000Z");
expect(result).toContain("2024");
expect(result).toContain("59:59");
});
it("produces consistent output format", () => {
const result = formatDatetime("2024-06-15T14:30:45.123Z");
expect(result.length).toBe(19);
});
});

View File

@@ -310,8 +310,9 @@ describe("ModelListPage", () => {
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*索/ }));
const input = screen.getByPlaceholderText("搜索模型名称或 ID");
fireEvent.change(input, { target: { value: "gpt" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
}, 15000);

View File

@@ -114,7 +114,7 @@ function LocationProbe() {
}
describe("ProjectsPage", () => {
test("渲染项目管理入口并按状态请求项目列表", async () => {
test("渲染项目管理入口并展示项目列表", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
@@ -123,27 +123,34 @@ describe("ProjectsPage", () => {
expect(screen.getByText("活跃项目")).not.toBeNull();
});
expect(screen.getByText("归档")).not.toBeNull();
expect(screen.getByText("归档项目")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
expect(screen.getByPlaceholderText("搜索名称或描述")).not.toBeNull();
expect(calls.some((call) => call.url.includes("status=active"))).toBe(true);
expect(calls.filter((call) => !call.url.includes("/api/meta")).length).toBeGreaterThan(0);
});
test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => {
test("搜索和状态筛选会更新请求参数与用户可见结果", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
fireEvent.change(searchInput1, { target: { value: "归档" } });
fireEvent.keyDown(searchInput1, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
fireEvent.click(screen.getByText("已归档"));
const statusLabels = screen.getAllByText("状态");
const selectLabel = statusLabels.find((el) => el.closest(".ant-select"));
if (selectLabel) fireEvent.mouseDown(selectLabel);
await waitFor(() => {
const archivedOptions = screen.getAllByText("已归档");
const dropdownOption = archivedOptions.find((el) => el.closest(".ant-select-item"));
if (dropdownOption) fireEvent.click(dropdownOption);
});
await waitFor(() => expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true));
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true);
expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true);
});
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
@@ -152,12 +159,19 @@ describe("ProjectsPage", () => {
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
fireEvent.change(searchInput2, { target: { value: "归档" } });
fireEvent.keyDown(searchInput2, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
const clearButton = searchInput3.closest(".ant-input-search")?.querySelector(".ant-input-clear-icon");
if (clearButton) fireEvent.click(clearButton);
await waitFor(() => {
const lastProjectCall = [...calls].reverse().find((call) => call.url.includes("/api/projects"));
expect(lastProjectCall && !lastProjectCall.url.includes("keyword=")).toBe(true);
});
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
});
@@ -249,13 +263,12 @@ describe("ProjectsPage", () => {
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
loading: false,
onArchive,
onChange: () => undefined,
onDelete,
onEdit: () => undefined,
onPageChange: () => undefined,
onRestore,
page: 1,
pageSize: 20,
status: "active",
}),
),
);

View File

@@ -205,8 +205,9 @@ describe("ProviderListPage", () => {
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*索/ }));
const input = screen.getByPlaceholderText("搜索供应商名称");
fireEvent.change(input, { target: { value: "Open" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
}, 15000);

View File

@@ -0,0 +1,53 @@
import { XProvider } from "@ant-design/x";
import { act, renderHook } from "@testing-library/react";
import { App } from "antd";
import { describe, expect, it } from "bun:test";
import { createElement } from "react";
import { useConfirmAction } from "../../src/web/shared/hooks/useConfirmAction";
function createWrapper() {
return ({ children }: { children: React.ReactNode }) =>
createElement(XProvider, null, createElement(App, null, children));
}
describe("useConfirmAction", () => {
it("calls action successfully and resolves", async () => {
let resolved = false;
const action = () => {
resolved = true;
};
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
await act(() => result.current.confirmAction(action, "成功"));
expect(resolved).toBe(true);
});
it("handles action error without throwing", async () => {
const action = () => {
throw new Error("失败");
};
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
await act(() => result.current.confirmAction(action, "成功"));
expect(resolved).toBe(true);
});
it("handles action error without throwing", async () => {
const action = () => {
throw new Error("失败");
};
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
let threw = false;
try {
await act(async () => {
await result.current.confirmAction(action, "成功");
});
} catch {
threw = true;
}
expect(threw).toBe(false);
});
});

View File

@@ -0,0 +1,86 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "bun:test";
import { createElement } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { usePageSearchParams } from "../../src/web/shared/hooks/usePageSearchParams";
function renderWithRouter(initialPath: string) {
return renderHook(() => usePageSearchParams(), {
wrapper: ({ children }) =>
createElement(
MemoryRouter,
{ initialEntries: [initialPath] },
createElement(Routes, null, createElement(Route, { element: children, path: "*" })),
),
});
}
function renderWithRouterAndDefaults(initialPath: string, defaults: Record<string, string>) {
return renderHook(() => usePageSearchParams({ defaults }), {
wrapper: ({ children }) =>
createElement(
MemoryRouter,
{ initialEntries: [initialPath] },
createElement(Routes, null, createElement(Route, { element: children, path: "*" })),
),
});
}
describe("usePageSearchParams", () => {
it("returns empty params when URL has no search params", () => {
const { result } = renderWithRouter("/test");
expect(result.current.params).toEqual({});
});
it("parses existing URL search params", () => {
const { result } = renderWithRouter("/test?page=2&keyword=abc");
expect(result.current.params).toEqual({ keyword: "abc", page: "2" });
});
it("merges defaults for missing keys", () => {
const { result } = renderWithRouterAndDefaults("/test", { page: "1", pageSize: "20" });
expect(result.current.params).toEqual({ page: "1", pageSize: "20" });
});
it("URL value takes precedence over default", () => {
const { result } = renderWithRouterAndDefaults("/test?page=3", { page: "1" });
expect(result.current.params).toEqual({ page: "3" });
});
it("setParam updates a single param", () => {
const { result } = renderWithRouter("/test");
act(() => result.current.setParam("keyword", "hello"));
expect(result.current.params["keyword"]).toBe("hello");
});
it("setParam with undefined removes the param", () => {
const { result } = renderWithRouter("/test?keyword=hello");
act(() => result.current.setParam("keyword", undefined));
expect(result.current.params["keyword"]).toBeUndefined();
});
it("setParam with empty string removes the param", () => {
const { result } = renderWithRouter("/test?keyword=hello");
act(() => result.current.setParam("keyword", ""));
expect(result.current.params["keyword"]).toBeUndefined();
});
it("setParams updates multiple params at once", () => {
const { result } = renderWithRouter("/test");
act(() => result.current.setParams({ keyword: "test", page: "2" }));
expect(result.current.params).toEqual({ keyword: "test", page: "2" });
});
it("setParams preserves existing params", () => {
const { result } = renderWithRouter("/test?existing=yes");
act(() => result.current.setParams({ page: "3" }));
expect(result.current.params).toEqual({ existing: "yes", page: "3" });
});
it("resetAll clears all params", () => {
const { result } = renderWithRouter("/test?page=5&keyword=abc");
act(() => result.current.resetAll());
expect(result.current.params).toEqual({});
});
});