feat: Admin/Workbench 双入口架构
- 抽取 ConsoleShell 共享外壳(Layout/Header/Sider/主题切换/侧边栏折叠) - Sidebar 纯化为接受 menuItems prop 的展示组件 - Admin 管理台:/ 总览 + /projects 项目管理 - Workbench 工作台:/workbench/:projectId 项目作用域 - WorkbenchProjectGate 入口守卫(loading/error/archived/不存在拦截) - ProjectContext 提供当前项目上下文 - 项目管理表格 active 行增加'进入工作台'按钮 - 项目名称 trim 后最多 10 字符(前后端一致) - Workbench 总览页展示项目 Descriptions - Header 区分:管理台显示副标题,工作台显示项目名 + 返回管理台按钮 - 28/28 前端测试通过 - 文档更新:frontend.md ConsoleShell 规范、usage.md 双入口说明
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
@@ -9,7 +8,7 @@ import { renderWithProviders } from "./test-utils";
|
||||
|
||||
describe("App", () => {
|
||||
test("渲染 Layout 骨架和品牌名", () => {
|
||||
window.fetch = (async () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
@@ -27,8 +26,8 @@ describe("App", () => {
|
||||
expect(screen.getByText("黑暗")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("渲染侧边栏菜单项", () => {
|
||||
window.fetch = (async () => {
|
||||
test("渲染 Admin 侧边栏菜单项", () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
@@ -40,12 +39,12 @@ describe("App", () => {
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("总览").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Sider 渲染侧边栏菜单", () => {
|
||||
window.fetch = (async () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
@@ -62,4 +61,20 @@ describe("App", () => {
|
||||
const menu = document.querySelector(".ant-menu");
|
||||
expect(menu).not.toBeNull();
|
||||
});
|
||||
|
||||
test("Admin header 显示管理台标题", () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getByText("管理台")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,18 +3,19 @@ import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { Sidebar } from "../../../../src/web/components/Sidebar";
|
||||
import { ADMIN_MENU_ITEMS } from "../../../../src/web/consoles/admin/menu";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("Sidebar", () => {
|
||||
test("渲染菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar));
|
||||
test("渲染 Admin 菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }));
|
||||
|
||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
||||
expect(screen.getByText("总览")).not.toBeNull();
|
||||
expect(screen.getByText("项目管理")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("项目管理菜单项可导航到 /projects", () => {
|
||||
renderWithProviders(createElement(Sidebar), {
|
||||
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
|
||||
initialRoute: "/projects",
|
||||
});
|
||||
|
||||
@@ -23,13 +24,13 @@ describe("Sidebar", () => {
|
||||
expect(activeItem?.textContent).toContain("项目管理");
|
||||
});
|
||||
|
||||
test("高亮当前路由对应的菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar), {
|
||||
test("高亮当前路由对应的总览菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
|
||||
initialRoute: "/",
|
||||
});
|
||||
|
||||
const activeItem = document.querySelector(".ant-menu-item-selected");
|
||||
expect(activeItem).not.toBeNull();
|
||||
expect(activeItem?.textContent).toContain("仪表盘");
|
||||
expect(activeItem?.textContent).toContain("总览");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,89 @@
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { ProjectsPage } from "../../../src/web/pages/projects";
|
||||
import { App } from "../../../src/web/app";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
const ACTIVE_PROJECT = {
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "",
|
||||
id: "p1",
|
||||
name: "活跃项目",
|
||||
status: "active",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const ARCHIVED_PROJECT = {
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "",
|
||||
id: "p2",
|
||||
name: "归档项目",
|
||||
status: "archived",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createMockHandler(projectList?: unknown[]) {
|
||||
const handler = (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/meta")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
service: "test-app",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "0.1.0",
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" }, status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/projects")) {
|
||||
const items = projectList ?? [];
|
||||
return new Response(JSON.stringify({ items, page: 1, pageSize: 10, total: items.length }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "Not Found" }), {
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
const mocked = handler as unknown as typeof fetch;
|
||||
globalThis.fetch = mocked;
|
||||
window.fetch = mocked;
|
||||
}
|
||||
|
||||
describe("ProjectsPage", () => {
|
||||
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
|
||||
renderWithProviders(createElement(ProjectsPage));
|
||||
createMockHandler();
|
||||
|
||||
expect(screen.getByText("进行中")).not.toBeNull();
|
||||
expect(screen.getByText("已归档")).not.toBeNull();
|
||||
expect(screen.getByText("新建项目")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("搜索项目名称或描述")).not.toBeNull();
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const body = document.body.textContent ?? "";
|
||||
expect(body).toContain("项目名称");
|
||||
expect(screen.getByText("进行中")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
expect(screen.getByText("已归档")).not.toBeNull();
|
||||
expect(screen.getByText("新建项目")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("搜索项目名称或描述")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("新建按钮点击打开弹窗", async () => {
|
||||
renderWithProviders(createElement(ProjectsPage));
|
||||
createMockHandler();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("进行中")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const createBtn = screen.getByRole("button", { name: /新建项目/ });
|
||||
fireEvent.click(createBtn);
|
||||
createBtn.click();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
@@ -36,4 +92,23 @@ describe("ProjectsPage", () => {
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("active 项目行显示'进入工作台',archived 行不显示", async () => {
|
||||
createMockHandler([ACTIVE_PROJECT, ARCHIVED_PROJECT]);
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText("活跃项目")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const enterBtns = screen.getAllByText("进入工作台");
|
||||
expect(enterBtns.length).toBe(1);
|
||||
|
||||
const archivedRow = screen.getByText("归档项目").closest("tr");
|
||||
expect(archivedRow?.textContent).not.toContain("进入工作台");
|
||||
});
|
||||
});
|
||||
|
||||
113
tests/web/routes/workbench.test.tsx
Normal file
113
tests/web/routes/workbench.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { App } from "../../../src/web/app";
|
||||
import { ProjectProvider } from "../../../src/web/consoles/workbench/ProjectContext";
|
||||
import { WorkbenchOverviewPage } from "../../../src/web/pages/workbench";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
const MOCK_PROJECT = {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "测试项目",
|
||||
id: "test-project-id",
|
||||
name: "测试项目",
|
||||
status: "active" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createMockHandler(overrides?: { archivedAt?: string; status?: "active" | "archived" }) {
|
||||
const project = { ...MOCK_PROJECT, ...overrides };
|
||||
const handler = (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/meta")) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{ headers: { "Content-Type": "application/json" }, status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes(`/api/projects/${project.id}`)) {
|
||||
return new Response(JSON.stringify({ project }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "Not Found" }), { status: 404 });
|
||||
};
|
||||
const mocked = handler as unknown as typeof fetch;
|
||||
globalThis.fetch = mocked;
|
||||
window.fetch = mocked;
|
||||
}
|
||||
|
||||
describe("Workbench 路由", () => {
|
||||
test("active 项目可进入 Workbench 并展示总览", async () => {
|
||||
createMockHandler();
|
||||
|
||||
renderWithProviders(createElement(App), {
|
||||
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const body = document.body.textContent ?? "";
|
||||
expect(body).toContain("工作台");
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("Workbench 显示返回管理台按钮", async () => {
|
||||
createMockHandler();
|
||||
|
||||
renderWithProviders(createElement(App), {
|
||||
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("返回管理台")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("不存在项目显示不可访问", async () => {
|
||||
createMockHandler();
|
||||
|
||||
renderWithProviders(createElement(App), {
|
||||
initialRoute: "/workbench/nonexistent-id",
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("项目不存在或不可访问")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("archived 项目显示不可访问", async () => {
|
||||
createMockHandler({ archivedAt: "2024-06-01T00:00:00.000Z", status: "archived" });
|
||||
|
||||
renderWithProviders(createElement(App), {
|
||||
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("项目不存在或不可访问")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("Workbench 总览页标题显示'总览'", () => {
|
||||
renderWithProviders(
|
||||
createElement(ProjectProvider, { children: createElement(WorkbenchOverviewPage), project: MOCK_PROJECT }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("总览")).not.toBeNull();
|
||||
expect(screen.getAllByText(MOCK_PROJECT.name).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user