From 6cb378d7cbb5ccdf52103bd0d3179178d72a6feb Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 28 May 2026 22:33:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Admin/Workbench=20=E5=8F=8C=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽取 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 双入口说明 --- docs/development/frontend.md | 11 ++ docs/user/usage.md | 20 +++- src/server/db/projects.ts | 2 + src/web/app.tsx | 69 +---------- .../components/ConsoleShell/ConsoleOutlet.tsx | 5 + .../components/ConsoleShell/ConsoleShell.tsx | 73 +++++++++++ src/web/components/ConsoleShell/types.ts | 9 ++ src/web/components/Sidebar/index.tsx | 16 ++- src/web/consoles/admin/AdminConsoleLayout.tsx | 6 + src/web/consoles/admin/menu.tsx | 9 ++ src/web/consoles/workbench/ProjectContext.tsx | 17 +++ .../workbench/WorkbenchConsoleLayout.tsx | 37 ++++++ .../workbench/WorkbenchProjectGate.tsx | 47 ++++++++ src/web/consoles/workbench/routes.ts | 22 ++++ src/web/menu.tsx | 8 -- src/web/pages/dashboard/index.tsx | 4 +- .../projects/components/ProjectFormModal.tsx | 4 +- .../projects/components/ProjectTable.tsx | 14 ++- src/web/pages/workbench/index.tsx | 22 ++++ src/web/routes.tsx | 12 +- src/web/styles.css | 20 ++++ tests/server/db/projects.test.ts | 59 +++++++++ tests/web/App.test.tsx | 27 ++++- tests/web/components/Sidebar/index.test.tsx | 15 +-- tests/web/routes/projects.test.tsx | 97 +++++++++++++-- tests/web/routes/workbench.test.tsx | 113 ++++++++++++++++++ 26 files changed, 618 insertions(+), 120 deletions(-) create mode 100644 src/web/components/ConsoleShell/ConsoleOutlet.tsx create mode 100644 src/web/components/ConsoleShell/ConsoleShell.tsx create mode 100644 src/web/components/ConsoleShell/types.ts create mode 100644 src/web/consoles/admin/AdminConsoleLayout.tsx create mode 100644 src/web/consoles/admin/menu.tsx create mode 100644 src/web/consoles/workbench/ProjectContext.tsx create mode 100644 src/web/consoles/workbench/WorkbenchConsoleLayout.tsx create mode 100644 src/web/consoles/workbench/WorkbenchProjectGate.tsx create mode 100644 src/web/consoles/workbench/routes.ts create mode 100644 src/web/pages/workbench/index.tsx create mode 100644 tests/web/routes/workbench.test.tsx diff --git a/docs/development/frontend.md b/docs/development/frontend.md index b270fbb..e52ee5e 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -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 控制,不使用硬编码主题色。 diff --git a/docs/user/usage.md b/docs/user/usage.md index e3683b1..480b57f 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -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 项目行可点击"进入工作台"跳转到对应项目的工作台。 diff --git a/src/server/db/projects.ts b/src/server/db/projects.ts index 66472b4..8a00d9a 100644 --- a/src/server/db/projects.ts +++ b/src/server/db/projects.ts @@ -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 = { updatedAt: new Date().toISOString(), diff --git a/src/web/app.tsx b/src/web/app.tsx index 3223cf1..72bc72d 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -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 ( - - - -
-
- - {APP.title} - {versionDisplay && {versionDisplay}} - -
-
- ({ label: option.label, value: option.value }))} - value={themePreference} - /> -
-
- - setCollapsed(collapsed)} - theme="light" - trigger={collapsed ? : } - width={232} - > - - - - - - - - -
-
-
- ); + return ; } diff --git a/src/web/components/ConsoleShell/ConsoleOutlet.tsx b/src/web/components/ConsoleShell/ConsoleOutlet.tsx new file mode 100644 index 0000000..d6e184a --- /dev/null +++ b/src/web/components/ConsoleShell/ConsoleOutlet.tsx @@ -0,0 +1,5 @@ +import { Outlet } from "react-router"; + +export function ConsoleOutlet() { + return ; +} diff --git a/src/web/components/ConsoleShell/ConsoleShell.tsx b/src/web/components/ConsoleShell/ConsoleShell.tsx new file mode 100644 index 0000000..b0caaba --- /dev/null +++ b/src/web/components/ConsoleShell/ConsoleShell.tsx @@ -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 ( + + + +
+
+ + {APP.title} + {versionDisplay && {versionDisplay}} + {title} + +
+
+ {headerExtra} + setThemePreference(value)} + options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))} + value={themePreference} + /> +
+
+ + setCollapsed(c)} + theme="light" + trigger={collapsed ? : } + width={232} + > + + + + + + + + +
+
+
+ ); +} diff --git a/src/web/components/ConsoleShell/types.ts b/src/web/components/ConsoleShell/types.ts new file mode 100644 index 0000000..b0e0cfd --- /dev/null +++ b/src/web/components/ConsoleShell/types.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +import type { MenuItemConfig } from "../../menu"; + +export interface ConsoleShellProps { + headerExtra?: ReactNode; + menuItems: readonly MenuItemConfig[]; + title: ReactNode; +} diff --git a/src/web/components/Sidebar/index.tsx b/src/web/components/Sidebar/index.tsx index 939f22c..9c6538e 100644 --- a/src/web/components/Sidebar/index.tsx +++ b/src/web/components/Sidebar/index.tsx @@ -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["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 ; + return ; } diff --git a/src/web/consoles/admin/AdminConsoleLayout.tsx b/src/web/consoles/admin/AdminConsoleLayout.tsx new file mode 100644 index 0000000..3b8e91d --- /dev/null +++ b/src/web/consoles/admin/AdminConsoleLayout.tsx @@ -0,0 +1,6 @@ +import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell"; +import { ADMIN_MENU_ITEMS } from "./menu"; + +export function AdminConsoleLayout() { + return ; +} diff --git a/src/web/consoles/admin/menu.tsx b/src/web/consoles/admin/menu.tsx new file mode 100644 index 0000000..bdd409b --- /dev/null +++ b/src/web/consoles/admin/menu.tsx @@ -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; diff --git a/src/web/consoles/workbench/ProjectContext.tsx b/src/web/consoles/workbench/ProjectContext.tsx new file mode 100644 index 0000000..c8fd182 --- /dev/null +++ b/src/web/consoles/workbench/ProjectContext.tsx @@ -0,0 +1,17 @@ +import { createContext, type ReactNode, useContext } from "react"; + +import type { Project } from "../../../shared/api"; + +const ProjectContext = createContext(null); + +export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) { + return {children}; +} + +export function useCurrentProject(): Project { + const project = useContext(ProjectContext); + if (!project) { + throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用"); + } + return project; +} diff --git a/src/web/consoles/workbench/WorkbenchConsoleLayout.tsx b/src/web/consoles/workbench/WorkbenchConsoleLayout.tsx new file mode 100644 index 0000000..c40d26f --- /dev/null +++ b/src/web/consoles/workbench/WorkbenchConsoleLayout.tsx @@ -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 ( + + } onClick={() => void navigate("/")} size="small" type="link"> + 返回管理台 + + } + menuItems={menuItems} + title={} + /> + + ); +} + +function WorkbenchTitle() { + const project = useCurrentProject(); + return <>工作台 · {project.name}; +} diff --git a/src/web/consoles/workbench/WorkbenchProjectGate.tsx b/src/web/consoles/workbench/WorkbenchProjectGate.tsx new file mode 100644 index 0000000..8ff4a81 --- /dev/null +++ b/src/web/consoles/workbench/WorkbenchProjectGate.tsx @@ -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 void navigate("/")} />; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !project || project.status === "archived") { + return void navigate("/")} />; + } + + return ; +} + +function WorkbenchUnavailable({ onBack }: { onBack: () => void }) { + return ( +
+ + 返回管理台 + + } + description="请确认项目是否存在且未归档。" + showIcon + title="项目不存在或不可访问" + type="error" + /> +
+ ); +} diff --git a/src/web/consoles/workbench/routes.ts b/src/web/consoles/workbench/routes.ts new file mode 100644 index 0000000..8465244 --- /dev/null +++ b/src/web/consoles/workbench/routes.ts @@ -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 || "/"), + })); +} diff --git a/src/web/menu.tsx b/src/web/menu.tsx index b45f694..3143812 100644 --- a/src/web/menu.tsx +++ b/src/web/menu.tsx @@ -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; diff --git a/src/web/pages/dashboard/index.tsx b/src/web/pages/dashboard/index.tsx index 50abf71..54f76a9 100644 --- a/src/web/pages/dashboard/index.tsx +++ b/src/web/pages/dashboard/index.tsx @@ -18,8 +18,8 @@ export function DashboardPage() { return ( - 欢迎使用 {APP.title} - 在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例): + 总览 + 欢迎使用 {APP.title}。以下是 /api/meta 的返回数据(前后端联调示例): {isLoading && } {error && } {meta && ( diff --git a/src/web/pages/projects/components/ProjectFormModal.tsx b/src/web/pages/projects/components/ProjectFormModal.tsx index f00053b..511c5e8 100644 --- a/src/web/pages/projects/components/ProjectFormModal.tsx +++ b/src/web/pages/projects/components/ProjectFormModal.tsx @@ -73,9 +73,9 @@ export function ProjectFormModal({ - + diff --git a/src/web/pages/projects/components/ProjectTable.tsx b/src/web/pages/projects/components/ProjectTable.tsx index a9bc1b0..fb559d6 100644 --- a/src/web/pages/projects/components/ProjectTable.tsx +++ b/src/web/pages/projects/components/ProjectTable.tsx @@ -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 ( + @@ -130,7 +140,7 @@ export function ProjectTable({ ); }, title: "操作", - width: 180, + width: 280, }; return ( diff --git a/src/web/pages/workbench/index.tsx b/src/web/pages/workbench/index.tsx new file mode 100644 index 0000000..df07ca2 --- /dev/null +++ b/src/web/pages/workbench/index.tsx @@ -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 ( + + 总览 + + + + + ); +} diff --git a/src/web/routes.tsx b/src/web/routes.tsx index e51b2e5..3e7ec10 100644 --- a/src/web/routes.tsx +++ b/src/web/routes.tsx @@ -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 ( - } path="/" /> - } path="/projects" /> + }> + } path="/" /> + } path="/projects" /> + + } path="/workbench/:projectId"> + } path="" /> + } path="*" /> ); diff --git a/src/web/styles.css b/src/web/styles.css index 0c0c3a4..74b94d8 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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; +} diff --git a/tests/server/db/projects.test.ts b/tests/server/db/projects.test.ts index 15274a5..a08ff73 100644 --- a/tests/server/db/projects.test.ts +++ b/tests/server/db/projects.test.ts @@ -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 }); + } + }); }); diff --git a/tests/web/App.test.tsx b/tests/web/App.test.tsx index bb6d2b8..7febce5 100644 --- a/tests/web/App.test.tsx +++ b/tests/web/App.test.tsx @@ -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(); + }); }); diff --git a/tests/web/components/Sidebar/index.test.tsx b/tests/web/components/Sidebar/index.test.tsx index 579c1c4..1839038 100644 --- a/tests/web/components/Sidebar/index.test.tsx +++ b/tests/web/components/Sidebar/index.test.tsx @@ -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("总览"); }); }); diff --git a/tests/web/routes/projects.test.tsx b/tests/web/routes/projects.test.tsx index 5ef10e2..d9c7d5f 100644 --- a/tests/web/routes/projects.test.tsx +++ b/tests/web/routes/projects.test.tsx @@ -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("进入工作台"); + }); }); diff --git a/tests/web/routes/workbench.test.tsx b/tests/web/routes/workbench.test.tsx new file mode 100644 index 0000000..6cf2014 --- /dev/null +++ b/tests/web/routes/workbench.test.tsx @@ -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); + }); +});