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:
@@ -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<React.ReactElement> {
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| 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<React.ReactElement> {
|
||||
- **三态规范**: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。
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
69
apps/portal-shell/src/app/shell/dev/templates/form/page.tsx
Normal file
69
apps/portal-shell/src/app/shell/dev/templates/form/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
apps/portal-shell/src/app/shell/dev/templates/list/page.tsx
Normal file
103
apps/portal-shell/src/app/shell/dev/templates/list/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/portal-shell/src/app/shell/dev/templates/page.tsx
Normal file
68
apps/portal-shell/src/app/shell/dev/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx
Normal file
131
apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 个 Skeleton(avatar + 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = 仅校验登录身份
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user