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