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:
@@ -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"` 或等价组件展示。
|
- 生产入口必须启用 `ErrorBoundary`,运行时渲染异常使用 antd `Result status="500"` 或等价组件展示。
|
||||||
- `ReactQueryDevtools` 仅在 `import.meta.env.DEV` 条件下渲染,不进入生产渲染路径。
|
- `ReactQueryDevtools` 仅在 `import.meta.env.DEV` 条件下渲染,不进入生产渲染路径。
|
||||||
- 主题切换统一通过 `ConfigProvider` 的 antd theme algorithm 控制,不使用硬编码主题色。
|
- 主题切换统一通过 `ConfigProvider` 的 antd theme algorithm 控制,不使用硬编码主题色。
|
||||||
|
|||||||
@@ -32,9 +32,17 @@ bun run dev config.yaml
|
|||||||
|
|
||||||
## 功能介绍
|
## 功能介绍
|
||||||
|
|
||||||
| 功能 | 路径 | 说明 |
|
| 功能 | 路径 | 说明 |
|
||||||
| -------- | ----------- | ------------------------------------ |
|
| ---------- | ----------------------- | ---------------------------------------- |
|
||||||
| 仪表盘 | `/` | 应用总览,展示运行时元信息 |
|
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
|
||||||
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
|
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
|
||||||
| 用户管理 | `/users` | 页面建设中 |
|
| 工作台总览 | `/workbench/:projectId` | Workbench 工作台总览,按项目维度查看信息 |
|
||||||
| 系统设置 | `/settings` | 页面建设中 |
|
| 用户管理 | `/users` | 页面建设中 |
|
||||||
|
| 系统设置 | `/settings` | 页面建设中 |
|
||||||
|
|
||||||
|
平台提供两个入口:
|
||||||
|
|
||||||
|
- **Admin(管理台)**:全局管理视角,包含总览和项目管理。默认入口,访问 `/` 即可进入。
|
||||||
|
- **Workbench(工作台)**:项目维度视角,通过 `/workbench/:projectId` 进入指定项目的工作台。URL 可保存为浏览器书签,下次直接进入。仅 active 状态的项目可进入工作台,archived 项目不可访问。
|
||||||
|
|
||||||
|
从项目管理页面的 active 项目行可点击"进入工作台"跳转到对应项目的工作台。
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function createProject(
|
|||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
const name = request.name.trim();
|
const name = request.name.trim();
|
||||||
if (!name) return { error: "项目名称不能为空", status: 400 };
|
if (!name) return { error: "项目名称不能为空", status: 400 };
|
||||||
|
if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
|
||||||
|
|
||||||
const description = (request.description ?? "").trim();
|
const description = (request.description ?? "").trim();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -142,6 +143,7 @@ export function updateProject(
|
|||||||
|
|
||||||
const name = request.name?.trim();
|
const name = request.name?.trim();
|
||||||
if (name === "") return { error: "项目名称不能为空", status: 400 };
|
if (name === "") return { error: "项目名称不能为空", status: 400 };
|
||||||
|
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
|
||||||
|
|
||||||
const updates: Partial<typeof projects.$inferInsert> = {
|
const updates: Partial<typeof projects.$inferInsert> = {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -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 { useEffect } from "react";
|
||||||
|
|
||||||
import { APP } from "../shared/app";
|
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";
|
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() {
|
export function App() {
|
||||||
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
|
||||||
const { collapsed, setCollapsed } = useSidebarCollapsed();
|
|
||||||
const { data: meta } = useMeta();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = APP.title;
|
document.title = APP.title;
|
||||||
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleThemeChange = (value: number | string) => {
|
return <AppRoutes />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/web/components/ConsoleShell/ConsoleOutlet.tsx
Normal file
5
src/web/components/ConsoleShell/ConsoleOutlet.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
export function ConsoleOutlet() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
73
src/web/components/ConsoleShell/ConsoleShell.tsx
Normal file
73
src/web/components/ConsoleShell/ConsoleShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/web/components/ConsoleShell/types.ts
Normal file
9
src/web/components/ConsoleShell/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,30 +3,34 @@ import type { MenuProps } from "antd";
|
|||||||
import { Menu } from "antd";
|
import { Menu } from "antd";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
import { MENU_ITEMS } from "../../menu";
|
import type { MenuItemConfig } from "../../menu";
|
||||||
|
|
||||||
type MenuItem = Required<MenuProps>["items"][number];
|
type MenuItem = Required<MenuProps>["items"][number];
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarProps {
|
||||||
|
menuItems: readonly MenuItemConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ menuItems }: SidebarProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const currentPath = location.pathname;
|
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 selectedKeys = currentItem ? [currentItem.value] : [];
|
||||||
|
|
||||||
const menuItems: MenuItem[] = MENU_ITEMS.map((item) => ({
|
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
|
||||||
icon: item.icon,
|
icon: item.icon,
|
||||||
key: item.value,
|
key: item.value,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
|
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||||
const item = MENU_ITEMS.find((i) => i.value === key);
|
const item = menuItems.find((i) => i.value === key);
|
||||||
if (item) {
|
if (item) {
|
||||||
void navigate(item.path);
|
void navigate(item.path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Menu items={menuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
|
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/web/consoles/admin/AdminConsoleLayout.tsx
Normal file
6
src/web/consoles/admin/AdminConsoleLayout.tsx
Normal 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="管理台" />;
|
||||||
|
}
|
||||||
9
src/web/consoles/admin/menu.tsx
Normal file
9
src/web/consoles/admin/menu.tsx
Normal 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;
|
||||||
17
src/web/consoles/workbench/ProjectContext.tsx
Normal file
17
src/web/consoles/workbench/ProjectContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
37
src/web/consoles/workbench/WorkbenchConsoleLayout.tsx
Normal file
37
src/web/consoles/workbench/WorkbenchConsoleLayout.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
47
src/web/consoles/workbench/WorkbenchProjectGate.tsx
Normal file
47
src/web/consoles/workbench/WorkbenchProjectGate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/web/consoles/workbench/routes.ts
Normal file
22
src/web/consoles/workbench/routes.ts
Normal 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 || "/"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
|
|
||||||
import { createElement } from "react";
|
|
||||||
|
|
||||||
export interface MenuItemConfig {
|
export interface MenuItemConfig {
|
||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
value: 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;
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Space size="large" vertical>
|
<Space size="large" vertical>
|
||||||
<Typography.Title level={2}>欢迎使用 {APP.title}</Typography.Title>
|
<Typography.Title level={2}>总览</Typography.Title>
|
||||||
<Typography.Paragraph>在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):</Typography.Paragraph>
|
<Typography.Paragraph>欢迎使用 {APP.title}。以下是 /api/meta 的返回数据(前后端联调示例):</Typography.Paragraph>
|
||||||
{isLoading && <Spin size="large" />}
|
{isLoading && <Spin size="large" />}
|
||||||
{error && <Alert description={error.message} showIcon title="加载失败" type="error" />}
|
{error && <Alert description={error.message} showIcon title="加载失败" type="error" />}
|
||||||
{meta && (
|
{meta && (
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ export function ProjectFormModal({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
label="项目名称"
|
label="项目名称"
|
||||||
name="name"
|
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>
|
||||||
<Form.Item label="项目描述" name="description">
|
<Form.Item label="项目描述" name="description">
|
||||||
<Input.TextArea autoSize={{ minRows: 5 }} maxLength={500} placeholder="请输入项目描述" />
|
<Input.TextArea autoSize={{ minRows: 5 }} maxLength={500} placeholder="请输入项目描述" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ColumnsType } from "antd/es/table";
|
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 { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { Project, ProjectListResponse } from "../../../../shared/api";
|
import type { Project, ProjectListResponse } from "../../../../shared/api";
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export function ProjectTable({
|
|||||||
pageSize,
|
pageSize,
|
||||||
}: ProjectTableProps) {
|
}: ProjectTableProps) {
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleArchive = async (id: string) => {
|
const handleArchive = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +97,14 @@ export function ProjectTable({
|
|||||||
if (record.status === "active") {
|
if (record.status === "active") {
|
||||||
return (
|
return (
|
||||||
<Space size="small">
|
<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 icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
@@ -130,7 +140,7 @@ export function ProjectTable({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
title: "操作",
|
title: "操作",
|
||||||
width: 180,
|
width: 280,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
22
src/web/pages/workbench/index.tsx
Normal file
22
src/web/pages/workbench/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import { Route, Routes } from "react-router";
|
import { Route, Routes } from "react-router";
|
||||||
|
|
||||||
|
import { AdminConsoleLayout } from "./consoles/admin/AdminConsoleLayout";
|
||||||
|
import { WorkbenchProjectGate } from "./consoles/workbench/WorkbenchProjectGate";
|
||||||
import { NotFoundPage } from "./pages/404";
|
import { NotFoundPage } from "./pages/404";
|
||||||
import { DashboardPage } from "./pages/dashboard";
|
import { DashboardPage } from "./pages/dashboard";
|
||||||
import { ProjectsPage } from "./pages/projects";
|
import { ProjectsPage } from "./pages/projects";
|
||||||
|
import { WorkbenchOverviewPage } from "./pages/workbench";
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<DashboardPage />} path="/" />
|
<Route element={<AdminConsoleLayout />}>
|
||||||
<Route element={<ProjectsPage />} path="/projects" />
|
<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="*" />
|
<Route element={<NotFoundPage />} path="*" />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,3 +52,23 @@ body {
|
|||||||
.app-content {
|
.app-content {
|
||||||
padding: var(--ant-padding-xl) var(--ant-padding-xl);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -300,4 +300,63 @@ describe("项目数据访问层", () => {
|
|||||||
rmSync(dir, { force: true, recursive: true });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/require-await */
|
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
@@ -9,7 +8,7 @@ import { renderWithProviders } from "./test-utils";
|
|||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
test("渲染 Layout 骨架和品牌名", () => {
|
test("渲染 Layout 骨架和品牌名", () => {
|
||||||
window.fetch = (async () => {
|
window.fetch = (() => {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
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();
|
expect(screen.getByText("黑暗")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("渲染侧边栏菜单项", () => {
|
test("渲染 Admin 侧边栏菜单项", () => {
|
||||||
window.fetch = (async () => {
|
window.fetch = (() => {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||||
{
|
{
|
||||||
@@ -40,12 +39,12 @@ describe("App", () => {
|
|||||||
|
|
||||||
renderWithProviders(createElement(App));
|
renderWithProviders(createElement(App));
|
||||||
|
|
||||||
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("总览").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Sider 渲染侧边栏菜单", () => {
|
test("Sider 渲染侧边栏菜单", () => {
|
||||||
window.fetch = (async () => {
|
window.fetch = (() => {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
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");
|
const menu = document.querySelector(".ant-menu");
|
||||||
expect(menu).not.toBeNull();
|
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 { createElement } from "react";
|
||||||
|
|
||||||
import { Sidebar } from "../../../../src/web/components/Sidebar";
|
import { Sidebar } from "../../../../src/web/components/Sidebar";
|
||||||
|
import { ADMIN_MENU_ITEMS } from "../../../../src/web/consoles/admin/menu";
|
||||||
import { renderWithProviders } from "../../test-utils";
|
import { renderWithProviders } from "../../test-utils";
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
test("渲染菜单项", () => {
|
test("渲染 Admin 菜单项", () => {
|
||||||
renderWithProviders(createElement(Sidebar));
|
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }));
|
||||||
|
|
||||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
expect(screen.getByText("总览")).not.toBeNull();
|
||||||
expect(screen.getByText("项目管理")).not.toBeNull();
|
expect(screen.getByText("项目管理")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("项目管理菜单项可导航到 /projects", () => {
|
test("项目管理菜单项可导航到 /projects", () => {
|
||||||
renderWithProviders(createElement(Sidebar), {
|
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
|
||||||
initialRoute: "/projects",
|
initialRoute: "/projects",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,13 +24,13 @@ describe("Sidebar", () => {
|
|||||||
expect(activeItem?.textContent).toContain("项目管理");
|
expect(activeItem?.textContent).toContain("项目管理");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("高亮当前路由对应的菜单项", () => {
|
test("高亮当前路由对应的总览菜单项", () => {
|
||||||
renderWithProviders(createElement(Sidebar), {
|
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
|
||||||
initialRoute: "/",
|
initialRoute: "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeItem = document.querySelector(".ant-menu-item-selected");
|
const activeItem = document.querySelector(".ant-menu-item-selected");
|
||||||
expect(activeItem).not.toBeNull();
|
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 { describe, expect, test } from "bun:test";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
|
|
||||||
import { ProjectsPage } from "../../../src/web/pages/projects";
|
import { App } from "../../../src/web/app";
|
||||||
import { renderWithProviders } from "../test-utils";
|
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", () => {
|
describe("ProjectsPage", () => {
|
||||||
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
|
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
|
||||||
renderWithProviders(createElement(ProjectsPage));
|
createMockHandler();
|
||||||
|
|
||||||
expect(screen.getByText("进行中")).not.toBeNull();
|
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||||
expect(screen.getByText("已归档")).not.toBeNull();
|
|
||||||
expect(screen.getByText("新建项目")).not.toBeNull();
|
|
||||||
expect(screen.getByPlaceholderText("搜索项目名称或描述")).not.toBeNull();
|
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
const body = document.body.textContent ?? "";
|
expect(screen.getByText("进行中")).not.toBeNull();
|
||||||
expect(body).toContain("项目名称");
|
|
||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
|
expect(screen.getByText("已归档")).not.toBeNull();
|
||||||
|
expect(screen.getByText("新建项目")).not.toBeNull();
|
||||||
|
expect(screen.getByPlaceholderText("搜索项目名称或描述")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("新建按钮点击打开弹窗", async () => {
|
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: /新建项目/ });
|
const createBtn = screen.getByRole("button", { name: /新建项目/ });
|
||||||
fireEvent.click(createBtn);
|
createBtn.click();
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
@@ -36,4 +92,23 @@ describe("ProjectsPage", () => {
|
|||||||
{ timeout: 10000 },
|
{ 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