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:
2026-05-28 22:33:03 +08:00
parent d33eb00377
commit 6cb378d7cb
26 changed files with 618 additions and 120 deletions

View File

@@ -120,6 +120,17 @@ token 和 CSS 变量规则:
## 运行时外壳规范
前端提供两个入口外壳,共享通用 Console Shell 组件:
- **Admin管理台**`src/web/consoles/admin/AdminConsoleLayout.tsx`,菜单配置在 `menu.tsx`,路由 `/``/projects`
- **Workbench工作台**`src/web/consoles/workbench/WorkbenchProjectGate.tsx``WorkbenchConsoleLayout.tsx`,菜单配置和路由构造在 `routes.ts`,路由 `/workbench/:projectId`
通用 Console Shell`src/web/components/ConsoleShell/ConsoleShell.tsx`)包含 Layout、Header、Sider、Content、主题切换、版本展示和侧边栏折叠状态由 Admin 和 Workbench 复用。Header 显示品牌名、版本号和控制台标题Admin 显示"管理台"Workbench 显示"工作台 · 项目名")。
Sidebar`src/web/components/Sidebar/index.tsx`)是纯展示/导航组件,通过 `menuItems` props 接收菜单配置由调用方决定菜单内容和路径。Admin 传入静态路径 `/``/projects`Workbench 通过 route builder`buildWorkbenchPath`)将相对菜单路径拼成 `/workbench/:projectId` 的子路径。
Workbench 项目上下文通过 `ProjectContext` 提供,在 `WorkbenchProjectGate` 中从 URL path param 读取 `projectId`,通过 `useProject(projectId)` 加载项目,仅 active 项目渲染工作台布局,不存在或 archived 项目显示"项目不存在或不可访问"。
- 生产入口必须启用 `ErrorBoundary`,运行时渲染异常使用 antd `Result status="500"` 或等价组件展示。
- `ReactQueryDevtools` 仅在 `import.meta.env.DEV` 条件下渲染,不进入生产渲染路径。
- 主题切换统一通过 `ConfigProvider` 的 antd theme algorithm 控制,不使用硬编码主题色。

View File

@@ -32,9 +32,17 @@ bun run dev config.yaml
## 功能介绍
| 功能 | 路径 | 说明 |
| -------- | ----------- | ------------------------------------ |
| 仪表盘 | `/` | 应用总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 用户管理 | `/users` | 页面建设中 |
| 系统设置 | `/settings` | 页面建设中 |
| 功能 | 路径 | 说明 |
| ---------- | ----------------------- | ---------------------------------------- |
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 工作台总览 | `/workbench/:projectId` | Workbench 工作台总览,按项目维度查看信息 |
| 用户管理 | `/users` | 页面建设中 |
| 系统设置 | `/settings` | 页面建设中 |
平台提供两个入口:
- **Admin管理台**:全局管理视角,包含总览和项目管理。默认入口,访问 `/` 即可进入。
- **Workbench工作台**:项目维度视角,通过 `/workbench/:projectId` 进入指定项目的工作台。URL 可保存为浏览器书签,下次直接进入。仅 active 状态的项目可进入工作台archived 项目不可访问。
从项目管理页面的 active 项目行可点击"进入工作台"跳转到对应项目的工作台。

View File

