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