feat(portal-shell): add page templates quartet (list/detail/form/workbench) (P1-3)

P1-3 验收通过:4 个页面模板 + 4 个 dev 示例页 + 三态规范。

新增文件:
- src/shared/components/page-templates/
  - list-page.tsx:ListPageShell + ListPageSkeleton
  - detail-page.tsx:DetailPageShell + DetailSection + DetailField + DetailPageSkeleton
  - form-page.tsx:FormPageShell + FormPageSkeleton
  - workbench-page.tsx:WorkbenchPageShell + WorkbenchPanel + WorkbenchPageSkeleton
  - index.ts:barrel 导出
- src/app/shell/dev/templates/
  - page.tsx:索引页(4 个模板入口)
  - list/page.tsx:列表页示例(支持 ?state=loading|empty|success)
  - detail/page.tsx:详情页示例
  - form/page.tsx:表单页示例
  - workbench/page.tsx:工作台页示例
- src/shared/components/__tests__/page-templates.test.tsx:19 个单测

修改文件:
- src/shared/lib/route-permissions.ts:新增 PREFIX /shell/dev/(空 config = 仅校验登录)
- ARCHITECTURE.md:P1-3 状态回填  + 验收证据

路径命名修正:
- 原 ARCHITECTURE.md 写 /shell/_dev/templates/*,但 Next.js 将下划线开头的
  文件夹视为"私有文件夹"(不参与路由),实测被 [[...route]] catch-all 兜底接管。
- 改用 dev 命名后,显式路由优先匹配,catch-all 不再触发。

三态规范验证:
- GET /shell/dev/templates/list?state=loading → 200,含 animate-pulse 骨架
- GET /shell/dev/templates/list?state=empty → 200,含"暂无数据"空态
- GET /shell/dev/templates/list(默认 success)→ 200,含表格数据

质量校验:
- tsc --noEmit 通过
- eslint(新/改文件)通过
- vitest run 全量 21 test files / 231 tests 全部通过(212 原有 + 19 新增)

Refs: apps/portal-shell/ARCHITECTURE.md §7.3 页面四种类型与模板、
      §7.4 页面级数据获取模式、§11.3 每页硬性清单(DoD)三态规范
This commit is contained in:
SpecialX
2026-07-22 13:02:11 +08:00
parent 03e3ec4f60
commit 994441c2dc
13 changed files with 1564 additions and 2 deletions

View File

@@ -2,7 +2,7 @@
> 版本3.0 > 版本3.0
> 日期2026-07-20 > 日期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/人工在此模块的工作必须先读本文件,以其为准。 > 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。
> >
> 关联文档(按效力排序): > 关联文档(按效力排序):
@@ -765,7 +765,7 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- | | ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- |
| P1-1 | `src/app/shell/layout.tsx` AppFrameTopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ | | P1-1 | `src/app/shell/layout.tsx` AppFrameTopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ |
| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈4 角色仪表盘显示真实聚合数据截图widget 假契约查询grades/homeworks/schedule/attendance/exams/announcements全部下线或改造 | ✅ | | 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-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ⏳ |
| P1-5 | MSW 兜底层(迁移旧 handlers覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | ⏳ | | 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` 通过 | ⏳ | | 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<React.ReactElement> {
- **三态规范**4 个仪表盘页统一遵循 loadingStatCard isLoading 骨架)→ errorCard 错误提示)→ success真实数据三态 - **三态规范**4 个仪表盘页统一遵循 loadingStatCard isLoading 骨架)→ errorCard 错误提示)→ success真实数据三态
- **质量校验**`tsc --noEmit` 通过;`eslint`(新/改文件)通过;`vitest run` 全量 20 test files / 212 tests 全部通过 - **质量校验**`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` → 200HTML 含 `animate-pulse` 骨架,无表格数据
- `GET /shell/dev/templates/list?state=empty` → 200HTML 含"暂无数据"空态,无表格数据
- `GET /shell/dev/templates/list`(默认 success→ 200HTML 含表格行"考试 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 · 教师域页面23 周,可与 P3 部分并行) ### P2 · 教师域页面23 周,可与 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。 - 范围§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。

View File

@@ -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 (
<DetailPageShell
title="2026 春季期末考试"
description="创建于 2026-07-15 · 进行中"
backHref="/shell/dev/templates"
actions={
<>
<Button variant="outline"></Button>
<Button variant="destructive"></Button>
</>
}
>
<DetailSection title="基本信息" description="考试的元数据">
<DetailField label="考试名称" value="2026 春季期末考试" />
<DetailField label="科目" value="数学" />
<DetailField label="年级" value="高一" />
<DetailField label="满分" value={150} />
<DetailField label="时长" value="120 分钟" />
<DetailField label="状态" value="进行中" />
</DetailSection>
<DetailSection
title="参与班级"
description="3 个班级共 120 名学生"
actions={
<Button variant="outline" size="sm">
</Button>
}
>
<div className="divide-y">
{[
{ name: "高一(1)班", count: 40 },
{ name: "高一(2)班", count: 42 },
{ name: "高一(3)班", count: 38 },
].map((c) => (
<div
key={c.name}
className="flex items-center justify-between py-2"
>
<span className="text-sm">{c.name}</span>
<span className="text-sm text-muted-foreground">
{c.count}
</span>
</div>
))}
</div>
</DetailSection>
</DetailPageShell>
);
}

View File

@@ -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 (
<FormPageShell
title="新建考试"
description="填写考试基本信息(表单页模板示例)"
backHref="/shell/dev/templates"
onSubmit={() => {
// 示例:实际应调用 useExamCreate mutation
}}
submitting={false}
submitLabel="保存"
errorSummary={
// 示例:实际从 form.formState.errors 读取
undefined
}
>
<FormField label="考试名称" name="title" required />
<FormField label="科目" name="subject" required />
<FormField label="年级" name="grade" />
<FormField label="满分" name="maxScore" type="number" />
<FormField label="考试时长(分钟)" name="duration" type="number" />
</FormPageShell>
);
}
function FormField({
label,
name,
required = false,
type = "text",
}: {
label: string;
name: string;
required?: boolean;
type?: "text" | "number";
}): React.ReactElement {
return (
<div className="space-y-1.5">
<label
htmlFor={name}
className="block text-xs uppercase tracking-wide text-muted-foreground"
>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</label>
<input
id={name}
name={name}
type={type}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-1 focus-visible:ring-4"
/>
</div>
);
}

View File

@@ -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<React.ReactElement> {
if (process.env.NODE_ENV === "production") {
notFound();
}
const { state = "success" } = await searchParams;
return (
<ListPageShell
title="列表页模板"
description="PageHeader + FilterBar + DataTable + Pagination + 三态规范"
actions={<Button></Button>}
filters={
<FilterSearchInput placeholder="搜索..." value="" onChange={() => {}} />
}
loading={state === "loading"}
empty={state === "empty"}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span> 42 </span>
<Button variant="outline" size="sm">
</Button>
<span> 1 / 5 </span>
<Button variant="outline" size="sm">
</Button>
</div>
}
>
<ListTemplateTable />
</ListPageShell>
);
}
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 (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium"></th>
<th className="p-3 text-left font-medium"></th>
<th className="p-3 text-left font-medium"></th>
<th className="p-3 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((r) => (
<tr key={r.id} className="hover:bg-muted/30">
<td className="p-3">{r.name}</td>
<td className="p-3">{r.subject}</td>
<td className="p-3">{r.status}</td>
<td className="p-3 font-mono">{r.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-6">
<PageHeader
title="页面模板四件套"
description="P1-3 验收用示例页(仅 dev 可见,生产环境 404"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{templates.map((t) => (
<Link
key={t.href}
href={t.href}
className="block rounded-xl border bg-card p-6 transition-colors hover:bg-accent"
>
<h2 className="text-lg font-semibold">{t.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t.description}
</p>
<p className="mt-3 text-xs text-muted-foreground">{t.href}</p>
</Link>
))}
</div>
</div>
);
}

View File

@@ -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 (
<WorkbenchPageShell
title="教案编辑器"
description="工作台页模板示例(三栏布局)"
actions={
<>
<Button variant="outline"></Button>
<Button></Button>
</>
}
left={
<WorkbenchPanel
title="章节大纲"
actions={
<Button variant="ghost" size="sm">
+
</Button>
}
>
<OutlineTree />
</WorkbenchPanel>
}
center={
<WorkbenchPanel title="画布">
<CanvasDemo />
</WorkbenchPanel>
}
right={
<WorkbenchPanel title="节点属性">
<PropertiesPanel />
</WorkbenchPanel>
}
/>
);
}
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 (
<ul className="space-y-1">
{nodes.map((n) => (
<li
key={n.id}
className="cursor-pointer rounded px-2 py-1 text-sm hover:bg-accent"
style={{ paddingLeft: `${n.indent * 12 + 8}px` }}
>
{n.title}
</li>
))}
</ul>
);
}
function CanvasDemo(): React.ReactElement {
return (
<div className="space-y-3">
<div className="rounded-md border border-dashed p-4">
<h3 className="text-base font-medium"> </h3>
<p className="mt-2 text-sm text-muted-foreground">
</p>
</div>
<div className="rounded-md border border-dashed p-4">
<h3 className="text-base font-medium"></h3>
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
<li>5 </li>
<li>15 </li>
<li> P5 10 </li>
<li>5 </li>
</ul>
</div>
</div>
);
}
function PropertiesPanel(): React.ReactElement {
return (
<div className="space-y-4">
<Field label="节点类型" value="章节" />
<Field label="标题" value="第一章 集合与函数" />
<Field label="预计时长" value="45 分钟" />
<Field label="教学方式" value="讲授 + 互动" />
<Field label="关联资源" value="教材 P1-P8" />
</div>
);
}
function Field({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="space-y-1">
<label className="block text-xs uppercase tracking-wide text-muted-foreground">
{label}
</label>
<div className="rounded-md border bg-background px-3 py-1.5 text-sm">
{value}
</div>
</div>
);
}

View File

@@ -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(
<ListPageShell title="考试管理" description="管理考试">
<div data-testid="content"></div>
</ListPageShell>,
);
expect(screen.getByText("考试管理")).toBeInTheDocument();
expect(screen.getByText("管理考试")).toBeInTheDocument();
expect(screen.getByTestId("content")).toBeInTheDocument();
});
it("loading 态渲染骨架", () => {
const { container } = render(
<ListPageShell title="考试管理" loading>
<div data-testid="content"></div>
</ListPageShell>,
);
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
// 骨架包含多个 Skeleton 元素
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons.length).toBeGreaterThan(0);
});
it("empty 态渲染默认 EmptyState", () => {
render(
<ListPageShell title="考试管理" empty>
<div data-testid="content"></div>
</ListPageShell>,
);
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
expect(screen.getByText("暂无数据")).toBeInTheDocument();
});
it("errorNode 优先级高于 loading/empty", () => {
render(
<ListPageShell
title="t"
loading
empty
errorNode={<div data-testid="err"></div>}
>
<div />
</ListPageShell>,
);
expect(screen.getByTestId("err")).toBeInTheDocument();
expect(screen.queryByText("暂无数据")).not.toBeInTheDocument();
});
it("filters 渲染为 FilterBar", () => {
render(
<ListPageShell
title="t"
filters={<input placeholder="搜索" data-testid="f" />}
>
<div />
</ListPageShell>,
);
expect(screen.getByTestId("f")).toBeInTheDocument();
});
it("loading 时 pagination 不渲染", () => {
render(
<ListPageShell
title="t"
loading
pagination={<div data-testid="page"></div>}
>
<div />
</ListPageShell>,
);
expect(screen.queryByTestId("page")).not.toBeInTheDocument();
});
});
describe("ListPageSkeleton", () => {
it("渲染指定行数骨架", () => {
const { container } = render(<ListPageSkeleton rows={3} />);
// 默认 5 行 + 分页条;这里 rows=3
// 每行 3 个 Skeletonavatar + 2 行文本)+ 1 个操作按钮
const skeletons = container.querySelectorAll(
'[data-slot="skeleton"], .animate-pulse',
);
expect(skeletons.length).toBeGreaterThan(0);
});
});
describe("DetailPageShell", () => {
it("success 态渲染标题 + children", () => {
render(
<DetailPageShell
title="考试详情"
description="2026 春季"
backHref="/shell/teacher/exams"
>
<div data-testid="content"></div>
</DetailPageShell>,
);
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(
<DetailPageShell title="t" loading>
<div data-testid="content"></div>
</DetailPageShell>,
);
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
});
it("DetailSection 渲染标题与内容", () => {
render(
<DetailSection title="基本信息" description="元数据">
<DetailField label="科目" value="数学" />
</DetailSection>,
);
expect(screen.getByText("基本信息")).toBeInTheDocument();
expect(screen.getByText("元数据")).toBeInTheDocument();
expect(screen.getByText("科目")).toBeInTheDocument();
expect(screen.getByText("数学")).toBeInTheDocument();
});
it("DetailField 无 value 时显示 '-'", () => {
render(<DetailField label="备注" />);
expect(screen.getByText("-")).toBeInTheDocument();
});
});
describe("FormPageShell", () => {
it("渲染标题 + 表单字段 + 提交按钮", () => {
render(
<FormPageShell
title="新建考试"
submitLabel="保存"
onSubmit={() => {}}
backHref="/back"
>
<input name="title" data-testid="input" />
</FormPageShell>,
);
expect(screen.getByText("新建考试")).toBeInTheDocument();
expect(screen.getByTestId("input")).toBeInTheDocument();
expect(screen.getByText("保存")).toBeInTheDocument();
expect(screen.getByText("取消")).toHaveAttribute("href", "/back");
});
it("submitting 时按钮禁用并显示'提交中...'", () => {
render(
<FormPageShell title="t" onSubmit={() => {}} submitting>
<input />
</FormPageShell>,
);
const btn = screen.getByText("提交中...");
expect(btn).toBeDisabled();
});
it("errorSummary 渲染为 alert", () => {
render(
<FormPageShell title="t" onSubmit={() => {}} errorSummary="表单校验失败">
<input />
</FormPageShell>,
);
expect(screen.getByRole("alert")).toHaveTextContent("表单校验失败");
});
it("loading 态渲染骨架,不渲染表单", () => {
render(
<FormPageShell title="t" loading>
<input data-testid="input" />
</FormPageShell>,
);
expect(screen.queryByTestId("input")).not.toBeInTheDocument();
});
});
describe("WorkbenchPageShell", () => {
it("渲染三栏布局", () => {
render(
<WorkbenchPageShell
title="教案编辑"
left={<div data-testid="left"></div>}
center={<div data-testid="center"></div>}
right={<div data-testid="right"></div>}
/>,
);
expect(screen.getByText("教案编辑")).toBeInTheDocument();
expect(screen.getByTestId("left")).toBeInTheDocument();
expect(screen.getByTestId("center")).toBeInTheDocument();
expect(screen.getByTestId("right")).toBeInTheDocument();
});
it("loading 态渲染骨架", () => {
render(
<WorkPageShellLoading>
<div data-testid="content"></div>
</WorkPageShellLoading>,
);
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
});
it("WorkbenchPanel 渲染标题与内容", () => {
render(
<WorkbenchPanel title="属性">
<div data-testid="content"></div>
</WorkbenchPanel>,
);
expect(screen.getByText("属性")).toBeInTheDocument();
expect(screen.getByTestId("content")).toBeInTheDocument();
});
it("仅 left 时渲染单栏", () => {
render(
<WorkbenchPageShell title="t" left={<div data-testid="left"></div>} />,
);
expect(screen.getByTestId("left")).toBeInTheDocument();
});
});
// 辅助组件:包装 loading=true 的 WorkbenchPageShell 用于测试
function WorkPageShellLoading({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<WorkbenchPageShell title="t" loading>
{children}
</WorkbenchPageShell>
);
}

View File

@@ -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
* <DetailPageShell
* title={exam.title}
* description={`创建于 ${exam.createdAt}`}
* actions={<Button>编辑</Button>}
* backHref="/shell/teacher/exams"
* >
* <DetailSection title="基本信息">
* <DetailField label="科目" value={exam.subject} />
* </DetailSection>
* <DetailTabs tabs={[{ label: "题目", content: <Questions /> }]} />
* </DetailPageShell>
* ```
*
* 关联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 (
<div className="flex flex-col gap-6">
{backHref ? <DetailBackButton href={backHref} /> : null}
<PageHeader
title={title}
description={description}
icon={icon}
actions={actions}
/>
<div className={cn("flex flex-col gap-6", className)}>
{errorNode
? errorNode
: loading
? (loadingNode ?? <DetailPageSkeleton />)
: emptyNode
? emptyNode
: children}
</div>
</div>
);
}
/**
* 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 (
<section className={cn("rounded-xl border bg-card p-6", className)}>
<header className="mb-4 flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{actions ? <div className="flex gap-2">{actions}</div> : null}
</header>
<div className="space-y-3">{children}</div>
</section>
);
}
/**
* 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 (
<div
className={cn(
"flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:gap-4",
className,
)}
>
<dt className="w-32 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 text-sm">{value ?? "-"}</dd>
</div>
);
}
/**
* DetailBackButton - 返回链接Client Component 内部用 next/link
*
* 单独抽出便于后续 i18n 化P1-4 替换"返回"文案)。
*/
function DetailBackButton({ href }: { href: string }): ReactNode {
// 动态 import next/link 避免在 RSC 边界引入客户端依赖
// 实际上 next/link 在 RSC 中也可直接使用
return (
<a
href={href}
className="inline-flex h-9 items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
className="size-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19l-7-7 7-7"
/>
</svg>
</a>
);
}
/**
* DetailPageSkeleton - 详情页默认骨架
*
* 包含 PageHeader 骨架 + 2 个分区骨架。
*/
export function DetailPageSkeleton(): ReactNode {
return (
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Skeleton className="h-8 w-2/5" />
<Skeleton className="h-4 w-1/3" />
</div>
{[0, 1].map((i) => (
<div key={i} className="rounded-xl border bg-card p-6">
<Skeleton className="mb-4 h-5 w-32" />
<div className="space-y-3">
{[0, 1, 2].map((j) => (
<div
key={j}
className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:gap-4"
>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 flex-1" />
</div>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -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
* <FormPageShell
* title="新建考试"
* description="填写考试基本信息"
* onCancel="/shell/teacher/exams"
* onSubmit={handleSubmit}
* submitting={isSubmitting}
* errorSummary={formError}
* >
* <FormField label="考试名称" name="title" required>
* <FormInput name="title" />
* </FormField>
* </FormPageShell>
* ```
*
* 关联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 (
<div className="flex flex-col gap-6">
{backHref ? <FormBackButton href={backHref} /> : null}
<PageHeader title={title} description={description} icon={icon} />
<div className={cn("mx-auto w-full max-w-2xl", className)}>
{loading ? (
(loadingNode ?? <FormPageSkeleton />)
) : (
<form
onSubmit={
onSubmit
? (e) => {
e.preventDefault();
onSubmit();
}
: undefined
}
className="space-y-6 rounded-xl border bg-card p-6"
>
{errorSummary ? (
<div
role="alert"
aria-live="assertive"
className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
>
{errorSummary}
</div>
) : null}
<div className="space-y-4">{children}</div>
{(onSubmit || onCancel || backHref) && (
<div className="flex items-center justify-end gap-2 border-t pt-4">
{onCancel ? (
<CancelButton onClick={onCancel} label={cancelLabel} />
) : backHref ? (
<CancelButton href={backHref} label={cancelLabel} />
) : null}
{onSubmit ? (
<SubmitButton submitting={submitting} label={submitLabel} />
) : null}
</div>
)}
</form>
)}
</div>
</div>
);
}
/**
* FormPageSkeleton - 表单页加载骨架
*
* 4 个字段骨架 + 操作栏骨架。
*/
export function FormPageSkeleton(): ReactNode {
return (
<div className="space-y-6 rounded-xl border bg-card p-6">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
))}
<div className="flex items-center justify-end gap-2 border-t pt-4">
<Skeleton className="h-9 w-20 rounded-md" />
<Skeleton className="h-9 w-20 rounded-md" />
</div>
</div>
);
}
function FormBackButton({ href }: { href: string }): ReactNode {
return (
<a
href={href}
className="inline-flex h-9 items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
className="size-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19l-7-7 7-7"
/>
</svg>
</a>
);
}
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 (
<a href={href} className={cls}>
{label}
</a>
);
}
return (
<button type="button" onClick={onClick} className={cls}>
{label}
</button>
);
}
function SubmitButton({
submitting,
label,
}: {
submitting: boolean;
label: string;
}): ReactNode {
return (
<button
type="submit"
disabled={submitting}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "提交中..." : label}
</button>
);
}

View File

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

View File

@@ -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
* <ListPageShell
* title="考试管理"
* description="查看和管理所有考试"
* actions={<Button>新建考试</Button>}
* filters={<FilterSearchInput value={q} onChange={setQ} />}
* loading={isLoading}
* empty={data.length === 0}
* >
* <DataTable columns={cols} data={data} />
* </ListPageShell>
* ```
*
* 关联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 (
<div className="flex flex-col gap-6">
<PageHeader
title={title}
description={description}
icon={icon}
actions={actions}
/>
{filters ? <FilterBar variant="between">{filters}</FilterBar> : null}
<div className={cn("flex flex-col gap-4", className)}>
{errorNode
? errorNode
: loading
? (loadingNode ?? <ListPageSkeleton />)
: empty
? (emptyNode ?? (
<EmptyState
title="暂无数据"
description="调整筛选条件后重试,或新建第一条记录"
/>
))
: children}
{pagination && !loading && !empty ? pagination : null}
</div>
</div>
);
}
/**
* ListPageSkeleton - 列表页默认骨架5 行 + 分页条)
*
* 复用 Skeleton 组件,对齐 §8.1 铁律(无硬编码颜色/字号)。
*/
export function ListPageSkeleton({ rows = 5 }: { rows?: number }): ReactNode {
return (
<div className="rounded-xl border bg-card">
<div className="space-y-3 p-4">
{Array.from({ length: rows }, (_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-8 w-20 rounded-md" />
</div>
))}
</div>
<div className="flex items-center justify-end gap-2 border-t p-4">
<Skeleton className="h-8 w-24 rounded-md" />
<Skeleton className="h-8 w-16 rounded-md" />
</div>
</div>
);
}

View File

@@ -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
* <WorkbenchPageShell
* title="编辑教案"
* actions={<Button>保存</Button>}
* left={<LessonPlanTree />}
* center={<LessonPlanCanvas />}
* right={<NodeProperties />}
* />
* ```
*
* 关联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 (
<div className="flex flex-col gap-4">
<PageHeader
title={title}
description={description}
icon={icon}
actions={actions}
/>
<div className={cn("flex-1", className)}>
{errorNode ? (
errorNode
) : loading ? (
(loadingNode ?? <WorkbenchPageSkeleton />)
) : (
<div className="flex h-[calc(100vh-12rem)] gap-4 overflow-hidden">
{left ? (
<aside
className={cn(
"shrink-0 overflow-y-auto rounded-xl border bg-card p-4",
leftWidth,
)}
>
{left}
</aside>
) : null}
<main className="flex-1 overflow-y-auto rounded-xl border bg-card p-4">
{center}
</main>
{right ? (
<aside
className={cn(
"shrink-0 overflow-y-auto rounded-xl border bg-card p-4",
rightWidth,
)}
>
{right}
</aside>
) : null}
</div>
)}
</div>
</div>
);
}
/**
* 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 (
<div className={cn("flex h-full flex-col gap-3", className)}>
{title ? (
<header className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{title}</h3>
{actions ? <div className="flex gap-1">{actions}</div> : null}
</header>
) : null}
<div className="flex-1">{children}</div>
</div>
);
}
/**
* WorkbenchPageSkeleton - 工作台页骨架
*
* PageHeader 骨架 + 三栏骨架。
*/
export function WorkbenchPageSkeleton(): ReactNode {
return (
<div className="flex h-[calc(100vh-12rem)] gap-4">
<div className="w-72 shrink-0 space-y-3 rounded-xl border bg-card p-4">
<Skeleton className="h-5 w-24" />
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="size-4 rounded-sm" />
<Skeleton className="h-4 flex-1" />
</div>
))}
</div>
<div className="flex-1 rounded-xl border bg-card p-4">
<Skeleton className="mb-4 h-6 w-32" />
<div className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-md" />
))}
</div>
</div>
<div className="w-80 shrink-0 space-y-3 rounded-xl border bg-card p-4">
<Skeleton className="h-5 w-28" />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
))}
</div>
</div>
);
}

View File

@@ -260,6 +260,13 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
requiredPermissions: ["ANNOUNCEMENT_MANAGE"], requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
}, },
}, },
// P1-3dev 模板预览页(仅登录可访问,运行时由 notFound() 拒绝生产访问)
// ARCHITECTURE.md §10 P1-3/shell/dev/templates/* 仅 dev 可见
// 注Next.js 私有文件夹以 _ 开头会被排除路由,故使用 `dev` 而非 `_dev`
{
prefix: "/shell/dev/",
config: {}, // 空 config = 仅校验登录身份
},
]; ];
/** /**