@@ -27,6 +27,7 @@ export function createProject(
const db = wrap(raw);
const name = request.name.trim();
if (!name) return { error: "项目名称不能为空", status: 400 };
if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const description = (request.description ?? "").trim();
const id = crypto.randomUUID();
@@ -142,6 +143,7 @@ export function updateProject(
const name = request.name?.trim();
if (name === "") return { error: "项目名称不能为空", status: 400 };
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const updates: Partial<typeof projects.$inferInsert> = {
updatedAt: new Date().toISOString(),

View File

@@ -1,80 +1,13 @@
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import { useEffect } from "react";
import { APP } from "../shared/app";
import { Sidebar } from "./components/Sidebar";
import { useMeta } from "./hooks/use-meta";
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
import { AppRoutes } from "./routes";
const { Content, Header, Sider } = Layout;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
{ label: "黑暗", value: "dark" },
] as const;
export function App() {
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { collapsed, setCollapsed } = useSidebarCollapsed();
const { data: meta } = useMeta();
useEffect(() => {
document.title = APP.title;
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
}, []);
const handleThemeChange = (value: number | string) => {
setThemePreference(value as ThemePreference);
};
const versionDisplay = meta?.version ? `v${meta.version}` : null;
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
return (
<ConfigProvider locale={zhCN} theme={{ algorithm: themeAlgorithm }}>
<AntApp>
<Layout className="app-layout">
<Header className="app-header">
<div className="app-header-left">
<span className="app-brand-group">
<span className="app-brand">{APP.title}</span>
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
</span>
</div>
<div className="app-header-right">
<Segmented
onChange={handleThemeChange}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
value={themePreference}
/>
</div>
</Header>
<Layout>
<Sider
collapsed={collapsed}
collapsedWidth={64}
collapsible
onCollapse={(collapsed) => setCollapsed(collapsed)}
theme="light"
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
width={232}
>
<Sidebar />
</Sider>
<Layout>
<Content className="app-content">
<AppRoutes />
</Content>
</Layout>
</Layout>
</Layout>
</AntApp>
</ConfigProvider>
);
return <AppRoutes />;
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router";
export function ConsoleOutlet() {
return <Outlet />;
}

View File

@@ -0,0 +1,73 @@
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import type { ConsoleShellProps } from "./types";
import { APP } from "../../../shared/app";
import { useMeta } from "../../hooks/use-meta";
import { useSidebarCollapsed } from "../../hooks/use-sidebar-collapsed";
import { useThemePreference } from "../../hooks/use-theme-preference";
import { Sidebar } from "../Sidebar";
import { ConsoleOutlet } from "./ConsoleOutlet";
const { Content, Header, Sider } = Layout;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
{ label: "黑暗", value: "dark" },
] as const;
export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) {
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { collapsed, setCollapsed } = useSidebarCollapsed();
const { data: meta } = useMeta();
const versionDisplay = meta?.version ? `v${meta.version}` : null;
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
return (
<ConfigProvider locale={zhCN} theme={{ algorithm: themeAlgorithm }}>
<AntApp>
<Layout className="app-layout">
<Header className="app-header">
<div className="app-header-left">
<span className="app-brand-group">
<span className="app-brand">{APP.title}</span>
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
<span className="app-console-title">{title}</span>
</span>
</div>
<div className="app-header-right">
{headerExtra}
<Segmented
onChange={(value) => setThemePreference(value)}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
value={themePreference}
/>
</div>
</Header>
<Layout>
<Sider
collapsed={collapsed}
collapsedWidth={64}
collapsible
onCollapse={(c) => setCollapsed(c)}
theme="light"
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
width={232}
>
<Sidebar menuItems={menuItems} />
</Sider>
<Layout>
<Content className="app-content">
<ConsoleOutlet />
</Content>
</Layout>
</Layout>
</Layout>
</AntApp>
</ConfigProvider>
);
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import type { MenuItemConfig } from "../../menu";
export interface ConsoleShellProps {
headerExtra?: ReactNode;
menuItems: readonly MenuItemConfig[];
title: ReactNode;
}

View File

@@ -3,30 +3,34 @@ import type { MenuProps } from "antd";
import { Menu } from "antd";
import { useLocation, useNavigate } from "react-router";
import { MENU_ITEMS } from "../../menu";
import type { MenuItemConfig } from "../../menu";
type MenuItem = Required<MenuProps>["items"][number];
export function Sidebar() {
interface SidebarProps {
menuItems: readonly MenuItemConfig[];
}
export function Sidebar({ menuItems }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
const currentItem = menuItems.find((item) => item.path === currentPath);
const selectedKeys = currentItem ? [currentItem.value] : [];
const menuItems: MenuItem[] = MENU_ITEMS.map((item) => ({
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
icon: item.icon,
key: item.value,
label: item.label,
}));
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
const item = MENU_ITEMS.find((i) => i.value === key);
const item = menuItems.find((i) => i.value === key);
if (item) {
void navigate(item.path);
}
};
return <Menu items={menuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
}

View File

@@ -0,0 +1,6 @@
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
import { ADMIN_MENU_ITEMS } from "./menu";
export function AdminConsoleLayout() {
return <ConsoleShell menuItems={ADMIN_MENU_ITEMS} title="管理台" />;
}

View File

@@ -0,0 +1,9 @@
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
import { createElement } from "react";
import type { MenuItemConfig } from "../../menu";
export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" },
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
] as const;

View File

@@ -0,0 +1,17 @@
import { createContext, type ReactNode, useContext } from "react";
import type { Project } from "../../../shared/api";
const ProjectContext = createContext<null | Project>(null);
export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) {
return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>;
}
export function useCurrentProject(): Project {
const project = useContext(ProjectContext);
if (!project) {
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
}
return project;
}

View File

@@ -0,0 +1,37 @@
import { HomeOutlined } from "@ant-design/icons";
import { Button } from "antd";
import { useNavigate } from "react-router";
import type { Project } from "../../../shared/api";
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
import { ProjectProvider, useCurrentProject } from "./ProjectContext";
import { getWorkbenchMenuItems } from "./routes";
interface WorkbenchConsoleLayoutProps {
project: Project;
}
export function WorkbenchConsoleLayout({ project }: WorkbenchConsoleLayoutProps) {
const navigate = useNavigate();
const menuItems = getWorkbenchMenuItems(project.id);
return (
<ProjectProvider project={project}>
<ConsoleShell
headerExtra={
<Button icon={<HomeOutlined />} onClick={() => void navigate("/")} size="small" type="link">
</Button>
}
menuItems={menuItems}
title={<WorkbenchTitle />}
/>
</ProjectProvider>
);
}
function WorkbenchTitle() {
const project = useCurrentProject();
return <> · {project.name}</>;
}

View File

@@ -0,0 +1,47 @@
import { Alert, Button, Spin } from "antd";
import { useNavigate, useParams } from "react-router";
import { useProject } from "../../hooks/use-projects";
import { WorkbenchConsoleLayout } from "./WorkbenchConsoleLayout";
export function WorkbenchProjectGate() {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const { data: project, error, isLoading } = useProject(projectId ?? "");
if (!projectId) {
return <WorkbenchUnavailable onBack={() => void navigate("/")} />;
}
if (isLoading) {
return (
<div className="app-loading">
<Spin size="large" />
</div>
);
}
if (error || !project || project.status === "archived") {
return <WorkbenchUnavailable onBack={() => void navigate("/")} />;
}
return <WorkbenchConsoleLayout project={project} />;
}
function WorkbenchUnavailable({ onBack }: { onBack: () => void }) {
return (
<div className="app-unavailable">
<Alert
action={
<Button onClick={onBack} size="small" type="primary">
</Button>
}
description="请确认项目是否存在且未归档。"
showIcon
title="项目不存在或不可访问"
type="error"
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { DashboardOutlined } from "@ant-design/icons";
import { createElement } from "react";
import type { MenuItemConfig } from "../../menu";
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "总览", path: "", value: "overview" },
] as const;
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
const base = `/workbench/${projectId}`;
if (!relativePath || relativePath === "/") return base;
const normalized = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
return `${base}${normalized}`;
}
export function getWorkbenchMenuItems(projectId: string): readonly MenuItemConfig[] {
return WORKBENCH_MENU_ITEMS.map((item) => ({
...item,
path: buildWorkbenchPath(projectId, item.path || "/"),
}));
}

