diff --git a/apps/portal-shell/ARCHITECTURE.md b/apps/portal-shell/ARCHITECTURE.md index 566724e..a164758 100644 --- a/apps/portal-shell/ARCHITECTURE.md +++ b/apps/portal-shell/ARCHITECTURE.md @@ -2,7 +2,7 @@ > 版本:3.0 > 日期:2026-07-20 -> 状态:**P0 已完成 + P1-1/P1-2 已完成(2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿** +> 状态:**P0 已完成 + P1-1/P1-2/P1-3 已完成(2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿** > 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。 > > 关联文档(按效力排序): @@ -765,7 +765,7 @@ export default async function ExamsPage(): Promise { | ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- | | P1-1 | `src/app/shell/layout.tsx` AppFrame(TopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ | | P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈,4 角色仪表盘显示真实聚合数据(截图);widget 假契约查询(grades/homeworks/schedule/attendance/exams/announcements)全部下线或改造 | ✅ | -| P1-3 | 页面模板四件套(list/detail/form/workbench)+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) | ⏳ | +| P1-3 | 页面模板四件套(list/detail/form/workbench)+ 三态规范 | Storybook 或示例页 4 张(/shell/dev/templates/*,仅 dev 可见;注:Next.js 私有文件夹 `_xxx` 不参与路由,故使用 `dev` 而非 `_dev`) | ✅ | | P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ⏳ | | P1-5 | MSW 兜底层(迁移旧 handlers,覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | ⏳ | | P1-6 | 31 widget 令牌清债(271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0;`pnpm lint:tokens` 通过 | ⏳ | @@ -801,6 +801,28 @@ export default async function ExamsPage(): Promise { - **三态规范**:4 个仪表盘页统一遵循 loading(StatCard isLoading 骨架)→ error(Card 错误提示)→ success(真实数据)三态 - **质量校验**:`tsc --noEmit` 通过;`eslint`(新/改文件)通过;`vitest run` 全量 20 test files / 212 tests 全部通过 +**P1-3 验收证据(2026-07-22)**: + +- **4 个页面模板组件**:[src/shared/components/page-templates/](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/components/page-templates/) + - [list-page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/components/page-templates/list-page.tsx):`ListPageShell` + `ListPageSkeleton`,结构 = PageHeader + FilterBar + 主内容 + Pagination,三态 = loading/empty/errorNode 优先级链 + - [detail-page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/components/page-templates/detail-page.tsx):`DetailPageShell` + `DetailSection` + `DetailField` + `DetailPageSkeleton`,结构 = PageHeader(含 backHref) + 分区 + 字段表 + - [form-page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/components/page-templates/form-page.tsx):`FormPageShell` + `FormPageSkeleton`,结构 = PageHeader + form + errorSummary + 提交/取消按钮 + - [workbench-page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/components/page-templates/workbench-page.tsx):`WorkbenchPageShell` + `WorkbenchPanel` + `WorkbenchPageSkeleton`,结构 = PageHeader + 三栏(左树/中画布/右属性),左右栏宽度可配置 + - [index.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/components/page-templates/index.ts):barrel 导出 +- **4 个 dev 示例页**(仅 dev 可见,生产环境 `notFound()` 兜底): + - [/shell/dev/templates](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/dev/templates/page.tsx):索引页,列出 4 张模板 + - [/shell/dev/templates/list](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/dev/templates/list/page.tsx):列表页示例,支持 `?state=loading|empty|success` 切换三态 + - [/shell/dev/templates/detail](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/dev/templates/detail/page.tsx):详情页示例 + - [/shell/dev/templates/form](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/dev/templates/form/page.tsx):表单页示例 + - [/shell/dev/templates/workbench](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx):工作台页示例 +- **路径命名修正**:原 ARCHITECTURE.md 写 `/shell/_dev/templates/*`,但 Next.js 将下划线开头的文件夹视为"私有文件夹"(不参与路由),实测 `/shell/_dev/templates` 被 `[[...route]]/page.tsx` catch-all 兜底接管(ClientShell 微内核渲染)。改用 `dev` 命名后,显式路由优先匹配,catch-all 不再触发。route-permissions.ts 同步登记 `PREFIX /shell/dev/`(空 config = 仅校验登录身份) +- **三态规范验证**(HTTP 200 + 内容断言): + - `GET /shell/dev/templates/list?state=loading` → 200,HTML 含 `animate-pulse` 骨架,无表格数据 + - `GET /shell/dev/templates/list?state=empty` → 200,HTML 含"暂无数据"空态,无表格数据 + - `GET /shell/dev/templates/list`(默认 success)→ 200,HTML 含表格行"考试 A" +- **单测**:`vitest run --reporter=verbose page-templates` → 19/19 passed(覆盖 4 个模板的三态、字段渲染、提交按钮禁用、errorSummary alert 等) +- **质量校验**:`tsc --noEmit` 通过;`eslint src/shared/components/page-templates src/app/shell/dev src/shared/lib/route-permissions.ts` 通过;`vitest run` 全量 21 test files / 231 tests 全部通过(212 原有 + 19 新增) + ### P2 · 教师域页面(2–3 周,可与 P3 部分并行) - 范围:§9.1 全表(~50 页)。顺序建议:exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。 diff --git a/apps/portal-shell/src/app/shell/dev/templates/detail/page.tsx b/apps/portal-shell/src/app/shell/dev/templates/detail/page.tsx new file mode 100644 index 0000000..fd547e2 --- /dev/null +++ b/apps/portal-shell/src/app/shell/dev/templates/detail/page.tsx @@ -0,0 +1,70 @@ +import { notFound } from "next/navigation"; + +import { Button } from "@/shared/components/ui/button"; +import { + DetailPageShell, + DetailSection, + DetailField, +} from "@/shared/components/page-templates"; + +/** + * 详情页模板示例(ARCHITECTURE.md §10 P1-3) + * + * /shell/dev/templates/detail — 仅 dev 可见 + */ +export default function DetailTemplatePage(): React.ReactElement { + if (process.env.NODE_ENV === "production") { + notFound(); + } + + return ( + + + + + } + > + + + + + + + + + + + 添加班级 + + } + > +
+ {[ + { name: "高一(1)班", count: 40 }, + { name: "高一(2)班", count: 42 }, + { name: "高一(3)班", count: 38 }, + ].map((c) => ( +
+ {c.name} + + {c.count} 人 + +
+ ))} +
+
+
+ ); +} diff --git a/apps/portal-shell/src/app/shell/dev/templates/form/page.tsx b/apps/portal-shell/src/app/shell/dev/templates/form/page.tsx new file mode 100644 index 0000000..47d6699 --- /dev/null +++ b/apps/portal-shell/src/app/shell/dev/templates/form/page.tsx @@ -0,0 +1,69 @@ +import { notFound } from "next/navigation"; + +import { FormPageShell } from "@/shared/components/page-templates"; + +/** + * 表单页模板示例(ARCHITECTURE.md §10 P1-3) + * + * /shell/dev/templates/form — 仅 dev 可见 + * + * 演示:表单字段 + 提交/取消按钮 + 错误摘要 + */ +export default function FormTemplatePage(): React.ReactElement { + if (process.env.NODE_ENV === "production") { + notFound(); + } + + return ( + { + // 示例:实际应调用 useExamCreate mutation + }} + submitting={false} + submitLabel="保存" + errorSummary={ + // 示例:实际从 form.formState.errors 读取 + undefined + } + > + + + + + + + ); +} + +function FormField({ + label, + name, + required = false, + type = "text", +}: { + label: string; + name: string; + required?: boolean; + type?: "text" | "number"; +}): React.ReactElement { + return ( +
+ + +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/dev/templates/list/page.tsx b/apps/portal-shell/src/app/shell/dev/templates/list/page.tsx new file mode 100644 index 0000000..efba9a9 --- /dev/null +++ b/apps/portal-shell/src/app/shell/dev/templates/list/page.tsx @@ -0,0 +1,103 @@ +import { notFound } from "next/navigation"; + +import { Button } from "@/shared/components/ui/button"; +import { ListPageShell } from "@/shared/components/page-templates"; +import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; + +/** + * 列表页模板示例(ARCHITECTURE.md §10 P1-3) + * + * /shell/dev/templates/list — 仅 dev 可见 + * + * 展示三态:loading / empty / success(通过 query 参数 ?state= 切换) + * 默认显示 success 态的假数据表格。 + */ +export default async function ListTemplatePage({ + searchParams, +}: { + searchParams: Promise<{ state?: string }>; +}): Promise { + if (process.env.NODE_ENV === "production") { + notFound(); + } + + const { state = "success" } = await searchParams; + + return ( + 新建} + filters={ + {}} /> + } + loading={state === "loading"} + empty={state === "empty"} + pagination={ +
+ 共 42 条 + + 第 1 / 5 页 + +
+ } + > + +
+ ); +} + +function ListTemplateTable(): React.ReactElement { + const rows = [ + { + id: 1, + name: "考试 A", + subject: "数学", + status: "进行中", + createdAt: "2026-07-20", + }, + { + id: 2, + name: "考试 B", + subject: "语文", + status: "已结束", + createdAt: "2026-07-18", + }, + { + id: 3, + name: "考试 C", + subject: "英语", + status: "草稿", + createdAt: "2026-07-22", + }, + ]; + + return ( +
+ + + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
名称科目状态创建时间
{r.name}{r.subject}{r.status}{r.createdAt}
+
+ ); +} diff --git a/apps/portal-shell/src/app/shell/dev/templates/page.tsx b/apps/portal-shell/src/app/shell/dev/templates/page.tsx new file mode 100644 index 0000000..8a903ff --- /dev/null +++ b/apps/portal-shell/src/app/shell/dev/templates/page.tsx @@ -0,0 +1,68 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; + +import { PageHeader } from "@/shared/components/ui/page-header"; + +/** + * 模板预览首页(ARCHITECTURE.md §10 P1-3) + * + * /shell/dev/templates — 仅 dev 可见 + * + * 生产环境调用 notFound() 渲染 404,避免模板示例暴露到线上。 + * middleware 已放行 /shell/dev/**(仅校验登录),此文件做二次守卫。 + * + * 注:原 ARCHITECTURE.md 使用 `_dev` 命名,但 Next.js 将下划线开头的文件夹 + * 视为私有文件夹(不参与路由),故改用 `dev` 命名。 + */ +export default function TemplatesIndexPage(): React.ReactElement { + if (process.env.NODE_ENV === "production") { + notFound(); + } + + const templates = [ + { + href: "/shell/dev/templates/list", + title: "列表页模板", + description: "PageHeader + FilterBar + DataTable + Pagination + 三态", + }, + { + href: "/shell/dev/templates/detail", + title: "详情页模板", + description: "PageHeader + 信息区 + Tabs/分区 + 关联列表", + }, + { + href: "/shell/dev/templates/form", + title: "表单页模板", + description: "PageHeader + 表单 + 提交/取消 + 错误摘要", + }, + { + href: "/shell/dev/templates/workbench", + title: "工作台页模板", + description: "三栏(树/画布/属性)复合组件", + }, + ]; + + return ( +
+ +
+ {templates.map((t) => ( + +

{t.title}

+

+ {t.description} +

+

{t.href}

+ + ))} +
+
+ ); +} diff --git a/apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx b/apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx new file mode 100644 index 0000000..2ebd58b --- /dev/null +++ b/apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx @@ -0,0 +1,131 @@ +import { notFound } from "next/navigation"; + +import { Button } from "@/shared/components/ui/button"; +import { + WorkbenchPageShell, + WorkbenchPanel, +} from "@/shared/components/page-templates"; + +/** + * 工作台页模板示例(ARCHITECTURE.md §10 P1-3) + * + * /shell/dev/templates/workbench — 仅 dev 可见 + * + * 演示:三栏(左:大纲树 / 中:画布 / 右:属性面板) + */ +export default function WorkbenchTemplatePage(): React.ReactElement { + if (process.env.NODE_ENV === "production") { + notFound(); + } + + return ( + + + + + } + left={ + + + + + } + > + + + } + center={ + + + + } + right={ + + + + } + /> + ); +} + +function OutlineTree(): React.ReactElement { + const nodes = [ + { id: 1, title: "第一章 集合与函数", indent: 0 }, + { id: 2, title: "1.1 集合的概念", indent: 1 }, + { id: 3, title: "1.2 函数的定义", indent: 1 }, + { id: 4, title: "第二章 三角函数", indent: 0 }, + { id: 5, title: "2.1 任意角", indent: 1 }, + ]; + return ( +
    + {nodes.map((n) => ( +
  • + {n.title} +
  • + ))} +
+ ); +} + +function CanvasDemo(): React.ReactElement { + return ( +
+
+

第一章 集合与函数

+

+ 教学目标:理解集合的概念,掌握函数的定义与表示。 +

+
+
+

教学活动

+
    +
  • 导入:生活中的集合实例(5 分钟)
  • +
  • 新授:集合的表示方法(15 分钟)
  • +
  • 练习:教材 P5 例题(10 分钟)
  • +
  • 小结与作业(5 分钟)
  • +
+
+
+ ); +} + +function PropertiesPanel(): React.ReactElement { + return ( +
+ + + + + +
+ ); +} + +function Field({ + label, + value, +}: { + label: string; + value: string; +}): React.ReactElement { + return ( +
+ +
+ {value} +
+
+ ); +} diff --git a/apps/portal-shell/src/shared/components/__tests__/page-templates.test.tsx b/apps/portal-shell/src/shared/components/__tests__/page-templates.test.tsx new file mode 100644 index 0000000..c8658ee --- /dev/null +++ b/apps/portal-shell/src/shared/components/__tests__/page-templates.test.tsx @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; + +/** + * 页面模板四件套单元测试(ARCHITECTURE.md §10 P1-3) + * + * 覆盖: + * - ListPageShell:三态(loading / empty / success)+ PageHeader + filters + * - DetailPageShell:三态 + DetailSection/DetailField 渲染 + * - FormPageShell:三态 + 提交/取消按钮 + errorSummary + * - WorkbenchPageShell:三态 + 三栏布局 + * + * 关联:portal-shell ARCHITECTURE.md §7.3、§7.4、§11.3 DoD 三态规范 + */ +import { + ListPageShell, + ListPageSkeleton, + DetailPageShell, + DetailSection, + DetailField, + FormPageShell, + WorkbenchPageShell, + WorkbenchPanel, +} from "@/shared/components/page-templates"; + +describe("ListPageShell", () => { + it("success 态渲染 PageHeader + children", () => { + render( + +
表格内容
+
, + ); + expect(screen.getByText("考试管理")).toBeInTheDocument(); + expect(screen.getByText("管理考试")).toBeInTheDocument(); + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + + it("loading 态渲染骨架", () => { + const { container } = render( + +
不应渲染
+
, + ); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + // 骨架包含多个 Skeleton 元素 + const skeletons = container.querySelectorAll('[data-slot="skeleton"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it("empty 态渲染默认 EmptyState", () => { + render( + +
不应渲染
+
, + ); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + expect(screen.getByText("暂无数据")).toBeInTheDocument(); + }); + + it("errorNode 优先级高于 loading/empty", () => { + render( + 出错了} + > +
+ , + ); + expect(screen.getByTestId("err")).toBeInTheDocument(); + expect(screen.queryByText("暂无数据")).not.toBeInTheDocument(); + }); + + it("filters 渲染为 FilterBar", () => { + render( + } + > +
+ , + ); + expect(screen.getByTestId("f")).toBeInTheDocument(); + }); + + it("loading 时 pagination 不渲染", () => { + render( + 分页
} + > +
+ , + ); + expect(screen.queryByTestId("page")).not.toBeInTheDocument(); + }); +}); + +describe("ListPageSkeleton", () => { + it("渲染指定行数骨架", () => { + const { container } = render(); + // 默认 5 行 + 分页条;这里 rows=3 + // 每行 3 个 Skeleton(avatar + 2 行文本)+ 1 个操作按钮 + const skeletons = container.querySelectorAll( + '[data-slot="skeleton"], .animate-pulse', + ); + expect(skeletons.length).toBeGreaterThan(0); + }); +}); + +describe("DetailPageShell", () => { + it("success 态渲染标题 + children", () => { + render( + +
详情内容
+
, + ); + expect(screen.getByText("考试详情")).toBeInTheDocument(); + expect(screen.getByText("2026 春季")).toBeInTheDocument(); + expect(screen.getByTestId("content")).toBeInTheDocument(); + expect(screen.getByText("返回")).toHaveAttribute( + "href", + "/shell/teacher/exams", + ); + }); + + it("loading 态渲染骨架", () => { + render( + +
不应渲染
+
, + ); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + }); + + it("DetailSection 渲染标题与内容", () => { + render( + + + , + ); + expect(screen.getByText("基本信息")).toBeInTheDocument(); + expect(screen.getByText("元数据")).toBeInTheDocument(); + expect(screen.getByText("科目")).toBeInTheDocument(); + expect(screen.getByText("数学")).toBeInTheDocument(); + }); + + it("DetailField 无 value 时显示 '-'", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); +}); + +describe("FormPageShell", () => { + it("渲染标题 + 表单字段 + 提交按钮", () => { + render( + {}} + backHref="/back" + > + + , + ); + expect(screen.getByText("新建考试")).toBeInTheDocument(); + expect(screen.getByTestId("input")).toBeInTheDocument(); + expect(screen.getByText("保存")).toBeInTheDocument(); + expect(screen.getByText("取消")).toHaveAttribute("href", "/back"); + }); + + it("submitting 时按钮禁用并显示'提交中...'", () => { + render( + {}} submitting> + + , + ); + const btn = screen.getByText("提交中..."); + expect(btn).toBeDisabled(); + }); + + it("errorSummary 渲染为 alert", () => { + render( + {}} errorSummary="表单校验失败"> + + , + ); + expect(screen.getByRole("alert")).toHaveTextContent("表单校验失败"); + }); + + it("loading 态渲染骨架,不渲染表单", () => { + render( + + + , + ); + expect(screen.queryByTestId("input")).not.toBeInTheDocument(); + }); +}); + +describe("WorkbenchPageShell", () => { + it("渲染三栏布局", () => { + render( +
} + center={
} + right={
} + />, + ); + expect(screen.getByText("教案编辑")).toBeInTheDocument(); + expect(screen.getByTestId("left")).toBeInTheDocument(); + expect(screen.getByTestId("center")).toBeInTheDocument(); + expect(screen.getByTestId("right")).toBeInTheDocument(); + }); + + it("loading 态渲染骨架", () => { + render( + +
不应渲染
+
, + ); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + }); + + it("WorkbenchPanel 渲染标题与内容", () => { + render( + +
内容
+
, + ); + expect(screen.getByText("属性")).toBeInTheDocument(); + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + + it("仅 left 时渲染单栏", () => { + render( +
} />, + ); + expect(screen.getByTestId("left")).toBeInTheDocument(); + }); +}); + +// 辅助组件:包装 loading=true 的 WorkbenchPageShell 用于测试 +function WorkPageShellLoading({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement { + return ( + + {children} + + ); +} diff --git a/apps/portal-shell/src/shared/components/page-templates/detail-page.tsx b/apps/portal-shell/src/shared/components/page-templates/detail-page.tsx new file mode 100644 index 0000000..1e2b865 --- /dev/null +++ b/apps/portal-shell/src/shared/components/page-templates/detail-page.tsx @@ -0,0 +1,231 @@ +import type { ReactNode } from "react"; + +import { PageHeader } from "@/shared/components/ui/page-header"; +import { Skeleton } from "@/shared/components/ui/skeleton"; +import { cn } from "@/shared/lib/utils"; + +/** + * DetailPageShell - 详情页模板(ARCHITECTURE.md §7.3) + * + * 结构:PageHeader(标题+操作) + 信息区 + Tabs/分区 + 关联列表 + * + * 三态规范(§7.4 / §11.3 DoD): + * - loading: 传入 `loading=true` 或 `loadingNode`,渲染骨架 + * - error: 由 error.tsx 处理(Route 级);本组件支持 `errorNode` 局部降级 + * - empty: 通常详情页无数据 = 404(交 notFound());保留 `emptyNode` 应对软删除场景 + * + * 用法: + * ```tsx + * 编辑} + * backHref="/shell/teacher/exams" + * > + * + * + * + * }]} /> + * + * ``` + * + * 关联:portal-shell ARCHITECTURE.md §7.3 详情页、§7.4 数据获取、§11.3 DoD 三态 + */ +export interface DetailPageShellProps { + /** 页面标题(实体名/标题) */ + title: string; + /** 页面描述(创建时间/状态/副标题) */ + description?: string; + /** 标题前图标 */ + icon?: ReactNode; + /** 右侧操作(编辑/删除/导出按钮) */ + actions?: ReactNode; + /** 返回链接(如 /shell/teacher/exams),渲染为顶部"返回"按钮 */ + backHref?: string; + /** 主内容(信息区 / Tabs / 关联列表) */ + children?: ReactNode; + /** 加载态 */ + loading?: boolean; + /** 自定义加载骨架 */ + loadingNode?: ReactNode; + /** 局部错误降级 */ + errorNode?: ReactNode; + /** 空态内容(详情页通常用 notFound(),此 prop 用于软删除等场景) */ + emptyNode?: ReactNode; + /** 自定义类名 */ + className?: string; +} + +export function DetailPageShell({ + title, + description, + icon, + actions, + backHref, + children, + loading = false, + loadingNode, + errorNode, + emptyNode, + className, +}: DetailPageShellProps): ReactNode { + return ( +
+ {backHref ? : null} + + + +
+ {errorNode + ? errorNode + : loading + ? (loadingNode ?? ) + : emptyNode + ? emptyNode + : children} +
+
+ ); +} + +/** + * DetailSection - 详情页信息分区 + * + * 用于组织"基本信息"、"关联资源"等垂直区块。 + */ +export interface DetailSectionProps { + /** 分区标题 */ + title: string; + /** 分区描述(可选) */ + description?: string; + /** 右侧操作(可选) */ + actions?: ReactNode; + /** 分区内容 */ + children?: ReactNode; + /** 自定义类名 */ + className?: string; +} + +export function DetailSection({ + title, + description, + actions, + children, + className, +}: DetailSectionProps): ReactNode { + return ( +
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +} + +/** + * DetailField - 详情页字段(label + value) + * + * 单行信息展示,对齐 §8.2 排版规范(label 用 muted-foreground)。 + */ +export interface DetailFieldProps { + /** 字段标签 */ + label: string; + /** 字段值(字符串/ReactNode) */ + value?: ReactNode; + /** 自定义类名 */ + className?: string; +} + +export function DetailField({ + label, + value, + className, +}: DetailFieldProps): ReactNode { + return ( +
+
{label}
+
{value ?? "-"}
+
+ ); +} + +/** + * DetailBackButton - 返回链接(Client Component 内部用 next/link) + * + * 单独抽出便于后续 i18n 化(P1-4 替换"返回"文案)。 + */ +function DetailBackButton({ href }: { href: string }): ReactNode { + // 动态 import next/link 避免在 RSC 边界引入客户端依赖 + // 实际上 next/link 在 RSC 中也可直接使用 + return ( + + + 返回 + + ); +} + +/** + * DetailPageSkeleton - 详情页默认骨架 + * + * 包含 PageHeader 骨架 + 2 个分区骨架。 + */ +export function DetailPageSkeleton(): ReactNode { + return ( +
+
+ + +
+ {[0, 1].map((i) => ( +
+ +
+ {[0, 1, 2].map((j) => ( +
+ + +
+ ))} +
+
+ ))} +
+ ); +} diff --git a/apps/portal-shell/src/shared/components/page-templates/form-page.tsx b/apps/portal-shell/src/shared/components/page-templates/form-page.tsx new file mode 100644 index 0000000..15996d3 --- /dev/null +++ b/apps/portal-shell/src/shared/components/page-templates/form-page.tsx @@ -0,0 +1,222 @@ +import type { ReactNode } from "react"; + +import { PageHeader } from "@/shared/components/ui/page-header"; +import { Skeleton } from "@/shared/components/ui/skeleton"; +import { cn } from "@/shared/lib/utils"; + +/** + * FormPageShell - 表单页模板(ARCHITECTURE.md §7.3) + * + * 结构:PageHeader + 表单(react-hook-form + zod)+ 提交/取消 + 错误摘要 + * + * 三态规范(§7.4 / §11.3 DoD): + * - loading: 传入 `loading=true` 或 `loadingNode`,渲染骨架 + * - error: 提交错误通过 `errorSummary` 显示;Route 级错误由 error.tsx 处理 + * - success: 由调用方控制(成功后 router.refresh 或 redirect) + * + * 用法: + * ```tsx + * + * + * + * + * + * ``` + * + * 关联:portal-shell ARCHITECTURE.md §7.3 表单页、§7.4 数据获取、§11.3 DoD 三态 + */ +export interface FormPageShellProps { + /** 页面标题 */ + title: string; + /** 页面描述 */ + description?: string; + /** 标题前图标 */ + icon?: ReactNode; + /** 返回链接(取消按钮的跳转目标) */ + backHref?: string; + /** 表单内容(FormField 组合) */ + children?: ReactNode; + /** 提交回调(不传则不渲染提交按钮,由 children 内部自管) */ + onSubmit?: () => void; + /** 提交中状态(true 时按钮 disabled + 显示"提交中") */ + submitting?: boolean; + /** 取消回调(不传则用 backHref 跳转;都没有则不渲染取消按钮) */ + onCancel?: () => void; + /** 错误摘要(表单级错误,渲染在表单顶部) */ + errorSummary?: ReactNode; + /** 自定义提交按钮文案(默认"提交") */ + submitLabel?: string; + /** 自定义取消按钮文案(默认"取消") */ + cancelLabel?: string; + /** 加载态(初始数据加载中) */ + loading?: boolean; + /** 自定义加载骨架 */ + loadingNode?: ReactNode; + /** 自定义类名 */ + className?: string; +} + +export function FormPageShell({ + title, + description, + icon, + backHref, + children, + onSubmit, + submitting = false, + onCancel, + errorSummary, + submitLabel = "提交", + cancelLabel = "取消", + loading = false, + loadingNode, + className, +}: FormPageShellProps): ReactNode { + return ( +
+ {backHref ? : null} + + + +
+ {loading ? ( + (loadingNode ?? ) + ) : ( +
{ + e.preventDefault(); + onSubmit(); + } + : undefined + } + className="space-y-6 rounded-xl border bg-card p-6" + > + {errorSummary ? ( +
+ {errorSummary} +
+ ) : null} + +
{children}
+ + {(onSubmit || onCancel || backHref) && ( +
+ {onCancel ? ( + + ) : backHref ? ( + + ) : null} + {onSubmit ? ( + + ) : null} +
+ )} +
+ )} +
+
+ ); +} + +/** + * FormPageSkeleton - 表单页加载骨架 + * + * 4 个字段骨架 + 操作栏骨架。 + */ +export function FormPageSkeleton(): ReactNode { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ + +
+
+ ); +} + +function FormBackButton({ href }: { href: string }): ReactNode { + return ( + + + 返回 + + ); +} + +function CancelButton({ + onClick, + href, + label, +}: { + onClick?: () => void; + href?: string; + label: string; +}): ReactNode { + const cls = + "inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"; + if (href) { + return ( + + {label} + + ); + } + return ( + + ); +} + +function SubmitButton({ + submitting, + label, +}: { + submitting: boolean; + label: string; +}): ReactNode { + return ( + + ); +} diff --git a/apps/portal-shell/src/shared/components/page-templates/index.ts b/apps/portal-shell/src/shared/components/page-templates/index.ts new file mode 100644 index 0000000..006142f --- /dev/null +++ b/apps/portal-shell/src/shared/components/page-templates/index.ts @@ -0,0 +1,45 @@ +/** + * 页面模板四件套(ARCHITECTURE.md §7.3) + * + * 4 种页面类型对应模板: + * - ListPageShell:列表页(PageHeader + FilterBar + DataTable + Pagination + 三态) + * - DetailPageShell:详情页(PageHeader + 信息区 + Tabs + 关联列表) + * - FormPageShell:表单页(PageHeader + 表单 + 提交/取消 + 错误摘要) + * - WorkbenchPageShell:工作台页(三栏:树/画布/属性) + * + * 强制规则(§7.3 / §11.3 DoD / §11.7 红线): + * - 新页面一律 `import` 模板而非手排布局 + * - 三态规范:loading + error + empty 三态齐全 + * - 数据走 `lib/api` hooks,模板不内置 gql + * + * 关联:portal-shell ARCHITECTURE.md §7.3、§7.4、§11.3 + */ +export { + ListPageShell, + ListPageSkeleton, + type ListPageShellProps, +} from "./list-page"; + +export { + DetailPageShell, + DetailSection, + DetailField, + DetailPageSkeleton, + type DetailPageShellProps, + type DetailSectionProps, + type DetailFieldProps, +} from "./detail-page"; + +export { + FormPageShell, + FormPageSkeleton, + type FormPageShellProps, +} from "./form-page"; + +export { + WorkbenchPageShell, + WorkbenchPanel, + WorkbenchPageSkeleton, + type WorkbenchPageShellProps, + type WorkbenchPanelProps, +} from "./workbench-page"; diff --git a/apps/portal-shell/src/shared/components/page-templates/list-page.tsx b/apps/portal-shell/src/shared/components/page-templates/list-page.tsx new file mode 100644 index 0000000..55dfb47 --- /dev/null +++ b/apps/portal-shell/src/shared/components/page-templates/list-page.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from "react"; + +import { EmptyState } from "@/shared/components/ui/empty-state"; +import { FilterBar } from "@/shared/components/ui/filter-bar"; +import { PageHeader } from "@/shared/components/ui/page-header"; +import { Skeleton } from "@/shared/components/ui/skeleton"; +import { cn } from "@/shared/lib/utils"; + +/** + * ListPageShell - 列表页模板(ARCHITECTURE.md §7.3) + * + * 结构:PageHeader + FilterBar + DataTable/列表 + Pagination + 三态 + * + * 三态规范(§7.4 / §11.3 DoD): + * - loading: 传入 `loading=true` 或 `loadingNode`,渲染骨架 + * - error: 由 error.tsx 处理(Route 级);本组件支持 `errorNode` 局部降级 + * - empty: 传入 `emptyNode` 或使用默认 EmptyState + * + * 用法: + * ```tsx + * 新建考试} + * filters={} + * loading={isLoading} + * empty={data.length === 0} + * > + * + * + * ``` + * + * 关联:portal-shell ARCHITECTURE.md §7.3 列表页、§7.4 数据获取、§11.3 DoD 三态 + */ +export interface ListPageShellProps { + /** 页面标题 */ + title: string; + /** 页面描述 */ + description?: string; + /** 标题前图标 */ + icon?: ReactNode; + /** 右侧操作(按钮/链接) */ + actions?: ReactNode; + /** 筛选栏内容(FilterBar 已包裹) */ + filters?: ReactNode; + /** 主内容(DataTable/列表/卡片网格) */ + children?: ReactNode; + /** 加载态:true 时显示骨架;优先级低于 loadingNode */ + loading?: boolean; + /** 自定义加载骨架(覆盖默认 loading) */ + loadingNode?: ReactNode; + /** 空态:true 时显示空态;优先级低于 emptyNode */ + empty?: boolean; + /** 自定义空态内容(覆盖默认 EmptyState) */ + emptyNode?: ReactNode; + /** 局部错误降级(Route 级错误由 error.tsx 处理) */ + errorNode?: ReactNode; + /** 分页区(Pagination 组件) */ + pagination?: ReactNode; + /** 自定义类名(附加到主内容容器) */ + className?: string; +} + +export function ListPageShell({ + title, + description, + icon, + actions, + filters, + children, + loading = false, + loadingNode, + empty = false, + emptyNode, + errorNode, + pagination, + className, +}: ListPageShellProps): ReactNode { + return ( +
+ + + {filters ? {filters} : null} + +
+ {errorNode + ? errorNode + : loading + ? (loadingNode ?? ) + : empty + ? (emptyNode ?? ( + + )) + : children} + + {pagination && !loading && !empty ? pagination : null} +
+
+ ); +} + +/** + * ListPageSkeleton - 列表页默认骨架(5 行 + 分页条) + * + * 复用 Skeleton 组件,对齐 §8.1 铁律(无硬编码颜色/字号)。 + */ +export function ListPageSkeleton({ rows = 5 }: { rows?: number }): ReactNode { + return ( +
+
+ {Array.from({ length: rows }, (_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+ + +
+
+ ); +} diff --git a/apps/portal-shell/src/shared/components/page-templates/workbench-page.tsx b/apps/portal-shell/src/shared/components/page-templates/workbench-page.tsx new file mode 100644 index 0000000..36540ed --- /dev/null +++ b/apps/portal-shell/src/shared/components/page-templates/workbench-page.tsx @@ -0,0 +1,197 @@ +import type { ReactNode } from "react"; + +import { PageHeader } from "@/shared/components/ui/page-header"; +import { Skeleton } from "@/shared/components/ui/skeleton"; +import { cn } from "@/shared/lib/utils"; + +/** + * WorkbenchPageShell - 工作台页模板(ARCHITECTURE.md §7.3) + * + * 结构:PageHeader + 三栏(树/画布/属性)复合组件 + * + * 适用:lesson-plans/[id]/edit、exams/[id]/build 等复杂协同任务页 + * + * 三态规范(§7.4 / §11.3 DoD): + * - loading: 传入 `loading=true` 或 `loadingNode`,渲染骨架 + * - error: 由 error.tsx 处理(Route 级);本组件支持 `errorNode` 局部降级 + * - 局部加载:三栏各自支持独立 loading(左侧树/中间画布/右侧属性面板) + * + * 用法: + * ```tsx + * 保存} + * left={} + * center={} + * right={} + * /> + * ``` + * + * 关联:portal-shell ARCHITECTURE.md §7.3 工作台页、§7.4 数据获取、§11.3 DoD 三态 + */ +export interface WorkbenchPageShellProps { + /** 页面标题 */ + title: string; + /** 页面描述 */ + description?: string; + /** 标题前图标 */ + icon?: ReactNode; + /** 右侧操作(保存/发布按钮) */ + actions?: ReactNode; + /** 左栏:树/大纲(默认宽度 280px) */ + left?: ReactNode; + /** 中栏:画布/编辑区(flex-1 自适应) */ + center?: ReactNode; + /** 右栏:属性面板(默认宽度 320px) */ + right?: ReactNode; + /** 加载态:整页骨架 */ + loading?: boolean; + /** 自定义加载骨架 */ + loadingNode?: ReactNode; + /** 局部错误降级 */ + errorNode?: ReactNode; + /** 自定义类名 */ + className?: string; + /** 左栏宽度(Tailwind 类,默认 w-72) */ + leftWidth?: string; + /** 右栏宽度(Tailwind 类,默认 w-80) */ + rightWidth?: string; +} + +export function WorkbenchPageShell({ + title, + description, + icon, + actions, + left, + center, + right, + loading = false, + loadingNode, + errorNode, + className, + leftWidth = "w-72", + rightWidth = "w-80", +}: WorkbenchPageShellProps): ReactNode { + return ( +
+ + +
+ {errorNode ? ( + errorNode + ) : loading ? ( + (loadingNode ?? ) + ) : ( +
+ {left ? ( + + ) : null} + +
+ {center} +
+ + {right ? ( + + ) : null} +
+ )} +
+
+ ); +} + +/** + * WorkbenchPanel - 工作台栏位容器 + * + * 用于在 left/center/right 内部组织标题 + 内容。 + */ +export interface WorkbenchPanelProps { + /** 面板标题 */ + title?: string; + /** 右侧操作(添加/筛选等小按钮) */ + actions?: ReactNode; + /** 面板内容 */ + children?: ReactNode; + /** 自定义类名 */ + className?: string; + /** 是否可折叠(视觉提示,实际折叠由调用方实现) */ + collapsible?: boolean; +} + +export function WorkbenchPanel({ + title, + actions, + children, + className, +}: WorkbenchPanelProps): ReactNode { + return ( +
+ {title ? ( +
+

{title}

+ {actions ?
{actions}
: null} +
+ ) : null} +
{children}
+
+ ); +} + +/** + * WorkbenchPageSkeleton - 工作台页骨架 + * + * PageHeader 骨架 + 三栏骨架。 + */ +export function WorkbenchPageSkeleton(): ReactNode { + return ( +
+
+ + {Array.from({ length: 6 }, (_, i) => ( +
+ + +
+ ))} +
+
+ +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+
+ + {[0, 1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+
+ ); +} diff --git a/apps/portal-shell/src/shared/lib/route-permissions.ts b/apps/portal-shell/src/shared/lib/route-permissions.ts index 88b527a..afa939e 100644 --- a/apps/portal-shell/src/shared/lib/route-permissions.ts +++ b/apps/portal-shell/src/shared/lib/route-permissions.ts @@ -260,6 +260,13 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{ requiredPermissions: ["ANNOUNCEMENT_MANAGE"], }, }, + // P1-3:dev 模板预览页(仅登录可访问,运行时由 notFound() 拒绝生产访问) + // ARCHITECTURE.md §10 P1-3:/shell/dev/templates/* 仅 dev 可见 + // 注:Next.js 私有文件夹以 _ 开头会被排除路由,故使用 `dev` 而非 `_dev` + { + prefix: "/shell/dev/", + config: {}, // 空 config = 仅校验登录身份 + }, ]; /**