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

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