View File

@@ -1,16 +1,8 @@
import type { ReactElement } from "react";
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
import { createElement } from "react";
export interface MenuItemConfig {
icon: ReactElement;
label: string;
path: string;
value: string;
}
export const MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "仪表盘", path: "/", value: "dashboard" },
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
] as const;

View File

@@ -18,8 +18,8 @@ export function DashboardPage() {
return (
<Space size="large" vertical>
<Typography.Title level={2}>使 {APP.title}</Typography.Title>
<Typography.Paragraph> /api/meta </Typography.Paragraph>
<Typography.Title level={2}></Typography.Title>
<Typography.Paragraph>使 {APP.title} /api/meta </Typography.Paragraph>
{isLoading && <Spin size="large" />}
{error && <Alert description={error.message} showIcon title="加载失败" type="error" />}
{meta && (

View File

@@ -73,9 +73,9 @@ export function ProjectFormModal({
<Form.Item
label="项目名称"
name="name"
rules={[{ message: "项目名称不能为空", required: true, whitespace: true }]}
rules={[{ max: 10, message: "项目名称不能超过 10 个字符", required: true, whitespace: true }]}
>
<Input maxLength={100} placeholder="请输入项目名称" />
<Input maxLength={10} placeholder="请输入项目名称" />
</Form.Item>
<Form.Item label="项目描述" name="description">
<Input.TextArea autoSize={{ minRows: 5 }} maxLength={500} placeholder="请输入项目描述" />

View File

@@ -1,7 +1,8 @@
import type { ColumnsType } from "antd/es/table";
import { DeleteOutlined, EditOutlined, InboxOutlined, RedoOutlined } from "@ant-design/icons";
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
import { useNavigate } from "react-router";
import type { Project, ProjectListResponse } from "../../../../shared/api";
@@ -60,6 +61,7 @@ export function ProjectTable({
pageSize,
}: ProjectTableProps) {
const { message } = AntApp.useApp();
const navigate = useNavigate();
const handleArchive = async (id: string) => {
try {
@@ -95,6 +97,14 @@ export function ProjectTable({
if (record.status === "active") {
return (
<Space size="small">
<Button
icon={<LoginOutlined />}
onClick={() => void navigate(`/workbench/${record.id}`)}
size="small"
type="link"
>
</Button>
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
@@ -130,7 +140,7 @@ export function ProjectTable({
);
},
title: "操作",
width: 180,
width: 280,
};
return (

View File

@@ -0,0 +1,22 @@
import { Card, Descriptions, Space, Typography } from "antd";
import { useCurrentProject } from "../../consoles/workbench/ProjectContext";
export function WorkbenchOverviewPage() {
const project = useCurrentProject();
const items = [
{ children: project.name, key: "name", label: "项目名称" },
{ children: project.description || "暂无描述", key: "description", label: "项目描述" },
{ children: project.status === "active" ? "进行中" : "已归档", key: "status", label: "状态" },
];
return (
<Space size="large" vertical>
<Typography.Title level={2}></Typography.Title>
<Card>
<Descriptions column={1} items={items} title={project.name} />
</Card>
</Space>
);
}

View File

@@ -1,14 +1,22 @@
import { Route, Routes } from "react-router";
import { AdminConsoleLayout } from "./consoles/admin/AdminConsoleLayout";
import { WorkbenchProjectGate } from "./consoles/workbench/WorkbenchProjectGate";
import { NotFoundPage } from "./pages/404";
import { DashboardPage } from "./pages/dashboard";
import { ProjectsPage } from "./pages/projects";
import { WorkbenchOverviewPage } from "./pages/workbench";
export function AppRoutes() {
return (
<Routes>
<Route element={<DashboardPage />} path="/" />
<Route element={<ProjectsPage />} path="/projects" />
<Route element={<AdminConsoleLayout />}>
<Route element={<DashboardPage />} path="/" />
<Route element={<ProjectsPage />} path="/projects" />
</Route>
<Route element={<WorkbenchProjectGate />} path="/workbench/:projectId">
<Route element={<WorkbenchOverviewPage />} path="" />
</Route>
<Route element={<NotFoundPage />} path="*" />
</Routes>
);

View File

@@ -52,3 +52,23 @@ body {
.app-content {
padding: var(--ant-padding-xl) var(--ant-padding-xl);
}
.app-console-title {
color: var(--ant-color-text-secondary);
font-size: var(--ant-font-size);
font-weight: 400;
}
.app-unavailable {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.app-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}

View File

@@ -300,4 +300,63 @@ describe("项目数据访问层", () => {
rmSync(dir, { force: true, recursive: true });
}
});
test("创建项目名称超过 10 个字符失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const result = createProject(db, { name: "这是一个很长的名字" });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("创建项目名称刚好 10 个字符成功", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const result = createProject(db, { name: "一二三四五六七八九十" });
expect("error" in result).toBe(false);
const project = (result as { project: { name: string } }).project;
expect(project.name).toBe("一二三四五六七八九十");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("更新项目名称超过 10 个字符失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "短名" });
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: "这是一个很长的名字" });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("更新项目名称 trim 后为空失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const created = createProject(db, { name: "原名" });
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: " " });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能为空");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});

View File

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

View File

@@ -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("总览");
});
});

View File

@@ -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("进入工作台");
});
});

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