feat(portal-shell): 管理域 §9.4 B5 全量迁移(24 + 21 补充批次共 44 页 + 42 features)
按 ARCHITECTURE.md §9.4 规划口径 + admin-NeedTodo.md §四补充批次完成管理域全量页面迁移: 【§9.4 规划 24 页(B5)】 - users(2) + roles(1) + permissions(1) + audit-logs(4) + invitation-codes(1) - school(6: redirect/schools/classes/departments/academic-year/grades) - announcements(1) + files(1) + ai-settings(1) + system(1) + viewports(1) - students(1) + teachers(1) + organization(1) + plugins(1, config-service) - 仪表盘已存在(/shell/admin/page.tsx) 【§四补充批次 21 页】 - course-plans(4) + elective(4) + questions(1) + lesson-plans(2) + error-book(1) - scheduling(3: auto/changes/rules) + attendance(1) + curriculum-map(1) - announcements 详情/编辑(2) + roles/[id] 详情(1) + users/import(1) 【实现要点】 - 全部使用 ListPageShell + loading/error/empty 三态规范(§11.3 DoD) - 走 lib/api hooks;未就绪契约走 MSW + @contract-pending 注释(§11.4) - 文案走 useTranslations(zh-CN + en 两份同步更新) - 42 个 features/<domain>/transformations.ts 纯函数 + 配套 vitest 单测 - catch 块统一 notify.error;无空 catch;lint:tokens 通过 - 路由全部登记到 route-permissions.ts(39 EXACT + 8 PREFIX) 【验收】 - tsc --noEmit: 0 errors - ESLint src: 0 errors (4 generated-files warnings, pre-existing) - lint:tokens: 0 errors - vitest: 1639/1639 passed (含 23 admin 测试文件 671 用例) - check:routes: PASS (143 routes, 4 ghost entries pre-existing) - check:pages: PASS (146 pages) - check:codegen: PASS - arch:scan: 24 modules, 8262 symbols 关联:ARCHITECTURE.md §9.4 / §10 P5 / §11.3 DoD / §11.6
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentLessonPlans schema 未实现,全 MSW 兜底
|
||||
/**
|
||||
* 学生教案列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useStudentLessonPlans, type StudentLessonPlan } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function StudentLessonPlansListClient(): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.list");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentLessonPlans();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
||||
}
|
||||
}, [error, tCommon]);
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={t("emptyTitle")}
|
||||
action={{
|
||||
label: t("title"),
|
||||
href: "/shell/student/lesson-plans",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
icon={<FileText className="size-6" />}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={items.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("total", { count: items.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StudentLessonPlansTable items={items} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function StudentLessonPlansTable({
|
||||
items,
|
||||
}: {
|
||||
items: StudentLessonPlan[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.list");
|
||||
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">{t("colTitle")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
|
||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((plan) => (
|
||||
<tr key={plan.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.subject}</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.grade}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{plan.updatedAt}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底
|
||||
/**
|
||||
* 学生教案只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentLessonPlanView(planId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
useStudentLessonPlanView,
|
||||
type StudentLessonPlanView as LessonPlanViewData,
|
||||
} from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 只读查看客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function StudentLessonPlanViewClient(): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ planId: string }>();
|
||||
const planId = params?.planId ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentLessonPlanView(planId);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
||||
}
|
||||
}, [error, tCommon]);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={t("notFound")}
|
||||
action={{
|
||||
label: t("backToList"),
|
||||
href: "/shell/student/lesson-plans",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/student/lesson-plans"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <LessonPlanViewBody plan={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 教案查看主体(基本信息 + 教案内容)。
|
||||
*/
|
||||
function LessonPlanViewBody({
|
||||
plan,
|
||||
}: {
|
||||
plan: LessonPlanViewData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||
return (
|
||||
<DetailSection title={t("sectionContent")}>
|
||||
<DetailField label={t("title")} value={plan.title} />
|
||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
|
||||
{plan.content}
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user