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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user