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

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