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

@@ -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 || "/"),
}));
}