;
}
-// Baseline as of P2 B3 (2026-07-22, error-book + diagnostic + analytics modules added).
+// Baseline as of B2 末 教师域 (2026-07-24, proctoring + student-diagnostic
+// added: 2 new pages).
// Update when adding pages.
const BASELINE: Baseline = {
- total: 58,
+ total: 69,
categories: {
dashboards: {
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
diff --git a/apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts b/apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts
index e58ba52..737b8d2 100644
--- a/apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts
+++ b/apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts
@@ -206,7 +206,7 @@ describe("E2E: 三层安全边界", () => {
it("teacher 有 QUESTION_READ → 访问题库放行(OR 语义)", () => {
const result = checkRoutePermission(
- "/shell/teacher/question-bank",
+ "/shell/teacher/questions",
TEACHER_USER.bitmap,
TEACHER_USER.role,
);
@@ -250,7 +250,7 @@ describe("E2E: 三层安全边界", () => {
it("student 有 ELECTIVE_SELECT → 访问选修课选择放行(OR 语义)", () => {
const result = checkRoutePermission(
- "/shell/student/electives",
+ "/shell/student/elective",
STUDENT_USER.bitmap,
STUDENT_USER.role,
);
@@ -263,7 +263,7 @@ describe("E2E: 三层安全边界", () => {
"DASHBOARD_READ",
]);
const result = checkRoutePermission(
- "/shell/student/electives",
+ "/shell/student/elective",
noElective,
"student",
);
@@ -313,7 +313,7 @@ describe("E2E: 三层安全边界", () => {
const teacherRoutes = [
"/shell/teacher",
"/shell/teacher/lesson-plans",
- "/shell/teacher/question-bank",
+ "/shell/teacher/questions",
];
const results = batchCheckRoutePermission(
teacherRoutes,
diff --git a/apps/portal-shell/src/app/shell/admin/ai-settings/error.tsx b/apps/portal-shell/src/app/shell/admin/ai-settings/error.tsx
new file mode 100644
index 0000000..c5f18e5
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/ai-settings/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * AI 配置路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AiSettingsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.aiSettings");
+
+ useEffect(() => {
+ console.error("[portal-shell] ai-settings route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/ai-settings/loading.tsx b/apps/portal-shell/src/app/shell/admin/ai-settings/loading.tsx
new file mode 100644
index 0000000..115d91a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/ai-settings/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * AI 配置路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AiSettingsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/ai-settings/page.tsx b/apps/portal-shell/src/app/shell/admin/ai-settings/page.tsx
new file mode 100644
index 0000000..4365134
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/ai-settings/page.tsx
@@ -0,0 +1,24 @@
+import { Suspense } from "react";
+
+import { AiSettingsClient } from "@/features/admin/ai-settings/ai-settings-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * AI Provider 配置与用量仪表盘(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AiSettingsClient(client component)中。
+ *
+ * 数据契约:
+ * - aiProviders(scope) ❌ schema 未就绪 → MSW 兜底(@contract-pending)
+ * - aiUsageDashboard(range) ❌ schema 未就绪 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AiSettingsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/error.tsx b/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/error.tsx
new file mode 100644
index 0000000..51222c0
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 公告编辑路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AnnouncementEditError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.announcements");
+
+ useEffect(() => {
+ console.error("[portal-shell] announcement edit route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/loading.tsx b/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/loading.tsx
new file mode 100644
index 0000000..7205524
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告编辑路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AnnouncementEditLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/page.tsx b/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/page.tsx
new file mode 100644
index 0000000..4298c88
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/[id]/edit/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AnnouncementEditClient } from "@/features/admin/announcements/announcement-edit-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告编辑表单页(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useParams 要求)。
+ * 业务逻辑在 AnnouncementEditClient(client component)中。
+ *
+ * 数据契约:adminAnnouncement(id) + updateAnnouncement(id, input) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AnnouncementEditPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/[id]/error.tsx b/apps/portal-shell/src/app/shell/admin/announcements/[id]/error.tsx
new file mode 100644
index 0000000..3271908
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/[id]/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 公告详情路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AnnouncementDetailError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.announcements");
+
+ useEffect(() => {
+ console.error("[portal-shell] announcement detail route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/[id]/loading.tsx b/apps/portal-shell/src/app/shell/admin/announcements/[id]/loading.tsx
new file mode 100644
index 0000000..a41fa77
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/[id]/loading.tsx
@@ -0,0 +1,9 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告详情路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AnnouncementDetailLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/[id]/page.tsx b/apps/portal-shell/src/app/shell/admin/announcements/[id]/page.tsx
new file mode 100644
index 0000000..05cfdff
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/[id]/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AnnouncementDetailClient } from "@/features/admin/announcements/announcement-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告详情页(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AnnouncementDetailClient(client component)中。
+ *
+ * 数据契约:adminAnnouncement(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+export default function AnnouncementDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/error.tsx b/apps/portal-shell/src/app/shell/admin/announcements/error.tsx
new file mode 100644
index 0000000..f1cc120
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 公告路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AnnouncementsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.announcements");
+
+ useEffect(() => {
+ console.error("[portal-shell] announcements route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/loading.tsx b/apps/portal-shell/src/app/shell/admin/announcements/loading.tsx
new file mode 100644
index 0000000..e6f1c32
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AnnouncementsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/announcements/page.tsx b/apps/portal-shell/src/app/shell/admin/announcements/page.tsx
new file mode 100644
index 0000000..48739ff
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/announcements/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AnnouncementsListClient } from "@/features/admin/announcements/announcements-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AnnouncementsListClient(client component)中。
+ *
+ * 数据契约:adminAnnouncements(status) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AnnouncementsListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/attendance/error.tsx b/apps/portal-shell/src/app/shell/admin/attendance/error.tsx
new file mode 100644
index 0000000..d6f1680
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/attendance/error.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+/**
+ * 考勤管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AdminAttendanceError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.attendance.error");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin attendance route error:", error);
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/attendance/loading.tsx b/apps/portal-shell/src/app/shell/admin/attendance/loading.tsx
new file mode 100644
index 0000000..b3ed1b6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/attendance/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 考勤管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminAttendanceLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/attendance/page.tsx b/apps/portal-shell/src/app/shell/admin/attendance/page.tsx
new file mode 100644
index 0000000..07cd3fe
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/attendance/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { AdminAttendanceClient } from "@/features/admin/attendance/admin-attendance-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 考勤管理页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AdminAttendanceClient(client component)中。
+ *
+ * 数据契约:adminAttendanceStats / attendanceGradeCorrelation / adminClasses
+ * ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminAttendancePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/error.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/error.tsx
new file mode 100644
index 0000000..8b22de1
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/error.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+/**
+ * 数据变更日志路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function DataChangesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.error");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin audit-logs/data-changes route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/loading.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/loading.tsx
new file mode 100644
index 0000000..69cff78
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 数据变更日志路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function DataChangesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/page.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/page.tsx
new file mode 100644
index 0000000..1bc2a0b
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/data-changes/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { DataChangesClient } from "@/features/admin/audit-logs/data-changes-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 数据变更日志页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 DataChangesClient(client component)中。
+ *
+ * 数据契约:dataChangeLogs(filter, pagination) / dataChangeTableOptions / dataChangeStats ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function DataChangesPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/error.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/error.tsx
new file mode 100644
index 0000000..90ba400
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/error.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+/**
+ * 审计日志路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AuditLogsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.error");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin audit-logs route error:", error);
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/loading.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/loading.tsx
new file mode 100644
index 0000000..59fedbd
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 审计日志路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AuditLogsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/error.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/error.tsx
new file mode 100644
index 0000000..77ab5fe
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/error.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+/**
+ * 登录日志路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function LoginLogsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.error");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin audit-logs/login-logs route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/loading.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/loading.tsx
new file mode 100644
index 0000000..e3cbf16
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 登录日志路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function LoginLogsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/page.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/page.tsx
new file mode 100644
index 0000000..aa0886d
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/login-logs/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { LoginLogsClient } from "@/features/admin/audit-logs/login-logs-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 登录日志页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 LoginLogsClient(client component)中。
+ *
+ * 数据契约:loginLogs(filter, pagination) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function LoginLogsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/overview/error.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/overview/error.tsx
new file mode 100644
index 0000000..0b3854d
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/overview/error.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+/**
+ * 审计概览路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AuditOverviewError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.error");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin audit-logs/overview route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/overview/loading.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/overview/loading.tsx
new file mode 100644
index 0000000..35b9cbb
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/overview/loading.tsx
@@ -0,0 +1,9 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 审计概览路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AuditOverviewLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/overview/page.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/overview/page.tsx
new file mode 100644
index 0000000..785d829
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/overview/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AuditOverviewClient } from "@/features/admin/audit-logs/audit-overview-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 审计概览页(ARCHITECTURE.md §7.3 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AuditOverviewClient(client component)中。
+ *
+ * 数据契约:auditOverviewStats / auditTrend / dataChangeActionStats ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AuditOverviewPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/audit-logs/page.tsx b/apps/portal-shell/src/app/shell/admin/audit-logs/page.tsx
new file mode 100644
index 0000000..d75beb2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/audit-logs/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AuditLogsListClient } from "@/features/admin/audit-logs/audit-logs-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 审计日志列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AuditLogsListClient(client component)中。
+ *
+ * 数据契约:auditLogs(filter, pagination) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AuditLogsListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/error.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/error.tsx
new file mode 100644
index 0000000..056a4b7
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 管理端课程计划编辑路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function CoursePlanEditError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.coursePlans");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin course-plan edit route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/loading.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/loading.tsx
new file mode 100644
index 0000000..68f6a05
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划编辑路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function CoursePlanEditLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/page.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/page.tsx
new file mode 100644
index 0000000..e49d6fe
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/edit/page.tsx
@@ -0,0 +1,21 @@
+import { Suspense } from "react";
+
+import { CoursePlanEditClient } from "@/features/admin/course-plans/course-plan-edit-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划编辑表单页(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useParams 要求)。
+ *
+ * 数据契约:adminCoursePlan(id) + updateCoursePlan(input) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function CoursePlanEditPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/[id]/error.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/error.tsx
new file mode 100644
index 0000000..0bbbae3
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/error.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+/**
+ * 管理端课程计划详情路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function CoursePlanDetailError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.coursePlans");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin course-plan detail route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/[id]/loading.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/loading.tsx
new file mode 100644
index 0000000..cc35aff
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/loading.tsx
@@ -0,0 +1,12 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划详情路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ *
+ * 子页面(编辑)的 Skeleton 由 server page 的 兜底,
+ * 本文件仅在 /shell/admin/course-plans/[id] 期间显示。
+ */
+export default function CoursePlanDetailLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/[id]/page.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/page.tsx
new file mode 100644
index 0000000..f28e4ab
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/[id]/page.tsx
@@ -0,0 +1,21 @@
+import { Suspense } from "react";
+
+import { CoursePlanDetailClient } from "@/features/admin/course-plans/course-plan-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划详情页(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ *
+ * 数据契约:adminCoursePlan(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function CoursePlanDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/create/error.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/create/error.tsx
new file mode 100644
index 0000000..d6151dc
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/create/error.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+/**
+ * 管理端课程计划新建路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function CoursePlanCreateError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.coursePlans");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin course-plan create route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/create/loading.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/create/loading.tsx
new file mode 100644
index 0000000..43ab891
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/create/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划新建路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function CoursePlanCreateLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/create/page.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/create/page.tsx
new file mode 100644
index 0000000..0146a8b
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/create/page.tsx
@@ -0,0 +1,21 @@
+import { Suspense } from "react";
+
+import { CoursePlanCreateClient } from "@/features/admin/course-plans/course-plan-create-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划新建表单页(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ *
+ * 数据契约:mutation createCoursePlan(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function CoursePlanCreatePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/error.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/error.tsx
new file mode 100644
index 0000000..1aff1c3
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 管理端课程计划路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function CoursePlansError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.coursePlans");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin course-plans route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/loading.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/loading.tsx
new file mode 100644
index 0000000..7cd4dd7
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/loading.tsx
@@ -0,0 +1,12 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ *
+ * 子页面(详情/编辑/新建)的 Skeleton 由各自 server page 的 兜底,
+ * 本文件仅在 /shell/admin/course-plans 列表/重定向期间显示。
+ */
+export default function CoursePlansLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/course-plans/page.tsx b/apps/portal-shell/src/app/shell/admin/course-plans/page.tsx
new file mode 100644
index 0000000..3216178
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/course-plans/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { CoursePlansListClient } from "@/features/admin/course-plans/course-plans-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 管理端课程计划列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 CoursePlansListClient(client component)中。
+ *
+ * 数据契约:adminCoursePlans(status) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function CoursePlansListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/curriculum-map/error.tsx b/apps/portal-shell/src/app/shell/admin/curriculum-map/error.tsx
new file mode 100644
index 0000000..fbb5d46
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/curriculum-map/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 课程地图路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function CurriculumMapError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.curriculumMap");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin curriculum-map route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/curriculum-map/loading.tsx b/apps/portal-shell/src/app/shell/admin/curriculum-map/loading.tsx
new file mode 100644
index 0000000..157d173
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/curriculum-map/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 课程地图路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function CurriculumMapLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/curriculum-map/page.tsx b/apps/portal-shell/src/app/shell/admin/curriculum-map/page.tsx
new file mode 100644
index 0000000..f43051a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/curriculum-map/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { CurriculumMapClient } from "@/features/admin/curriculum-map/curriculum-map-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 课程地图页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 CurriculumMapClient(client component)中。
+ *
+ * 数据契约:standardsCoverageHeatmap / globalLessonPlanStats ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function CurriculumMapPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/error.tsx b/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/error.tsx
new file mode 100644
index 0000000..947f984
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 选修课编辑路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function ElectiveEditError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.elective");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin elective edit route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/loading.tsx b/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/loading.tsx
new file mode 100644
index 0000000..20c6eb6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课编辑路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function ElectiveEditLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/page.tsx b/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/page.tsx
new file mode 100644
index 0000000..459befc
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/[id]/edit/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { ElectiveEditClient } from "@/features/admin/elective/elective-edit-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课编辑表单页(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useParams 要求)。
+ * 业务逻辑在 ElectiveEditClient(client component)中。
+ *
+ * 数据契约:adminElective(id) + updateElective(id, input) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function ElectiveEditPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/[id]/error.tsx b/apps/portal-shell/src/app/shell/admin/elective/[id]/error.tsx
new file mode 100644
index 0000000..b91f469
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/[id]/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 选修课详情路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function ElectiveDetailError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.elective");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin elective detail route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/[id]/loading.tsx b/apps/portal-shell/src/app/shell/admin/elective/[id]/loading.tsx
new file mode 100644
index 0000000..c0fc31c
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/[id]/loading.tsx
@@ -0,0 +1,9 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课详情路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function ElectiveDetailLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/[id]/page.tsx b/apps/portal-shell/src/app/shell/admin/elective/[id]/page.tsx
new file mode 100644
index 0000000..84cba66
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/[id]/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { ElectiveDetailClient } from "@/features/admin/elective/elective-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课详情页(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 ElectiveDetailClient(client component)中。
+ *
+ * 数据契约:adminElective(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+export default function ElectiveDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/create/error.tsx b/apps/portal-shell/src/app/shell/admin/elective/create/error.tsx
new file mode 100644
index 0000000..c2e7fc6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/create/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 选修课新建路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function ElectiveCreateError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.elective");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin elective create route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/create/loading.tsx b/apps/portal-shell/src/app/shell/admin/elective/create/loading.tsx
new file mode 100644
index 0000000..5ccc3f6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/create/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课新建路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function ElectiveCreateLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/create/page.tsx b/apps/portal-shell/src/app/shell/admin/elective/create/page.tsx
new file mode 100644
index 0000000..4899298
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/create/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { ElectiveCreateClient } from "@/features/admin/elective/elective-create-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课新建表单页(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 ElectiveCreateClient(client component)中。
+ *
+ * 数据契约:createElective(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function ElectiveCreatePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/error.tsx b/apps/portal-shell/src/app/shell/admin/elective/error.tsx
new file mode 100644
index 0000000..3a118de
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 选修课管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function ElectiveListError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.elective");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin elective list route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/loading.tsx b/apps/portal-shell/src/app/shell/admin/elective/loading.tsx
new file mode 100644
index 0000000..c6e5e85
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function ElectiveListLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/elective/page.tsx b/apps/portal-shell/src/app/shell/admin/elective/page.tsx
new file mode 100644
index 0000000..710ddb4
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/elective/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { ElectiveListClient } from "@/features/admin/elective/elective-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 选修课管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 ElectiveListClient(client component)中。
+ *
+ * 数据契约:adminElectives ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function ElectiveListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/error-book/error.tsx b/apps/portal-shell/src/app/shell/admin/error-book/error.tsx
new file mode 100644
index 0000000..86df1a4
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/error-book/error.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+/**
+ * 错题本分析路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AdminErrorBookError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.errorBook.error");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin error-book route error:", error);
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/error-book/loading.tsx b/apps/portal-shell/src/app/shell/admin/error-book/loading.tsx
new file mode 100644
index 0000000..d6e1359
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/error-book/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 错题本分析路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminErrorBookLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/error-book/page.tsx b/apps/portal-shell/src/app/shell/admin/error-book/page.tsx
new file mode 100644
index 0000000..bd5d5e0
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/error-book/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { ErrorBookClient } from "@/features/admin/error-book/error-book-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 错题本分析页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 ErrorBookClient(client component)中。
+ *
+ * 数据契约:adminErrorBookStats ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminErrorBookPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/files/error.tsx b/apps/portal-shell/src/app/shell/admin/files/error.tsx
new file mode 100644
index 0000000..d739f1d
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/files/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 文件路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function FilesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.files");
+
+ useEffect(() => {
+ console.error("[portal-shell] files route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/files/loading.tsx b/apps/portal-shell/src/app/shell/admin/files/loading.tsx
new file mode 100644
index 0000000..68b218f
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/files/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 文件路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function FilesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/files/page.tsx b/apps/portal-shell/src/app/shell/admin/files/page.tsx
new file mode 100644
index 0000000..1aa4673
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/files/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { FilesListClient } from "@/features/admin/files/files-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 文件管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 FilesListClient(client component)中。
+ *
+ * 数据契约:fileAttachments(filter) + fileStats ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function FilesListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/invitation-codes/error.tsx b/apps/portal-shell/src/app/shell/admin/invitation-codes/error.tsx
new file mode 100644
index 0000000..39685b2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/invitation-codes/error.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+/**
+ * 邀请码管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function InvitationCodesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.invitationCodes.error");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin invitation-codes route error:", error);
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/invitation-codes/loading.tsx b/apps/portal-shell/src/app/shell/admin/invitation-codes/loading.tsx
new file mode 100644
index 0000000..1c0f0c2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/invitation-codes/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 邀请码管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function InvitationCodesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/invitation-codes/page.tsx b/apps/portal-shell/src/app/shell/admin/invitation-codes/page.tsx
new file mode 100644
index 0000000..6db048b
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/invitation-codes/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { InvitationCodesListClient } from "@/features/admin/invitation-codes/invitation-codes-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 邀请码管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 InvitationCodesListClient(client component)中。
+ *
+ * 数据契约:invitationCodes(status) / createInvitationCode / revokeInvitationCode ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function InvitationCodesListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/error.tsx b/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/error.tsx
new file mode 100644
index 0000000..dca5bd6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 教案详情只读路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+
+export default function AdminLessonPlanViewError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.lessonPlans");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin lesson plan view route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/loading.tsx b/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/loading.tsx
new file mode 100644
index 0000000..70f3398
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/loading.tsx
@@ -0,0 +1,9 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 教案详情只读路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminLessonPlanViewLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/page.tsx b/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/page.tsx
new file mode 100644
index 0000000..400d841
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/lesson-plans/[planId]/view/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AdminLessonPlanViewClient } from "@/features/admin/lesson-plans/lesson-plan-view-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 教案详情只读页(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AdminLessonPlanViewClient(client component)中。
+ *
+ * 数据契约:adminLessonPlan(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+export default function AdminLessonPlanViewPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/lesson-plans/error.tsx b/apps/portal-shell/src/app/shell/admin/lesson-plans/error.tsx
new file mode 100644
index 0000000..56de88a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/lesson-plans/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 教案管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+
+export default function AdminLessonPlansError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.lessonPlans");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin lesson plans route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/lesson-plans/loading.tsx b/apps/portal-shell/src/app/shell/admin/lesson-plans/loading.tsx
new file mode 100644
index 0000000..6a1e876
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/lesson-plans/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 教案管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminLessonPlansLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/lesson-plans/page.tsx b/apps/portal-shell/src/app/shell/admin/lesson-plans/page.tsx
new file mode 100644
index 0000000..1e60745
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/lesson-plans/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AdminLessonPlansListClient } from "@/features/admin/lesson-plans/lesson-plans-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 教案管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AdminLessonPlansListClient(client component)中。
+ *
+ * 数据契约:adminLessonPlans ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminLessonPlansListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/organization/error.tsx b/apps/portal-shell/src/app/shell/admin/organization/error.tsx
new file mode 100644
index 0000000..e4b2bc8
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/organization/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 组织管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function OrganizationError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.organization");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin organization route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/organization/loading.tsx b/apps/portal-shell/src/app/shell/admin/organization/loading.tsx
new file mode 100644
index 0000000..80f40a2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/organization/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 组织管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function OrganizationLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/organization/page.tsx b/apps/portal-shell/src/app/shell/admin/organization/page.tsx
new file mode 100644
index 0000000..ab60892
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/organization/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { OrganizationTreeClient } from "@/features/admin/organization/organization-tree-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 组织管理树视图页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 OrganizationTreeClient(client component)中。
+ *
+ * 数据契约:organizationTree() ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function OrganizationTreePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/permissions/error.tsx b/apps/portal-shell/src/app/shell/admin/permissions/error.tsx
new file mode 100644
index 0000000..5213a53
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/permissions/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 权限目录路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function PermissionsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.permissions");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin permissions route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/permissions/loading.tsx b/apps/portal-shell/src/app/shell/admin/permissions/loading.tsx
new file mode 100644
index 0000000..c738a07
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/permissions/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 权限目录路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function PermissionsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/permissions/page.tsx b/apps/portal-shell/src/app/shell/admin/permissions/page.tsx
new file mode 100644
index 0000000..09dca3d
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/permissions/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { PermissionsListClient } from "@/features/admin/permissions/permissions-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 权限目录列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 PermissionsListClient(client component)中。
+ *
+ * 数据契约:permissions / permissionRoleCounts ❌ schema 无 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/iam_contract.md#permissions
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function PermissionsListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/plugins/error.tsx b/apps/portal-shell/src/app/shell/admin/plugins/error.tsx
new file mode 100644
index 0000000..279747a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/plugins/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 插件管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function PluginsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.plugins");
+
+ useEffect(() => {
+ console.error("[portal-shell] plugins route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/plugins/loading.tsx b/apps/portal-shell/src/app/shell/admin/plugins/loading.tsx
new file mode 100644
index 0000000..ad742e1
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/plugins/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 插件管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function PluginsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/plugins/page.tsx b/apps/portal-shell/src/app/shell/admin/plugins/page.tsx
new file mode 100644
index 0000000..5f86ab2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/plugins/page.tsx
@@ -0,0 +1,24 @@
+import { Suspense } from "react";
+
+import { PluginsClient } from "@/features/admin/plugins/plugins-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 插件管理(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / 004 §5.4)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 PluginsClient(client component)中。
+ *
+ * 数据契约:
+ * - pluginRegistry ✅ schema 已就绪(config-service)
+ * - updatePluginRegistry(pluginId, input) ✅ schema 已就绪
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function PluginsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/questions/error.tsx b/apps/portal-shell/src/app/shell/admin/questions/error.tsx
new file mode 100644
index 0000000..30e7310
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/questions/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 题库管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+
+export default function AdminQuestionsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.questions");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin questions route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/questions/loading.tsx b/apps/portal-shell/src/app/shell/admin/questions/loading.tsx
new file mode 100644
index 0000000..85454f0
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/questions/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 题库管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminQuestionsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/questions/page.tsx b/apps/portal-shell/src/app/shell/admin/questions/page.tsx
new file mode 100644
index 0000000..4e7e1d9
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/questions/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AdminQuestionsListClient } from "@/features/admin/questions/questions-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 题库管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AdminQuestionsListClient(client component)中。
+ *
+ * 数据契约:adminQuestions(filter) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminQuestionsListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/roles/[id]/error.tsx b/apps/portal-shell/src/app/shell/admin/roles/[id]/error.tsx
new file mode 100644
index 0000000..dcab580
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/roles/[id]/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 角色详情路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function RoleDetailError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.roles");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin role detail route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/roles/[id]/loading.tsx b/apps/portal-shell/src/app/shell/admin/roles/[id]/loading.tsx
new file mode 100644
index 0000000..e64f02f
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/roles/[id]/loading.tsx
@@ -0,0 +1,9 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 角色详情路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function RoleDetailLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/roles/[id]/page.tsx b/apps/portal-shell/src/app/shell/admin/roles/[id]/page.tsx
new file mode 100644
index 0000000..de711b0
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/roles/[id]/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { RoleDetailClient } from "@/features/admin/roles/role-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 角色详情页(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 RoleDetailClient(client component)中。
+ *
+ * 数据契约:role(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+export default function RoleDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/roles/error.tsx b/apps/portal-shell/src/app/shell/admin/roles/error.tsx
new file mode 100644
index 0000000..d427a80
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/roles/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 角色管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function RolesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.roles");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin roles route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/roles/loading.tsx b/apps/portal-shell/src/app/shell/admin/roles/loading.tsx
new file mode 100644
index 0000000..6424519
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/roles/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 角色管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function RolesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/roles/page.tsx b/apps/portal-shell/src/app/shell/admin/roles/page.tsx
new file mode 100644
index 0000000..353e655
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/roles/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { RolesListClient } from "@/features/admin/roles/roles-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 角色管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 RolesListClient(client component)中。
+ *
+ * 数据契约:roles ❌ schema 无 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/iam_contract.md#roles
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function RolesListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/auto/error.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/auto/error.tsx
new file mode 100644
index 0000000..3db532a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/auto/error.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+/**
+ * 自动排课路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AdminSchedulingAutoError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.scheduling.error");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin scheduling/auto route error:", error);
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/auto/loading.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/auto/loading.tsx
new file mode 100644
index 0000000..6745d34
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/auto/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 自动排课路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminSchedulingAutoLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/auto/page.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/auto/page.tsx
new file mode 100644
index 0000000..a9e6a1a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/auto/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AutoScheduleClient } from "@/features/admin/scheduling/auto-schedule-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 自动排课页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AutoScheduleClient(client component)中。
+ *
+ * 数据契约:adminClasses / autoSchedule ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminSchedulingAutoPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/changes/error.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/changes/error.tsx
new file mode 100644
index 0000000..d9aba2e
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/changes/error.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+/**
+ * 排课变更审批路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AdminSchedulingChangesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.scheduling.error");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin scheduling/changes route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/changes/loading.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/changes/loading.tsx
new file mode 100644
index 0000000..0957ccf
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/changes/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 排课变更审批路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminSchedulingChangesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/changes/page.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/changes/page.tsx
new file mode 100644
index 0000000..ccd4227
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/changes/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { ScheduleChangesClient } from "@/features/admin/scheduling/schedule-changes-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 排课变更审批页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 ScheduleChangesClient(client component)中。
+ *
+ * 数据契约:adminScheduleChanges / adminScheduleEntries / approve-reject ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminSchedulingChangesPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/rules/error.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/rules/error.tsx
new file mode 100644
index 0000000..8dab2b3
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/rules/error.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+/**
+ * 排课规则配置路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AdminSchedulingRulesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.scheduling.error");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin scheduling/rules route error:", error);
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/rules/loading.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/rules/loading.tsx
new file mode 100644
index 0000000..0dbc875
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/rules/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 排课规则配置路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminSchedulingRulesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/rules/page.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/rules/page.tsx
new file mode 100644
index 0000000..1450a0a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/scheduling/rules/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { SchedulingRulesClient } from "@/features/admin/scheduling/scheduling-rules-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 排课规则配置页(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 SchedulingRulesClient(client component)中。
+ *
+ * 数据契约:schedulingRules / updateSchedulingRules ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminSchedulingRulesPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/academic-year/error.tsx b/apps/portal-shell/src/app/shell/admin/school/academic-year/error.tsx
new file mode 100644
index 0000000..7a67397
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/academic-year/error.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+/**
+ * 学年管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AcademicYearError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.school.academicYear");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin school academic-year route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/academic-year/loading.tsx b/apps/portal-shell/src/app/shell/admin/school/academic-year/loading.tsx
new file mode 100644
index 0000000..be23fde
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/academic-year/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学年管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AcademicYearLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/academic-year/page.tsx b/apps/portal-shell/src/app/shell/admin/school/academic-year/page.tsx
new file mode 100644
index 0000000..3e9e9d6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/academic-year/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { AcademicYearClient } from "@/features/admin/school/academic-year-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学年管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AcademicYearClient(client component)中。
+ *
+ * 数据契约:academicYears() / createAcademicYear / updateAcademicYear / deleteAcademicYear
+ * ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AcademicYearListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/classes/error.tsx b/apps/portal-shell/src/app/shell/admin/school/classes/error.tsx
new file mode 100644
index 0000000..babd7cd
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/classes/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 班级管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AdminClassesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.school.classes");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin school classes route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/classes/loading.tsx b/apps/portal-shell/src/app/shell/admin/school/classes/loading.tsx
new file mode 100644
index 0000000..e0b0a76
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/classes/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 班级管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function AdminClassesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/classes/page.tsx b/apps/portal-shell/src/app/shell/admin/school/classes/page.tsx
new file mode 100644
index 0000000..676b338
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/classes/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AdminClassesClient } from "@/features/admin/school/admin-classes-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 班级管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 AdminClassesClient(client component)中。
+ *
+ * 数据契约:adminClasses() ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function AdminClassesListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/departments/error.tsx b/apps/portal-shell/src/app/shell/admin/school/departments/error.tsx
new file mode 100644
index 0000000..eb9c542
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/departments/error.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+/**
+ * 部门管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function DepartmentsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.school.departments");
+
+ useEffect(() => {
+ console.error(
+ "[portal-shell] admin school departments route error:",
+ error,
+ );
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/departments/loading.tsx b/apps/portal-shell/src/app/shell/admin/school/departments/loading.tsx
new file mode 100644
index 0000000..8fa6182
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/departments/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 部门管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function DepartmentsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/departments/page.tsx b/apps/portal-shell/src/app/shell/admin/school/departments/page.tsx
new file mode 100644
index 0000000..4b47984
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/departments/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { DepartmentsClient } from "@/features/admin/school/departments-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 部门管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 DepartmentsClient(client component)中。
+ *
+ * 数据契约:departments() / createDepartment / updateDepartment / deleteDepartment
+ * ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function DepartmentsListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/grades/error.tsx b/apps/portal-shell/src/app/shell/admin/school/grades/error.tsx
new file mode 100644
index 0000000..8b51e7e
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/grades/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 年级管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function GradesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.school.grades");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin school grades route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/grades/loading.tsx b/apps/portal-shell/src/app/shell/admin/school/grades/loading.tsx
new file mode 100644
index 0000000..d3ca50a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/grades/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 年级管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function GradesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/grades/page.tsx b/apps/portal-shell/src/app/shell/admin/school/grades/page.tsx
new file mode 100644
index 0000000..e22a44e
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/grades/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { GradesClient } from "@/features/admin/school/grades-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 年级管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 GradesClient(client component)中。
+ *
+ * 数据契约:grades() / gradeOverviewStats() / createGrade / updateGrade / deleteGrade
+ * ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function GradesListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/page.tsx b/apps/portal-shell/src/app/shell/admin/school/page.tsx
new file mode 100644
index 0000000..999c8f6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/page.tsx
@@ -0,0 +1,12 @@
+import { redirect } from "next/navigation";
+
+/**
+ * 学校管理入口重定向(ARCHITECTURE.md §9.4 / §10 P5)。
+ *
+ * /shell/admin/school 默认跳转到学校列表子页,避免空白入口。
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §9.4 / §10 P5
+ */
+export default function SchoolAdminIndexPage(): never {
+ redirect("/shell/admin/school/schools");
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/schools/error.tsx b/apps/portal-shell/src/app/shell/admin/school/schools/error.tsx
new file mode 100644
index 0000000..0b3c569
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/schools/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 学校列表路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function SchoolsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.school.schools");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin school schools route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/schools/loading.tsx b/apps/portal-shell/src/app/shell/admin/school/schools/loading.tsx
new file mode 100644
index 0000000..46f9b4d
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/schools/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学校列表路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function SchoolsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/school/schools/page.tsx b/apps/portal-shell/src/app/shell/admin/school/schools/page.tsx
new file mode 100644
index 0000000..4fce526
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/school/schools/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { SchoolsClient } from "@/features/admin/school/schools-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学校列表管理页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 SchoolsClient(client component)中。
+ *
+ * 数据契约:schools() / createSchool / deleteSchool ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function SchoolsListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/students/error.tsx b/apps/portal-shell/src/app/shell/admin/students/error.tsx
new file mode 100644
index 0000000..8610b6f
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/students/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 学生管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function StudentsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.students");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin students route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/students/loading.tsx b/apps/portal-shell/src/app/shell/admin/students/loading.tsx
new file mode 100644
index 0000000..40c84c6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/students/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function StudentsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/students/page.tsx b/apps/portal-shell/src/app/shell/admin/students/page.tsx
new file mode 100644
index 0000000..b267678
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/students/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { StudentsListClient } from "@/features/admin/students/students-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentsListClient(client component)中。
+ *
+ * 数据契约:adminStudents(gradeId, classId, limit, offset) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function StudentsListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/system/error.tsx b/apps/portal-shell/src/app/shell/admin/system/error.tsx
new file mode 100644
index 0000000..fc93443
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/system/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 系统设置路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function SystemSettingsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.system");
+
+ useEffect(() => {
+ console.error("[portal-shell] system settings route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/system/loading.tsx b/apps/portal-shell/src/app/shell/admin/system/loading.tsx
new file mode 100644
index 0000000..743a2c0
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/system/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 系统设置路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function SystemSettingsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/system/page.tsx b/apps/portal-shell/src/app/shell/admin/system/page.tsx
new file mode 100644
index 0000000..725fa54
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/system/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { SystemSettingsClient } from "@/features/admin/system/system-settings-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 系统设置(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 SystemSettingsClient(client component)中。
+ *
+ * 数据契约:systemSettings ❌ schema 未就绪 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function SystemSettingsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/teachers/error.tsx b/apps/portal-shell/src/app/shell/admin/teachers/error.tsx
new file mode 100644
index 0000000..34438b6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/teachers/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 教师管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function TeachersError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.teachers");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin teachers route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/teachers/loading.tsx b/apps/portal-shell/src/app/shell/admin/teachers/loading.tsx
new file mode 100644
index 0000000..29f2f7f
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/teachers/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 教师管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function TeachersLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/teachers/page.tsx b/apps/portal-shell/src/app/shell/admin/teachers/page.tsx
new file mode 100644
index 0000000..3c48a12
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/teachers/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { TeachersListClient } from "@/features/admin/teachers/teachers-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 教师管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 TeachersListClient(client component)中。
+ *
+ * 数据契约:adminTeachers(department, limit, offset) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function TeachersListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/users/error.tsx b/apps/portal-shell/src/app/shell/admin/users/error.tsx
new file mode 100644
index 0000000..eb0b458
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/users/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 用户管理路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function UsersError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.users");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin users route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/users/import/error.tsx b/apps/portal-shell/src/app/shell/admin/users/import/error.tsx
new file mode 100644
index 0000000..0c68109
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/users/import/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 用户批量导入路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function UsersImportError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.users");
+
+ useEffect(() => {
+ console.error("[portal-shell] admin users import route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/users/import/loading.tsx b/apps/portal-shell/src/app/shell/admin/users/import/loading.tsx
new file mode 100644
index 0000000..9bb79ee
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/users/import/loading.tsx
@@ -0,0 +1,9 @@
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 用户批量导入路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function UsersImportLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/users/import/page.tsx b/apps/portal-shell/src/app/shell/admin/users/import/page.tsx
new file mode 100644
index 0000000..6edc3f6
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/users/import/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { UsersImportClient } from "@/features/admin/users/users-import-client";
+import { FormPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 用户批量导入页(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 UsersImportClient(client component)中。
+ *
+ * 数据契约:importUsers(file) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/iam_contract.md#importUsers
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function UsersImportPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/users/loading.tsx b/apps/portal-shell/src/app/shell/admin/users/loading.tsx
new file mode 100644
index 0000000..81534e0
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/users/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 用户管理路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function UsersLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/users/page.tsx b/apps/portal-shell/src/app/shell/admin/users/page.tsx
new file mode 100644
index 0000000..1a3ddcc
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/users/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { UsersListClient } from "@/features/admin/users/users-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 用户管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 UsersListClient(client component)中。
+ *
+ * 数据契约:users(role, limit, offset) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/iam_contract.md#users
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function UsersListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/viewports/error.tsx b/apps/portal-shell/src/app/shell/admin/viewports/error.tsx
new file mode 100644
index 0000000..70226eb
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/viewports/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 视口配置路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function ViewportsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.viewports");
+
+ useEffect(() => {
+ console.error("[portal-shell] viewports route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/admin/viewports/loading.tsx b/apps/portal-shell/src/app/shell/admin/viewports/loading.tsx
new file mode 100644
index 0000000..2d0dfb2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/viewports/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 视口配置路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function ViewportsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/admin/viewports/page.tsx b/apps/portal-shell/src/app/shell/admin/viewports/page.tsx
new file mode 100644
index 0000000..1f46a64
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/admin/viewports/page.tsx
@@ -0,0 +1,24 @@
+import { Suspense } from "react";
+
+import { ViewportsClient } from "@/features/admin/viewports/viewports-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 视口配置(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / 004 §5.4 视口四层模型)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 ViewportsClient(client component)中。
+ *
+ * 数据契约:
+ * - viewports ❌ schema 未就绪 → MSW 兜底(@contract-pending)
+ * - updateViewport(id, input) ❌ schema 无 Mutation → MSW 兜底
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+export default function ViewportsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/announcements/[id]/page.tsx b/apps/portal-shell/src/app/shell/announcements/[id]/page.tsx
new file mode 100644
index 0000000..ba56c79
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/announcements/[id]/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AnnouncementDetailClient } from "@/features/shared/announcements/announcement-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告详情页(共享路由,ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AnnouncementDetailClient(client component)中。
+ *
+ * 数据契约:studentAnnouncementDetail ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function AnnouncementDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/announcements/page.tsx b/apps/portal-shell/src/app/shell/announcements/page.tsx
new file mode 100644
index 0000000..9fddd44
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/announcements/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { AnnouncementsListClient } from "@/features/shared/announcements/announcements-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 公告列表页(共享路由,ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AnnouncementsListClient(client component)中。
+ *
+ * 数据契约:studentAnnouncements ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function AnnouncementsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/layout.tsx b/apps/portal-shell/src/app/shell/layout.tsx
index 20dc772..aaa1388 100644
--- a/apps/portal-shell/src/app/shell/layout.tsx
+++ b/apps/portal-shell/src/app/shell/layout.tsx
@@ -1,6 +1,7 @@
import { headers } from "next/headers";
import type { Role } from "@edu/shared-ts/contracts";
+import { ApolloProvider } from "@/providers/ApolloProvider";
import { ShellSidebar } from "@/shared/components/layout/shell-sidebar";
import { SidebarProvider } from "@/shared/components/layout/sidebar-provider";
import { SiteHeader } from "@/shared/components/layout/site-header";
@@ -43,12 +44,14 @@ export default async function ShellLayout({
const role = roleHeader as Role;
return (
-
-
-
- } />
- {children}
-
-
+
+
+
+
+ } />
+ {children}
+
+
+
);
}
diff --git a/apps/portal-shell/src/app/shell/messages/page.tsx b/apps/portal-shell/src/app/shell/messages/page.tsx
new file mode 100644
index 0000000..3dc4dc4
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/messages/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { MessagesListClient } from "@/features/shared/messages/messages-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 消息中心页(共享路由,ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P5 B3 末)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 MessagesListClient(client component)中。
+ *
+ * 数据契约:studentMessages ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
+ */
+export default function MessagesPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/notifications/error.tsx b/apps/portal-shell/src/app/shell/notifications/error.tsx
new file mode 100644
index 0000000..791d363
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/notifications/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 通知中心路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function NotificationsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("notifications");
+
+ useEffect(() => {
+ console.error("[portal-shell] notifications route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/notifications/loading.tsx b/apps/portal-shell/src/app/shell/notifications/loading.tsx
new file mode 100644
index 0000000..9f0e3ca
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/notifications/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 通知中心路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function NotificationsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/notifications/page.tsx b/apps/portal-shell/src/app/shell/notifications/page.tsx
new file mode 100644
index 0000000..9a142ce
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/notifications/page.tsx
@@ -0,0 +1,26 @@
+import { Suspense } from "react";
+
+import { NotificationsListClient } from "@/features/notifications/notifications-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 通知中心列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 B1 共享页)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 NotificationsListClient(client component)中。
+ *
+ * 数据契约:
+ * - notifications(limit, offset, type, isRead) ✅ 真实字段
+ * - isRead 字段与 type/isRead 筛选参数 @contract-pending(MSW 兜底)
+ * - markNotificationRead / markAllNotificationsRead mutation ❌ MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/msg_contract.md#notifications-mutation
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 B1 / §11.3 / §11.4
+ */
+export default function NotificationsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/settings/error.tsx b/apps/portal-shell/src/app/shell/settings/error.tsx
new file mode 100644
index 0000000..22651fc
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/settings/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 设置路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function SettingsError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("settings");
+
+ useEffect(() => {
+ console.error("[portal-shell] settings route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/settings/loading.tsx b/apps/portal-shell/src/app/shell/settings/loading.tsx
new file mode 100644
index 0000000..9f9f8b2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/settings/loading.tsx
@@ -0,0 +1,9 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 设置路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function SettingsLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/settings/page.tsx b/apps/portal-shell/src/app/shell/settings/page.tsx
new file mode 100644
index 0000000..add492c
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/settings/page.tsx
@@ -0,0 +1,25 @@
+import { Suspense } from "react";
+
+import { SettingsClient } from "@/features/settings/settings-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 设置页(ARCHITECTURE.md §7.3 详情/表单页 / §9.1 B1 共享页)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ *
+ * 数据契约:
+ * - settings 查询 ❌ schema 无此根字段 → MSW 兜底(@contract-pending)
+ * - updateProfile / changePassword / toggle2FA / updatePreferences /
+ * updateNotificationPreferences mutation ❌ schema 无 Mutation 类型 → MSW 兜底
+ * 契约工单:docs/architecture/issues/contracts/iam_contract.md#settings
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 B1 / §11.3 / §11.4
+ */
+export default function SettingsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/ai-tutor/page.tsx b/apps/portal-shell/src/app/shell/student/ai-tutor/page.tsx
new file mode 100644
index 0000000..49d073d
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/ai-tutor/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentAiTutorClient } from "@/features/student/ai-tutor/ai-tutor-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生 AI 辅导页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P5 B3 末)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentAiTutorClient(client component)中。
+ *
+ * 数据契约:aiTutorSessions ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-ai-tutor
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
+ */
+export default function StudentAiTutorPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/attendance/page.tsx b/apps/portal-shell/src/app/shell/student/attendance/page.tsx
new file mode 100644
index 0000000..d1e2cbe
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/attendance/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentAttendanceClient } from "@/features/student/attendance/attendance-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生考勤页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentAttendanceClient(client component)中。
+ *
+ * 数据契约:studentAttendance ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-attendance
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentAttendancePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/classes/page.tsx b/apps/portal-shell/src/app/shell/student/classes/page.tsx
new file mode 100644
index 0000000..7392b92
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/classes/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentClassesListClient } from "@/features/student/classes/classes-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生班级列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentClassesListClient(client component)中。
+ *
+ * 数据契约:studentClasses ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-classes
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentClassesPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/course-plans/[id]/page.tsx b/apps/portal-shell/src/app/shell/student/course-plans/[id]/page.tsx
new file mode 100644
index 0000000..e38ba3c
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/course-plans/[id]/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentCoursePlanDetail schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentCoursePlanDetailClient } from "@/features/student/course-plans/course-plan-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生课程计划详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentCoursePlanDetailClient(client component)中。
+ *
+ * 数据契约:studentCoursePlanDetail ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentCoursePlanDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/course-plans/page.tsx b/apps/portal-shell/src/app/shell/student/course-plans/page.tsx
new file mode 100644
index 0000000..861e001
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/course-plans/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentCoursePlans schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentCoursePlansListClient } from "@/features/student/course-plans/course-plan-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生课程计划列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentCoursePlansListClient(client component)中。
+ *
+ * 数据契约:studentCoursePlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentCoursePlansListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/courses/[id]/page.tsx b/apps/portal-shell/src/app/shell/student/courses/[id]/page.tsx
new file mode 100644
index 0000000..46faa91
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/courses/[id]/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentCourseDetail schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentCourseDetailClient } from "@/features/student/courses/course-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生课程详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentCourseDetailClient(client component)中。
+ *
+ * 数据契约:studentCourseDetail ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentCourseDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/courses/page.tsx b/apps/portal-shell/src/app/shell/student/courses/page.tsx
new file mode 100644
index 0000000..419f60a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/courses/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentCourses schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentCoursesListClient } from "@/features/student/courses/courses-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生课程列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentCoursesListClient(client component)中。
+ *
+ * 数据契约:studentCourses ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentCoursesListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/elective/[id]/page.tsx b/apps/portal-shell/src/app/shell/student/elective/[id]/page.tsx
new file mode 100644
index 0000000..872713a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/elective/[id]/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { StudentElectiveDetailClient } from "@/features/student/elective/elective-detail-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生选课详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentElectiveDetailClient(client component)中。
+ *
+ * 数据契约:electiveCourses ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentElectiveDetailPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/elective/page.tsx b/apps/portal-shell/src/app/shell/student/elective/page.tsx
new file mode 100644
index 0000000..2597949
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/elective/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentElectiveListClient } from "@/features/student/elective/elective-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生选课列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentElectiveListClient(client component)中。
+ *
+ * 数据契约:electiveCourses ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-elective
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentElectivePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/error-book/page.tsx b/apps/portal-shell/src/app/shell/student/error-book/page.tsx
new file mode 100644
index 0000000..61e6ddd
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/error-book/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentErrorBookListClient } from "@/features/student/error-book/error-book-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生错题本页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentErrorBookListClient(client component)中。
+ *
+ * 数据契约:studentErrorBook(q, status, source, dueOnly) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-error-book-v2
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentErrorBookPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/error.tsx b/apps/portal-shell/src/app/shell/student/error.tsx
new file mode 100644
index 0000000..de8b636
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 学生域路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获 /shell/student/** 子树未处理异常。
+ *
+ * 关联:ARCHITECTURE.md §5.3 三级错误边界(Route/Section/Widget)
+ */
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+
+export default function StudentError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.error");
+
+ useEffect(() => {
+ console.error("[portal-shell] student route error:", error);
+ }, [error]);
+
+ return (
+
+
{t("title")}
+
+ {error.message || t("unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/exams/[id]/result/page.tsx b/apps/portal-shell/src/app/shell/student/exams/[id]/result/page.tsx
new file mode 100644
index 0000000..cd6adad
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/exams/[id]/result/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentExamResult 查询契约待补齐,全 MSW 兜底
+// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+import { Suspense } from "react";
+
+import { StudentExamResultClient } from "@/features/student/exams/exam-result-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生考试结果页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentExamResultClient(client component)中。
+ *
+ * 数据契约:studentExamResult(id) ❌ schema 无此根字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentExamResultPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/exams/[id]/take/page.tsx b/apps/portal-shell/src/app/shell/student/exams/[id]/take/page.tsx
new file mode 100644
index 0000000..d252386
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/exams/[id]/take/page.tsx
@@ -0,0 +1,26 @@
+// @contract-pending:studentExamTake 查询 + submitStudentExam mutation 契约待补齐,全 MSW 兜底
+// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+import { Suspense } from "react";
+
+import { StudentExamTakeClient } from "@/features/student/exams/exam-take-client";
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生考试作答工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentExamTakeClient(client component)中。
+ *
+ * 数据契约(@contract-pending 全 MSW):
+ * - studentExamTake(id) ❌ schema 无此根字段 → MSW 兜底
+ * - submitStudentExam(id, answers) mutation ❌ → MSW 兜底
+ *
+ * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentExamTakePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/exams/page.tsx b/apps/portal-shell/src/app/shell/student/exams/page.tsx
new file mode 100644
index 0000000..60383df
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/exams/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentExams 查询契约待补齐,全 MSW 兜底
+// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+import { Suspense } from "react";
+
+import { StudentExamsListClient } from "@/features/student/exams/exams-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生考试列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentExamsListClient(client component)中。
+ *
+ * 数据契约:studentExams ❌ schema 无此根字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentExamsPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/grades/page.tsx b/apps/portal-shell/src/app/shell/student/grades/page.tsx
new file mode 100644
index 0000000..34aa8c9
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/grades/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentGradesListClient } from "@/features/student/grades/grades-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生成绩列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentGradesListClient(client component)中。
+ *
+ * 数据契约:studentGrades(subject, type, q) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-grades-list
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentGradesListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/grades/report-card/page.tsx b/apps/portal-shell/src/app/shell/student/grades/report-card/page.tsx
new file mode 100644
index 0000000..3d4b527
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/grades/report-card/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentReportCardClient } from "@/features/student/grades/report-card-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生成绩报告卡页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentReportCardClient(client component)中。
+ *
+ * 数据契约:studentReportCard(academicYearId, semester) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-report-card
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentReportCardPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/homework/[id]/analysis/page.tsx b/apps/portal-shell/src/app/shell/student/homework/[id]/analysis/page.tsx
new file mode 100644
index 0000000..c1ec095
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/homework/[id]/analysis/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending: 学生作业分析契约待补齐,MSW 兜底
+import { Suspense } from "react";
+
+import { StudentHomeworkAnalysisClient } from "@/features/student/homework/homework-analysis-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生作业分析页(ARCHITECTURE.md §7.3 详情页 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentHomeworkAnalysisClient(client component)中。
+ *
+ * 数据契约:❌ @contract-pending
+ * - studentHomeworkAnalysis(id) 根字段不存在 → MSW 兜底
+ *
+ * 关联:ARCHITECTURE.md §5.4 / §7.3 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentHomeworkAnalysisPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/homework/[id]/submit/page.tsx b/apps/portal-shell/src/app/shell/student/homework/[id]/submit/page.tsx
new file mode 100644
index 0000000..f50d5a7
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/homework/[id]/submit/page.tsx
@@ -0,0 +1,25 @@
+// @contract-pending: 学生作业作答契约待补齐,MSW 兜底
+import { Suspense } from "react";
+
+import { StudentHomeworkSubmitClient } from "@/features/student/homework/homework-submit-client";
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生作业作答工作台页(ARCHITECTURE.md §7.3 工作台页 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentHomeworkSubmitClient(client component)中。
+ *
+ * 数据契约:❌ @contract-pending
+ * - studentHomeworkSubmit(id) 根字段不存在 → MSW 兜底
+ * - submitStudentHomework(input) mutation 不存在 → MSW 兜底
+ *
+ * 关联:ARCHITECTURE.md §5.4 / §7.3 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentHomeworkSubmitPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/homework/page.tsx b/apps/portal-shell/src/app/shell/student/homework/page.tsx
new file mode 100644
index 0000000..2c62f1f
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/homework/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending: 学生作业列表契约待补齐,MSW 兜底
+import { Suspense } from "react";
+
+import { StudentHomeworkListClient } from "@/features/student/homework/homework-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生作业列表页(ARCHITECTURE.md §7.3 列表页 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentHomeworkListClient(client component)中。
+ *
+ * 数据契约:❌ @contract-pending
+ * - studentHomework(status) 根字段不存在 → MSW 兜底
+ *
+ * 关联:ARCHITECTURE.md §5.4 / §7.3 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentHomeworkPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/learning-path/page.tsx b/apps/portal-shell/src/app/shell/student/learning-path/page.tsx
new file mode 100644
index 0000000..3a0e60c
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/learning-path/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:myLearningPath schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentLearningPathClient } from "@/features/student/learning-path/learning-path-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生 AI 学习路径页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentLearningPathClient(client component)中。
+ *
+ * 数据契约:myLearningPath ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentLearningPathPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/learning/page.tsx b/apps/portal-shell/src/app/shell/student/learning/page.tsx
new file mode 100644
index 0000000..9399499
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/learning/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentLearningCenter schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentLearningCenterClient } from "@/features/student/learning/learning-center-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学习中心首页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentLearningCenterClient(client component)中。
+ *
+ * 数据契约:studentLearningCenter ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentLearningPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/leave/page.tsx b/apps/portal-shell/src/app/shell/student/leave/page.tsx
new file mode 100644
index 0000000..726e80c
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/leave/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentLeaveClient } from "@/features/student/leave/leave-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生在线请假页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentLeaveClient(client component)中。
+ *
+ * 数据契约:studentLeave ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-leave
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentLeavePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/lesson-plans/[planId]/view/page.tsx b/apps/portal-shell/src/app/shell/student/lesson-plans/[planId]/view/page.tsx
new file mode 100644
index 0000000..172bbc1
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/lesson-plans/[planId]/view/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentLessonPlanViewClient } from "@/features/student/lesson-plans/lesson-plan-view-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生教案只读查看页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentLessonPlanViewClient(client component)中。
+ *
+ * 数据契约:studentLessonPlanView ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentLessonPlanViewPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/lesson-plans/page.tsx b/apps/portal-shell/src/app/shell/student/lesson-plans/page.tsx
new file mode 100644
index 0000000..005c4b0
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/lesson-plans/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentLessonPlans schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentLessonPlansListClient } from "@/features/student/lesson-plans/lesson-plan-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生教案列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentLessonPlansListClient(client component)中。
+ *
+ * 数据契约:studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentLessonPlansListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/loading.tsx b/apps/portal-shell/src/app/shell/student/loading.tsx
new file mode 100644
index 0000000..d874fec
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生域共享加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹 /shell/student/** 下所有页面渲染期间。
+ */
+export default function StudentLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/student/page.tsx b/apps/portal-shell/src/app/shell/student/page.tsx
index f637d1b..d7e8766 100644
--- a/apps/portal-shell/src/app/shell/student/page.tsx
+++ b/apps/portal-shell/src/app/shell/student/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { BookOpen, GraduationCap, TrendingUp } from "lucide-react";
+import { useTranslations } from "next-intl";
import { useStudentDashboard } from "@/lib/api";
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
@@ -17,11 +18,12 @@ import { Card, CardContent } from "@/shared/components/ui/card";
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function StudentDashboardPage(): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
const { data, loading, error } = useStudentDashboard();
if (loading) {
return (
-
+
{Array.from({ length: 4 }).map((_, i) => (
@@ -33,10 +35,10 @@ export default function StudentDashboardPage(): React.ReactElement {
if (error || !data) {
return (
-
+
- 仪表盘数据加载失败,请稍后重试。
+ {t("loadFailed")}
@@ -45,22 +47,22 @@ export default function StudentDashboardPage(): React.ReactElement {
return (
0}
@@ -68,7 +70,7 @@ export default function StudentDashboardPage(): React.ReactElement {
>
}
>
-
+
{data.weak_points ? (
@@ -76,30 +78,37 @@ export default function StudentDashboardPage(): React.ReactElement {
{data.weak_points.title ?? "--"}
- 掌握度 {data.weak_points.mastery?.toFixed(1) ?? "--"}%
+ {t("labelMastery")}{" "}
+ {data.weak_points.mastery?.toFixed(1) ?? "--"}%
- 错误次数:{data.weak_points.error_count ?? 0}
+ {t("labelErrorCount", {
+ count: data.weak_points.error_count ?? 0,
+ })}
) : (
- 暂无薄弱知识点数据
+
+ {t("emptyWeakPoints")}
+
)}
-
+
{data.recent_trends ? (
- {data.recent_trends.date ?? "--"}:
+ {t("labelScoreOnDate", { date: data.recent_trends.date ?? "--" })}
- {data.recent_trends.score?.toFixed(1) ?? "--"} 分
+ {t("labelScoreValue", {
+ score: data.recent_trends.score?.toFixed(1) ?? "--",
+ })}
) : (
- 暂无趋势数据
+ {t("emptyTrends")}
)}
diff --git a/apps/portal-shell/src/app/shell/student/practice/[sessionId]/page.tsx b/apps/portal-shell/src/app/shell/student/practice/[sessionId]/page.tsx
new file mode 100644
index 0000000..4e6e0a5
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/practice/[sessionId]/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentPracticeSessionClient } from "@/features/student/practice/practice-session-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生练习会话详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentPracticeSessionClient(client component)中。
+ *
+ * 数据契约:studentPracticeSession ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-practice
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentPracticeSessionPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/practice/page.tsx b/apps/portal-shell/src/app/shell/student/practice/page.tsx
new file mode 100644
index 0000000..1983a0e
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/practice/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentPracticeListClient } from "@/features/student/practice/practice-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生自适应练习首页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentPracticeListClient(client component)中。
+ *
+ * 数据契约:studentPractice ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-practice
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentPracticePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/schedule/page.tsx b/apps/portal-shell/src/app/shell/student/schedule/page.tsx
new file mode 100644
index 0000000..7528dcf
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/schedule/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { StudentScheduleListClient } from "@/features/student/schedule/schedule-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生课表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentScheduleListClient(client component)中。
+ *
+ * 数据契约:studentSchedule ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-schedule
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentSchedulePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/textbooks/[id]/chapters/page.tsx b/apps/portal-shell/src/app/shell/student/textbooks/[id]/chapters/page.tsx
new file mode 100644
index 0000000..e6bd022
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/textbooks/[id]/chapters/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentTextbookChapters schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentTextbookChaptersClient } from "@/features/student/textbooks/textbook-chapters-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生教材章节阅读器页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentTextbookChaptersClient(client component)中。
+ *
+ * 数据契约:studentTextbookChapters ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentTextbookChaptersPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/textbooks/page.tsx b/apps/portal-shell/src/app/shell/student/textbooks/page.tsx
new file mode 100644
index 0000000..a80892c
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/textbooks/page.tsx
@@ -0,0 +1,24 @@
+// @contract-pending:studentTextbooks schema 未实现,全 MSW 兜底
+import { Suspense } from "react";
+
+import { StudentTextbooksListClient } from "@/features/student/textbooks/textbooks-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生教材列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 StudentTextbooksListClient(client component)中。
+ *
+ * 数据契约:studentTextbooks ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentTextbooksListPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/trend/page.tsx b/apps/portal-shell/src/app/shell/student/trend/page.tsx
new file mode 100644
index 0000000..c5010c9
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/trend/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { StudentTrendClient } from "@/features/student/dashboard/trend-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生学习趋势详情页(ARCHITECTURE.md §7.3 详情页 / §9.2 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentTrendClient(client component)中。
+ *
+ * 数据契约:studentTrend ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.2 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentTrendPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/student/weakness/page.tsx b/apps/portal-shell/src/app/shell/student/weakness/page.tsx
new file mode 100644
index 0000000..0539200
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/student/weakness/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { StudentWeaknessClient } from "@/features/student/dashboard/weakness-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生薄弱知识点详情页(ARCHITECTURE.md §7.3 详情页 / §9.2 / §10 P3)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 StudentWeaknessClient(client component)中。
+ *
+ * 数据契约:studentWeakness ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.2 / §10 P3 / §11.3 / §11.4
+ */
+export default function StudentWeaknessPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/ai/ai-assist/page.tsx b/apps/portal-shell/src/app/shell/teacher/ai/ai-assist/page.tsx
new file mode 100644
index 0000000..60c79c1
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/ai/ai-assist/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { AiAssistClient } from "@/features/teacher/ai/ai-assist-client";
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * AI 助手工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2 B2 末)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AiAssistClient(client component)中。
+ *
+ * 数据契约:全 ❌ MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/ai_contract.md#ai-assist
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4
+ */
+export default function AiAssistPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/ai/ai-lesson-plan/page.tsx b/apps/portal-shell/src/app/shell/teacher/ai/ai-lesson-plan/page.tsx
new file mode 100644
index 0000000..fe4cba1
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/ai/ai-lesson-plan/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { AiLessonPlanClient } from "@/features/teacher/ai/ai-lesson-plan-client";
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * AI 教案生成工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2 B2 末)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AiLessonPlanClient(client component)中。
+ *
+ * 数据契约:generateLessonPlan(input) ❌ MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/ai_contract.md#ai-lesson-plan
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4
+ */
+export default function AiLessonPlanPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/ai/ai-report/page.tsx b/apps/portal-shell/src/app/shell/teacher/ai/ai-report/page.tsx
new file mode 100644
index 0000000..78bb560
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/ai/ai-report/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { AiReportClient } from "@/features/teacher/ai/ai-report-client";
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * AI 学情报告工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2 B2 末)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 AiReportClient(client component)中。
+ *
+ * 数据契约:generateStudentReport(input) ❌ MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/ai_contract.md#ai-report
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4
+ */
+export default function AiReportPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/ai/error.tsx b/apps/portal-shell/src/app/shell/teacher/ai/error.tsx
new file mode 100644
index 0000000..5486327
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/ai/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * AI 路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function AiError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("ai");
+
+ useEffect(() => {
+ console.error("[portal-shell] ai route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/ai/loading.tsx b/apps/portal-shell/src/app/shell/teacher/ai/loading.tsx
new file mode 100644
index 0000000..6c4e23f
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/ai/loading.tsx
@@ -0,0 +1,12 @@
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * AI 路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ *
+ * AI 模块全部为工作台页(ai-assist / ai-lesson-plan / ai-report),
+ * 故使用 WorkbenchPageSkeleton 而非 ListPageSkeleton。
+ */
+export default function AiLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/error.tsx b/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/error.tsx
new file mode 100644
index 0000000..edffeaa
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 学生诊断详情页错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+
+export default function StudentDiagnosticError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("diagnostic");
+
+ useEffect(() => {
+ console.error("[portal-shell] diagnostic student route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("studentDetail.errorTitle")}
+
+
+ {error.message || t("studentDetail.errorUnknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/loading.tsx b/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/loading.tsx
new file mode 100644
index 0000000..05a6117
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/loading.tsx
@@ -0,0 +1,9 @@
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生诊断详情页加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function StudentDiagnosticLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/page.tsx b/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/page.tsx
new file mode 100644
index 0000000..b050e74
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/diagnostic/student/[studentId]/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+
+import { StudentDiagnosticClient } from "@/features/teacher/diagnostic/student-diagnostic-client";
+import { DetailPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 学生诊断详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ *
+ * 数据契约:单查 studentDiagnostic(studentId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/data-ana_contract.md#diagnostic-student-detail
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function StudentDiagnosticPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/error.tsx b/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/error.tsx
new file mode 100644
index 0000000..c23dbff
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 监考工作台页错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+
+export default function ExamProctoringError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("exams");
+
+ useEffect(() => {
+ console.error("[portal-shell] exams proctoring route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("proctoring.errorTitle")}
+
+
+ {error.message || t("proctoring.errorUnknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/loading.tsx b/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/loading.tsx
new file mode 100644
index 0000000..a59234a
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/loading.tsx
@@ -0,0 +1,9 @@
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 监考工作台页加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function ExamProctoringLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/page.tsx b/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/page.tsx
new file mode 100644
index 0000000..170cfab
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/exams/[id]/proctoring/page.tsx
@@ -0,0 +1,28 @@
+import { Suspense } from "react";
+
+import { ProctoringClient } from "@/features/teacher/exams/proctoring-client";
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 监考工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 B2 末 / §10 P2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹。
+ * 业务逻辑在 ProctoringClient(client component)中。
+ *
+ * 数据契约:❌ @contract-pending 全 MSW
+ * - examProctoring(examId) 根字段不存在 → MSW 兜底
+ * - studentProctoringStatuses(examId) 根字段不存在 → MSW 兜底
+ * - proctoringEvents(examId, limit) 根字段不存在 → MSW 兜底
+ *
+ * WS 契约未就绪(ARCHITECTURE.md §9.1 line 626 标注 "二期,WS"),
+ * 当前阶段通过 5s 轮询 MSW 模拟实时刷新,后端补齐 WS subscription 后切换。
+ *
+ * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
+ */
+export default function ExamProctoringPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/exams/error.tsx b/apps/portal-shell/src/app/shell/teacher/exams/error.tsx
index 3c8b4e0..23c0f66 100644
--- a/apps/portal-shell/src/app/shell/teacher/exams/error.tsx
+++ b/apps/portal-shell/src/app/shell/teacher/exams/error.tsx
@@ -5,6 +5,7 @@
* Next.js Route Segment error.tsx,捕获子树未处理异常。
*/
import { useEffect } from "react";
+import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
@@ -15,18 +16,22 @@ export default function ExamsError({
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
+ const t = useTranslations("exams");
+
useEffect(() => {
console.error("[portal-shell] exams route error:", error);
}, [error]);
return (
-
考试页面出错了
+
+ {t("error.title")}
+
- {error.message || "未知错误"}
+ {error.message || t("error.unknown")}
);
diff --git a/apps/portal-shell/src/app/shell/teacher/knowledge-graph/error.tsx b/apps/portal-shell/src/app/shell/teacher/knowledge-graph/error.tsx
new file mode 100644
index 0000000..f3b8b67
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/knowledge-graph/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 知识图谱路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function KnowledgeGraphError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("knowledgeGraph");
+
+ useEffect(() => {
+ console.error("[portal-shell] knowledge-graph route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/knowledge-graph/loading.tsx b/apps/portal-shell/src/app/shell/teacher/knowledge-graph/loading.tsx
new file mode 100644
index 0000000..e342d0f
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/knowledge-graph/loading.tsx
@@ -0,0 +1,11 @@
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 知识图谱路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ *
+ * 知识图谱为工作台页(左列表 + 中详情 + 右属性),故使用 WorkbenchPageSkeleton。
+ */
+export default function KnowledgeGraphLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/knowledge-graph/page.tsx b/apps/portal-shell/src/app/shell/teacher/knowledge-graph/page.tsx
new file mode 100644
index 0000000..72983b2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/knowledge-graph/page.tsx
@@ -0,0 +1,25 @@
+import { Suspense } from "react";
+
+import { KnowledgeGraphClient } from "@/features/teacher/knowledge-graph/knowledge-graph-client";
+import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 知识图谱工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2 B2 末)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 KnowledgeGraphClient(client component)中。
+ *
+ * 数据契约(🟡 混合):
+ * - knowledgePoint(id) ✅ 真实查询(schema 已就绪)
+ * - knowledgePoints(filter) ❌ MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/content_contract.md#knowledge-graph
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4
+ */
+export default function KnowledgeGraphPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/leave/error.tsx b/apps/portal-shell/src/app/shell/teacher/leave/error.tsx
new file mode 100644
index 0000000..439a0f2
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/leave/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 请假审批路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function LeaveError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("leave");
+
+ useEffect(() => {
+ console.error("[portal-shell] leave route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/leave/loading.tsx b/apps/portal-shell/src/app/shell/teacher/leave/loading.tsx
new file mode 100644
index 0000000..9225662
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/leave/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 请假审批路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function LeaveLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/leave/page.tsx b/apps/portal-shell/src/app/shell/teacher/leave/page.tsx
new file mode 100644
index 0000000..0eda195
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/leave/page.tsx
@@ -0,0 +1,25 @@
+import { Suspense } from "react";
+
+import { LeaveListClient } from "@/features/teacher/leave/leave-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 请假审批列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2 B2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 LeaveListClient(client component)中。
+ *
+ * 数据契约:
+ * - leaveRequests(filter) ❌ MSW 兜底(@contract-pending)
+ * - approveLeave / rejectLeave mutation ❌ MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/classes_contract.md#leave-requests-list
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
+ */
+export default function LeavePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/page.tsx b/apps/portal-shell/src/app/shell/teacher/page.tsx
index 9cfab3c..810acad 100644
--- a/apps/portal-shell/src/app/shell/teacher/page.tsx
+++ b/apps/portal-shell/src/app/shell/teacher/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
+import { useTranslations } from "next-intl";
import { useTeacherDashboard } from "@/lib/api";
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
@@ -14,14 +15,15 @@ import { Card, CardContent } from "@/shared/components/ui/card";
* 改接 data-ana 的 teacherDashboard 真实聚合查询,替换原
* grades/homeworks/schedule/attendance/exams 假契约 widget 查询。
*
- * 关联:ARCHITECTURE.md §5.5 / §10 P1-2
+ * 关联:ARCHITECTURE.md §5.5 / §10 P1-2 / §11.3 DoD #6(i18n)
*/
export default function TeacherDashboardPage(): React.ReactElement {
+ const t = useTranslations("dashboard.teacher");
const { data, loading, error } = useTeacherDashboard();
if (loading) {
return (
-
+
{Array.from({ length: 4 }).map((_, i) => (
@@ -33,10 +35,10 @@ export default function TeacherDashboardPage(): React.ReactElement {
if (error || !data) {
return (
-
+
- 仪表盘数据加载失败,请稍后重试。
+ {t("loadFailed")}
@@ -45,27 +47,27 @@ export default function TeacherDashboardPage(): React.ReactElement {
return (
0}
@@ -73,7 +75,7 @@ export default function TeacherDashboardPage(): React.ReactElement {
>
}
>
-
+
{data.classes ? (
@@ -81,17 +83,24 @@ export default function TeacherDashboardPage(): React.ReactElement {
{data.classes.class_name ?? "--"}
- {data.classes.student_count ?? 0} 人 · 均分{" "}
- {data.classes.average_score?.toFixed(1) ?? "--"}
+ {t("classesOverview.studentCount", {
+ count: data.classes.student_count ?? 0,
+ })}{" "}
+ ·{" "}
+ {t("classesOverview.avgScore", {
+ score: data.classes.average_score?.toFixed(1) ?? "--",
+ })}
) : (
- 暂无班级数据
+
+ {t("classesOverview.empty")}
+
)}
-
+
{data.recent_warnings ? (
@@ -103,13 +112,15 @@ export default function TeacherDashboardPage(): React.ReactElement {
- 类型:{data.recent_warnings.warning_type ?? "--"} · 当前值{" "}
- {data.recent_warnings.current_value?.toFixed(1) ?? "--"} / 阈值{" "}
+ {t("warnings.type")}:{data.recent_warnings.warning_type ?? "--"}{" "}
+ · {t("warnings.currentValue")}{" "}
+ {data.recent_warnings.current_value?.toFixed(1) ?? "--"} /{" "}
+ {t("warnings.threshold")}{" "}
{data.recent_warnings.threshold?.toFixed(1) ?? "--"}
) : (
- 暂无预警
+ {t("warnings.empty")}
)}
diff --git a/apps/portal-shell/src/app/shell/teacher/practice/error.tsx b/apps/portal-shell/src/app/shell/teacher/practice/error.tsx
new file mode 100644
index 0000000..078fdca
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/practice/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 练习分析路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function PracticeError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("practice");
+
+ useEffect(() => {
+ console.error("[portal-shell] practice route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/practice/loading.tsx b/apps/portal-shell/src/app/shell/teacher/practice/loading.tsx
new file mode 100644
index 0000000..847cb53
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/practice/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 练习分析路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function PracticeLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/practice/page.tsx b/apps/portal-shell/src/app/shell/teacher/practice/page.tsx
new file mode 100644
index 0000000..a94b578
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/practice/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { PracticeListClient } from "@/features/teacher/practice/practice-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 练习分析列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2 B2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 PracticeListClient(client component)中。
+ *
+ * 数据契约:列表查询 practices(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#practice-list
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
+ */
+export default function PracticePage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/schedule-changes/error.tsx b/apps/portal-shell/src/app/shell/teacher/schedule-changes/error.tsx
new file mode 100644
index 0000000..312c8fd
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/schedule-changes/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * 调课申请路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment error.tsx,捕获子树未处理异常。
+ */
+import { useEffect } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import { useTranslations } from "next-intl";
+
+export default function ScheduleChangesError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}): React.ReactElement {
+ const t = useTranslations("scheduleChanges");
+
+ useEffect(() => {
+ console.error("[portal-shell] schedule-changes route error:", error);
+ }, [error]);
+
+ return (
+
+
+ {t("error.title")}
+
+
+ {error.message || t("error.unknown")}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/schedule-changes/loading.tsx b/apps/portal-shell/src/app/shell/teacher/schedule-changes/loading.tsx
new file mode 100644
index 0000000..24b308b
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/schedule-changes/loading.tsx
@@ -0,0 +1,9 @@
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 调课申请路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
+ * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
+ */
+export default function ScheduleChangesLoading(): React.ReactElement {
+ return ;
+}
diff --git a/apps/portal-shell/src/app/shell/teacher/schedule-changes/page.tsx b/apps/portal-shell/src/app/shell/teacher/schedule-changes/page.tsx
new file mode 100644
index 0000000..1f0498c
--- /dev/null
+++ b/apps/portal-shell/src/app/shell/teacher/schedule-changes/page.tsx
@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+
+import { ScheduleChangesListClient } from "@/features/teacher/schedule-changes/schedule-changes-list-client";
+import { ListPageSkeleton } from "@/shared/components/page-templates";
+
+/**
+ * 调课申请列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2 B2)
+ *
+ * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
+ * 业务逻辑在 ScheduleChangesListClient(client component)中。
+ *
+ * 数据契约:列表查询 scheduleChanges(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/classes_contract.md#schedule-changes-list
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
+ */
+export default function ScheduleChangesPage(): React.ReactElement {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts
new file mode 100644
index 0000000..e3e4c48
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts
@@ -0,0 +1,175 @@
+/**
+ * AI Settings 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ PROVIDER_TYPE_LABEL,
+ PROVIDER_TYPES,
+ activeToBadgeClass,
+ formatActiveLabel,
+ formatAiDate,
+ formatCostCents,
+ formatNumber,
+ formatProviderType,
+ isValidProviderType,
+ maskApiKey,
+ truncateBaseUrl,
+} from "../transformations";
+
+describe("formatProviderType", () => {
+ it("maps known types to display labels", () => {
+ expect(formatProviderType("openai")).toBe("OpenAI");
+ expect(formatProviderType("anthropic")).toBe("Anthropic");
+ expect(formatProviderType("azure")).toBe("Azure OpenAI");
+ expect(formatProviderType("local")).toBe("本地模型");
+ });
+
+ it("returns original value for unknown type", () => {
+ expect(formatProviderType("custom")).toBe("custom");
+ expect(formatProviderType("")).toBe("");
+ });
+
+ it("PROVIDER_TYPE_LABEL covers 4 standard types", () => {
+ expect(Object.keys(PROVIDER_TYPE_LABEL)).toHaveLength(4);
+ });
+});
+
+describe("isValidProviderType", () => {
+ it("returns true for supported types", () => {
+ expect(isValidProviderType("openai")).toBe(true);
+ expect(isValidProviderType("anthropic")).toBe(true);
+ expect(isValidProviderType("azure")).toBe(true);
+ expect(isValidProviderType("local")).toBe(true);
+ });
+
+ it("returns false for unsupported types", () => {
+ expect(isValidProviderType("custom")).toBe(false);
+ expect(isValidProviderType("")).toBe(false);
+ });
+
+ it("PROVIDER_TYPES has exactly 4 entries", () => {
+ expect(PROVIDER_TYPES).toHaveLength(4);
+ });
+});
+
+describe("activeToBadgeClass", () => {
+ it("returns emerald class for active", () => {
+ expect(activeToBadgeClass(true)).toContain("emerald");
+ });
+
+ it("returns muted class for inactive", () => {
+ expect(activeToBadgeClass(false)).toBe("bg-muted text-muted-foreground");
+ });
+});
+
+describe("formatActiveLabel", () => {
+ it("returns 'active' for true", () => {
+ expect(formatActiveLabel(true)).toBe("active");
+ });
+
+ it("returns 'inactive' for false", () => {
+ expect(formatActiveLabel(false)).toBe("inactive");
+ });
+});
+
+describe("formatCostCents", () => {
+ it("formats valid cost in cents to yuan", () => {
+ expect(formatCostCents(0)).toBe("¥0.00");
+ expect(formatCostCents(100)).toBe("¥1.00");
+ expect(formatCostCents(12345)).toBe("¥123.45");
+ });
+
+ it("returns ¥0.00 for invalid input", () => {
+ expect(formatCostCents(-1)).toBe("¥0.00");
+ expect(formatCostCents(Number.NaN)).toBe("¥0.00");
+ expect(formatCostCents(Number.POSITIVE_INFINITY)).toBe("¥0.00");
+ });
+});
+
+describe("formatNumber", () => {
+ it("formats valid number with thousand separators", () => {
+ expect(formatNumber(0)).toBe("0");
+ expect(formatNumber(1000)).toBe("1,000");
+ expect(formatNumber(1234567)).toBe("1,234,567");
+ });
+
+ it("returns '0' for invalid input", () => {
+ expect(formatNumber(-1)).toBe("0");
+ expect(formatNumber(Number.NaN)).toBe("0");
+ expect(formatNumber(Number.POSITIVE_INFINITY)).toBe("0");
+ });
+});
+
+describe("maskApiKey", () => {
+ it("masks long API key keeping first 4 and last 4 chars", () => {
+ const result = maskApiKey("sk-abcdef1234567890");
+ expect(result).toBe("sk-a...7890");
+ });
+
+ it("returns key as-is for short keys (<=8 chars)", () => {
+ expect(maskApiKey("short")).toBe("short");
+ expect(maskApiKey("12345678")).toBe("12345678");
+ });
+
+ it("returns placeholder for null/undefined/empty/whitespace", () => {
+ expect(maskApiKey(null)).toBe("--");
+ expect(maskApiKey(undefined)).toBe("--");
+ expect(maskApiKey("")).toBe("--");
+ expect(maskApiKey(" ")).toBe("--");
+ });
+
+ it("trims whitespace before masking", () => {
+ const result = maskApiKey(" sk-abcdef1234567890 ");
+ expect(result).toBe("sk-a...7890");
+ });
+});
+
+describe("truncateBaseUrl", () => {
+ it("returns url unchanged when within limit", () => {
+ expect(truncateBaseUrl("https://api.openai.com/v1", 40)).toBe(
+ "https://api.openai.com/v1",
+ );
+ });
+
+ it("truncates and appends ellipsis when over limit", () => {
+ const long =
+ "https://very-long-domain-name.example.com/api/v1/chat/completions";
+ const result = truncateBaseUrl(long, 30);
+ expect(result.endsWith("...")).toBe(true);
+ expect(result.length).toBe(33);
+ });
+
+ it("uses default maxLen of 40", () => {
+ const long = "a".repeat(50);
+ const result = truncateBaseUrl(long);
+ expect(result.endsWith("...")).toBe(true);
+ expect(result.length).toBe(43);
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(truncateBaseUrl(null)).toBe("--");
+ expect(truncateBaseUrl(undefined)).toBe("--");
+ expect(truncateBaseUrl("")).toBe("--");
+ });
+});
+
+describe("formatAiDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatAiDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatAiDate(null)).toBe("--");
+ expect(formatAiDate(undefined)).toBe("--");
+ expect(formatAiDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatAiDate("not-a-date")).toBe("--");
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/ai-settings/ai-settings-client.tsx b/apps/portal-shell/src/features/admin/ai-settings/ai-settings-client.tsx
new file mode 100644
index 0000000..9a622b0
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/ai-settings/ai-settings-client.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+/**
+ * AI Provider 配置与用量仪表盘 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - aiProviders(scope) ❌ schema 无此根字段 → MSW 兜底(@contract-pending)
+ * - aiUsageDashboard(range) ❌ schema 无此根字段 → MSW 兜底(@contract-pending)
+ * - createAiProvider / updateAiProvider / deleteAiProvider mutation ❌ schema 无 Mutation → MSW 兜底
+ *
+ * URL 状态:?scope=&range=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Bot, Plus, Trash2, Pencil, PlugZap } from "lucide-react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAiProviders,
+ useAiUsageDashboard,
+ useDeleteAiProvider,
+ type AiProvider,
+} from "@/lib/api";
+import { notify } from "@/shared/lib/notify";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import {
+ activeToBadgeClass,
+ formatActiveLabel,
+ formatCostCents,
+ formatNumber,
+ formatProviderType,
+ maskApiKey,
+ truncateBaseUrl,
+} from "@/features/admin/ai-settings/transformations";
+
+/**
+ * 从 Provider config 中安全提取 apiKey 字符串。未知类型守卫。
+ */
+function extractApiKey(
+ config: Record | undefined,
+): string | null {
+ if (!config) return null;
+ const raw = config.apiKey;
+ return typeof raw === "string" ? raw : null;
+}
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function AiSettingsClient(): React.ReactElement {
+ const t = useTranslations("admin.aiSettings.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const scope = searchParams.get("scope") ?? "";
+ const range = searchParams.get("range") ?? "7d";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error, refetch } = useAiProviders(scope || null);
+ const usageResult = useAiUsageDashboard(range);
+ const { run: deleteProvider } = useDeleteAiProvider();
+
+ const providers = useMemo(() => data ?? [], [data]);
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/ai-settings?${params.toString()}`);
+ });
+ };
+
+ const handleDelete = async (id: string, name: string): Promise => {
+ if (!window.confirm(t("delete") + " " + name + " ?")) {
+ return;
+ }
+ try {
+ await deleteProvider(id);
+ notify.success(t("delete") + ": " + name);
+ await refetch();
+ } catch (err) {
+ notify.error(`${t("delete")}: ${String(err)}`);
+ }
+ };
+
+ const handleTestConnection = (provider: AiProvider): void => {
+ notify.info(`${t("testConnection")}: ${provider.name}`);
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+ <>
+ updateQuery("scope", e.target.value)}
+ placeholder="scope filter (e.g. global / school)"
+ className="h-9 w-64"
+ aria-label="scope filter"
+ />
+ updateQuery("range", e.target.value)}
+ placeholder="range (e.g. 7d / 30d)"
+ className="h-9 w-32"
+ aria-label="range filter"
+ />
+ >
+ }
+ loading={loading}
+ loadingNode={}
+ empty={providers.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+
+
+
+
+
+ | {t("colName")} |
+ {t("colType")} |
+ {t("colApiKey")} |
+ {t("colBaseUrl")} |
+ {t("colIsActive")} |
+ {t("colActions")} |
+
+
+
+ {providers.map((p) => (
+
+ |
+ {p.name}
+
+ {p.model}
+
+ |
+
+ {formatProviderType(p.type)}
+ |
+
+ {maskApiKey(extractApiKey(p.config))}
+ |
+
+ {truncateBaseUrl(p.apiBase)}
+ |
+
+
+ |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ {t("mswNotice")}
+
+ );
+}
+
+/**
+ * AI 用量仪表盘分区(StatCard 总览 + 按 Provider 明细)。
+ */
+function UsageDashboardSection({
+ loading,
+ totalRequests,
+ totalTokens,
+ totalCostCents,
+ byProvider,
+}: {
+ loading: boolean;
+ totalRequests: number;
+ totalTokens: number;
+ totalCostCents: number;
+ byProvider: Array<{
+ providerId: string;
+ providerName: string;
+ requests: number;
+ tokens: number;
+ costCents: number;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("admin.aiSettings.list");
+
+ return (
+
+
{t("usageTitle")}
+
+
+
+
+
+
+
+
+ {t("usageByProvider")}
+
+
+ {byProvider.length === 0 ? (
+ {t("emptyUsage")}
+ ) : (
+
+
+
+
+ |
+ {t("colProviderName")}
+ |
+
+ {t("colRequests")}
+ |
+
+ {t("colTokens")}
+ |
+
+ {t("colCostCents")}
+ |
+
+
+
+ {byProvider.map((row) => (
+
+ | {row.providerName} |
+
+ {formatNumber(row.requests)}
+ |
+
+ {formatNumber(row.tokens)}
+ |
+
+ {formatCostCents(row.costCents)}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * 状态徽章(按 isActive 渲染启用/停用徽章)。
+ */
+function ActiveBadge({ isActive }: { isActive: boolean }): React.ReactElement {
+ const t = useTranslations("admin.aiSettings.list");
+ const label = t(formatActiveLabel(isActive));
+ const cls = activeToBadgeClass(isActive);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/ai-settings/transformations.ts b/apps/portal-shell/src/features/admin/ai-settings/transformations.ts
new file mode 100644
index 0000000..f4745c8
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/ai-settings/transformations.ts
@@ -0,0 +1,111 @@
+/**
+ * AI Settings 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+/** AI Provider 类型标签映射 */
+export const PROVIDER_TYPE_LABEL: Record = {
+ openai: "OpenAI",
+ anthropic: "Anthropic",
+ azure: "Azure OpenAI",
+ local: "本地模型",
+};
+
+/** AI Provider 类型支持的取值列表 */
+export const PROVIDER_TYPES: readonly string[] = [
+ "openai",
+ "anthropic",
+ "azure",
+ "local",
+] as const;
+
+/**
+ * 将 Provider 类型代码映射为展示标签。未知值回退为原始值。
+ */
+export function formatProviderType(type: string): string {
+ return PROVIDER_TYPE_LABEL[type] ?? type;
+}
+
+/**
+ * 判断字符串是否为受支持的 Provider 类型。
+ */
+export function isValidProviderType(type: string): boolean {
+ return PROVIDER_TYPES.includes(type);
+}
+
+/**
+ * 根据 isActive 返回 Tailwind 徽章语义类名。
+ */
+export function activeToBadgeClass(isActive: boolean): string {
+ return isActive
+ ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
+ : "bg-muted text-muted-foreground";
+}
+
+/**
+ * 根据 isActive 返回展示标签。
+ */
+export function formatActiveLabel(isActive: boolean): string {
+ return isActive ? "active" : "inactive";
+}
+
+/**
+ * 格式化 AI 用量费用(分)为元展示字符串。
+ * 输入无效返回 "¥0.00"。
+ */
+export function formatCostCents(costCents: number): string {
+ if (!Number.isFinite(costCents) || costCents < 0) return "¥0.00";
+ const yuan = costCents / 100;
+ return `¥${yuan.toFixed(2)}`;
+}
+
+/**
+ * 格式化数字为千分位展示字符串。
+ * 输入无效返回 "0"。
+ */
+export function formatNumber(value: number): string {
+ if (!Number.isFinite(value) || value < 0) return "0";
+ return value.toLocaleString("zh-CN");
+}
+
+/**
+ * 截断 API Key 用于列表展示(仅保留前 4 + 后 4 字符,中间以 ... 占位)。
+ * 输入为空返回占位符 "--"。
+ */
+export function maskApiKey(apiKey: string | null | undefined): string {
+ if (!apiKey || apiKey.trim().length === 0) return "--";
+ const text = apiKey.trim();
+ if (text.length <= 8) return text;
+ return `${text.slice(0, 4)}...${text.slice(-4)}`;
+}
+
+/**
+ * 截断 BaseUrl 用于列表展示(超过 maxLen 字符时截断并加省略号)。
+ * maxLen 默认 40。
+ */
+export function truncateBaseUrl(
+ url: string | null | undefined,
+ maxLen = 40,
+): string {
+ if (!url) return "--";
+ const text = url.trim();
+ if (text.length <= maxLen) return text;
+ return `${text.slice(0, maxLen)}...`;
+}
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,仅年月日)。
+ * 输入无效时返回占位符。
+ */
+export function formatAiDate(isoDate: string | null | undefined): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+}
diff --git a/apps/portal-shell/src/features/admin/announcements/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/announcements/__tests__/transformations.test.ts
new file mode 100644
index 0000000..1cd3b15
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/announcements/__tests__/transformations.test.ts
@@ -0,0 +1,132 @@
+/**
+ * Announcements 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ AUDIENCE_LABEL,
+ STATUS_LABEL,
+ announcementStatusToBadgeClass,
+ formatAnnouncementDate,
+ formatAnnouncementStatus,
+ formatAudience,
+ isAnnouncementArchivable,
+ isAnnouncementEditable,
+ isAnnouncementPinned,
+} from "../transformations";
+
+describe("announcementStatusToBadgeClass", () => {
+ it("returns muted class for draft", () => {
+ expect(announcementStatusToBadgeClass("draft")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+
+ it("returns emerald class for published", () => {
+ expect(announcementStatusToBadgeClass("published")).toContain("emerald");
+ });
+
+ it("returns amber class for archived", () => {
+ expect(announcementStatusToBadgeClass("archived")).toContain("amber");
+ });
+
+ it("returns muted class for unknown status", () => {
+ expect(announcementStatusToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ expect(announcementStatusToBadgeClass("")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+describe("formatAnnouncementDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatAnnouncementDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatAnnouncementDate(null)).toBe("--");
+ expect(formatAnnouncementDate(undefined)).toBe("--");
+ expect(formatAnnouncementDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatAnnouncementDate("not-a-date")).toBe("--");
+ });
+});
+
+describe("isAnnouncementPinned", () => {
+ it("returns true for non-empty pinnedAt", () => {
+ expect(isAnnouncementPinned("2026-07-22T10:30:00Z")).toBe(true);
+ });
+
+ it("returns false for null/undefined/empty/whitespace", () => {
+ expect(isAnnouncementPinned(null)).toBe(false);
+ expect(isAnnouncementPinned(undefined)).toBe(false);
+ expect(isAnnouncementPinned("")).toBe(false);
+ expect(isAnnouncementPinned(" ")).toBe(false);
+ });
+});
+
+describe("isAnnouncementEditable", () => {
+ it("returns true for draft and published", () => {
+ expect(isAnnouncementEditable("draft")).toBe(true);
+ expect(isAnnouncementEditable("published")).toBe(true);
+ });
+
+ it("returns false for archived and unknown", () => {
+ expect(isAnnouncementEditable("archived")).toBe(false);
+ expect(isAnnouncementEditable("unknown")).toBe(false);
+ });
+});
+
+describe("isAnnouncementArchivable", () => {
+ it("returns true for draft and published", () => {
+ expect(isAnnouncementArchivable("draft")).toBe(true);
+ expect(isAnnouncementArchivable("published")).toBe(true);
+ });
+
+ it("returns false for archived", () => {
+ expect(isAnnouncementArchivable("archived")).toBe(false);
+ });
+});
+
+describe("formatAudience", () => {
+ it("maps known audiences to Chinese labels", () => {
+ expect(formatAudience("all")).toBe("全校");
+ expect(formatAudience("teachers")).toBe("教师");
+ expect(formatAudience("students")).toBe("学生");
+ expect(formatAudience("parents")).toBe("家长");
+ });
+
+ it("returns original value for unknown audience", () => {
+ expect(formatAudience("staff")).toBe("staff");
+ expect(formatAudience("")).toBe("");
+ });
+
+ it("AUDIENCE_LABEL covers 4 standard audiences", () => {
+ expect(Object.keys(AUDIENCE_LABEL)).toHaveLength(4);
+ });
+});
+
+describe("formatAnnouncementStatus", () => {
+ it("maps known statuses to Chinese labels", () => {
+ expect(formatAnnouncementStatus("draft")).toBe("草稿");
+ expect(formatAnnouncementStatus("published")).toBe("已发布");
+ expect(formatAnnouncementStatus("archived")).toBe("已归档");
+ });
+
+ it("returns original value for unknown status", () => {
+ expect(formatAnnouncementStatus("other")).toBe("other");
+ expect(formatAnnouncementStatus("")).toBe("");
+ });
+
+ it("STATUS_LABEL covers 3 standard statuses", () => {
+ expect(Object.keys(STATUS_LABEL)).toHaveLength(3);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/announcements/announcement-detail-client.tsx b/apps/portal-shell/src/features/admin/announcements/announcement-detail-client.tsx
new file mode 100644
index 0000000..5e861d5
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/announcements/announcement-detail-client.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+/**
+ * 公告详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 单查 adminAnnouncement(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - mutation archive / pin / delete ❌ → MSW 兜底
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:DetailPageSkeleton
+ * - error:errorNode 局部降级
+ * - notFound:data 为 null 时显示空态节点
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+import { Megaphone } from "lucide-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import {
+ useAdminAnnouncement,
+ useArchiveAnnouncement,
+ useDeleteAnnouncement,
+ usePinAnnouncement,
+ type AnnouncementDetail,
+} from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import {
+ DetailPageShell,
+ DetailPageSkeleton,
+ DetailSection,
+ DetailField,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ announcementStatusToBadgeClass,
+ formatAnnouncementDate,
+ formatAnnouncementStatus,
+ formatAudience,
+ isAnnouncementArchivable,
+ isAnnouncementEditable,
+ isAnnouncementPinned,
+} from "@/features/admin/announcements/transformations";
+
+/**
+ * 详情客户端主体。需由 server page 包裹在 中。
+ */
+export function AnnouncementDetailClient(): React.ReactElement {
+ const t = useTranslations("admin.announcements.detail");
+ const tCommon = useTranslations("common");
+ const params = useParams<{ id: string }>();
+ const announcementId = params?.id ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminAnnouncement(announcementId);
+
+ // @contract-pending:MSW 兜底
+ const { run: archiveAnnouncement } = useArchiveAnnouncement();
+ const { run: pinAnnouncement } = usePinAnnouncement();
+ const { run: deleteAnnouncement } = useDeleteAnnouncement();
+
+ const handleArchive = async (): Promise => {
+ try {
+ await archiveAnnouncement(announcementId);
+ notify.success(t("archive"));
+ } catch (err) {
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ const handlePinToggle = async (): Promise => {
+ try {
+ await pinAnnouncement(announcementId);
+ notify.success(
+ isAnnouncementPinned(data?.pinnedAt) ? t("unpin") : t("pin"),
+ );
+ } catch (err) {
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ const handleDelete = async (): Promise => {
+ if (!window.confirm(t("deleteConfirm"))) return;
+ try {
+ await deleteAnnouncement(announcementId);
+ notify.success(t("delete"));
+ } catch (err) {
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ ) : undefined;
+
+ const pinned = isAnnouncementPinned(data?.pinnedAt);
+
+ return (
+ }
+ backHref="/shell/admin/announcements"
+ actions={
+ data && isAnnouncementEditable(data.status) ? (
+
+
+
+ {isAnnouncementArchivable(data.status) ? (
+
+ ) : null}
+
+
+ ) : null
+ }
+ loading={loading}
+ loadingNode={}
+ errorNode={errorNode}
+ emptyNode={
+ !loading && !error && !data ? (
+
+ {t("notFound")}
+
+ ) : undefined
+ }
+ >
+ {data ? : null}
+
+ );
+}
+
+/**
+ * 详情内容区(基本信息 + 公告内容 + 发布范围)。
+ */
+function AnnouncementDetailBody({
+ announcement,
+}: {
+ announcement: AnnouncementDetail;
+}): React.ReactElement {
+ const t = useTranslations("admin.announcements.detail");
+ return (
+ <>
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {announcement.content}
+
+
+
+
+
+ 0
+ ? announcement.grades.join(", ")
+ : "-"
+ }
+ />
+ 0
+ ? announcement.classes.join(", ")
+ : "-"
+ }
+ />
+
+ >
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatAnnouncementStatus(status);
+ const cls = announcementStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/announcements/announcement-edit-client.tsx b/apps/portal-shell/src/features/admin/announcements/announcement-edit-client.tsx
new file mode 100644
index 0000000..591a439
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/announcements/announcement-edit-client.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+/**
+ * 公告编辑表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 单查 adminAnnouncement(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - mutation updateAnnouncement(id, input) ❌ → MSW 兜底
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:FormPageSkeleton(初始数据加载)
+ * - error:errorSummary 表单级错误
+ * - success:notify.success + router.push 回详情页
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Megaphone } from "lucide-react";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useState, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAdminAnnouncement,
+ usePinAnnouncement,
+ useUpdateAnnouncement,
+ type AnnouncementInput,
+} from "@/lib/api";
+import { FormPageShell } from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import { isAnnouncementPinned } from "@/features/admin/announcements/transformations";
+
+/** 可选状态枚举(与列表筛选对齐) */
+const STATUS_OPTIONS = ["draft", "published", "archived"] as const;
+/** 可选受众枚举 */
+const AUDIENCE_OPTIONS = ["all", "teachers", "students", "parents"] as const;
+
+/**
+ * 编辑表单客户端主体。需由 server page 包裹在 中。
+ */
+export function AnnouncementEditClient(): React.ReactElement {
+ const t = useTranslations("admin.announcements.edit");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const params = useParams<{ id: string }>();
+ const announcementId = params?.id ?? "";
+ const [, startTransition] = useTransition();
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminAnnouncement(announcementId);
+ // @contract-pending:MSW 兜底
+ const { run: updateAnnouncement, loading: submitting } =
+ useUpdateAnnouncement();
+ const { run: pinAnnouncement } = usePinAnnouncement();
+
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+ const [status, setStatus] = useState("draft");
+ const [audience, setAudience] = useState("all");
+ const [pinned, setPinned] = useState(false);
+ const [originalPinned, setOriginalPinned] = useState(false);
+ const [formError, setFormError] = useState(null);
+ const [initialized, setInitialized] = useState(false);
+
+ // 数据到达后预填表单
+ useEffect(() => {
+ if (data && !initialized) {
+ setTitle(data.title);
+ setContent(data.content);
+ setStatus(data.status);
+ setAudience(data.audience);
+ const isPinned = isAnnouncementPinned(data.pinnedAt);
+ setPinned(isPinned);
+ setOriginalPinned(isPinned);
+ setInitialized(true);
+ }
+ }, [data, initialized]);
+
+ const handleFormSubmit = async (): Promise => {
+ setFormError(null);
+
+ if (!title.trim()) {
+ setFormError(t("errorTitleRequired"));
+ return;
+ }
+ if (!content.trim()) {
+ setFormError(t("errorContentRequired"));
+ return;
+ }
+
+ const input: AnnouncementInput = {
+ title: title.trim(),
+ content: content.trim(),
+ status,
+ audience,
+ };
+
+ try {
+ await updateAnnouncement(announcementId, input);
+ // 若置顶状态变更,同步调用 pin(后端按 toggle 处理)
+ if (pinned !== originalPinned) {
+ try {
+ await pinAnnouncement(announcementId);
+ } catch (pinErr) {
+ notify.error(
+ tCommon("error.loadFailed", { message: String(pinErr) }),
+ );
+ }
+ }
+ notify.success(t("success"));
+ startTransition(() => {
+ router.push(`/shell/admin/announcements/${announcementId}`);
+ });
+ } catch (err) {
+ setFormError(`${t("error")}: ${String(err)}`);
+ }
+ };
+
+ if (loading) {
+ return (
+ }
+ backHref={`/shell/admin/announcements/${announcementId}`}
+ loading
+ />
+ );
+ }
+
+ if (error || (!data && !loading)) {
+ return (
+ }
+ backHref={`/shell/admin/announcements/${announcementId}`}
+ errorSummary={
+
+ {tCommon("error.loadFailed", { message: String(error ?? "") })}
+
+ }
+ />
+ );
+ }
+
+ return (
+ }
+ backHref={`/shell/admin/announcements/${announcementId}`}
+ onSubmit={handleFormSubmit}
+ submitting={submitting}
+ submitLabel={t("submit")}
+ cancelLabel={t("cancel")}
+ errorSummary={
+ formError ? (
+ {formError}
+ ) : undefined
+ }
+ >
+ {/* 标题 */}
+
+ setTitle(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ required
+ />
+
+
+ {/* 内容 */}
+
+
+
+ {/* 状态 + 受众 */}
+
+
+
+
+
+
+
+
+
+
+ {/* 置顶 */}
+
+
+
+
+ );
+}
+
+/**
+ * 表单字段容器(label + children)。
+ */
+function FormField({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/announcements/announcements-list-client.tsx b/apps/portal-shell/src/features/admin/announcements/announcements-list-client.tsx
new file mode 100644
index 0000000..87802f3
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/announcements/announcements-list-client.tsx
@@ -0,0 +1,310 @@
+"use client";
+
+/**
+ * 公告管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 adminAnnouncements(status) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - mutation archiveAnnouncement / pinAnnouncement / deleteAnnouncement ❌ → MSW 兜底
+ *
+ * URL 状态:?status=&page=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Megaphone } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAdminAnnouncements,
+ useArchiveAnnouncement,
+ useDeleteAnnouncement,
+ usePinAnnouncement,
+ type AnnouncementListItem,
+} from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ announcementStatusToBadgeClass,
+ formatAnnouncementDate,
+ formatAnnouncementStatus,
+ formatAudience,
+ isAnnouncementPinned,
+} from "@/features/admin/announcements/transformations";
+
+/** 可选状态筛选项(与 URL ?status= 对齐) */
+const STATUS_OPTIONS = ["draft", "published", "archived"] as const;
+type StatusOption = (typeof STATUS_OPTIONS)[number];
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function AnnouncementsListClient(): React.ReactElement {
+ const t = useTranslations("admin.announcements.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const statusParam = searchParams.get("status") ?? "";
+ const status = STATUS_OPTIONS.includes(statusParam as StatusOption)
+ ? (statusParam as StatusOption)
+ : "";
+ const pageParam = searchParams.get("page") ?? "1";
+ const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminAnnouncements(status || null);
+
+ // @contract-pending:MSW 兜底
+ const { run: archiveAnnouncement } = useArchiveAnnouncement();
+ const { run: pinAnnouncement } = usePinAnnouncement();
+ const { run: deleteAnnouncement } = useDeleteAnnouncement();
+
+ const items = data?.items ?? [];
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ // 切换筛选时重置页码
+ if (key === "status") {
+ params.delete("page");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/announcements?${params.toString()}`);
+ });
+ };
+
+ const handleArchive = async (id: string): Promise => {
+ try {
+ await archiveAnnouncement(id);
+ notify.success(t("archive"));
+ } catch (err) {
+ notify.error(`${tCommon("error.loadFailed", { message: String(err) })}`);
+ }
+ };
+
+ const handlePinToggle = async (
+ id: string,
+ pinned: boolean,
+ ): Promise => {
+ try {
+ await pinAnnouncement(id);
+ notify.success(pinned ? t("unpin") : t("pin"));
+ } catch (err) {
+ notify.error(`${tCommon("error.loadFailed", { message: String(err) })}`);
+ }
+ };
+
+ const handleDelete = async (id: string): Promise => {
+ if (!window.confirm(t("deleteConfirm"))) return;
+ try {
+ await deleteAnnouncement(id);
+ notify.success(t("delete"));
+ } catch (err) {
+ notify.error(`${tCommon("error.loadFailed", { message: String(err) })}`);
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={items.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: data?.total ?? 0 })}
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * 公告列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function AnnouncementsTable({
+ items,
+ page,
+ onArchive,
+ onPinToggle,
+ onDelete,
+}: {
+ items: AnnouncementListItem[];
+ page: number;
+ onArchive: (id: string) => Promise;
+ onPinToggle: (id: string, pinned: boolean) => Promise;
+ onDelete: (id: string) => Promise;
+}): React.ReactElement {
+ const t = useTranslations("admin.announcements.list");
+ return (
+
+
+
+
+ | {t("colTitle")} |
+ {t("colStatus")} |
+ {t("colAudience")} |
+ {t("colPinnedAt")} |
+ {t("colPublishedAt")} |
+ {t("colActions")} |
+
+
+
+ {items.map((item) => {
+ const pinned = isAnnouncementPinned(item.pinnedAt);
+ return (
+
+ |
+
+ {item.title}
+
+ |
+
+
+ |
+
+ {formatAudience(item.audience)}
+ |
+
+ {formatAnnouncementDate(item.pinnedAt)}
+ |
+
+ {formatAnnouncementDate(item.publishedAt)}
+ |
+
+
+
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatAnnouncementStatus(status);
+ const cls = announcementStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/announcements/transformations.ts b/apps/portal-shell/src/features/admin/announcements/transformations.ts
new file mode 100644
index 0000000..5e52262
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/announcements/transformations.ts
@@ -0,0 +1,96 @@
+/**
+ * Announcements 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+/** 公告状态徽章语义类名(draft / published / archived) */
+export function announcementStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "draft":
+ return "bg-muted text-muted-foreground";
+ case "published":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "archived":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效(null/undefined/空/非法)时返回占位符 "--"。
+ */
+export function formatAnnouncementDate(
+ isoDate: string | null | undefined,
+): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * 判断公告是否处于已置顶状态(pinnedAt 非空即置顶)。
+ */
+export function isAnnouncementPinned(
+ pinnedAt: string | null | undefined,
+): boolean {
+ return Boolean(pinnedAt && pinnedAt.trim().length > 0);
+}
+
+/**
+ * 判断公告是否可编辑(草稿与已发布状态允许编辑,归档后不可编辑)。
+ */
+export function isAnnouncementEditable(status: string): boolean {
+ return status === "draft" || status === "published";
+}
+
+/**
+ * 判断公告是否可归档(仅非归档状态可归档)。
+ */
+export function isAnnouncementArchivable(status: string): boolean {
+ return status !== "archived";
+}
+
+/**
+ * 公告受众标签映射。未知值回退为原始值。
+ */
+export const AUDIENCE_LABEL: Record = {
+ all: "全校",
+ teachers: "教师",
+ students: "学生",
+ parents: "家长",
+};
+
+/**
+ * 将受众代码映射为中文标签。未知值回退为原始值。
+ */
+export function formatAudience(audience: string): string {
+ return AUDIENCE_LABEL[audience] ?? audience;
+}
+
+/**
+ * 状态标签映射(对齐 i18n,但保留纯函数映射便于列表徽章兜底)。
+ * 未知值回退为原始值。
+ */
+export const STATUS_LABEL: Record = {
+ draft: "草稿",
+ published: "已发布",
+ archived: "已归档",
+};
+
+/**
+ * 将公告状态枚举值映射为中文标签。未知值回退为原始值。
+ */
+export function formatAnnouncementStatus(status: string): string {
+ return STATUS_LABEL[status] ?? status;
+}
diff --git a/apps/portal-shell/src/features/admin/attendance/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/attendance/__tests__/transformations.test.ts
new file mode 100644
index 0000000..24500ce
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/attendance/__tests__/transformations.test.ts
@@ -0,0 +1,439 @@
+/**
+ * Admin Attendance 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type {
+ AdminAttendanceRecord,
+ AdminAttendanceStats,
+ AttendanceGradeCorrelation,
+} from "@/lib/api/admin-p5";
+
+import {
+ attendanceStatusToBadgeClass,
+ attendanceStatusToKey,
+ computeAbnormalRate,
+ computeAvgCorrelation,
+ countRecords,
+ filterRecords,
+ formatAbnormalRate,
+ formatAvgScore,
+ formatCorrelation,
+ formatRate,
+ formatRecordDate,
+ hasAttendanceData,
+ presentRateToColorClass,
+ sortClassesByPresentRate,
+ truncateNote,
+} from "../transformations";
+
+const baseStats: AdminAttendanceStats = {
+ totalRecords: 1000,
+ presentRate: 0.92,
+ absentRate: 0.05,
+ lateRate: 0.02,
+ earlyLeaveRate: 0.01,
+ byClass: [
+ {
+ classId: "cls-1",
+ className: "高三(1)班",
+ presentRate: 0.98,
+ absentRate: 0.02,
+ },
+ {
+ classId: "cls-2",
+ className: "高三(2)班",
+ presentRate: 0.85,
+ absentRate: 0.1,
+ },
+ {
+ classId: "cls-3",
+ className: "高三(3)班",
+ presentRate: 0.9,
+ absentRate: 0.05,
+ },
+ ],
+};
+
+const baseRecords: AdminAttendanceRecord[] = [
+ {
+ id: "rec-1",
+ studentId: "stu-1",
+ studentName: "张三",
+ classId: "cls-1",
+ className: "高三(1)班",
+ date: "2026-07-22",
+ status: "present",
+ recordedBy: "李老师",
+ note: "",
+ },
+ {
+ id: "rec-2",
+ studentId: "stu-2",
+ studentName: "李四",
+ classId: "cls-2",
+ className: "高三(2)班",
+ date: "2026-07-22",
+ status: "absent",
+ recordedBy: "李老师",
+ note: "病假",
+ },
+ {
+ id: "rec-3",
+ studentId: "stu-3",
+ studentName: "王五",
+ classId: "cls-1",
+ className: "高三(1)班",
+ date: "2026-07-23",
+ status: "late",
+ recordedBy: "王老师",
+ note: "迟到10分钟",
+ },
+];
+
+describe("attendanceStatusToKey", () => {
+ it("returns known statuses as-is", () => {
+ expect(attendanceStatusToKey("present")).toBe("present");
+ expect(attendanceStatusToKey("absent")).toBe("absent");
+ expect(attendanceStatusToKey("late")).toBe("late");
+ expect(attendanceStatusToKey("leave")).toBe("leave");
+ });
+
+ it("falls back to present for unknown/null/empty", () => {
+ expect(attendanceStatusToKey("unknown")).toBe("present");
+ expect(attendanceStatusToKey("")).toBe("present");
+ expect(attendanceStatusToKey(null)).toBe("present");
+ expect(attendanceStatusToKey(undefined)).toBe("present");
+ });
+});
+
+describe("attendanceStatusToBadgeClass", () => {
+ it("returns emerald for present", () => {
+ expect(attendanceStatusToBadgeClass("present")).toContain("emerald");
+ });
+
+ it("returns destructive for absent", () => {
+ expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
+ });
+
+ it("returns amber for late", () => {
+ expect(attendanceStatusToBadgeClass("late")).toContain("amber");
+ });
+
+ it("returns sky for leave", () => {
+ expect(attendanceStatusToBadgeClass("leave")).toContain("sky");
+ });
+
+ it("falls back to emerald (present) for unknown", () => {
+ expect(attendanceStatusToBadgeClass("unknown")).toContain("emerald");
+ });
+});
+
+describe("formatRate", () => {
+ it("formats valid rates as percentage", () => {
+ expect(formatRate(0)).toBe("0%");
+ expect(formatRate(0.5)).toBe("50%");
+ expect(formatRate(0.92)).toBe("92%");
+ expect(formatRate(1)).toBe("100%");
+ });
+
+ it("returns placeholder for null/undefined/non-finite/out-of-range", () => {
+ expect(formatRate(null)).toBe("--");
+ expect(formatRate(undefined)).toBe("--");
+ expect(formatRate(Number.NaN)).toBe("--");
+ expect(formatRate(-0.1)).toBe("--");
+ expect(formatRate(1.1)).toBe("--");
+ });
+});
+
+describe("presentRateToColorClass", () => {
+ it("returns emerald for rate >= 0.95", () => {
+ expect(presentRateToColorClass(0.95)).toContain("emerald");
+ expect(presentRateToColorClass(1)).toContain("emerald");
+ });
+
+ it("returns amber for 0.9 <= rate < 0.95", () => {
+ expect(presentRateToColorClass(0.9)).toContain("amber");
+ expect(presentRateToColorClass(0.94)).toContain("amber");
+ });
+
+ it("returns destructive for rate < 0.9", () => {
+ expect(presentRateToColorClass(0.89)).toContain("destructive");
+ expect(presentRateToColorClass(0.5)).toContain("destructive");
+ });
+
+ it("returns muted for invalid input", () => {
+ expect(presentRateToColorClass(null)).toContain("muted-foreground");
+ expect(presentRateToColorClass(undefined)).toContain("muted-foreground");
+ expect(presentRateToColorClass(-0.1)).toContain("muted-foreground");
+ expect(presentRateToColorClass(1.1)).toContain("muted-foreground");
+ });
+});
+
+describe("computeAbnormalRate", () => {
+ it("sums absent + late + earlyLeave rates", () => {
+ expect(computeAbnormalRate(0.05, 0.02, 0.01)).toBeCloseTo(0.08);
+ expect(computeAbnormalRate(0.1, 0.2, 0.3)).toBeCloseTo(0.6);
+ });
+
+ it("returns 0 for null/undefined inputs", () => {
+ expect(computeAbnormalRate(null, null, null)).toBe(0);
+ expect(computeAbnormalRate(undefined, undefined, undefined)).toBe(0);
+ });
+
+ it("returns 0 for non-finite inputs", () => {
+ expect(computeAbnormalRate(Number.NaN, 0.1, 0.1)).toBe(0);
+ expect(computeAbnormalRate(0.1, Number.POSITIVE_INFINITY, 0.1)).toBe(0);
+ });
+});
+
+describe("formatAbnormalRate", () => {
+ it("formats abnormal rate as percentage", () => {
+ expect(formatAbnormalRate(0.05, 0.02, 0.01)).toBe("8%");
+ expect(formatAbnormalRate(0, 0, 0)).toBe("0%");
+ });
+
+ it("returns placeholder for invalid inputs", () => {
+ expect(formatAbnormalRate(null, null, null)).toBe("0%");
+ });
+});
+
+describe("hasAttendanceData", () => {
+ it("returns true for stats with records", () => {
+ expect(hasAttendanceData(baseStats)).toBe(true);
+ });
+
+ it("returns true for stats with byClass entries", () => {
+ expect(
+ hasAttendanceData({
+ ...baseStats,
+ totalRecords: 0,
+ presentRate: 0,
+ }),
+ ).toBe(true);
+ });
+
+ it("returns false for null/undefined", () => {
+ expect(hasAttendanceData(null)).toBe(false);
+ expect(hasAttendanceData(undefined)).toBe(false);
+ });
+
+ it("returns false for empty stats", () => {
+ expect(
+ hasAttendanceData({
+ totalRecords: 0,
+ presentRate: 0,
+ absentRate: 0,
+ lateRate: 0,
+ earlyLeaveRate: 0,
+ byClass: [],
+ }),
+ ).toBe(false);
+ });
+
+ it("returns true for non-zero presentRate even without records", () => {
+ expect(
+ hasAttendanceData({
+ totalRecords: 0,
+ presentRate: 0.92,
+ absentRate: 0.05,
+ lateRate: 0.02,
+ earlyLeaveRate: 0.01,
+ byClass: [],
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("sortClassesByPresentRate", () => {
+ it("sorts classes ascending by presentRate (lowest first)", () => {
+ const sorted = sortClassesByPresentRate(baseStats);
+ expect(sorted[0]?.classId).toBe("cls-2"); // 0.85
+ expect(sorted[1]?.classId).toBe("cls-3"); // 0.9
+ expect(sorted[2]?.classId).toBe("cls-1"); // 0.98
+ });
+
+ it("returns empty array for null/undefined stats", () => {
+ expect(sortClassesByPresentRate(null)).toEqual([]);
+ expect(sortClassesByPresentRate(undefined)).toEqual([]);
+ });
+
+ it("returns empty array when byClass is empty", () => {
+ expect(sortClassesByPresentRate({ ...baseStats, byClass: [] })).toEqual([]);
+ });
+
+ it("does not mutate the original array", () => {
+ const original = [...baseStats.byClass];
+ sortClassesByPresentRate(baseStats);
+ expect(baseStats.byClass).toEqual(original);
+ });
+});
+
+describe("computeAvgCorrelation", () => {
+ const correlations: AttendanceGradeCorrelation[] = [
+ {
+ classId: "cls-1",
+ className: "高三(1)班",
+ presentRate: 0.95,
+ avgScore: 85,
+ correlation: 0.8,
+ },
+ {
+ classId: "cls-2",
+ className: "高三(2)班",
+ presentRate: 0.9,
+ avgScore: 80,
+ correlation: 0.6,
+ },
+ ];
+
+ it("computes average of valid correlations", () => {
+ expect(computeAvgCorrelation(correlations)).toBeCloseTo(0.7);
+ });
+
+ it("returns 0 for null/undefined/empty", () => {
+ expect(computeAvgCorrelation(null)).toBe(0);
+ expect(computeAvgCorrelation(undefined)).toBe(0);
+ expect(computeAvgCorrelation([])).toBe(0);
+ });
+
+ it("filters out invalid correlations (out of [-1, 1])", () => {
+ const withInvalid: AttendanceGradeCorrelation[] = [
+ ...correlations,
+ {
+ classId: "cls-3",
+ className: "高三(3)班",
+ presentRate: 0.9,
+ avgScore: 80,
+ correlation: 2, // invalid
+ },
+ ];
+ expect(computeAvgCorrelation(withInvalid)).toBeCloseTo(0.7);
+ });
+});
+
+describe("formatCorrelation", () => {
+ it("formats valid correlation with 3 decimal places", () => {
+ expect(formatCorrelation(0.8)).toBe("0.800");
+ expect(formatCorrelation(-0.5)).toBe("-0.500");
+ expect(formatCorrelation(0)).toBe("0.000");
+ expect(formatCorrelation(1)).toBe("1.000");
+ expect(formatCorrelation(-1)).toBe("-1.000");
+ });
+
+ it("returns placeholder for null/undefined/non-finite/out-of-range", () => {
+ expect(formatCorrelation(null)).toBe("--");
+ expect(formatCorrelation(undefined)).toBe("--");
+ expect(formatCorrelation(Number.NaN)).toBe("--");
+ expect(formatCorrelation(1.5)).toBe("--");
+ expect(formatCorrelation(-1.5)).toBe("--");
+ });
+});
+
+describe("formatAvgScore", () => {
+ it("formats valid score with 1 decimal place", () => {
+ expect(formatAvgScore(85)).toBe("85.0");
+ expect(formatAvgScore(82.56)).toBe("82.6");
+ expect(formatAvgScore(0)).toBe("0.0");
+ });
+
+ it("returns placeholder for null/undefined/non-finite/negative", () => {
+ expect(formatAvgScore(null)).toBe("--");
+ expect(formatAvgScore(undefined)).toBe("--");
+ expect(formatAvgScore(Number.NaN)).toBe("--");
+ expect(formatAvgScore(-1)).toBe("--");
+ });
+});
+
+describe("formatRecordDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatRecordDate("2026-07-22");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ expect(result).toContain("22");
+ });
+
+ it("returns placeholder for null/undefined/empty/invalid", () => {
+ expect(formatRecordDate(null)).toBe("--");
+ expect(formatRecordDate(undefined)).toBe("--");
+ expect(formatRecordDate("")).toBe("--");
+ expect(formatRecordDate("not-a-date")).toBe("--");
+ });
+});
+
+describe("filterRecords", () => {
+ it("returns all records when filter is empty", () => {
+ expect(filterRecords(baseRecords, {})).toHaveLength(3);
+ });
+
+ it("filters by classId", () => {
+ const result = filterRecords(baseRecords, { classId: "cls-1" });
+ expect(result).toHaveLength(2);
+ expect(result.every((r) => r.classId === "cls-1")).toBe(true);
+ });
+
+ it("filters by status", () => {
+ const result = filterRecords(baseRecords, { status: "present" });
+ expect(result).toHaveLength(1);
+ expect(result[0]?.id).toBe("rec-1");
+ });
+
+ it("filters by date", () => {
+ const result = filterRecords(baseRecords, { date: "2026-07-22" });
+ expect(result).toHaveLength(2);
+ expect(result.every((r) => r.date === "2026-07-22")).toBe(true);
+ });
+
+ it("combines multiple filters", () => {
+ const result = filterRecords(baseRecords, {
+ classId: "cls-1",
+ date: "2026-07-22",
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0]?.id).toBe("rec-1");
+ });
+
+ it("returns empty for null/undefined records", () => {
+ expect(filterRecords(null, {})).toEqual([]);
+ expect(filterRecords(undefined, {})).toEqual([]);
+ });
+
+ it("returns empty array when no matches", () => {
+ expect(filterRecords(baseRecords, { classId: "non-existent" })).toEqual([]);
+ });
+});
+
+describe("countRecords", () => {
+ it("counts records", () => {
+ expect(countRecords(baseRecords)).toBe(3);
+ });
+
+ it("returns 0 for null/undefined/empty", () => {
+ expect(countRecords(null)).toBe(0);
+ expect(countRecords(undefined)).toBe(0);
+ expect(countRecords([])).toBe(0);
+ });
+});
+
+describe("truncateNote", () => {
+ it("returns note as-is when within limit", () => {
+ expect(truncateNote("短备注", 40)).toBe("短备注");
+ expect(truncateNote("12345", 5)).toBe("12345");
+ });
+
+ it("truncates and appends ellipsis when exceeding limit", () => {
+ const long = "这是一段很长的备注信息需要被截断处理";
+ const result = truncateNote(long, 10);
+ expect(result.length).toBe(13); // 10 + "..."
+ expect(result.endsWith("...")).toBe(true);
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(truncateNote(null)).toBe("--");
+ expect(truncateNote(undefined)).toBe("--");
+ expect(truncateNote("")).toBe("--");
+ expect(truncateNote(" ")).toBe("--");
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx b/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx
new file mode 100644
index 0000000..1945652
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx
@@ -0,0 +1,412 @@
+"use client";
+
+/**
+ * 考勤管理页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - adminAttendanceStats():❌ schema 无 → MSW 兜底(@contract-pending)
+ * - attendanceGradeCorrelation():❌ schema 无 → MSW 兜底
+ * - adminClasses():❌ schema 无 → MSW 兜底(用于班级筛选下拉)
+ *
+ * URL 状态:?classId=xxx&status=xxx&date=xxx
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { CalendarCheck } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAdminAttendanceStats,
+ useAdminClasses,
+ useAttendanceGradeCorrelation,
+} from "@/lib/api";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ attendanceStatusToBadgeClass,
+ attendanceStatusToKey,
+ computeAbnormalRate,
+ computeAvgCorrelation,
+ formatAbnormalRate,
+ formatAvgScore,
+ formatCorrelation,
+ formatRate,
+ formatRecordDate,
+ hasAttendanceData,
+ presentRateToColorClass,
+ sortClassesByPresentRate,
+ truncateNote,
+ type AttendanceStatus,
+} from "@/features/admin/attendance/transformations";
+
+/** 考勤状态选项(用于筛选下拉) */
+const STATUS_OPTIONS: readonly AttendanceStatus[] = [
+ "present",
+ "absent",
+ "late",
+ "leave",
+] as const;
+
+/**
+ * 考勤管理客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function AdminAttendanceClient(): React.ReactElement {
+ const t = useTranslations("admin.attendance.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const classId = searchParams.get("classId") ?? "";
+ const status = searchParams.get("status") ?? "";
+ const date = searchParams.get("date") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data: stats, loading, error } = useAdminAttendanceStats();
+ const { data: correlations } = useAttendanceGradeCorrelation();
+ const { data: classes } = useAdminClasses();
+
+ const avgCorrelation = useMemo(
+ () => computeAvgCorrelation(correlations ?? []),
+ [correlations],
+ );
+
+ const updateFilter = (key: string, next: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (next) {
+ params.set(key, next);
+ } else {
+ params.delete(key);
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/attendance?${params.toString()}`);
+ });
+ };
+
+ const hasData = hasAttendanceData(stats);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ filters={
+ updateFilter("classId", v)}
+ onStatusChange={(v) => updateFilter("status", v)}
+ onDateChange={(v) => updateFilter("date", v)}
+ />
+ }
+ loading={loading}
+ loadingNode={}
+ empty={!hasData && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+ {stats ? (
+
+ ) : null}
+ {t("mswNotice")}
+
+ );
+}
+
+/**
+ * 考勤筛选栏(班级 + 状态 + 日期)。
+ */
+function AttendanceFilters({
+ classId,
+ status,
+ date,
+ classes,
+ onClassChange,
+ onStatusChange,
+ onDateChange,
+}: {
+ classId: string;
+ status: string;
+ date: string;
+ classes: NonNullable["data"]>;
+ onClassChange: (v: string) => void;
+ onStatusChange: (v: string) => void;
+ onDateChange: (v: string) => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.attendance.list");
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onDateChange(e.target.value)}
+ className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
+ />
+
+
+ );
+}
+
+/**
+ * 考勤主体内容(统计卡片 + 班级对比 + 考勤-成绩关联分析)。
+ */
+function AttendanceContent({
+ stats,
+ correlations,
+ avgCorrelation,
+}: {
+ stats: NonNullable["data"]>;
+ correlations: NonNullable<
+ ReturnType["data"]
+ >;
+ avgCorrelation: number;
+}): React.ReactElement {
+ const t = useTranslations("admin.attendance.list");
+ const abnormalRate = computeAbnormalRate(
+ stats.absentRate,
+ stats.lateRate,
+ stats.earlyLeaveRate,
+ );
+ const sortedClasses = sortClassesByPresentRate(stats);
+
+ return (
+
+ {/* 统计卡片 */}
+
+
+
+ 0.1 ? "text-destructive" : undefined}
+ />
+
+
+
+ {/* 次级统计卡片 */}
+
+
+
+
+
+
+ {/* 班级对比 */}
+
+
+
+ {t("classComparisonTitle")}
+
+ {sortedClasses.length === 0 ? (
+
+ {t("emptyTitle")}
+
+ ) : (
+
+
+
+
+ |
+ {t("classComparisonClass")}
+ |
+
+ {t("statsAbsentRate")}
+ |
+
+ {t("classComparisonRate")}
+ |
+
+
+
+ {sortedClasses.map((cls) => (
+
+ | {cls.className} |
+
+ {formatRate(cls.absentRate)}
+ |
+
+ {formatRate(cls.presentRate)}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* 考勤-成绩关联分析 */}
+
+
+
+ {t("correlationTitle")}
+
+ {correlations.length === 0 ? (
+
+ {t("emptyTitle")}
+
+ ) : (
+
+
+
+
+ |
+ {t("classComparisonClass")}
+ |
+
+ {t("correlationAttendance")}
+ |
+
+ {t("correlationGrade")}
+ |
+
+ {t("statsAvgCorrelation")}
+ |
+
+
+
+ {correlations.map((c) => {
+ const statusKey = attendanceStatusToKey(
+ c.presentRate >= 0.9 ? "present" : "absent",
+ );
+ return (
+
+ | {c.className} |
+
+ {formatRate(c.presentRate)}
+ |
+
+ {formatAvgScore(c.avgScore)}
+ |
+
+
+ {formatCorrelation(c.correlation)}
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * 备注/日期格式化辅助导出(供页面其他部分复用,对齐 DoD 纯函数)。
+ */
+export { formatRecordDate, truncateNote };
diff --git a/apps/portal-shell/src/features/admin/attendance/transformations.ts b/apps/portal-shell/src/features/admin/attendance/transformations.ts
new file mode 100644
index 0000000..062a045
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/attendance/transformations.ts
@@ -0,0 +1,253 @@
+/**
+ * Admin Attendance 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type {
+ AdminAttendanceRecord,
+ AdminAttendanceStats,
+ AttendanceGradeCorrelation,
+} from "@/lib/api/admin-p5";
+
+/** 考勤状态枚举(与 i18n admin.attendance.list.status* 对齐) */
+export type AttendanceStatus = "present" | "absent" | "late" | "leave";
+
+/** 已知考勤状态白名单 */
+const KNOWN_STATUSES: readonly AttendanceStatus[] = [
+ "present",
+ "absent",
+ "late",
+ "leave",
+] as const;
+
+/** 班级维度统计项(从 AdminAttendanceStats.byClass 派生) */
+type ClassStat = AdminAttendanceStats["byClass"][number];
+
+/**
+ * 将考勤状态字符串映射为已知枚举值。
+ * 未知状态回退为 "present"。
+ */
+export function attendanceStatusToKey(
+ status: string | null | undefined,
+): AttendanceStatus {
+ if (!status) return "present";
+ if (KNOWN_STATUSES.includes(status as AttendanceStatus)) {
+ return status as AttendanceStatus;
+ }
+ return "present";
+}
+
+/**
+ * 根据考勤状态返回 Tailwind 徽章语义类名。
+ * - present → emerald
+ * - absent → destructive
+ * - late → amber
+ * - leave → sky
+ */
+export function attendanceStatusToBadgeClass(
+ status: string | null | undefined,
+): string {
+ switch (attendanceStatusToKey(status)) {
+ case "present":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "absent":
+ return "bg-destructive/10 text-destructive";
+ case "late":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ case "leave":
+ return "bg-sky-500/10 text-sky-600 dark:text-sky-400";
+ }
+}
+
+/**
+ * 格式化比率(0-1 浮点)为百分比字符串。
+ * 输入无效或越界返回 "--"。
+ */
+export function formatRate(rate: number | null | undefined): string {
+ if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
+ return "--";
+ }
+ return `${Math.round(rate * 100)}%`;
+}
+
+/**
+ * 根据出勤率(0-1)返回 Tailwind 文本语义类名。
+ * - >= 0.95 → emerald(优秀)
+ * - >= 0.9 → amber(一般)
+ * - 其他 → destructive(低出勤率)
+ */
+export function presentRateToColorClass(
+ rate: number | null | undefined,
+): string {
+ if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
+ return "text-muted-foreground";
+ }
+ if (rate >= 0.95) return "text-emerald-600 dark:text-emerald-400";
+ if (rate >= 0.9) return "text-amber-600 dark:text-amber-400";
+ return "text-destructive";
+}
+
+/**
+ * 计算异常率(缺勤 + 迟到 + 早退)。
+ * 输入无效返回 0。
+ */
+export function computeAbnormalRate(
+ absentRate: number | null | undefined,
+ lateRate: number | null | undefined,
+ earlyLeaveRate: number | null | undefined,
+): number {
+ const absent = absentRate ?? 0;
+ const late = lateRate ?? 0;
+ const earlyLeave = earlyLeaveRate ?? 0;
+ if (
+ !Number.isFinite(absent) ||
+ !Number.isFinite(late) ||
+ !Number.isFinite(earlyLeave)
+ ) {
+ return 0;
+ }
+ return absent + late + earlyLeave;
+}
+
+/**
+ * 格式化异常率为百分比字符串。
+ */
+export function formatAbnormalRate(
+ absentRate: number | null | undefined,
+ lateRate: number | null | undefined,
+ earlyLeaveRate: number | null | undefined,
+): string {
+ return formatRate(computeAbnormalRate(absentRate, lateRate, earlyLeaveRate));
+}
+
+/**
+ * 判断 stats 是否有可展示数据(任一核心字段 > 0 即视为有数据)。
+ */
+export function hasAttendanceData(
+ stats: AdminAttendanceStats | null | undefined,
+): boolean {
+ if (!stats) return false;
+ return (
+ stats.totalRecords > 0 ||
+ (stats.byClass?.length ?? 0) > 0 ||
+ (Number.isFinite(stats.presentRate) && stats.presentRate > 0)
+ );
+}
+
+/**
+ * 按出勤率升序排序的班级列表(出勤率低的在前,便于关注薄弱班级)。
+ */
+export function sortClassesByPresentRate(
+ stats: AdminAttendanceStats | null | undefined,
+): ClassStat[] {
+ if (!stats?.byClass) return [];
+ return [...stats.byClass].sort((a, b) => a.presentRate - b.presentRate);
+}
+
+/**
+ * 计算相关系数列表的平均相关系数。
+ * 输入空或无效返回 0。
+ */
+export function computeAvgCorrelation(
+ correlations: ReadonlyArray | null | undefined,
+): number {
+ if (!correlations || correlations.length === 0) return 0;
+ const valid = correlations.filter(
+ (c) =>
+ Number.isFinite(c.correlation) &&
+ c.correlation >= -1 &&
+ c.correlation <= 1,
+ );
+ if (valid.length === 0) return 0;
+ const sum = valid.reduce((acc, c) => acc + c.correlation, 0);
+ return sum / valid.length;
+}
+
+/**
+ * 格式化相关系数为展示字符串(保留 3 位小数)。
+ * 输入无效返回 "--"。
+ */
+export function formatCorrelation(
+ correlation: number | null | undefined,
+): string {
+ if (
+ correlation == null ||
+ !Number.isFinite(correlation) ||
+ correlation < -1 ||
+ correlation > 1
+ ) {
+ return "--";
+ }
+ return correlation.toFixed(3);
+}
+
+/**
+ * 格式化平均分(保留 1 位小数)。
+ * 输入无效返回 "--"。
+ */
+export function formatAvgScore(score: number | null | undefined): string {
+ if (score == null || !Number.isFinite(score) || score < 0) return "--";
+ return score.toFixed(1);
+}
+
+/**
+ * 格式化考勤记录日期(ISO → 本地化日期字符串)。
+ * 输入无效返回 "--"。
+ */
+export function formatRecordDate(isoDate: string | null | undefined): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+}
+
+/**
+ * 根据筛选条件过滤考勤记录列表。
+ * - classId:精确匹配 classId
+ * - status:精确匹配 status
+ * - date:精确匹配 date 字符串
+ */
+export function filterRecords(
+ records: ReadonlyArray | null | undefined,
+ filter: {
+ classId?: string | null;
+ status?: string | null;
+ date?: string | null;
+ },
+): AdminAttendanceRecord[] {
+ if (!records) return [];
+ return records.filter((r) => {
+ if (filter.classId && r.classId !== filter.classId) return false;
+ if (filter.status && r.status !== filter.status) return false;
+ if (filter.date && r.date !== filter.date) return false;
+ return true;
+ });
+}
+
+/**
+ * 统计记录总数。
+ */
+export function countRecords(
+ records: ReadonlyArray | null | undefined,
+): number {
+ return records?.length ?? 0;
+}
+
+/**
+ * 截断备注到指定长度并追加省略号。
+ */
+export function truncateNote(
+ note: string | null | undefined,
+ max = 40,
+): string {
+ if (!note || note.trim().length === 0) return "--";
+ const safeMax = Math.max(1, Math.floor(max));
+ if (note.length <= safeMax) return note;
+ return `${note.slice(0, safeMax)}...`;
+}
diff --git a/apps/portal-shell/src/features/admin/audit-logs/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/audit-logs/__tests__/transformations.test.ts
new file mode 100644
index 0000000..188a08a
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/audit-logs/__tests__/transformations.test.ts
@@ -0,0 +1,490 @@
+/**
+ * Audit Logs 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type {
+ AuditLog,
+ AuditTrendPoint,
+ DataChangeActionStat,
+ DataChangeLog,
+ LoginLog,
+} from "@/lib/api";
+
+import {
+ AUDIT_ACTION_LABEL,
+ AUDIT_STATUS_LABEL,
+ DATA_CHANGE_ACTION_LABEL,
+ LOGIN_ACTION_LABEL,
+ LOGIN_STATUS_LABEL,
+ auditActionToLabel,
+ auditLogsToCsv,
+ auditStatusToBadgeClass,
+ auditStatusToLabel,
+ dataChangeActionToBadgeClass,
+ dataChangeActionToLabel,
+ dataChangeLogsToCsv,
+ downloadCsv,
+ formatAuditDate,
+ formatAuditTimestamp,
+ getDistributionColor,
+ getDistributionPercent,
+ getDistributionTotal,
+ getMaxTrendCount,
+ loginActionToLabel,
+ loginLogsToCsv,
+ loginStatusToBadgeClass,
+ loginStatusToLabel,
+ toCsvCell,
+ toCsvRow,
+} from "../transformations";
+
+// ============================================================
+// Fixtures
+// ============================================================
+
+const sampleAuditLog: AuditLog = {
+ id: "log-001",
+ userId: "u-001",
+ userName: "张三",
+ action: "create",
+ resource: "exam",
+ resourceId: "exam-001",
+ ip: "192.168.1.1",
+ timestamp: "2026-07-22T10:30:00Z",
+ details: "创建考试 exam-001",
+};
+
+const sampleLoginLog: LoginLog = {
+ id: "login-001",
+ userId: "u-001",
+ userName: "张三",
+ action: "signin",
+ status: "success",
+ ip: "192.168.1.1",
+ userAgent: "Mozilla/5.0",
+ timestamp: "2026-07-22T10:30:00Z",
+};
+
+const sampleDataChangeLog: DataChangeLog = {
+ id: "dc-001",
+ tableName: "users",
+ recordId: "u-001",
+ action: "update",
+ userId: "u-002",
+ userName: "李四",
+ changes: '{"name": "张三"}',
+ timestamp: "2026-07-22T10:30:00Z",
+};
+
+const sampleTrend: AuditTrendPoint[] = [
+ { date: "2026-07-16", count: 10 },
+ { date: "2026-07-17", count: 25 },
+ { date: "2026-07-18", count: 5 },
+ { date: "2026-07-19", count: 30 },
+];
+
+const sampleDistribution: DataChangeActionStat[] = [
+ { action: "create", count: 10 },
+ { action: "update", count: 20 },
+ { action: "delete", count: 5 },
+];
+
+// ============================================================
+// formatAuditTimestamp / formatAuditDate
+// ============================================================
+
+describe("formatAuditTimestamp", () => {
+ it("formats valid ISO date string with time", () => {
+ const result = formatAuditTimestamp("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatAuditTimestamp(null)).toBe("--");
+ expect(formatAuditTimestamp(undefined)).toBe("--");
+ expect(formatAuditTimestamp("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatAuditTimestamp("not-a-date")).toBe("--");
+ });
+});
+
+describe("formatAuditDate", () => {
+ it("formats valid ISO date string as date only", () => {
+ const result = formatAuditDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatAuditDate(null)).toBe("--");
+ expect(formatAuditDate(undefined)).toBe("--");
+ expect(formatAuditDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatAuditDate("invalid")).toBe("--");
+ });
+});
+
+// ============================================================
+// Audit action / status mapping
+// ============================================================
+
+describe("auditActionToLabel", () => {
+ it("maps known actions to Chinese labels", () => {
+ expect(auditActionToLabel("create")).toBe("创建");
+ expect(auditActionToLabel("update")).toBe("更新");
+ expect(auditActionToLabel("delete")).toBe("删除");
+ expect(auditActionToLabel("login")).toBe("登录");
+ });
+
+ it("returns original value for unknown action", () => {
+ expect(auditActionToLabel("custom")).toBe("custom");
+ expect(auditActionToLabel("")).toBe("");
+ });
+
+ it("AUDIT_ACTION_LABEL covers common actions", () => {
+ expect(Object.keys(AUDIT_ACTION_LABEL).length).toBeGreaterThan(5);
+ });
+});
+
+describe("auditStatusToLabel", () => {
+ it("maps known statuses to Chinese labels", () => {
+ expect(auditStatusToLabel("success")).toBe("成功");
+ expect(auditStatusToLabel("failure")).toBe("失败");
+ expect(auditStatusToLabel("error")).toBe("错误");
+ });
+
+ it("returns original value for unknown status", () => {
+ expect(auditStatusToLabel("custom")).toBe("custom");
+ });
+
+ it("AUDIT_STATUS_LABEL covers core statuses", () => {
+ expect(Object.keys(AUDIT_STATUS_LABEL).length).toBeGreaterThanOrEqual(4);
+ });
+});
+
+describe("auditStatusToBadgeClass", () => {
+ it("returns emerald class for success", () => {
+ expect(auditStatusToBadgeClass("success")).toContain("emerald");
+ });
+
+ it("returns red class for failure and error", () => {
+ expect(auditStatusToBadgeClass("failure")).toContain("red");
+ expect(auditStatusToBadgeClass("error")).toContain("red");
+ });
+
+ it("returns amber class for pending", () => {
+ expect(auditStatusToBadgeClass("pending")).toContain("amber");
+ });
+
+ it("returns muted for unknown status", () => {
+ expect(auditStatusToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+// ============================================================
+// Login action / status mapping
+// ============================================================
+
+describe("loginActionToLabel", () => {
+ it("maps known login actions to Chinese labels", () => {
+ expect(loginActionToLabel("signin")).toBe("登录");
+ expect(loginActionToLabel("signout")).toBe("登出");
+ expect(loginActionToLabel("signup")).toBe("注册");
+ });
+
+ it("returns original value for unknown action", () => {
+ expect(loginActionToLabel("other")).toBe("other");
+ });
+
+ it("LOGIN_ACTION_LABEL covers signin/signout/signup", () => {
+ expect(LOGIN_ACTION_LABEL).toHaveProperty("signin");
+ expect(LOGIN_ACTION_LABEL).toHaveProperty("signout");
+ expect(LOGIN_ACTION_LABEL).toHaveProperty("signup");
+ });
+});
+
+describe("loginStatusToLabel", () => {
+ it("maps known login statuses", () => {
+ expect(loginStatusToLabel("success")).toBe("成功");
+ expect(loginStatusToLabel("failure")).toBe("失败");
+ });
+
+ it("returns original value for unknown status", () => {
+ expect(loginStatusToLabel("unknown")).toBe("unknown");
+ });
+
+ it("LOGIN_STATUS_LABEL covers success/failure", () => {
+ expect(LOGIN_STATUS_LABEL).toHaveProperty("success");
+ expect(LOGIN_STATUS_LABEL).toHaveProperty("failure");
+ });
+});
+
+describe("loginStatusToBadgeClass", () => {
+ it("returns emerald class for success", () => {
+ expect(loginStatusToBadgeClass("success")).toContain("emerald");
+ });
+
+ it("returns red class for failure", () => {
+ expect(loginStatusToBadgeClass("failure")).toContain("red");
+ });
+
+ it("returns muted for unknown status", () => {
+ expect(loginStatusToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+// ============================================================
+// Data change action mapping
+// ============================================================
+
+describe("dataChangeActionToLabel", () => {
+ it("maps known actions to Chinese labels", () => {
+ expect(dataChangeActionToLabel("create")).toBe("创建");
+ expect(dataChangeActionToLabel("update")).toBe("更新");
+ expect(dataChangeActionToLabel("delete")).toBe("删除");
+ expect(dataChangeActionToLabel("insert")).toBe("插入");
+ });
+
+ it("returns original value for unknown action", () => {
+ expect(dataChangeActionToLabel("custom")).toBe("custom");
+ });
+
+ it("DATA_CHANGE_ACTION_LABEL covers create/update/delete", () => {
+ expect(DATA_CHANGE_ACTION_LABEL).toHaveProperty("create");
+ expect(DATA_CHANGE_ACTION_LABEL).toHaveProperty("update");
+ expect(DATA_CHANGE_ACTION_LABEL).toHaveProperty("delete");
+ });
+});
+
+describe("dataChangeActionToBadgeClass", () => {
+ it("returns emerald class for create/insert", () => {
+ expect(dataChangeActionToBadgeClass("create")).toContain("emerald");
+ expect(dataChangeActionToBadgeClass("insert")).toContain("emerald");
+ });
+
+ it("returns blue class for update", () => {
+ expect(dataChangeActionToBadgeClass("update")).toContain("blue");
+ });
+
+ it("returns red class for delete", () => {
+ expect(dataChangeActionToBadgeClass("delete")).toContain("red");
+ });
+
+ it("returns muted for unknown action", () => {
+ expect(dataChangeActionToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+// ============================================================
+// Trend / distribution helpers
+// ============================================================
+
+describe("getMaxTrendCount", () => {
+ it("returns max count from trend", () => {
+ expect(getMaxTrendCount(sampleTrend)).toBe(30);
+ });
+
+ it("returns 0 for empty array", () => {
+ expect(getMaxTrendCount([])).toBe(0);
+ });
+
+ it("returns 0 for non-array input", () => {
+ expect(getMaxTrendCount([] as AuditTrendPoint[])).toBe(0);
+ });
+
+ it("ignores invalid count values", () => {
+ const trend = [
+ { date: "2026-07-01", count: 10 },
+ { date: "2026-07-02", count: Number.NaN },
+ { date: "2026-07-03", count: -5 },
+ { date: "2026-07-04", count: 20 },
+ ];
+ expect(getMaxTrendCount(trend)).toBe(20);
+ });
+});
+
+describe("getDistributionTotal", () => {
+ it("returns sum of all counts", () => {
+ expect(getDistributionTotal(sampleDistribution)).toBe(35);
+ });
+
+ it("returns 0 for empty array", () => {
+ expect(getDistributionTotal([])).toBe(0);
+ });
+
+ it("ignores invalid count values", () => {
+ const stats = [
+ { action: "a", count: 10 },
+ { action: "b", count: Number.NaN },
+ { action: "c", count: -5 },
+ ];
+ expect(getDistributionTotal(stats)).toBe(10);
+ });
+});
+
+describe("getDistributionPercent", () => {
+ it("returns percentage of count over total", () => {
+ expect(getDistributionPercent(25, 100)).toBe(25);
+ expect(getDistributionPercent(10, 40)).toBe(25);
+ });
+
+ it("returns 0 when total is 0", () => {
+ expect(getDistributionPercent(10, 0)).toBe(0);
+ });
+
+ it("returns 0 for invalid inputs", () => {
+ expect(getDistributionPercent(Number.NaN, 100)).toBe(0);
+ expect(getDistributionPercent(10, Number.NaN)).toBe(0);
+ expect(getDistributionPercent(10, -5)).toBe(0);
+ });
+});
+
+describe("getDistributionColor", () => {
+ it("returns hsl string for valid inputs", () => {
+ const color = getDistributionColor(0, 3);
+ expect(color).toMatch(/^hsl\(/);
+ });
+
+ it("returns muted for zero total", () => {
+ expect(getDistributionColor(0, 0)).toBe("hsl(var(--muted))");
+ });
+
+ it("distributes hues across total", () => {
+ const c0 = getDistributionColor(0, 2);
+ const c1 = getDistributionColor(1, 2);
+ expect(c0).not.toBe(c1);
+ });
+});
+
+// ============================================================
+// CSV helpers
+// ============================================================
+
+describe("toCsvCell", () => {
+ it("returns plain text as-is", () => {
+ expect(toCsvCell("hello")).toBe("hello");
+ expect(toCsvCell(123)).toBe("123");
+ });
+
+ it("returns empty string for null/undefined", () => {
+ expect(toCsvCell(null)).toBe("");
+ expect(toCsvCell(undefined)).toBe("");
+ });
+
+ it("quotes cells containing comma", () => {
+ expect(toCsvCell("a,b")).toBe('"a,b"');
+ });
+
+ it("quotes cells containing quote and doubles the quote", () => {
+ expect(toCsvCell('a"b')).toBe('"a""b"');
+ });
+
+ it("quotes cells containing newline", () => {
+ expect(toCsvCell("a\nb")).toBe('"a\nb"');
+ });
+});
+
+describe("toCsvRow", () => {
+ it("joins cells with comma", () => {
+ expect(toCsvRow(["a", "b", "c"])).toBe("a,b,c");
+ });
+
+ it("escapes cells with special characters", () => {
+ expect(toCsvRow(["a,b", "c"])).toBe('"a,b",c');
+ });
+
+ it("handles null/undefined cells as empty", () => {
+ expect(toCsvRow(["a", null, undefined, "b"])).toBe("a,,,b");
+ });
+});
+
+describe("auditLogsToCsv", () => {
+ it("produces CSV with header and rows", () => {
+ const csv = auditLogsToCsv([sampleAuditLog]);
+ const lines = csv.split("\n");
+ expect(lines).toHaveLength(2);
+ expect(lines[0]).toContain("timestamp");
+ expect(lines[0]).toContain("userId");
+ expect(lines[0]).toContain("userName");
+ expect(lines[1]).toContain("u-001");
+ expect(lines[1]).toContain("张三");
+ });
+
+ it("produces only header for empty input", () => {
+ const csv = auditLogsToCsv([]);
+ const lines = csv.split("\n");
+ expect(lines).toHaveLength(1);
+ expect(lines[0]).toContain("timestamp");
+ });
+
+ it("escapes details field with comma", () => {
+ const log: AuditLog = {
+ ...sampleAuditLog,
+ details: "创建,删除",
+ };
+ const csv = auditLogsToCsv([log]);
+ const lines = csv.split("\n");
+ expect(lines[1]).toContain('"创建,删除"');
+ });
+});
+
+describe("loginLogsToCsv", () => {
+ it("produces CSV with header and rows", () => {
+ const csv = loginLogsToCsv([sampleLoginLog]);
+ const lines = csv.split("\n");
+ expect(lines).toHaveLength(2);
+ expect(lines[0]).toContain("timestamp");
+ expect(lines[0]).toContain("userAgent");
+ expect(lines[1]).toContain("signin");
+ expect(lines[1]).toContain("success");
+ });
+
+ it("produces only header for empty input", () => {
+ const csv = loginLogsToCsv([]);
+ expect(csv.split("\n")).toHaveLength(1);
+ });
+});
+
+describe("dataChangeLogsToCsv", () => {
+ it("produces CSV with header and rows", () => {
+ const csv = dataChangeLogsToCsv([sampleDataChangeLog]);
+ const lines = csv.split("\n");
+ expect(lines).toHaveLength(2);
+ expect(lines[0]).toContain("tableName");
+ expect(lines[0]).toContain("recordId");
+ expect(lines[1]).toContain("users");
+ expect(lines[1]).toContain("update");
+ });
+
+ it("escapes changes field with JSON content", () => {
+ const csv = dataChangeLogsToCsv([sampleDataChangeLog]);
+ const lines = csv.split("\n");
+ // CSV 规范:包含引号/逗号的字段需用双引号包裹,内部引号翻倍转义
+ expect(lines[1]).toContain('"{""name"": ""张三""}"');
+ });
+
+ it("produces only header for empty input", () => {
+ const csv = dataChangeLogsToCsv([]);
+ expect(csv.split("\n")).toHaveLength(1);
+ });
+});
+
+describe("downloadCsv", () => {
+ it("returns false in non-browser environment", () => {
+ expect(downloadCsv("test.csv", "a,b,c")).toBe(false);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/audit-logs/audit-logs-list-client.tsx b/apps/portal-shell/src/features/admin/audit-logs/audit-logs-list-client.tsx
new file mode 100644
index 0000000..ec98fea
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/audit-logs/audit-logs-list-client.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+/**
+ * 审计日志列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 auditLogs(...):❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * - 模块选项 auditModuleOptions():❌ schema 无 → MSW 兜底
+ *
+ * URL 状态:?page=&module=&action=&status=&userId=&startDate=&endDate=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { ClipboardList, Download } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useAuditLogs, useAuditModuleOptions } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ FilterBar,
+ FilterSearchInput,
+} from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ auditActionToLabel,
+ auditLogsToCsv,
+ auditStatusToBadgeClass,
+ auditStatusToLabel,
+ downloadCsv,
+ formatAuditTimestamp,
+} from "@/features/admin/audit-logs/transformations";
+
+/** 状态选项常量(避免硬编码字符串) */
+const STATUS_OPTIONS = ["success", "failure", "error", "pending"] as const;
+
+/** 动作选项常量 */
+const ACTION_OPTIONS = [
+ "create",
+ "update",
+ "delete",
+ "read",
+ "login",
+ "logout",
+ "export",
+ "import",
+] as const;
+
+/** 每页条数 */
+const PAGE_SIZE = 20;
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function AuditLogsListClient(): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const page = Number(searchParams.get("page") ?? "1") || 1;
+ const moduleFilter = searchParams.get("module") ?? "";
+ const actionFilter = searchParams.get("action") ?? "";
+ const statusFilter = searchParams.get("status") ?? "";
+ const userId = searchParams.get("userId") ?? "";
+ const startDate = searchParams.get("startDate") ?? "";
+ const endDate = searchParams.get("endDate") ?? "";
+
+ // 模块选项(@contract-pending MSW 兜底)
+ const { data: moduleOptions } = useAuditModuleOptions();
+
+ // @contract-pending:MSW 兜底
+ // 注意:AuditLogFilter 仅支持 userId/action/resource,其余为 URL 状态待契约补齐
+ const { data, loading, error } = useAuditLogs(
+ {
+ userId: userId || null,
+ action: actionFilter || null,
+ resource: moduleFilter || null,
+ },
+ { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },
+ );
+
+ const items = data?.items ?? [];
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ if (key !== "page") {
+ params.delete("page");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/audit-logs?${params.toString()}`);
+ });
+ };
+
+ const handleExport = (): void => {
+ try {
+ const csv = auditLogsToCsv(items);
+ const ok = downloadCsv(`audit-logs-${Date.now()}.csv`, csv);
+ if (ok) {
+ notify.success(t("exportCsv"));
+ } else {
+ notify.error(tCommon("error.loadFailed", { message: "" }));
+ }
+ } catch (err) {
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+
+ updateQuery("userId", v)}
+ />
+
+
+
+ updateQuery("startDate", e.target.value)}
+ aria-label={t("startDate")}
+ className="h-9 rounded-md border border-input bg-background px-3 text-sm"
+ />
+ updateQuery("endDate", e.target.value)}
+ aria-label={t("endDate")}
+ className="h-9 rounded-md border border-input bg-background px-3 text-sm"
+ />
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={items.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: data?.total ?? 0 })}
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * 审计日志列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function AuditLogsTable({
+ items,
+}: {
+ items: ReadonlyArray<{
+ id: string;
+ userId: string;
+ userName: string;
+ action: string;
+ resource: string;
+ resourceId: string;
+ ip: string;
+ timestamp: string;
+ details: string;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.list");
+ return (
+
+
+
+
+ | {t("colTimestamp")} |
+ {t("colUserId")} |
+ {t("colUserName")} |
+ {t("colModule")} |
+ {t("colAction")} |
+ {t("colStatus")} |
+ {t("colIp")} |
+ {t("colDetails")} |
+
+
+
+ {items.map((log) => (
+
+ |
+ {formatAuditTimestamp(log.timestamp)}
+ |
+
+ {log.userId}
+ |
+ {log.userName} |
+ {log.resource} |
+ {auditActionToLabel(log.action)} |
+
+
+ |
+
+ {log.ip}
+ |
+ {log.details} |
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ * AuditLog 类型无独立 status 字段,根据 details 是否存在做基础推断,
+ * 真实契约补齐后切换为 log.status。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.list");
+ const label = status ? auditStatusToLabel(status) : "--";
+ const cls = auditStatusToBadgeClass(status || "unknown");
+ return (
+
+ {label === "--" ? t("allStatuses") : label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/audit-logs/audit-overview-client.tsx b/apps/portal-shell/src/features/admin/audit-logs/audit-overview-client.tsx
new file mode 100644
index 0000000..2b04106
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/audit-logs/audit-overview-client.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+/**
+ * 审计概览页 - 客户端组件(ARCHITECTURE.md §7.3 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - auditOverviewStats():❌ schema 无 → MSW 兜底(@contract-pending)
+ * - auditTrend(7):❌ schema 无 → MSW 兜底
+ * - dataChangeActionStats():❌ schema 无 → MSW 兜底
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { BarChart3 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAuditOverviewStats,
+ useAuditTrend,
+ useDataChangeActionStats,
+} from "@/lib/api";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import {
+ DetailPageShell,
+ DetailPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ dataChangeActionToLabel,
+ formatAuditDate,
+ getDistributionColor,
+ getDistributionPercent,
+ getDistributionTotal,
+ getMaxTrendCount,
+} from "@/features/admin/audit-logs/transformations";
+
+/**
+ * 审计概览客户端主体。需由 server page 包裹在 中。
+ */
+export function AuditOverviewClient(): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.overview");
+ const tCommon = useTranslations("common");
+
+ const {
+ data: stats,
+ loading: statsLoading,
+ error: statsError,
+ } = useAuditOverviewStats();
+ const { data: trend, loading: trendLoading } = useAuditTrend(7);
+ const { data: distribution, loading: distLoading } =
+ useDataChangeActionStats();
+
+ const isLoading = statsLoading || trendLoading || distLoading;
+ const hasError = Boolean(statsError);
+
+ if (hasError) {
+ return (
+
+
+
+ {tCommon("error.loadFailed", { message: String(statsError) })}
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const trendData = trend ?? [];
+ const distData = distribution ?? [];
+ const distTotal = getDistributionTotal(distData);
+ const maxCount = getMaxTrendCount(trendData);
+
+ return (
+
+ {/* 统计卡片 */}
+
+
+
+
+
+
+
+ {/* 近 7 天趋势 - 简易柱状图 */}
+
+
+ {t("trendTitle")}
+ {trendData.length === 0 ? (
+
+ {t("emptyTrend")}
+
+ ) : (
+
+ )}
+
+
+
+ {/* 数据变更动作分布 - 简易饼图 */}
+
+
+
+ {t("distributionTitle")}
+
+ {distData.length === 0 ? (
+
+ {t("emptyTrend")}
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * 趋势柱状图(纯 CSS 实现,不引入图表库)。
+ */
+function TrendBarChart({
+ data,
+ maxCount,
+ labelLast7,
+}: {
+ data: ReadonlyArray<{ date: string; count: number }>;
+ maxCount: number;
+ labelLast7: string;
+}): React.ReactElement {
+ const safeMax = maxCount > 0 ? maxCount : 1;
+ return (
+
+
{labelLast7}
+
+ {data.map((point) => {
+ const heightPct = (point.count / safeMax) * 100;
+ return (
+
+
+ {point.count}
+
+
+
+ {formatAuditDate(point.date)}
+
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * 动作分布图(纯 CSS 饼图 + 图例,不引入图表库)。
+ */
+function DistributionChart({
+ data,
+ total,
+ colAction,
+ colCount,
+}: {
+ data: ReadonlyArray<{ action: string; count: number }>;
+ total: number;
+ colAction: string;
+ colCount: string;
+}): React.ReactElement {
+ // 构建圆锥渐变实现饼图
+ const segments = data.map((stat, index) => {
+ const percent = getDistributionPercent(stat.count, total);
+ return {
+ ...stat,
+ percent,
+ color: getDistributionColor(index, data.length),
+ };
+ });
+
+ let cumulative = 0;
+ const gradientStops = segments
+ .map((seg) => {
+ const start = cumulative;
+ cumulative += seg.percent;
+ const end = cumulative;
+ return `${seg.color} ${start}% ${end}%`;
+ })
+ .join(", ");
+
+ return (
+
+ {/* 饼图 */}
+
+
+ {/* 图例表格 */}
+
+
+
+
+ | {colAction} |
+ {colCount} |
+ % |
+
+
+
+ {segments.map((seg) => (
+
+ |
+
+
+ {dataChangeActionToLabel(seg.action)}
+
+ |
+ {seg.count} |
+
+ {seg.percent.toFixed(1)}%
+ |
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/audit-logs/data-changes-client.tsx b/apps/portal-shell/src/features/admin/audit-logs/data-changes-client.tsx
new file mode 100644
index 0000000..63d4d81
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/audit-logs/data-changes-client.tsx
@@ -0,0 +1,315 @@
+"use client";
+
+/**
+ * 数据变更日志页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - dataChangeLogs(filter, pagination):❌ schema 无 → MSW 兜底(@contract-pending)
+ * - dataChangeTableOptions():❌ schema 无 → MSW 兜底
+ * - dataChangeStats():❌ schema 无 → MSW 兜底
+ *
+ * URL 状态:?page=&table=&action=&userId=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Database, Download } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useDataChangeLogs,
+ useDataChangeStats,
+ useDataChangeTableOptions,
+} from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ FilterBar,
+ FilterSearchInput,
+} from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ dataChangeActionToBadgeClass,
+ dataChangeActionToLabel,
+ dataChangeLogsToCsv,
+ downloadCsv,
+ formatAuditTimestamp,
+} from "@/features/admin/audit-logs/transformations";
+
+/** 数据变更动作选项(value + i18n key 映射) */
+const ACTION_OPTIONS = [
+ { value: "create", labelKey: "actionCreate" },
+ { value: "update", labelKey: "actionUpdate" },
+ { value: "delete", labelKey: "actionDelete" },
+] as const;
+
+/** 每页条数 */
+const PAGE_SIZE = 20;
+
+/**
+ * 数据变更日志客户端主体。需由 server page 包裹在 中。
+ */
+export function DataChangesClient(): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.dataChanges");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const page = Number(searchParams.get("page") ?? "1") || 1;
+ const tableFilter = searchParams.get("table") ?? "";
+ const actionFilter = searchParams.get("action") ?? "";
+ const userId = searchParams.get("userId") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data: tableOptions } = useDataChangeTableOptions();
+ const { data: stats } = useDataChangeStats();
+ const { data, loading, error } = useDataChangeLogs(
+ {
+ tableName: tableFilter || null,
+ action: actionFilter || null,
+ userId: userId || null,
+ },
+ { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },
+ );
+
+ const items = data?.items ?? [];
+ const statsData = stats ?? [];
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ if (key !== "page") {
+ params.delete("page");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/audit-logs/data-changes?${params.toString()}`);
+ });
+ };
+
+ const handleExport = (): void => {
+ try {
+ const csv = dataChangeLogsToCsv(items);
+ const ok = downloadCsv(`data-change-logs-${Date.now()}.csv`, csv);
+ if (ok) {
+ notify.success(t("exportCsv"));
+ } else {
+ notify.error(tCommon("error.loadFailed", { message: "" }));
+ }
+ } catch (err) {
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+
+ updateQuery("userId", v)}
+ />
+
+
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={items.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: data?.total ?? 0 })}
+
+ }
+ >
+ {/* 变更统计卡片 */}
+ {statsData.length > 0 ? (
+
+
+ {t("statsTitle")}
+
+
+
+
+ |
+ {t("statsAction")}
+ |
+
+ {t("statsCount")}
+ |
+
+ {t("statsLastChange")}
+ |
+
+
+
+ {statsData.map((stat) => (
+
+ |
+
+ |
+ {stat.count} |
+
+ {formatAuditTimestamp(stat.lastChangeAt)}
+ |
+
+ ))}
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+/**
+ * 数据变更日志表格(纯展示组件)。
+ */
+function DataChangesTable({
+ items,
+}: {
+ items: ReadonlyArray<{
+ id: string;
+ tableName: string;
+ recordId: string;
+ action: string;
+ userId: string;
+ userName: string;
+ changes: string;
+ timestamp: string;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.dataChanges");
+ return (
+
+
+
+
+ | {t("colTimestamp")} |
+ {t("colTable")} |
+ {t("colRecordId")} |
+ {t("colAction")} |
+ {t("colUserId")} |
+ {t("colUserName")} |
+ {t("colChanges")} |
+
+
+
+ {items.map((log) => (
+
+ |
+ {formatAuditTimestamp(log.timestamp)}
+ |
+ {log.tableName} |
+
+ {log.recordId}
+ |
+
+
+ |
+
+ {log.userId}
+ |
+ {log.userName} |
+
+ {log.changes}
+ |
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 数据变更动作徽章。
+ */
+function DataChangeActionBadge({
+ action,
+}: {
+ action: string;
+}): React.ReactElement {
+ const label = dataChangeActionToLabel(action);
+ const cls = dataChangeActionToBadgeClass(action);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/audit-logs/login-logs-client.tsx b/apps/portal-shell/src/features/admin/audit-logs/login-logs-client.tsx
new file mode 100644
index 0000000..a4ab4b6
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/audit-logs/login-logs-client.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+/**
+ * 登录日志页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - loginLogs(filter, pagination):❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * URL 状态:?page=&action=&status=&userId=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Download, LogIn } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useLoginLogs } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ FilterBar,
+ FilterSearchInput,
+} from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ downloadCsv,
+ formatAuditTimestamp,
+ loginActionToLabel,
+ loginLogsToCsv,
+ loginStatusToBadgeClass,
+ loginStatusToLabel,
+} from "@/features/admin/audit-logs/transformations";
+
+/** 登录动作选项(value + i18n key 映射) */
+const ACTION_OPTIONS = [
+ { value: "signin", labelKey: "actionSignin" },
+ { value: "signout", labelKey: "actionSignout" },
+ { value: "signup", labelKey: "actionSignup" },
+] as const;
+
+/** 登录状态选项(value + i18n key 映射) */
+const STATUS_OPTIONS = [
+ { value: "success", labelKey: "statusSuccess" },
+ { value: "failure", labelKey: "statusFailure" },
+] as const;
+
+/** 每页条数 */
+const PAGE_SIZE = 20;
+
+/**
+ * 登录日志客户端主体。需由 server page 包裹在 中。
+ */
+export function LoginLogsClient(): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.loginLogs");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const page = Number(searchParams.get("page") ?? "1") || 1;
+ const actionFilter = searchParams.get("action") ?? "";
+ const statusFilter = searchParams.get("status") ?? "";
+ const userId = searchParams.get("userId") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useLoginLogs(
+ {
+ action: actionFilter || null,
+ status: statusFilter || null,
+ userId: userId || null,
+ },
+ { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },
+ );
+
+ const items = data?.items ?? [];
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ if (key !== "page") {
+ params.delete("page");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/audit-logs/login-logs?${params.toString()}`);
+ });
+ };
+
+ const handleExport = (): void => {
+ try {
+ const csv = loginLogsToCsv(items);
+ const ok = downloadCsv(`login-logs-${Date.now()}.csv`, csv);
+ if (ok) {
+ notify.success(t("exportCsv"));
+ } else {
+ notify.error(tCommon("error.loadFailed", { message: "" }));
+ }
+ } catch (err) {
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+
+ updateQuery("userId", v)}
+ />
+
+
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={items.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: data?.total ?? 0 })}
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * 登录日志表格(纯展示组件)。
+ */
+function LoginLogsTable({
+ items,
+}: {
+ items: ReadonlyArray<{
+ id: string;
+ userId: string;
+ userName: string;
+ action: string;
+ status: string;
+ ip: string;
+ userAgent: string;
+ timestamp: string;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("admin.auditLogs.loginLogs");
+ return (
+
+
+
+
+ | {t("colTimestamp")} |
+ {t("colUserId")} |
+ {t("colUserName")} |
+ {t("colAction")} |
+ {t("colStatus")} |
+ {t("colIp")} |
+ {t("colUserAgent")} |
+
+
+
+ {items.map((log) => (
+
+ |
+ {formatAuditTimestamp(log.timestamp)}
+ |
+
+ {log.userId}
+ |
+ {log.userName} |
+ {loginActionToLabel(log.action)} |
+
+
+ |
+
+ {log.ip}
+ |
+
+ {log.userAgent}
+ |
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 登录状态徽章。
+ */
+function LoginStatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = loginStatusToLabel(status);
+ const cls = loginStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/audit-logs/transformations.ts b/apps/portal-shell/src/features/admin/audit-logs/transformations.ts
new file mode 100644
index 0000000..af8722a
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/audit-logs/transformations.ts
@@ -0,0 +1,366 @@
+/**
+ * Audit Logs 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射/CSV 导出函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type {
+ AuditLog,
+ AuditTrendPoint,
+ DataChangeActionStat,
+ DataChangeLog,
+ LoginLog,
+} from "@/lib/api";
+
+// ============================================================
+// 通用日期格式化
+// ============================================================
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效时返回占位符。
+ */
+export function formatAuditTimestamp(
+ isoDate: string | null | undefined,
+): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * 格式化日期为纯日期展示(zh-CN,仅年月日)。用于趋势图横轴等。
+ * 输入无效时返回占位符。
+ */
+export function formatAuditDate(isoDate: string | null | undefined): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+}
+
+// ============================================================
+// 审计日志(通用)动作 / 状态映射
+// ============================================================
+
+/** 审计动作中文标签映射(覆盖常见动作,未命中回退原值) */
+export const AUDIT_ACTION_LABEL: Record = {
+ create: "创建",
+ update: "更新",
+ delete: "删除",
+ read: "查看",
+ login: "登录",
+ logout: "登出",
+ signup: "注册",
+ export: "导出",
+ import: "导入",
+ revoke: "撤销",
+ approve: "审批",
+ reject: "拒绝",
+};
+
+/** 将审计动作代码映射为中文标签。未知值回退为原始值。 */
+export function auditActionToLabel(action: string): string {
+ return AUDIT_ACTION_LABEL[action] ?? action;
+}
+
+/** 审计状态中文标签映射 */
+export const AUDIT_STATUS_LABEL: Record = {
+ success: "成功",
+ failure: "失败",
+ error: "错误",
+ pending: "进行中",
+};
+
+/** 将审计状态代码映射为中文标签。未知值回退为原始值。 */
+export function auditStatusToLabel(status: string): string {
+ return AUDIT_STATUS_LABEL[status] ?? status;
+}
+
+/**
+ * 根据审计状态返回 Tailwind 徽章语义类名。
+ */
+export function auditStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "success":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "failure":
+ case "error":
+ return "bg-red-500/10 text-red-600 dark:text-red-400";
+ case "pending":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+// ============================================================
+// 登录日志动作 / 状态映射
+// ============================================================
+
+/** 登录动作中文标签映射 */
+export const LOGIN_ACTION_LABEL: Record = {
+ signin: "登录",
+ signout: "登出",
+ signup: "注册",
+ login: "登录",
+ logout: "登出",
+};
+
+/** 将登录动作代码映射为中文标签。未知值回退为原始值。 */
+export function loginActionToLabel(action: string): string {
+ return LOGIN_ACTION_LABEL[action] ?? action;
+}
+
+/** 登录状态中文标签映射 */
+export const LOGIN_STATUS_LABEL: Record = {
+ success: "成功",
+ failure: "失败",
+ error: "错误",
+};
+
+/** 将登录状态代码映射为中文标签。未知值回退为原始值。 */
+export function loginStatusToLabel(status: string): string {
+ return LOGIN_STATUS_LABEL[status] ?? status;
+}
+
+/**
+ * 根据登录状态返回 Tailwind 徽章语义类名。
+ */
+export function loginStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "success":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "failure":
+ case "error":
+ return "bg-red-500/10 text-red-600 dark:text-red-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+// ============================================================
+// 数据变更日志动作映射
+// ============================================================
+
+/** 数据变更动作中文标签映射 */
+export const DATA_CHANGE_ACTION_LABEL: Record = {
+ create: "创建",
+ update: "更新",
+ delete: "删除",
+ insert: "插入",
+};
+
+/** 将数据变更动作代码映射为中文标签。未知值回退为原始值。 */
+export function dataChangeActionToLabel(action: string): string {
+ return DATA_CHANGE_ACTION_LABEL[action] ?? action;
+}
+
+/**
+ * 根据数据变更动作返回 Tailwind 徽章语义类名。
+ */
+export function dataChangeActionToBadgeClass(action: string): string {
+ switch (action) {
+ case "create":
+ case "insert":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "update":
+ return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
+ case "delete":
+ return "bg-red-500/10 text-red-600 dark:text-red-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+// ============================================================
+// 趋势 / 分布数据辅助
+// ============================================================
+
+/**
+ * 从趋势数据中获取最大 count,用于柱状图纵向比例。
+ * 空数组或无效值返回 0。
+ */
+export function getMaxTrendCount(trend: AuditTrendPoint[]): number {
+ if (!Array.isArray(trend) || trend.length === 0) return 0;
+ return trend.reduce((max, point) => {
+ const count = Number(point?.count);
+ if (!Number.isFinite(count) || count < 0) return max;
+ return count > max ? count : max;
+ }, 0);
+}
+
+/**
+ * 计算分布数据的总数。
+ */
+export function getDistributionTotal(stats: DataChangeActionStat[]): number {
+ if (!Array.isArray(stats) || stats.length === 0) return 0;
+ return stats.reduce((sum, stat) => {
+ const count = Number(stat?.count);
+ if (!Number.isFinite(count) || count < 0) return sum;
+ return sum + count;
+ }, 0);
+}
+
+/**
+ * 计算分布项的百分比(0-100)。总数为 0 时返回 0。
+ */
+export function getDistributionPercent(count: number, total: number): number {
+ if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) {
+ return 0;
+ }
+ return (count / total) * 100;
+}
+
+/**
+ * HSL 色环上等距取色,用于饼图分段。
+ * 返回 hsl 字符串,避免硬编码 hex(对齐设计令牌规范)。
+ */
+export function getDistributionColor(index: number, total: number): string {
+ if (total <= 0) return "hsl(var(--muted))";
+ const hue = (index * 360) / total;
+ return `hsl(${hue.toFixed(0)}, 65%, 55%)`;
+}
+
+// ============================================================
+// CSV 导出辅助
+// ============================================================
+
+/**
+ * 转义 CSV 单元格:包含逗号、引号、换行符时用双引号包裹,内部引号翻倍。
+ */
+export function toCsvCell(value: string | number | null | undefined): string {
+ if (value === null || value === undefined) return "";
+ const text = String(value);
+ if (/[",\n\r]/.test(text)) {
+ return `"${text.replace(/"/g, '""')}"`;
+ }
+ return text;
+}
+
+/**
+ * 将一组单元格值拼接为一行 CSV。
+ */
+export function toCsvRow(
+ cells: Array,
+): string {
+ return cells.map(toCsvCell).join(",");
+}
+
+/**
+ * 将审计日志数组导出为 CSV 字符串(含表头)。
+ */
+export function auditLogsToCsv(logs: AuditLog[]): string {
+ const header = toCsvRow([
+ "timestamp",
+ "userId",
+ "userName",
+ "action",
+ "resource",
+ "resourceId",
+ "ip",
+ "details",
+ ]);
+ const rows = logs.map((log) =>
+ toCsvRow([
+ log.timestamp,
+ log.userId,
+ log.userName,
+ log.action,
+ log.resource,
+ log.resourceId,
+ log.ip,
+ log.details,
+ ]),
+ );
+ return [header, ...rows].join("\n");
+}
+
+/**
+ * 将登录日志数组导出为 CSV 字符串(含表头)。
+ */
+export function loginLogsToCsv(logs: LoginLog[]): string {
+ const header = toCsvRow([
+ "timestamp",
+ "userId",
+ "userName",
+ "action",
+ "status",
+ "ip",
+ "userAgent",
+ ]);
+ const rows = logs.map((log) =>
+ toCsvRow([
+ log.timestamp,
+ log.userId,
+ log.userName,
+ log.action,
+ log.status,
+ log.ip,
+ log.userAgent,
+ ]),
+ );
+ return [header, ...rows].join("\n");
+}
+
+/**
+ * 将数据变更日志数组导出为 CSV 字符串(含表头)。
+ */
+export function dataChangeLogsToCsv(logs: DataChangeLog[]): string {
+ const header = toCsvRow([
+ "timestamp",
+ "tableName",
+ "recordId",
+ "action",
+ "userId",
+ "userName",
+ "changes",
+ ]);
+ const rows = logs.map((log) =>
+ toCsvRow([
+ log.timestamp,
+ log.tableName,
+ log.recordId,
+ log.action,
+ log.userId,
+ log.userName,
+ log.changes,
+ ]),
+ );
+ return [header, ...rows].join("\n");
+}
+
+/**
+ * 将字符串内容触发浏览器下载为 CSV 文件。
+ * 返回是否成功触发下载。
+ */
+export function downloadCsv(filename: string, content: string): boolean {
+ if (
+ typeof window === "undefined" ||
+ typeof URL.createObjectURL !== "function"
+ ) {
+ return false;
+ }
+ const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ return true;
+}
diff --git a/apps/portal-shell/src/features/admin/course-plans/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/course-plans/__tests__/transformations.test.ts
new file mode 100644
index 0000000..4dfb793
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/course-plans/__tests__/transformations.test.ts
@@ -0,0 +1,156 @@
+/**
+ * Admin Course Plans 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ ADMIN_COURSE_PLAN_STATUS_LABEL,
+ ADMIN_COURSE_PLAN_STATUS_OPTIONS,
+ adminCoursePlanStatusToBadgeClass,
+ displayText,
+ formatAdminCoursePlanDate,
+ formatAdminCoursePlanStatus,
+ hasContent,
+ isAdminCoursePlanArchived,
+ isAdminCoursePlanEditable,
+ isValidAdminCoursePlanStatus,
+} from "../transformations";
+
+describe("formatAdminCoursePlanStatus", () => {
+ it("maps known statuses to Chinese labels", () => {
+ expect(formatAdminCoursePlanStatus("DRAFT")).toBe("草稿");
+ expect(formatAdminCoursePlanStatus("PUBLISHED")).toBe("已发布");
+ expect(formatAdminCoursePlanStatus("ARCHIVED")).toBe("已归档");
+ });
+
+ it("returns original value for unknown status", () => {
+ expect(formatAdminCoursePlanStatus("other")).toBe("other");
+ expect(formatAdminCoursePlanStatus("")).toBe("");
+ });
+
+ it("ADMIN_COURSE_PLAN_STATUS_LABEL covers 3 standard statuses", () => {
+ expect(Object.keys(ADMIN_COURSE_PLAN_STATUS_LABEL)).toHaveLength(3);
+ });
+});
+
+describe("ADMIN_COURSE_PLAN_STATUS_OPTIONS", () => {
+ it("contains DRAFT, PUBLISHED, ARCHIVED in order", () => {
+ expect(ADMIN_COURSE_PLAN_STATUS_OPTIONS).toEqual([
+ "DRAFT",
+ "PUBLISHED",
+ "ARCHIVED",
+ ]);
+ });
+});
+
+describe("adminCoursePlanStatusToBadgeClass", () => {
+ it("returns muted class for DRAFT", () => {
+ expect(adminCoursePlanStatusToBadgeClass("DRAFT")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+
+ it("returns emerald class for PUBLISHED", () => {
+ expect(adminCoursePlanStatusToBadgeClass("PUBLISHED")).toContain("emerald");
+ });
+
+ it("returns amber class for ARCHIVED", () => {
+ expect(adminCoursePlanStatusToBadgeClass("ARCHIVED")).toContain("amber");
+ });
+
+ it("returns muted class for unknown status", () => {
+ expect(adminCoursePlanStatusToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ expect(adminCoursePlanStatusToBadgeClass("")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+describe("formatAdminCoursePlanDate", () => {
+ it("formats valid ISO date string with time", () => {
+ const result = formatAdminCoursePlanDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatAdminCoursePlanDate(null)).toBe("--");
+ expect(formatAdminCoursePlanDate(undefined)).toBe("--");
+ expect(formatAdminCoursePlanDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatAdminCoursePlanDate("not-a-date")).toBe("--");
+ });
+});
+
+describe("isAdminCoursePlanEditable", () => {
+ it("returns true for DRAFT and PUBLISHED", () => {
+ expect(isAdminCoursePlanEditable("DRAFT")).toBe(true);
+ expect(isAdminCoursePlanEditable("PUBLISHED")).toBe(true);
+ });
+
+ it("returns false for ARCHIVED and unknown", () => {
+ expect(isAdminCoursePlanEditable("ARCHIVED")).toBe(false);
+ expect(isAdminCoursePlanEditable("unknown")).toBe(false);
+ });
+});
+
+describe("isAdminCoursePlanArchived", () => {
+ it("returns true only for ARCHIVED", () => {
+ expect(isAdminCoursePlanArchived("ARCHIVED")).toBe(true);
+ expect(isAdminCoursePlanArchived("DRAFT")).toBe(false);
+ expect(isAdminCoursePlanArchived("PUBLISHED")).toBe(false);
+ expect(isAdminCoursePlanArchived("unknown")).toBe(false);
+ });
+});
+
+describe("isValidAdminCoursePlanStatus", () => {
+ it("returns true for valid statuses", () => {
+ expect(isValidAdminCoursePlanStatus("DRAFT")).toBe(true);
+ expect(isValidAdminCoursePlanStatus("PUBLISHED")).toBe(true);
+ expect(isValidAdminCoursePlanStatus("ARCHIVED")).toBe(true);
+ });
+
+ it("returns false for invalid statuses", () => {
+ expect(isValidAdminCoursePlanStatus("other")).toBe(false);
+ expect(isValidAdminCoursePlanStatus("")).toBe(false);
+ expect(isValidAdminCoursePlanStatus("IN_PROGRESS")).toBe(false);
+ });
+});
+
+describe("hasContent", () => {
+ it("returns true for non-empty string", () => {
+ expect(hasContent("课程计划简介")).toBe(true);
+ expect(hasContent(" x ")).toBe(true);
+ });
+
+ it("returns false for null/undefined/empty/whitespace", () => {
+ expect(hasContent(null)).toBe(false);
+ expect(hasContent(undefined)).toBe(false);
+ expect(hasContent("")).toBe(false);
+ expect(hasContent(" ")).toBe(false);
+ });
+});
+
+describe("displayText", () => {
+ it("returns original value for non-empty string", () => {
+ expect(displayText("课程计划简介")).toBe("课程计划简介");
+ });
+
+ it("returns default placeholder for empty/whitespace", () => {
+ expect(displayText(null)).toBe("-");
+ expect(displayText(undefined)).toBe("-");
+ expect(displayText("")).toBe("-");
+ expect(displayText(" ")).toBe("-");
+ });
+
+ it("returns custom placeholder when provided", () => {
+ expect(displayText(null, "暂无")).toBe("暂无");
+ expect(displayText("", "暂无")).toBe("暂无");
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/course-plans/course-plan-create-client.tsx b/apps/portal-shell/src/features/admin/course-plans/course-plan-create-client.tsx
new file mode 100644
index 0000000..7b17408
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/course-plans/course-plan-create-client.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+/**
+ * 管理端课程计划新建表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - mutation createCoursePlan(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
+ * - 选项数据 grades / adminClasses / teacherOptions / academicYears:MSW 兜底
+ * - 选项数据 subjectOptions:❌ 暂无 hook → 文本输入兜底(@contract-pending)
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:选项数据加载时 Select 显示"加载中"
+ * - error:errorSummary 表单级错误
+ * - success:notify.success + router.push 回详情页
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { ClipboardList } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAcademicYears,
+ useAdminClasses,
+ useCreateCoursePlan,
+ useGrades,
+ useTeacherOptions,
+ type CreateCoursePlanInput,
+} from "@/lib/api";
+import { FormPageShell } from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ ADMIN_COURSE_PLAN_STATUS_OPTIONS,
+ formatAdminCoursePlanStatus,
+} from "@/features/admin/course-plans/transformations";
+
+/**
+ * 表单客户端主体。需由 server page 包裹在 中。
+ */
+export function CoursePlanCreateClient(): React.ReactElement {
+ const t = useTranslations("admin.coursePlans.create");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const [, startTransition] = useTransition();
+
+ // @contract-pending:MSW 兜底
+ const { data: grades, loading: gradesLoading } = useGrades();
+ const { data: classes, loading: classesLoading } = useAdminClasses();
+ const { data: teachers, loading: teachersLoading } = useTeacherOptions();
+ const { data: academicYears, loading: yearsLoading } = useAcademicYears();
+ // @contract-pending:MSW 兜底
+ const { run: createCoursePlan, loading: submitting } = useCreateCoursePlan();
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [gradeId, setGradeId] = useState("");
+ const [classId, setClassId] = useState("");
+ const [subjectId, setSubjectId] = useState("");
+ const [teacherId, setTeacherId] = useState("");
+ const [academicYearId, setAcademicYearId] = useState("");
+ const [status, setStatus] = useState("DRAFT");
+ const [formError, setFormError] = useState(null);
+
+ const handleSubmit = async (): Promise => {
+ setFormError(null);
+
+ if (!title.trim()) {
+ setFormError(t("errorTitleRequired"));
+ return;
+ }
+ if (!gradeId) {
+ setFormError(t("errorGradeRequired"));
+ return;
+ }
+ if (!classId) {
+ setFormError(t("errorClassRequired"));
+ return;
+ }
+ if (!subjectId.trim()) {
+ setFormError(t("errorSubjectRequired"));
+ return;
+ }
+
+ // @contract-pending:CreateCoursePlanInput 仅支持 name/gradeId/subjectId/semester/description
+ // classId / teacherId / academicYearId / status 暂无对应字段,由后端补齐后扩展输入类型
+ const input: CreateCoursePlanInput = {
+ name: title.trim(),
+ gradeId,
+ subjectId: subjectId.trim(),
+ semester: academicYearId || "current",
+ description: description.trim() || undefined,
+ };
+
+ try {
+ const result = await createCoursePlan(input);
+ notify.success(t("success"));
+ startTransition(() => {
+ router.push(`/shell/admin/course-plans/${result.id}`);
+ });
+ } catch (err) {
+ setFormError(`${t("error")}: ${String(err)}`);
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ return (
+ }
+ backHref="/shell/admin/course-plans"
+ onSubmit={handleSubmit}
+ submitting={submitting}
+ submitLabel={t("submit")}
+ cancelLabel={t("cancel")}
+ errorSummary={
+ formError ? (
+ {formError}
+ ) : undefined
+ }
+ >
+ {/* 标题 */}
+
+ setTitle(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder={t("fieldTitle")}
+ required
+ />
+
+
+ {/* 描述 */}
+
+
+
+
+ {/* 年级 */}
+
+
+
+
+ {/* 班级 */}
+
+
+
+
+
+
+ {/* 科目(@contract-pending:无 useSubjectOptions hook,文本输入兜底) */}
+
+ setSubjectId(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder="sub-math"
+ required
+ />
+
+
+ {/* 教师 */}
+
+
+
+
+
+
+ {/* 学年 */}
+
+
+
+
+ {/* 状态 */}
+
+
+
+
+
+ {/* @contract-pending 提示 */}
+ {t("contractPending")}
+
+ );
+}
+
+/**
+ * 表单字段容器(label + children)。
+ */
+function FormField({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/course-plans/course-plan-detail-client.tsx b/apps/portal-shell/src/features/admin/course-plans/course-plan-detail-client.tsx
new file mode 100644
index 0000000..71615d5
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/course-plans/course-plan-detail-client.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+/**
+ * 管理端课程计划详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 单查 adminCoursePlan(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#admin-course-plan-detail
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:DetailPageSkeleton
+ * - error:errorNode 局部降级
+ * - notFound:data 为 null 时显示空态节点
+ *
+ * 与教师域 course-plan-detail-client 的差异:
+ * - 管理端 scope.isAdmin=true,展示全校视角字段(班级/科目/教师/学年)
+ * - 不展示教师域的"单元进度/教学目标"等教师私有字段(AdminCoursePlan 仅含 content)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { ClipboardList } from "lucide-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { useAdminCoursePlan } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import {
+ DetailPageShell,
+ DetailPageSkeleton,
+ DetailSection,
+ DetailField,
+} from "@/shared/components/page-templates";
+import {
+ adminCoursePlanStatusToBadgeClass,
+ displayText,
+ formatAdminCoursePlanDate,
+ formatAdminCoursePlanStatus,
+ hasContent,
+ isAdminCoursePlanEditable,
+} from "@/features/admin/course-plans/transformations";
+
+/**
+ * 详情客户端主体。需由 server page 包裹在 中。
+ */
+export function CoursePlanDetailClient(): React.ReactElement {
+ const t = useTranslations("admin.coursePlans.detail");
+ const tCommon = useTranslations("common");
+ const params = useParams<{ id: string }>();
+ const planId = params?.id ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminCoursePlan(planId);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ ) : undefined;
+
+ return (
+ }
+ backHref="/shell/admin/course-plans"
+ actions={
+ data && isAdminCoursePlanEditable(data.status) ? (
+
+ ) : null
+ }
+ loading={loading}
+ loadingNode={}
+ errorNode={errorNode}
+ emptyNode={
+ !loading && !error && !data ? (
+
+ {t("notFound")}
+
+ ) : undefined
+ }
+ >
+ {data ? : null}
+
+ );
+}
+
+/**
+ * 详情内容区(基本信息 + 计划内容)。
+ */
+function CoursePlanDetailBody({
+ detail,
+}: {
+ detail: NonNullable["data"]>;
+}): React.ReactElement {
+ const t = useTranslations("admin.coursePlans.detail");
+ return (
+ <>
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {hasContent(detail.content) ? (
+ {detail.content}
+ ) : (
+ {t("emptyResources")}
+ )}
+
+ >
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatAdminCoursePlanStatus(status);
+ const cls = adminCoursePlanStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/course-plans/course-plan-edit-client.tsx b/apps/portal-shell/src/features/admin/course-plans/course-plan-edit-client.tsx
new file mode 100644
index 0000000..38c35a4
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/course-plans/course-plan-edit-client.tsx
@@ -0,0 +1,353 @@
+"use client";
+
+/**
+ * 管理端课程计划编辑表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 单查 adminCoursePlan(id: ID!) ❌ schema 无 → MSW 兜底(用于表单预填,@contract-pending)
+ * - mutation updateCoursePlan(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
+ * - 选项数据 grades / adminClasses / teacherOptions / academicYears:MSW 兜底
+ * - 选项数据 subjectOptions:❌ 暂无 hook → 文本输入兜底(@contract-pending)
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:FormPageSkeleton(加载预填数据)
+ * - error:errorSummary 表单级错误
+ * - success:notify.success + router.push 回详情页
+ *
+ * 与 course-plan-create-client 的差异:预填表单 + 调用 updateCoursePlan(input)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { ClipboardList } from "lucide-react";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useState, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAcademicYears,
+ useAdminClasses,
+ useAdminCoursePlan,
+ useGrades,
+ useTeacherOptions,
+ useUpdateCoursePlan,
+ type UpdateCoursePlanInput,
+} from "@/lib/api";
+import { FormPageShell } from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ ADMIN_COURSE_PLAN_STATUS_OPTIONS,
+ formatAdminCoursePlanStatus,
+ isAdminCoursePlanEditable,
+} from "@/features/admin/course-plans/transformations";
+
+/**
+ * 编辑表单客户端主体。需由 server page 包裹在 中。
+ */
+export function CoursePlanEditClient(): React.ReactElement {
+ const t = useTranslations("admin.coursePlans.edit");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const params = useParams<{ id: string }>();
+ const planId = params?.id ?? "";
+ const [, startTransition] = useTransition();
+
+ // @contract-pending:MSW 兜底(预填数据)
+ const { data, loading, error } = useAdminCoursePlan(planId);
+
+ // @contract-pending:MSW 兜底
+ const { data: grades, loading: gradesLoading } = useGrades();
+ const { data: classes, loading: classesLoading } = useAdminClasses();
+ const { data: teachers, loading: teachersLoading } = useTeacherOptions();
+ const { data: academicYears, loading: yearsLoading } = useAcademicYears();
+ // @contract-pending:MSW 兜底
+ const { run: updateCoursePlan, loading: submitting } = useUpdateCoursePlan();
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [gradeId, setGradeId] = useState("");
+ const [classId, setClassId] = useState("");
+ const [subjectId, setSubjectId] = useState("");
+ const [teacherId, setTeacherId] = useState("");
+ const [academicYearId, setAcademicYearId] = useState("");
+ const [status, setStatus] = useState("DRAFT");
+ const [formError, setFormError] = useState(null);
+ const [initialized, setInitialized] = useState(false);
+
+ // 首次拿到数据时初始化表单
+ // 注意:AdminCoursePlan 不含 gradeId 字段,gradeId 由用户在表单中重新选择(@contract-pending)
+ useEffect(() => {
+ if (data && !initialized) {
+ setTitle(data.name);
+ setDescription(data.content ?? "");
+ setClassId(data.classId ?? "");
+ setSubjectId(data.subjectId ?? "");
+ setTeacherId(data.teacherId ?? "");
+ setAcademicYearId(data.academicYearId ?? "");
+ setStatus(data.status ?? "DRAFT");
+ setInitialized(true);
+ }
+ }, [data, initialized]);
+
+ const handleFormSubmit = async (): Promise => {
+ setFormError(null);
+
+ if (!title.trim()) {
+ setFormError(t("errorTitleRequired"));
+ return;
+ }
+ if (!gradeId) {
+ setFormError(t("errorGradeRequired"));
+ return;
+ }
+ if (!classId) {
+ setFormError(t("errorClassRequired"));
+ return;
+ }
+ if (!subjectId.trim()) {
+ setFormError(t("errorSubjectRequired"));
+ return;
+ }
+
+ // @contract-pending:UpdateCoursePlanInput 仅支持 id/name/description/objectives/status
+ // gradeId / classId / subjectId / teacherId / academicYearId 暂无对应字段,由后端补齐后扩展输入类型
+ const input: UpdateCoursePlanInput = {
+ id: planId,
+ name: title.trim(),
+ description: description.trim() || undefined,
+ status: status as UpdateCoursePlanInput["status"],
+ };
+
+ try {
+ await updateCoursePlan(input);
+ notify.success(t("success"));
+ startTransition(() => {
+ router.push(`/shell/admin/course-plans/${planId}`);
+ });
+ } catch (err) {
+ setFormError(`${t("error")}: ${String(err)}`);
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ }
+ };
+
+ // 加载中:渲染骨架
+ if (loading) {
+ return (
+ }
+ backHref={`/shell/admin/course-plans/${planId}`}
+ loading
+ />
+ );
+ }
+
+ // 错误或无数据:渲染错误占位
+ if (error || (!data && !loading)) {
+ return (
+ }
+ backHref={`/shell/admin/course-plans/${planId}`}
+ errorSummary={
+
+ {error
+ ? tCommon("error.loadFailed", { message: String(error) })
+ : t("notFound")}
+
+ }
+ />
+ );
+ }
+
+ // 已归档计划不可编辑:渲染提示
+ if (data && !isAdminCoursePlanEditable(data.status)) {
+ return (
+ }
+ backHref={`/shell/admin/course-plans/${planId}`}
+ errorSummary={
+ {t("notFound")}
+ }
+ />
+ );
+ }
+
+ return (
+ }
+ backHref={`/shell/admin/course-plans/${planId}`}
+ onSubmit={handleFormSubmit}
+ submitting={submitting}
+ submitLabel={t("submit")}
+ cancelLabel={t("cancel")}
+ errorSummary={
+ formError ? (
+ {formError}
+ ) : undefined
+ }
+ >
+ {/* 标题 */}
+
+ setTitle(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder={t("fieldTitle")}
+ required
+ />
+
+
+ {/* 描述 */}
+
+
+
+
+ {/* 年级 */}
+
+
+
+
+ {/* 班级 */}
+
+
+
+
+
+
+ {/* 科目(@contract-pending:无 useSubjectOptions hook,文本输入兜底) */}
+
+ setSubjectId(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder="sub-math"
+ required
+ />
+
+
+ {/* 教师 */}
+
+
+
+
+
+
+ {/* 学年 */}
+
+
+
+
+ {/* 状态 */}
+
+
+
+
+
+ );
+}
+
+/**
+ * 表单字段容器(label + children)。
+ */
+function FormField({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/course-plans/course-plans-list-client.tsx b/apps/portal-shell/src/features/admin/course-plans/course-plans-list-client.tsx
new file mode 100644
index 0000000..86109cb
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/course-plans/course-plans-list-client.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+/**
+ * 管理端课程计划列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 adminCoursePlans(status):❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#admin-course-plans-list
+ *
+ * URL 状态:?search=&status=&page=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 与教师域 course-plan-list-client 的差异:
+ * - 管理端 scope.isAdmin=true,展示全校课程计划
+ * - 字段维度:班级 / 科目 / 教师 / 学年(聚合视图),不展示教师域的"进度/单元"
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { ClipboardList } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useAdminCoursePlans, type AdminCoursePlanListItem } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ ADMIN_COURSE_PLAN_STATUS_OPTIONS,
+ adminCoursePlanStatusToBadgeClass,
+ formatAdminCoursePlanDate,
+ formatAdminCoursePlanStatus,
+ isValidAdminCoursePlanStatus,
+} from "@/features/admin/course-plans/transformations";
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function CoursePlansListClient(): React.ReactElement {
+ const t = useTranslations("admin.coursePlans.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const search = searchParams.get("search") ?? "";
+ const statusParam = searchParams.get("status") ?? "";
+ const status = isValidAdminCoursePlanStatus(statusParam) ? statusParam : "";
+ const pageParam = searchParams.get("page") ?? "1";
+ const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminCoursePlans(status || null);
+
+ // 客户端二次筛选(search)—— 后端补齐列表查询后改服务端筛选
+ const filteredItems = useMemo(() => {
+ const items = data?.items ?? [];
+ if (!search) return items;
+ const lower = search.toLowerCase();
+ return items.filter((item) => {
+ return (
+ item.name.toLowerCase().includes(lower) ||
+ (item.className ?? "").toLowerCase().includes(lower) ||
+ (item.subjectName ?? "").toLowerCase().includes(lower) ||
+ (item.teacherName ?? "").toLowerCase().includes(lower)
+ );
+ });
+ }, [data, search]);
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ // 切换筛选时重置页码
+ if (key === "status" || key === "search") {
+ params.delete("page");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/course-plans?${params.toString()}`);
+ });
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+ <>
+ updateQuery("search", v)}
+ />
+
+ >
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredItems.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: data?.total ?? 0 })}
+ ·
+ 第 {page} 页
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * 课程计划列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function CoursePlansTable({
+ items,
+ page,
+}: {
+ items: AdminCoursePlanListItem[];
+ page: number;
+}): React.ReactElement {
+ const t = useTranslations("admin.coursePlans.list");
+ return (
+
+
+
+
+ | {t("colTitle")} |
+ {t("colClass")} |
+ {t("colSubject")} |
+ {t("colTeacher")} |
+ {t("colStatus")} |
+ {t("colUpdatedAt")} |
+ {t("colActions")} |
+
+
+
+ {items.map((plan) => (
+
+ |
+
+ {plan.name}
+
+ |
+
+ {plan.className || "-"}
+ |
+
+ {plan.subjectName || "-"}
+ |
+
+ {plan.teacherName || "-"}
+ |
+
+
+ |
+
+ {formatAdminCoursePlanDate(plan.createdAt)}
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 课程计划状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatAdminCoursePlanStatus(status);
+ const cls = adminCoursePlanStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/course-plans/transformations.ts b/apps/portal-shell/src/features/admin/course-plans/transformations.ts
new file mode 100644
index 0000000..deb8a19
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/course-plans/transformations.ts
@@ -0,0 +1,119 @@
+/**
+ * Admin Course Plans 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ *
+ * 管理端课程计划状态枚举(与 MSW mock 对齐,@contract-pending):
+ * - DRAFT 草稿
+ * - PUBLISHED 已发布
+ * - ARCHIVED 已归档
+ */
+
+/** 管理端课程计划状态中文标签映射 */
+export const ADMIN_COURSE_PLAN_STATUS_LABEL: Record = {
+ DRAFT: "草稿",
+ PUBLISHED: "已发布",
+ ARCHIVED: "已归档",
+};
+
+/** 管理端课程计划状态枚举值列表(用于筛选与表单选项) */
+export const ADMIN_COURSE_PLAN_STATUS_OPTIONS = [
+ "DRAFT",
+ "PUBLISHED",
+ "ARCHIVED",
+] as const;
+
+/** 管理端课程计划状态类型 */
+export type AdminCoursePlanStatus =
+ (typeof ADMIN_COURSE_PLAN_STATUS_OPTIONS)[number];
+
+/**
+ * 将管理端课程计划状态枚举值映射为中文标签。
+ * 未知状态回退为原始值(兜底,避免空白)。
+ */
+export function formatAdminCoursePlanStatus(status: string): string {
+ return ADMIN_COURSE_PLAN_STATUS_LABEL[status] ?? status;
+}
+
+/**
+ * 根据管理端课程计划状态返回 Tailwind 徽章语义类名。
+ * - DRAFT → muted
+ * - PUBLISHED → emerald(已发布正面语义)
+ * - ARCHIVED → amber(归档中性提示)
+ * - 未知 → muted(兜底)
+ */
+export function adminCoursePlanStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "DRAFT":
+ return "bg-muted text-muted-foreground";
+ case "PUBLISHED":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "ARCHIVED":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效(null/undefined/空/非法)时返回占位符 "--"。
+ */
+export function formatAdminCoursePlanDate(
+ isoDate: string | null | undefined,
+): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * 判断管理端课程计划是否可编辑(DRAFT / PUBLISHED 状态可编辑,归档后不可编辑)。
+ */
+export function isAdminCoursePlanEditable(status: string): boolean {
+ return status === "DRAFT" || status === "PUBLISHED";
+}
+
+/**
+ * 判断管理端课程计划是否已归档。
+ */
+export function isAdminCoursePlanArchived(status: string): boolean {
+ return status === "ARCHIVED";
+}
+
+/**
+ * 判断字符串是否为合法的管理端课程计划状态。
+ */
+export function isValidAdminCoursePlanStatus(
+ status: string,
+): status is AdminCoursePlanStatus {
+ return ADMIN_COURSE_PLAN_STATUS_OPTIONS.includes(
+ status as AdminCoursePlanStatus,
+ );
+}
+
+/**
+ * 判断字段值是否有内容(非空且去除首尾空白后非空)。
+ * 用于详情页判断 description/content/objectives 等字段是否展示。
+ */
+export function hasContent(value: string | null | undefined): boolean {
+ return Boolean(value && value.trim().length > 0);
+}
+
+/**
+ * 安全展示文本字段,空值回退占位符。
+ */
+export function displayText(
+ value: string | null | undefined,
+ placeholder = "-",
+): string {
+ return hasContent(value) ? (value as string) : placeholder;
+}
diff --git a/apps/portal-shell/src/features/admin/curriculum-map/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/curriculum-map/__tests__/transformations.test.ts
new file mode 100644
index 0000000..1851b7e
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/curriculum-map/__tests__/transformations.test.ts
@@ -0,0 +1,320 @@
+/**
+ * 课程地图数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { StandardsCoverageCell } from "@/lib/api/admin-p5";
+
+import {
+ COVERAGE_HIGH_THRESHOLD,
+ COVERAGE_LOW_THRESHOLD,
+ COVERAGE_MEDIUM_THRESHOLD,
+ buildHeatmapMatrix,
+ calcAverageCoverage,
+ coverageIntensityToCellClass,
+ coverageRateToCellClass,
+ coverageRateToIntensity,
+ formatCoverageRate,
+ formatLessonPlanCount,
+ safeCoverageRate,
+ safeLessonPlanCount,
+} from "../transformations";
+
+const sampleCells: StandardsCoverageCell[] = [
+ {
+ standardId: "std-001",
+ standardName: "集合",
+ gradeId: "grade-12",
+ gradeName: "高三",
+ coverageRate: 0.85,
+ lessonPlanCount: 8,
+ },
+ {
+ standardId: "std-002",
+ standardName: "函数",
+ gradeId: "grade-12",
+ gradeName: "高三",
+ coverageRate: 0.4,
+ lessonPlanCount: 4,
+ },
+ {
+ standardId: "std-001",
+ standardName: "集合",
+ gradeId: "grade-11",
+ gradeName: "高二",
+ coverageRate: 0.1,
+ lessonPlanCount: 1,
+ },
+];
+
+describe("coverageRateToIntensity", () => {
+ it("returns 'high' for rate >= 0.8", () => {
+ expect(coverageRateToIntensity(0.8)).toBe("high");
+ expect(coverageRateToIntensity(0.95)).toBe("high");
+ expect(coverageRateToIntensity(1)).toBe("high");
+ });
+
+ it("returns 'medium' for 0.5 <= rate < 0.8", () => {
+ expect(coverageRateToIntensity(0.5)).toBe("medium");
+ expect(coverageRateToIntensity(0.79)).toBe("medium");
+ });
+
+ it("returns 'low' for 0.2 <= rate < 0.5", () => {
+ expect(coverageRateToIntensity(0.2)).toBe("low");
+ expect(coverageRateToIntensity(0.49)).toBe("low");
+ });
+
+ it("returns 'none' for rate < 0.2 (including 0)", () => {
+ expect(coverageRateToIntensity(0)).toBe("none");
+ expect(coverageRateToIntensity(0.19)).toBe("none");
+ });
+
+ it("returns 'none' for invalid input (NaN/negative/>1)", () => {
+ expect(coverageRateToIntensity(Number.NaN)).toBe("none");
+ expect(coverageRateToIntensity(-0.1)).toBe("none");
+ expect(coverageRateToIntensity(1.5)).toBe("none");
+ expect(coverageRateToIntensity(Number.POSITIVE_INFINITY)).toBe("none");
+ });
+
+ it("threshold constants align with implementation", () => {
+ expect(COVERAGE_HIGH_THRESHOLD).toBe(0.8);
+ expect(COVERAGE_MEDIUM_THRESHOLD).toBe(0.5);
+ expect(COVERAGE_LOW_THRESHOLD).toBe(0.2);
+ });
+});
+
+describe("coverageIntensityToCellClass", () => {
+ it("returns primary/80 class for high", () => {
+ const cls = coverageIntensityToCellClass("high");
+ expect(cls).toContain("bg-primary/80");
+ expect(cls).toContain("text-primary-foreground");
+ });
+
+ it("returns primary/40 class for medium", () => {
+ const cls = coverageIntensityToCellClass("medium");
+ expect(cls).toContain("bg-primary/40");
+ });
+
+ it("returns primary/15 class for low", () => {
+ const cls = coverageIntensityToCellClass("low");
+ expect(cls).toContain("bg-primary/15");
+ });
+
+ it("returns muted class for none", () => {
+ const cls = coverageIntensityToCellClass("none");
+ expect(cls).toContain("bg-muted");
+ expect(cls).toContain("text-muted-foreground");
+ });
+});
+
+describe("coverageRateToCellClass", () => {
+ it("delegates to intensity class correctly", () => {
+ expect(coverageRateToCellClass(0.9)).toBe(
+ coverageIntensityToCellClass("high"),
+ );
+ expect(coverageRateToCellClass(0.6)).toBe(
+ coverageIntensityToCellClass("medium"),
+ );
+ expect(coverageRateToCellClass(0.3)).toBe(
+ coverageIntensityToCellClass("low"),
+ );
+ expect(coverageRateToCellClass(0)).toBe(
+ coverageIntensityToCellClass("none"),
+ );
+ });
+});
+
+describe("formatCoverageRate", () => {
+ it("formats valid rate as percentage", () => {
+ expect(formatCoverageRate(0)).toBe("0%");
+ expect(formatCoverageRate(0.5)).toBe("50%");
+ expect(formatCoverageRate(0.85)).toBe("85%");
+ expect(formatCoverageRate(1)).toBe("100%");
+ });
+
+ it("rounds to nearest integer percent", () => {
+ expect(formatCoverageRate(0.123)).toBe("12%");
+ expect(formatCoverageRate(0.456)).toBe("46%");
+ expect(formatCoverageRate(0.789)).toBe("79%");
+ });
+
+ it("returns placeholder for null/undefined", () => {
+ expect(formatCoverageRate(null)).toBe("--");
+ expect(formatCoverageRate(undefined)).toBe("--");
+ });
+
+ it("returns placeholder for invalid input (NaN/negative/>1)", () => {
+ expect(formatCoverageRate(Number.NaN)).toBe("--");
+ expect(formatCoverageRate(-0.1)).toBe("--");
+ expect(formatCoverageRate(1.5)).toBe("--");
+ expect(formatCoverageRate(Number.POSITIVE_INFINITY)).toBe("--");
+ });
+});
+
+describe("formatLessonPlanCount", () => {
+ it("formats valid count as string", () => {
+ expect(formatLessonPlanCount(0)).toBe("0");
+ expect(formatLessonPlanCount(8)).toBe("8");
+ expect(formatLessonPlanCount(100)).toBe("100");
+ });
+
+ it("floors non-integer count", () => {
+ expect(formatLessonPlanCount(8.9)).toBe("8");
+ expect(formatLessonPlanCount(8.1)).toBe("8");
+ });
+
+ it("returns placeholder for null/undefined", () => {
+ expect(formatLessonPlanCount(null)).toBe("--");
+ expect(formatLessonPlanCount(undefined)).toBe("--");
+ });
+
+ it("returns placeholder for invalid input (NaN/negative)", () => {
+ expect(formatLessonPlanCount(Number.NaN)).toBe("--");
+ expect(formatLessonPlanCount(-1)).toBe("--");
+ expect(formatLessonPlanCount(Number.POSITIVE_INFINITY)).toBe("--");
+ });
+});
+
+describe("safeCoverageRate", () => {
+ it("returns coverageRate when present", () => {
+ expect(safeCoverageRate({ coverageRate: 0.85 })).toBe(0.85);
+ });
+
+ it("falls back to coverage when coverageRate missing", () => {
+ expect(safeCoverageRate({ coverage: 0.92 })).toBe(0.92);
+ });
+
+ it("prefers coverageRate over coverage when both present", () => {
+ expect(safeCoverageRate({ coverageRate: 0.5, coverage: 0.9 })).toBe(0.5);
+ });
+
+ it("returns 0 when neither field present", () => {
+ expect(safeCoverageRate({})).toBe(0);
+ });
+
+ it("returns 0 when coverageRate is non-finite", () => {
+ expect(safeCoverageRate({ coverageRate: Number.NaN })).toBe(0);
+ expect(safeCoverageRate({ coverageRate: Number.POSITIVE_INFINITY })).toBe(
+ 0,
+ );
+ });
+
+ it("returns 0 when coverage is non-number", () => {
+ expect(safeCoverageRate({ coverage: "high" })).toBe(0);
+ expect(safeCoverageRate({ coverage: null })).toBe(0);
+ });
+});
+
+describe("safeLessonPlanCount", () => {
+ it("returns lessonPlanCount when present", () => {
+ expect(safeLessonPlanCount({ lessonPlanCount: 8 })).toBe(8);
+ });
+
+ it("returns 0 when missing", () => {
+ expect(safeLessonPlanCount({})).toBe(0);
+ });
+
+ it("returns 0 when non-finite", () => {
+ expect(safeLessonPlanCount({ lessonPlanCount: Number.NaN })).toBe(0);
+ });
+});
+
+describe("buildHeatmapMatrix", () => {
+ it("returns empty matrix for empty cells", () => {
+ const { rows, grades } = buildHeatmapMatrix([]);
+ expect(rows).toEqual([]);
+ expect(grades).toEqual([]);
+ });
+
+ it("groups cells into rows by standard and columns by grade", () => {
+ const { rows, grades } = buildHeatmapMatrix(sampleCells);
+ expect(rows).toHaveLength(2); // std-001, std-002
+ expect(grades).toHaveLength(2); // grade-12, grade-11
+
+ const std001 = rows.find((r) => r.standardId === "std-001");
+ expect(std001).toBeDefined();
+ expect(std001?.cells).toHaveLength(2);
+
+ const cellStd001Grade12 = std001?.cells.find(
+ (c) => c.gradeId === "grade-12",
+ );
+ expect(cellStd001Grade12?.rate).toBe(0.85);
+ expect(cellStd001Grade12?.lessonPlanCount).toBe(8);
+ });
+
+ it("fills missing combinations with rate=0", () => {
+ const { rows } = buildHeatmapMatrix(sampleCells);
+ const std002 = rows.find((r) => r.standardId === "std-002");
+ // std-002 only has grade-12; grade-11 should be filled with 0
+ const cellStd002Grade11 = std002?.cells.find(
+ (c) => c.gradeId === "grade-11",
+ );
+ expect(cellStd002Grade11?.rate).toBe(0);
+ expect(cellStd002Grade11?.lessonPlanCount).toBe(0);
+ });
+
+ it("preserves first-seen order of standards and grades", () => {
+ const { rows, grades } = buildHeatmapMatrix(sampleCells);
+ expect(rows[0]!.standardId).toBe("std-001");
+ expect(rows[1]!.standardId).toBe("std-002");
+ expect(grades[0]!.gradeId).toBe("grade-12");
+ expect(grades[1]!.gradeId).toBe("grade-11");
+ });
+
+ it("falls back to gradeId when gradeName missing", () => {
+ const cells: StandardsCoverageCell[] = [
+ {
+ standardId: "std-001",
+ standardName: "集合",
+ gradeId: "grade-12",
+ gradeName: "",
+ coverageRate: 0.5,
+ lessonPlanCount: 1,
+ },
+ ];
+ const { grades } = buildHeatmapMatrix(cells);
+ expect(grades[0]!.gradeName).toBe("grade-12");
+ });
+});
+
+describe("calcAverageCoverage", () => {
+ it("returns 0 for empty array", () => {
+ expect(calcAverageCoverage([])).toBe(0);
+ });
+
+ it("calculates average for valid array", () => {
+ // (0.85 + 0.4 + 0.1) / 3 ≈ 0.45
+ expect(calcAverageCoverage(sampleCells)).toBeCloseTo(0.45, 2);
+ });
+
+ it("handles single cell", () => {
+ expect(
+ calcAverageCoverage([
+ {
+ standardId: "std-001",
+ standardName: "集合",
+ gradeId: "grade-12",
+ gradeName: "高三",
+ coverageRate: 0.7,
+ lessonPlanCount: 1,
+ },
+ ]),
+ ).toBe(0.7);
+ });
+
+ it("uses coverage fallback when coverageRate missing", () => {
+ const cells = [
+ {
+ standardId: "std-001",
+ standardName: "集合",
+ gradeId: "grade-12",
+ gradeName: "高三",
+ coverage: 0.6,
+ lessonPlanCount: 1,
+ } as unknown as StandardsCoverageCell,
+ ];
+ expect(calcAverageCoverage(cells)).toBeCloseTo(0.6, 2);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/curriculum-map/curriculum-map-client.tsx b/apps/portal-shell/src/features/admin/curriculum-map/curriculum-map-client.tsx
new file mode 100644
index 0000000..cb5d0fa
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/curriculum-map/curriculum-map-client.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+/**
+ * 课程地图页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - standardsCoverageHeatmap():❌ schema 无 → MSW 兜底(@contract-pending)
+ * - globalLessonPlanStats():❌ schema 无 → MSW 兜底
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Map } from "lucide-react";
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useGlobalLessonPlanStats,
+ useStandardsCoverageHeatmap,
+} from "@/lib/api/admin-p5";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ buildHeatmapMatrix,
+ coverageRateToCellClass,
+ formatCoverageRate,
+ formatLessonPlanCount,
+} from "@/features/admin/curriculum-map/transformations";
+import type { GlobalLessonPlanStats } from "@/lib/api/admin-p5";
+
+/**
+ * 课程地图客户端主体。需由 server page 包裹在 中。
+ */
+export function CurriculumMapClient(): React.ReactElement {
+ const t = useTranslations("admin.curriculumMap");
+ const tCommon = useTranslations("common");
+
+ const {
+ data: cells,
+ loading: cellsLoading,
+ error: cellsError,
+ } = useStandardsCoverageHeatmap();
+ const {
+ data: stats,
+ loading: statsLoading,
+ error: statsError,
+ } = useGlobalLessonPlanStats();
+
+ const loading = cellsLoading || statsLoading;
+ const error = cellsError ?? statsError;
+
+ const matrix = useMemo(() => buildHeatmapMatrix(cells ?? []), [cells]);
+
+ const isEmpty = !loading && !error && (cells ?? []).length === 0;
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ {t("list.mswNotice")}
+
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ loading={loading}
+ loadingNode={}
+ empty={isEmpty}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+
+
+ );
+}
+
+/**
+ * 统计卡片网格(5 个指标)。
+ */
+function StatsCardsGrid({
+ stats,
+}: {
+ stats: GlobalLessonPlanStats | null | undefined;
+}): React.ReactElement {
+ const t = useTranslations("admin.curriculumMap");
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 标准覆盖热图卡片(标准 × 年级 矩阵)。
+ */
+function HeatmapCard({
+ matrix,
+}: {
+ matrix: ReturnType;
+}): React.ReactElement {
+ const t = useTranslations("admin.curriculumMap");
+
+ if (matrix.rows.length === 0) {
+ return (
+
+
+ {t("list.emptyTitle")}
+
+
+ );
+ }
+
+ return (
+
+
+ {t("list.heatmapTitle")}
+
+
+
+
+ |
+ {t("list.heatmapStandards")}
+ |
+ {matrix.grades.map((g) => (
+
+ {g.gradeName}
+ |
+ ))}
+
+
+
+ {matrix.rows.map((row) => (
+
+ | {row.standardName} |
+ {row.cells.map((cell) => {
+ const rate = cell.rate;
+ const cls = coverageRateToCellClass(rate);
+ return (
+
+
+ {formatCoverageRate(rate)}
+
+
+ {formatLessonPlanCount(cell.lessonPlanCount)}
+
+ |
+ );
+ })}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/curriculum-map/transformations.ts b/apps/portal-shell/src/features/admin/curriculum-map/transformations.ts
new file mode 100644
index 0000000..96647b7
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/curriculum-map/transformations.ts
@@ -0,0 +1,215 @@
+/**
+ * 课程地图数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import type { StandardsCoverageCell } from "@/lib/api/admin-p5";
+
+/** 覆盖率强度等级 */
+export type CoverageIntensity = "high" | "medium" | "low" | "none";
+
+/** 覆盖率百分比下限阈值(与 coverageRateToIntensity 对齐) */
+export const COVERAGE_HIGH_THRESHOLD = 0.8;
+export const COVERAGE_MEDIUM_THRESHOLD = 0.5;
+export const COVERAGE_LOW_THRESHOLD = 0.2;
+
+/**
+ * 将覆盖率(0-1)映射为强度等级。
+ * - >= 0.8 → high
+ * - >= 0.5 → medium
+ * - >= 0.2 → low
+ * - < 0.2(含 0) → none
+ * 输入无效(NaN/负数/大于 1)→ none
+ */
+export function coverageRateToIntensity(rate: number): CoverageIntensity {
+ if (!Number.isFinite(rate) || rate < 0 || rate > 1) {
+ return "none";
+ }
+ if (rate >= COVERAGE_HIGH_THRESHOLD) return "high";
+ if (rate >= COVERAGE_MEDIUM_THRESHOLD) return "medium";
+ if (rate >= COVERAGE_LOW_THRESHOLD) return "low";
+ return "none";
+}
+
+/**
+ * 根据覆盖率返回 Tailwind 单元格背景类名(语义色阶,避免硬编码 #hex)。
+ * - high → bg-primary/80 text-primary-foreground
+ * - medium → bg-primary/40 text-foreground
+ * - low → bg-primary/15 text-foreground
+ * - none → bg-muted text-muted-foreground
+ */
+export function coverageIntensityToCellClass(
+ intensity: CoverageIntensity,
+): string {
+ switch (intensity) {
+ case "high":
+ return "bg-primary/80 text-primary-foreground";
+ case "medium":
+ return "bg-primary/40 text-foreground";
+ case "low":
+ return "bg-primary/15 text-foreground";
+ case "none":
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/**
+ * 根据覆盖率(0-1)直接返回单元格背景类名(组合上面两个函数)。
+ */
+export function coverageRateToCellClass(rate: number): string {
+ return coverageIntensityToCellClass(coverageRateToIntensity(rate));
+}
+
+/**
+ * 将覆盖率(0-1)格式化为百分比字符串(如 "85%")。
+ * 输入无效(NaN/负数/大于 1)→ "--"。
+ */
+export function formatCoverageRate(rate: number | undefined | null): string {
+ if (rate === null || rate === undefined || !Number.isFinite(rate)) {
+ return "--";
+ }
+ if (rate < 0 || rate > 1) return "--";
+ return `${Math.round(rate * 100)}%`;
+}
+
+/**
+ * 格式化教案数为字符串。
+ * 输入无效 → "--"。
+ */
+export function formatLessonPlanCount(
+ count: number | undefined | null,
+): string {
+ if (count === null || count === undefined || !Number.isFinite(count)) {
+ return "--";
+ }
+ if (count < 0) return "--";
+ return String(Math.floor(count));
+}
+
+/**
+ * 安全读取 cell.coverageRate(防御 MSW 与 schema 类型不一致场景)。
+ * 若 coverageRate 缺失但存在 coverage 字段,回退使用 coverage。
+ */
+export function safeCoverageRate(cell: {
+ coverageRate?: number;
+ lessonPlanCount?: number;
+ coverage?: unknown;
+}): number {
+ if (
+ typeof cell.coverageRate === "number" &&
+ Number.isFinite(cell.coverageRate)
+ ) {
+ return cell.coverageRate;
+ }
+ if (typeof cell.coverage === "number" && Number.isFinite(cell.coverage)) {
+ return cell.coverage;
+ }
+ return 0;
+}
+
+/**
+ * 安全读取 cell.lessonPlanCount(防御 MSW 与 schema 类型不一致场景)。
+ */
+export function safeLessonPlanCount(cell: {
+ lessonPlanCount?: number;
+}): number {
+ if (
+ typeof cell.lessonPlanCount === "number" &&
+ Number.isFinite(cell.lessonPlanCount)
+ ) {
+ return cell.lessonPlanCount;
+ }
+ return 0;
+}
+
+/** 热图矩阵行:标准 → 各年级的覆盖率 */
+export interface HeatmapRow {
+ standardId: string;
+ standardName: string;
+ cells: HeatmapCell[];
+}
+
+/** 热图单元格 */
+export interface HeatmapCell {
+ standardId: string;
+ standardName: string;
+ gradeId: string;
+ gradeName: string;
+ rate: number;
+ lessonPlanCount: number;
+}
+
+/**
+ * 将扁平 cells 列表转换为热图矩阵(按标准分组,列对齐到 grades)。
+ * - 行:标准(按首次出现顺序去重)
+ * - 列:年级(按首次出现顺序去重)
+ * - 单元格:标准 × 年级 的覆盖率(无数据则 rate=0)
+ */
+export function buildHeatmapMatrix(
+ cells: ReadonlyArray,
+): {
+ rows: HeatmapRow[];
+ grades: Array<{ gradeId: string; gradeName: string }>;
+} {
+ const gradeMap = new Map();
+ const standardMap = new Map<
+ string,
+ { standardId: string; standardName: string }
+ >();
+
+ for (const cell of cells) {
+ if (!gradeMap.has(cell.gradeId)) {
+ gradeMap.set(cell.gradeId, {
+ gradeId: cell.gradeId,
+ gradeName: cell.gradeName || cell.gradeId,
+ });
+ }
+ if (!standardMap.has(cell.standardId)) {
+ standardMap.set(cell.standardId, {
+ standardId: cell.standardId,
+ standardName: cell.standardName || cell.standardId,
+ });
+ }
+ }
+
+ const grades = Array.from(gradeMap.values());
+ const standards = Array.from(standardMap.values());
+
+ // 索引:(standardId, gradeId) → cell
+ const cellMap = new Map();
+ for (const cell of cells) {
+ cellMap.set(`${cell.standardId}:${cell.gradeId}`, cell);
+ }
+
+ const rows: HeatmapRow[] = standards.map((std) => ({
+ standardId: std.standardId,
+ standardName: std.standardName,
+ cells: grades.map((g) => {
+ const cell = cellMap.get(`${std.standardId}:${g.gradeId}`);
+ return {
+ standardId: std.standardId,
+ standardName: std.standardName,
+ gradeId: g.gradeId,
+ gradeName: g.gradeName,
+ rate: cell ? safeCoverageRate(cell) : 0,
+ lessonPlanCount: cell ? safeLessonPlanCount(cell) : 0,
+ };
+ }),
+ }));
+
+ return { rows, grades };
+}
+
+/**
+ * 计算平均覆盖率(用于总览统计)。
+ * 输入空数组 → 0。
+ */
+export function calcAverageCoverage(
+ cells: ReadonlyArray,
+): number {
+ if (cells.length === 0) return 0;
+ const sum = cells.reduce((acc, c) => acc + safeCoverageRate(c), 0);
+ return sum / cells.length;
+}
diff --git a/apps/portal-shell/src/features/admin/elective/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/elective/__tests__/transformations.test.ts
new file mode 100644
index 0000000..0ea4e2f
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/elective/__tests__/transformations.test.ts
@@ -0,0 +1,421 @@
+/**
+ * 选修课管理数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { FlexibleElectiveItem } from "../transformations";
+import {
+ ELECTIVE_STATUS_LABEL,
+ calcEnrollmentRate,
+ electiveStatusToBadgeClass,
+ enrollmentRateToColorClass,
+ formatElectiveDate,
+ formatElectiveDateOnly,
+ formatElectiveStatus,
+ formatEnrollmentCount,
+ getCapacity,
+ getEnrolledCount,
+ getGradeName,
+ getSubjectName,
+ hasAvailableSpot,
+ isElectiveEditable,
+ isValidElectiveStatus,
+ matchElectiveSearch,
+ matchElectiveStatus,
+} from "../transformations";
+
+const sampleItem: FlexibleElectiveItem = {
+ id: "ele-001",
+ name: "高等数学拓展",
+ subjectName: "数学",
+ gradeName: "高三",
+ teacherName: "张老师",
+ capacity: 30,
+ selectedCount: 20,
+ status: "OPEN",
+ startDate: "2026-09-01T08:00:00.000Z",
+ endDate: "2027-01-15T10:00:00.000Z",
+};
+
+describe("formatElectiveStatus", () => {
+ it("maps known statuses to Chinese labels", () => {
+ expect(formatElectiveStatus("DRAFT")).toBe("草稿");
+ expect(formatElectiveStatus("OPEN")).toBe("报名中");
+ expect(formatElectiveStatus("CLOSED")).toBe("已关闭");
+ expect(formatElectiveStatus("FULL")).toBe("已满");
+ });
+
+ it("falls back to raw value for unknown status", () => {
+ expect(formatElectiveStatus("UNKNOWN")).toBe("UNKNOWN");
+ expect(formatElectiveStatus("ARCHIVED")).toBe("ARCHIVED");
+ });
+
+ it("ELECTIVE_STATUS_LABEL constant aligns with implementation", () => {
+ expect(ELECTIVE_STATUS_LABEL.DRAFT).toBe("草稿");
+ expect(ELECTIVE_STATUS_LABEL.OPEN).toBe("报名中");
+ expect(ELECTIVE_STATUS_LABEL.CLOSED).toBe("已关闭");
+ expect(ELECTIVE_STATUS_LABEL.FULL).toBe("已满");
+ });
+});
+
+describe("electiveStatusToBadgeClass", () => {
+ it("returns muted class for DRAFT", () => {
+ const cls = electiveStatusToBadgeClass("DRAFT");
+ expect(cls).toContain("bg-muted");
+ expect(cls).toContain("text-muted-foreground");
+ });
+
+ it("returns primary class for OPEN", () => {
+ const cls = electiveStatusToBadgeClass("OPEN");
+ expect(cls).toContain("bg-primary/10");
+ expect(cls).toContain("text-primary");
+ });
+
+ it("returns amber class for CLOSED", () => {
+ const cls = electiveStatusToBadgeClass("CLOSED");
+ expect(cls).toContain("bg-amber-500/10");
+ expect(cls).toContain("text-amber-600");
+ });
+
+ it("returns destructive class for FULL", () => {
+ const cls = electiveStatusToBadgeClass("FULL");
+ expect(cls).toContain("bg-destructive/10");
+ expect(cls).toContain("text-destructive");
+ });
+
+ it("returns muted class for unknown status", () => {
+ const cls = electiveStatusToBadgeClass("UNKNOWN");
+ expect(cls).toContain("bg-muted");
+ expect(cls).toContain("text-muted-foreground");
+ });
+});
+
+describe("getSubjectName", () => {
+ it("returns subjectName when present", () => {
+ expect(getSubjectName({ subjectName: "数学" })).toBe("数学");
+ });
+
+ it("falls back to subject string when subjectName missing", () => {
+ expect(getSubjectName({ subject: "物理" })).toBe("物理");
+ });
+
+ it("prefers subjectName over subject", () => {
+ expect(getSubjectName({ subjectName: "数学", subject: "物理" })).toBe(
+ "数学",
+ );
+ });
+
+ it("returns placeholder when both missing", () => {
+ expect(getSubjectName({})).toBe("--");
+ });
+
+ it("returns placeholder for empty/whitespace subjectName", () => {
+ expect(getSubjectName({ subjectName: "" })).toBe("--");
+ });
+
+ it("returns placeholder when subject is non-string", () => {
+ expect(getSubjectName({ subject: { name: "数学" } })).toBe("--");
+ expect(getSubjectName({ subject: 42 })).toBe("--");
+ });
+});
+
+describe("getGradeName", () => {
+ it("returns gradeName when present", () => {
+ expect(getGradeName({ gradeName: "高三" })).toBe("高三");
+ });
+
+ it("falls back to gradeLevel string when gradeName missing", () => {
+ expect(getGradeName({ gradeLevel: "grade-12" })).toBe("grade-12");
+ });
+
+ it("prefers gradeName over gradeLevel", () => {
+ expect(getGradeName({ gradeName: "高三", gradeLevel: "grade-12" })).toBe(
+ "高三",
+ );
+ });
+
+ it("returns placeholder when both missing", () => {
+ expect(getGradeName({})).toBe("--");
+ });
+
+ it("returns placeholder for empty gradeName", () => {
+ expect(getGradeName({ gradeName: "" })).toBe("--");
+ });
+
+ it("returns placeholder when gradeLevel is non-string", () => {
+ expect(getGradeName({ gradeLevel: 12 })).toBe("--");
+ expect(getGradeName({ gradeLevel: null })).toBe("--");
+ });
+});
+
+describe("getEnrolledCount", () => {
+ it("returns selectedCount when present", () => {
+ expect(getEnrolledCount({ selectedCount: 20 })).toBe(20);
+ });
+
+ it("falls back to enrolledCount when selectedCount missing", () => {
+ expect(getEnrolledCount({ enrolledCount: 25 })).toBe(25);
+ });
+
+ it("prefers selectedCount over enrolledCount", () => {
+ expect(getEnrolledCount({ selectedCount: 20, enrolledCount: 25 })).toBe(20);
+ });
+
+ it("returns 0 when both missing", () => {
+ expect(getEnrolledCount({})).toBe(0);
+ });
+
+ it("returns 0 for non-finite values", () => {
+ expect(getEnrolledCount({ selectedCount: Number.NaN })).toBe(0);
+ expect(getEnrolledCount({ selectedCount: Number.POSITIVE_INFINITY })).toBe(
+ 0,
+ );
+ });
+});
+
+describe("getCapacity", () => {
+ it("returns capacity when present", () => {
+ expect(getCapacity({ capacity: 30 })).toBe(30);
+ });
+
+ it("returns 0 when missing", () => {
+ expect(getCapacity({})).toBe(0);
+ });
+
+ it("returns 0 for non-finite values", () => {
+ expect(getCapacity({ capacity: Number.NaN })).toBe(0);
+ expect(getCapacity({ capacity: Number.POSITIVE_INFINITY })).toBe(0);
+ });
+});
+
+describe("calcEnrollmentRate", () => {
+ it("calculates rate as percentage", () => {
+ expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 20 })).toBe(67);
+ expect(calcEnrollmentRate({ capacity: 100, enrolledCount: 50 })).toBe(50);
+ expect(calcEnrollmentRate({ capacity: 4, enrolledCount: 3 })).toBe(75);
+ });
+
+ it("returns 100 when enrolled >= capacity", () => {
+ expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 30 })).toBe(100);
+ expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 35 })).toBe(100);
+ });
+
+ it("returns 0 when capacity is 0", () => {
+ expect(calcEnrollmentRate({ capacity: 0, enrolledCount: 10 })).toBe(0);
+ });
+
+ it("returns 0 for negative ratio", () => {
+ expect(calcEnrollmentRate({ capacity: 30, enrolledCount: -5 })).toBe(0);
+ });
+
+ it("returns 0 for invalid inputs (NaN/infinity)", () => {
+ expect(
+ calcEnrollmentRate({ capacity: Number.NaN, enrolledCount: 10 }),
+ ).toBe(0);
+ expect(
+ calcEnrollmentRate({
+ capacity: 30,
+ enrolledCount: Number.POSITIVE_INFINITY,
+ }),
+ ).toBe(0);
+ });
+});
+
+describe("enrollmentRateToColorClass", () => {
+ it("returns destructive class for rate >= 90", () => {
+ expect(enrollmentRateToColorClass(90)).toBe("text-destructive");
+ expect(enrollmentRateToColorClass(100)).toBe("text-destructive");
+ });
+
+ it("returns amber class for 50 <= rate < 90", () => {
+ expect(enrollmentRateToColorClass(50)).toContain("text-amber-600");
+ expect(enrollmentRateToColorClass(89)).toContain("text-amber-600");
+ });
+
+ it("returns primary class for 0 < rate < 50", () => {
+ expect(enrollmentRateToColorClass(1)).toBe("text-primary");
+ expect(enrollmentRateToColorClass(49)).toBe("text-primary");
+ });
+
+ it("returns muted class for rate == 0", () => {
+ expect(enrollmentRateToColorClass(0)).toBe("text-muted-foreground");
+ });
+
+ it("returns muted class for invalid inputs", () => {
+ expect(enrollmentRateToColorClass(Number.NaN)).toBe(
+ "text-muted-foreground",
+ );
+ expect(enrollmentRateToColorClass(-1)).toBe("text-muted-foreground");
+ expect(enrollmentRateToColorClass(101)).toBe("text-muted-foreground");
+ });
+});
+
+describe("formatEnrollmentCount", () => {
+ it("formats as enrolled/capacity", () => {
+ expect(formatEnrollmentCount({ capacity: 30, enrolledCount: 20 })).toBe(
+ "20/30",
+ );
+ expect(formatEnrollmentCount({ capacity: 0, enrolledCount: 0 })).toBe(
+ "0/0",
+ );
+ });
+
+ it("returns placeholder for invalid inputs", () => {
+ expect(
+ formatEnrollmentCount({ capacity: Number.NaN, enrolledCount: 10 }),
+ ).toBe("--");
+ expect(
+ formatEnrollmentCount({
+ capacity: 30,
+ enrolledCount: Number.POSITIVE_INFINITY,
+ }),
+ ).toBe("--");
+ });
+});
+
+describe("formatElectiveDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatElectiveDate("2026-09-01T08:00:00.000Z");
+ expect(result).toContain("2026");
+ expect(result).not.toBe("--");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatElectiveDate(null)).toBe("--");
+ expect(formatElectiveDate(undefined)).toBe("--");
+ expect(formatElectiveDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date string", () => {
+ expect(formatElectiveDate("not-a-date")).toBe("--");
+ expect(formatElectiveDate("2026-13-45")).toBe("--");
+ });
+});
+
+describe("formatElectiveDateOnly", () => {
+ it("formats valid ISO date as YYYY-MM-DD", () => {
+ const result = formatElectiveDateOnly("2026-09-01T08:00:00.000Z");
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatElectiveDateOnly(null)).toBe("--");
+ expect(formatElectiveDateOnly(undefined)).toBe("--");
+ expect(formatElectiveDateOnly("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatElectiveDateOnly("invalid")).toBe("--");
+ });
+});
+
+describe("isElectiveEditable", () => {
+ it("returns true for DRAFT status", () => {
+ expect(isElectiveEditable("DRAFT")).toBe(true);
+ });
+
+ it("returns false for non-DRAFT statuses", () => {
+ expect(isElectiveEditable("OPEN")).toBe(false);
+ expect(isElectiveEditable("CLOSED")).toBe(false);
+ expect(isElectiveEditable("FULL")).toBe(false);
+ expect(isElectiveEditable("UNKNOWN")).toBe(false);
+ });
+});
+
+describe("isValidElectiveStatus", () => {
+ it("returns true for known statuses", () => {
+ expect(isValidElectiveStatus("DRAFT")).toBe(true);
+ expect(isValidElectiveStatus("OPEN")).toBe(true);
+ expect(isValidElectiveStatus("CLOSED")).toBe(true);
+ expect(isValidElectiveStatus("FULL")).toBe(true);
+ });
+
+ it("returns false for unknown statuses", () => {
+ expect(isValidElectiveStatus("ARCHIVED")).toBe(false);
+ expect(isValidElectiveStatus("UNKNOWN")).toBe(false);
+ expect(isValidElectiveStatus("")).toBe(false);
+ });
+});
+
+describe("hasAvailableSpot", () => {
+ it("returns true when enrolled < capacity", () => {
+ expect(hasAvailableSpot({ capacity: 30, enrolledCount: 20 })).toBe(true);
+ expect(hasAvailableSpot({ capacity: 30, enrolledCount: 29 })).toBe(true);
+ });
+
+ it("returns false when enrolled >= capacity", () => {
+ expect(hasAvailableSpot({ capacity: 30, enrolledCount: 30 })).toBe(false);
+ expect(hasAvailableSpot({ capacity: 30, enrolledCount: 35 })).toBe(false);
+ });
+
+ it("returns false when capacity is 0", () => {
+ expect(hasAvailableSpot({ capacity: 0, enrolledCount: 0 })).toBe(false);
+ });
+
+ it("returns false for invalid inputs", () => {
+ expect(hasAvailableSpot({ capacity: Number.NaN, enrolledCount: 10 })).toBe(
+ false,
+ );
+ expect(
+ hasAvailableSpot({
+ capacity: 30,
+ enrolledCount: Number.POSITIVE_INFINITY,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("matchElectiveSearch", () => {
+ it("returns true when query is empty", () => {
+ expect(matchElectiveSearch(sampleItem, "")).toBe(true);
+ });
+
+ it("matches by name (case-insensitive)", () => {
+ expect(matchElectiveSearch(sampleItem, "高等数学")).toBe(true);
+ expect(matchElectiveSearch(sampleItem, "高等")).toBe(true);
+ expect(matchElectiveSearch(sampleItem, "MATH")).toBe(false);
+ });
+
+ it("matches by subjectName (case-insensitive)", () => {
+ expect(matchElectiveSearch(sampleItem, "数学")).toBe(true);
+ });
+
+ it("matches by teacherName (case-insensitive)", () => {
+ expect(matchElectiveSearch(sampleItem, "张老师")).toBe(true);
+ expect(matchElectiveSearch(sampleItem, "张")).toBe(true);
+ });
+
+ it("returns false when no field matches", () => {
+ expect(matchElectiveSearch(sampleItem, "不存在")).toBe(false);
+ });
+
+ it("handles missing fields gracefully", () => {
+ const partial: FlexibleElectiveItem = { id: "ele-002", name: "测试" };
+ expect(matchElectiveSearch(partial, "测试")).toBe(true);
+ expect(matchElectiveSearch(partial, "数学")).toBe(false);
+ });
+});
+
+describe("matchElectiveStatus", () => {
+ it("returns true when status filter is empty/null/undefined", () => {
+ expect(matchElectiveStatus(sampleItem, "")).toBe(true);
+ expect(matchElectiveStatus(sampleItem, null)).toBe(true);
+ expect(matchElectiveStatus(sampleItem, undefined)).toBe(true);
+ });
+
+ it("returns true when item status matches filter", () => {
+ expect(matchElectiveStatus(sampleItem, "OPEN")).toBe(true);
+ });
+
+ it("returns false when item status does not match filter", () => {
+ expect(matchElectiveStatus(sampleItem, "DRAFT")).toBe(false);
+ expect(matchElectiveStatus(sampleItem, "CLOSED")).toBe(false);
+ });
+
+ it("returns false when item status is missing", () => {
+ const noStatus: FlexibleElectiveItem = { id: "ele-003" };
+ expect(matchElectiveStatus(noStatus, "OPEN")).toBe(false);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/elective/elective-create-client.tsx b/apps/portal-shell/src/features/admin/elective/elective-create-client.tsx
new file mode 100644
index 0000000..ea5859b
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/elective/elective-create-client.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+/**
+ * 管理端选修课新建表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - mutation createElective(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
+ * - 选项数据 grades / teacherOptions:MSW 兜底
+ * - 选项数据 subjectOptions:❌ 暂无 hook → 文本输入兜底(@contract-pending)
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:选项数据加载时 Select 显示"加载中"
+ * - error:errorSummary 表单级错误
+ * - success:notify.success + router.push 回列表页
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { BookOpen } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useGrades, useTeacherOptions } from "@/lib/api/admin-p5";
+import { FormPageShell } from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import { formatElectiveStatus } from "@/features/admin/elective/transformations";
+
+/** 状态选项(与 admin.elective.list i18n 对齐) */
+const STATUS_OPTIONS = ["DRAFT", "OPEN", "CLOSED", "FULL"] as const;
+
+/**
+ * 新建表单客户端主体。需由 server page 包裹在 中。
+ */
+export function ElectiveCreateClient(): React.ReactElement {
+ const t = useTranslations("admin.elective.create");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const [, startTransition] = useTransition();
+
+ // @contract-pending:MSW 兜底
+ const { data: grades, loading: gradesLoading } = useGrades();
+ const { data: teachers, loading: teachersLoading } = useTeacherOptions();
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [subjectId, setSubjectId] = useState("");
+ const [gradeId, setGradeId] = useState("");
+ const [teacherId, setTeacherId] = useState("");
+ const [capacity, setCapacity] = useState("");
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+ const [status, setStatus] = useState("DRAFT");
+ const [submitting, setSubmitting] = useState(false);
+ const [formError, setFormError] = useState(null);
+
+ const handleSubmit = async (): Promise => {
+ setFormError(null);
+
+ if (!name.trim()) {
+ setFormError(t("errorNameRequired"));
+ return;
+ }
+ if (!subjectId.trim()) {
+ setFormError(t("errorSubjectRequired"));
+ return;
+ }
+ const capacityNum = Number.parseInt(capacity, 10);
+ if (!capacity || !Number.isFinite(capacityNum) || capacityNum <= 0) {
+ setFormError(t("errorCapacityInvalid"));
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ // @contract-pending:createElective mutation 契约未补齐
+ // 当前通过 MSW 兜底模拟提交成功,后端补齐后切换为真实 mutation
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ notify.success(t("success"));
+ startTransition(() => {
+ router.push("/shell/admin/elective");
+ });
+ } catch (err) {
+ setFormError(`${t("error")}: ${String(err)}`);
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ }
+ backHref="/shell/admin/elective"
+ onSubmit={handleSubmit}
+ submitting={submitting}
+ submitLabel={t("submit")}
+ cancelLabel={t("cancel")}
+ errorSummary={
+ formError ? (
+ {formError}
+ ) : undefined
+ }
+ >
+ {/* 名称 */}
+
+ setName(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder={t("fieldName")}
+ required
+ />
+
+
+ {/* 描述 */}
+
+
+
+
+ {/* 科目(@contract-pending:无 useSubjectOptions hook,文本输入兜底) */}
+
+ setSubjectId(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder="sub-math"
+ required
+ />
+
+
+ {/* 年级 */}
+
+
+
+
+
+
+ {/* 教师 */}
+
+
+
+
+ {/* 容量 */}
+
+ setCapacity(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder="30"
+ />
+
+
+
+
+ {/* 开始日期 */}
+
+ setStartDate(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ />
+
+
+ {/* 结束日期 */}
+
+ setEndDate(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ />
+
+
+
+ {/* 状态 */}
+
+
+
+
+ {/* @contract-pending 提示 */}
+ {t("contractPending")}
+
+ );
+}
+
+/**
+ * 表单字段容器(label + children)。
+ */
+function FormField({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/elective/elective-detail-client.tsx b/apps/portal-shell/src/features/admin/elective/elective-detail-client.tsx
new file mode 100644
index 0000000..c95cdb7
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/elective/elective-detail-client.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+/**
+ * 管理端选修课详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - 单查 adminElective(id: ID!):❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:DetailPageSkeleton
+ * - error:errorNode 局部降级
+ * - notFound:data 为 null 时显示空态节点
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+import { BookOpen } from "lucide-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { useAdminElective } from "@/lib/api/admin-p5";
+import type { AdminElective } from "@/lib/api/admin-p5";
+import { Button } from "@/shared/components/ui/button";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import {
+ DetailPageShell,
+ DetailPageSkeleton,
+ DetailSection,
+ DetailField,
+} from "@/shared/components/page-templates";
+import {
+ calcEnrollmentRate,
+ electiveStatusToBadgeClass,
+ enrollmentRateToColorClass,
+ formatElectiveDate,
+ formatElectiveStatus,
+ formatEnrollmentCount,
+ getEnrolledCount,
+ getGradeName,
+ getSubjectName,
+ isElectiveEditable,
+ type FlexibleElectiveItem,
+} from "@/features/admin/elective/transformations";
+
+/**
+ * 详情客户端主体。需由 server page 包裹在 中。
+ */
+export function ElectiveDetailClient(): React.ReactElement {
+ const t = useTranslations("admin.elective.detail");
+ const tCommon = useTranslations("common");
+ const params = useParams<{ id: string }>();
+ const electiveId = params?.id ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminElective(electiveId);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ ) : undefined;
+
+ return (
+ }
+ backHref="/shell/admin/elective"
+ actions={
+ data && isElectiveEditable(data.status) ? (
+
+ ) : null
+ }
+ loading={loading}
+ loadingNode={}
+ errorNode={errorNode}
+ emptyNode={
+ !loading && !error && !data ? (
+
+ {t("notFound")}
+
+ ) : undefined
+ }
+ >
+ {data ? : null}
+
+ );
+}
+
+/**
+ * 详情内容区(基本信息 + 时间安排 + 选课记录)。
+ */
+function ElectiveDetailBody({
+ elective,
+}: {
+ elective: AdminElective;
+}): React.ReactElement {
+ const t = useTranslations("admin.elective.detail");
+ const flexible = elective as unknown as FlexibleElectiveItem;
+ const enrolled = getEnrolledCount(flexible);
+ const capacity =
+ typeof elective.capacity === "number" && Number.isFinite(elective.capacity)
+ ? elective.capacity
+ : 0;
+ const rate = calcEnrollmentRate({ capacity, enrolledCount: enrolled });
+ const selections = elective.selections ?? [];
+
+ return (
+ <>
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+ {formatEnrollmentCount({ capacity, enrolledCount: enrolled })}
+
+
+ ({rate}%)
+
+
+ }
+ />
+
+
+
+
+ {t("enrollmentTitle")}
+ {selections.length === 0 ? (
+
+ {t("emptyEnrollment")}
+
+ ) : (
+
+
+
+
+ |
+ {t("enrollmentStudentName")}
+ |
+
+ {t("enrollmentStudentNo")}
+ |
+
+ {t("enrollmentEnrolledAt")}
+ |
+
+
+
+ {selections.map((selection) => (
+
+ |
+ {selection.studentName}
+ |
+
+ {selection.studentId}
+ |
+
+ {formatElectiveDate(selection.selectedAt)}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ >
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatElectiveStatus(status);
+ const cls = electiveStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/elective/elective-edit-client.tsx b/apps/portal-shell/src/features/admin/elective/elective-edit-client.tsx
new file mode 100644
index 0000000..6a453b9
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/elective/elective-edit-client.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+/**
+ * 管理端选修课编辑表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - 单查 adminElective(id: ID!):❌ schema 无 → MSW 兜底(@contract-pending)
+ * - mutation updateElective(id, input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:FormPageSkeleton(初始数据加载)
+ * - error:errorSummary 表单级错误
+ * - success:notify.success + router.push 回详情页
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { BookOpen } from "lucide-react";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useState, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAdminElective,
+ useGrades,
+ useTeacherOptions,
+} from "@/lib/api/admin-p5";
+import { FormPageShell } from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ formatElectiveStatus,
+ getGradeName,
+ getSubjectName,
+ type FlexibleElectiveItem,
+} from "@/features/admin/elective/transformations";
+
+/** 状态选项(与 admin.elective.list i18n 对齐) */
+const STATUS_OPTIONS = ["DRAFT", "OPEN", "CLOSED", "FULL"] as const;
+
+/**
+ * 编辑表单客户端主体。需由 server page 包裹在 中。
+ */
+export function ElectiveEditClient(): React.ReactElement {
+ const t = useTranslations("admin.elective.edit");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const params = useParams<{ id: string }>();
+ const electiveId = params?.id ?? "";
+ const [, startTransition] = useTransition();
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminElective(electiveId);
+ const { data: grades, loading: gradesLoading } = useGrades();
+ const { data: teachers, loading: teachersLoading } = useTeacherOptions();
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [subjectId, setSubjectId] = useState("");
+ const [gradeId, setGradeId] = useState("");
+ const [teacherId, setTeacherId] = useState("");
+ const [capacity, setCapacity] = useState("");
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+ const [status, setStatus] = useState("DRAFT");
+ const [submitting, setSubmitting] = useState(false);
+ const [formError, setFormError] = useState(null);
+ const [initialized, setInitialized] = useState(false);
+
+ // 数据到达后预填表单
+ useEffect(() => {
+ if (data && !initialized) {
+ const flexible = data as unknown as FlexibleElectiveItem;
+ setName(data.name ?? "");
+ setDescription(data.description ?? "");
+ setSubjectId(getSubjectName(flexible));
+ setGradeId(data.gradeId ?? getGradeName(flexible));
+ setTeacherId(data.teacherId ?? "");
+ setCapacity(
+ typeof data.capacity === "number" ? String(data.capacity) : "",
+ );
+ setStatus(data.status ?? "DRAFT");
+ setInitialized(true);
+ }
+ }, [data, initialized]);
+
+ const handleSubmit = async (): Promise => {
+ setFormError(null);
+
+ if (!name.trim()) {
+ setFormError(t("errorNameRequired"));
+ return;
+ }
+ if (!subjectId.trim()) {
+ setFormError(t("errorSubjectRequired"));
+ return;
+ }
+ const capacityNum = Number.parseInt(capacity, 10);
+ if (capacity && (!Number.isFinite(capacityNum) || capacityNum <= 0)) {
+ setFormError(t("errorCapacityInvalid"));
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ // @contract-pending:updateElective mutation 契约未补齐
+ // 当前通过 MSW 兜底模拟提交成功,后端补齐后切换为真实 mutation
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ notify.success(t("success"));
+ startTransition(() => {
+ router.push(`/shell/admin/elective/${electiveId}`);
+ });
+ } catch (err) {
+ setFormError(`${t("error")}: ${String(err)}`);
+ notify.error(tCommon("error.loadFailed", { message: String(err) }));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ }
+ backHref={`/shell/admin/elective/${electiveId}`}
+ loading
+ />
+ );
+ }
+
+ if (error || (!data && !loading)) {
+ return (
+ }
+ backHref={`/shell/admin/elective/${electiveId}`}
+ errorSummary={
+ {t("notFound")}
+ }
+ />
+ );
+ }
+
+ return (
+ }
+ backHref={`/shell/admin/elective/${electiveId}`}
+ onSubmit={handleSubmit}
+ submitting={submitting}
+ submitLabel={t("submit")}
+ cancelLabel={t("cancel")}
+ errorSummary={
+ formError ? (
+ {formError}
+ ) : undefined
+ }
+ >
+ {/* 名称 */}
+
+ setName(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder={t("fieldName")}
+ required
+ />
+
+
+ {/* 描述 */}
+
+
+
+
+ {/* 科目(@contract-pending:无 useSubjectOptions hook,文本输入兜底) */}
+
+ setSubjectId(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder="sub-math"
+ required
+ />
+
+
+ {/* 年级 */}
+
+
+
+
+
+
+ {/* 教师 */}
+
+
+
+
+ {/* 容量 */}
+
+ setCapacity(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder="30"
+ />
+
+
+
+
+ {/* 开始日期 */}
+
+ setStartDate(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ />
+
+
+ {/* 结束日期 */}
+
+ setEndDate(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ />
+
+
+
+ {/* 状态 */}
+
+
+
+
+ );
+}
+
+/**
+ * 表单字段容器(label + children)。
+ */
+function FormField({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/elective/elective-list-client.tsx b/apps/portal-shell/src/features/admin/elective/elective-list-client.tsx
new file mode 100644
index 0000000..9295a6a
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/elective/elective-list-client.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+/**
+ * 管理端选修课列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - 列表查询 adminElectives:❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * URL 状态:?search=&status=&page=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { BookOpen } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useAdminElectives } from "@/lib/api/admin-p5";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ calcEnrollmentRate,
+ electiveStatusToBadgeClass,
+ enrollmentRateToColorClass,
+ formatEnrollmentCount,
+ formatElectiveStatus,
+ getEnrolledCount,
+ getGradeName,
+ getSubjectName,
+ isValidElectiveStatus,
+ matchElectiveSearch,
+ matchElectiveStatus,
+ type FlexibleElectiveItem,
+} from "@/features/admin/elective/transformations";
+
+/** 状态筛选选项(与 admin.elective.list i18n 对齐) */
+const STATUS_OPTIONS = ["DRAFT", "OPEN", "CLOSED", "FULL"] as const;
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function ElectiveListClient(): React.ReactElement {
+ const t = useTranslations("admin.elective.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const search = searchParams.get("search") ?? "";
+ const statusParam = searchParams.get("status") ?? "";
+ const status = isValidElectiveStatus(statusParam) ? statusParam : "";
+ const pageParam = searchParams.get("page") ?? "1";
+ const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminElectives();
+
+ // 客户端二次筛选(search + status)
+ const filteredItems = useMemo(() => {
+ const items = (data?.items ?? []) as FlexibleElectiveItem[];
+ return items.filter((item) => {
+ return (
+ matchElectiveSearch(item, search) && matchElectiveStatus(item, status)
+ );
+ });
+ }, [data, search, status]);
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ // 切换筛选时重置页码
+ if (key === "status" || key === "search") {
+ params.delete("page");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/elective?${params.toString()}`);
+ });
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+ <>
+ updateQuery("search", v)}
+ />
+
+ >
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredItems.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: data?.total ?? 0 })}
+ ·
+ 第 {page} 页
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * 选修课列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function ElectiveTable({
+ items,
+ page,
+}: {
+ items: FlexibleElectiveItem[];
+ page: number;
+}): React.ReactElement {
+ const t = useTranslations("admin.elective.list");
+ return (
+
+
+
+
+ | {t("colName")} |
+ {t("colSubject")} |
+ {t("colGrade")} |
+ {t("colTeacher")} |
+ {t("colEnrolled")} |
+ {t("colStatus")} |
+ {t("colActions")} |
+
+
+
+ {items.map((item) => {
+ const id = item.id ?? "";
+ const enrolled = getEnrolledCount(item);
+ const capacity =
+ typeof item.capacity === "number" &&
+ Number.isFinite(item.capacity)
+ ? item.capacity
+ : 0;
+ const rate = calcEnrollmentRate({
+ capacity,
+ enrolledCount: enrolled,
+ });
+ return (
+
+ |
+
+ {item.name ?? "-"}
+
+ |
+
+ {getSubjectName(item)}
+ |
+
+ {getGradeName(item)}
+ |
+
+ {item.teacherName || "-"}
+ |
+
+
+
+ {formatEnrollmentCount({
+ capacity,
+ enrolledCount: enrolled,
+ })}
+
+
+ {rate}%
+
+
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+/**
+ * 选修课状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatElectiveStatus(status);
+ const cls = electiveStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/elective/transformations.ts b/apps/portal-shell/src/features/admin/elective/transformations.ts
new file mode 100644
index 0000000..647ee2f
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/elective/transformations.ts
@@ -0,0 +1,280 @@
+/**
+ * 选修课管理数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+/** 选修课状态中文标签映射(与 admin.elective.list i18n 对齐) */
+export const ELECTIVE_STATUS_LABEL: Record = {
+ DRAFT: "草稿",
+ OPEN: "报名中",
+ CLOSED: "已关闭",
+ FULL: "已满",
+};
+
+/** 已知选修课状态枚举 */
+export type AdminElectiveStatus = "DRAFT" | "OPEN" | "CLOSED" | "FULL";
+
+/**
+ * 防御性读取字段:兼容 schema 声明的字段名(subjectName)与 MSW mock 字段名(subject)。
+ * 用于规避 @contract-pending 阶段的 mock/schema 形状不一致。
+ */
+export interface FlexibleElectiveItem {
+ id?: string;
+ name?: string;
+ description?: string;
+ subjectId?: string;
+ subjectName?: string;
+ subject?: unknown;
+ gradeId?: string;
+ gradeName?: string;
+ gradeLevel?: unknown;
+ teacherId?: string;
+ teacherName?: string;
+ capacity?: number;
+ selectedCount?: number;
+ enrolledCount?: unknown;
+ status?: string;
+ startDate?: string;
+ endDate?: string;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+/**
+ * 将选修课状态枚举值映射为中文标签。
+ * 未知状态回退为原始值。
+ */
+export function formatElectiveStatus(status: string): string {
+ return ELECTIVE_STATUS_LABEL[status] ?? status;
+}
+
+/**
+ * 根据选修课状态返回 Tailwind 徽章语义类名。
+ */
+export function electiveStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "DRAFT":
+ return "bg-muted text-muted-foreground";
+ case "OPEN":
+ return "bg-primary/10 text-primary";
+ case "CLOSED":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ case "FULL":
+ return "bg-destructive/10 text-destructive";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/**
+ * 安全读取科目名称(防御 MSW 与 schema 字段不一致)。
+ * 优先返回 subjectName,缺失时回退到 subject,最终回退占位符。
+ */
+export function getSubjectName(item: FlexibleElectiveItem): string {
+ if (typeof item.subjectName === "string" && item.subjectName) {
+ return item.subjectName;
+ }
+ if (typeof item.subject === "string" && item.subject) {
+ return item.subject;
+ }
+ return "--";
+}
+
+/**
+ * 安全读取年级名称(防御 MSW 与 schema 字段不一致)。
+ * 优先返回 gradeName,缺失时回退到 gradeLevel,最终回退占位符。
+ */
+export function getGradeName(item: FlexibleElectiveItem): string {
+ if (typeof item.gradeName === "string" && item.gradeName) {
+ return item.gradeName;
+ }
+ if (typeof item.gradeLevel === "string" && item.gradeLevel) {
+ return item.gradeLevel;
+ }
+ return "--";
+}
+
+/**
+ * 安全读取已选人数(防御 MSW 与 schema 字段不一致)。
+ * 优先返回 selectedCount,缺失时回退到 enrolledCount,最终回退 0。
+ */
+export function getEnrolledCount(item: FlexibleElectiveItem): number {
+ if (
+ typeof item.selectedCount === "number" &&
+ Number.isFinite(item.selectedCount)
+ ) {
+ return item.selectedCount;
+ }
+ if (
+ typeof item.enrolledCount === "number" &&
+ Number.isFinite(item.enrolledCount)
+ ) {
+ return item.enrolledCount;
+ }
+ return 0;
+}
+
+/**
+ * 安全读取容量(防御 schema 字段缺失)。
+ */
+export function getCapacity(item: FlexibleElectiveItem): number {
+ if (typeof item.capacity === "number" && Number.isFinite(item.capacity)) {
+ return item.capacity;
+ }
+ return 0;
+}
+
+/**
+ * 计算报名率(enrolled / capacity * 100)。
+ * capacity 为 0 或输入无效时返回 0。
+ */
+export function calcEnrollmentRate(item: {
+ capacity: number;
+ enrolledCount: number;
+}): number {
+ if (
+ !Number.isFinite(item.capacity) ||
+ !Number.isFinite(item.enrolledCount) ||
+ item.capacity <= 0
+ ) {
+ return 0;
+ }
+ const ratio = item.enrolledCount / item.capacity;
+ if (ratio < 0) return 0;
+ if (ratio > 1) return 100;
+ return Math.round(ratio * 100);
+}
+
+/**
+ * 根据报名率(0-100)返回 Tailwind 文本语义类名。
+ * - 100(已满) → destructive
+ * - >= 90 → destructive
+ * - >= 50 → amber
+ * - > 0 → primary
+ * - == 0 → muted
+ */
+export function enrollmentRateToColorClass(rate: number): string {
+ if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
+ return "text-muted-foreground";
+ }
+ if (rate >= 90) return "text-destructive";
+ if (rate >= 50) return "text-amber-600 dark:text-amber-400";
+ if (rate > 0) return "text-primary";
+ return "text-muted-foreground";
+}
+
+/**
+ * 格式化报名进度展示(如 "20/30")。
+ * 输入无效返回 "--"。
+ */
+export function formatEnrollmentCount(item: {
+ capacity: number;
+ enrolledCount: number;
+}): string {
+ if (!Number.isFinite(item.capacity) || !Number.isFinite(item.enrolledCount)) {
+ return "--";
+ }
+ return `${item.enrolledCount}/${item.capacity}`;
+}
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效时返回占位符。
+ */
+export function formatElectiveDate(isoDate: string | null | undefined): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * 格式化 ISO 日期为仅日期(YYYY-MM-DD)。
+ * 输入无效时返回占位符。
+ */
+export function formatElectiveDateOnly(
+ isoDate: string | null | undefined,
+): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+/**
+ * 判断选修课是否可编辑(DRAFT 状态)。
+ */
+export function isElectiveEditable(status: string): boolean {
+ return status === "DRAFT";
+}
+
+/**
+ * 判断选修课状态字符串是否合法。
+ */
+export function isValidElectiveStatus(
+ status: string,
+): status is AdminElectiveStatus {
+ return (
+ status === "DRAFT" ||
+ status === "OPEN" ||
+ status === "CLOSED" ||
+ status === "FULL"
+ );
+}
+
+/**
+ * 判断选修课是否还有名额(enrolled < capacity)。
+ */
+export function hasAvailableSpot(item: {
+ capacity: number;
+ enrolledCount: number;
+}): boolean {
+ if (
+ !Number.isFinite(item.capacity) ||
+ !Number.isFinite(item.enrolledCount) ||
+ item.capacity <= 0
+ ) {
+ return false;
+ }
+ return item.enrolledCount < item.capacity;
+}
+
+/**
+ * 客户端搜索匹配:在 name / subjectName / teacherName 字段中匹配关键词(大小写不敏感)。
+ */
+export function matchElectiveSearch(
+ item: FlexibleElectiveItem,
+ q: string,
+): boolean {
+ if (!q) return true;
+ const lower = q.toLowerCase();
+ const name = (item.name ?? "").toLowerCase();
+ const subject = getSubjectName(item).toLowerCase();
+ const teacher = (item.teacherName ?? "").toLowerCase();
+ return (
+ name.includes(lower) || subject.includes(lower) || teacher.includes(lower)
+ );
+}
+
+/**
+ * 客户端按状态过滤。
+ * status 为空字符串或 null 时不过滤。
+ */
+export function matchElectiveStatus(
+ item: FlexibleElectiveItem,
+ status: string | null | undefined,
+): boolean {
+ if (!status) return true;
+ return item.status === status;
+}
diff --git a/apps/portal-shell/src/features/admin/error-book/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/error-book/__tests__/transformations.test.ts
new file mode 100644
index 0000000..1abf179
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/error-book/__tests__/transformations.test.ts
@@ -0,0 +1,338 @@
+/**
+ * Admin Error Book 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { AdminErrorBookStats } from "@/lib/api/admin-p5";
+
+import {
+ computeAvgPerStudent,
+ countHighFreqErrors,
+ errorRateToColorClass,
+ filterStatsBySubject,
+ formatAvgPerStudent,
+ formatErrorCount,
+ formatErrorRate,
+ getSubjectTabs,
+ hasErrorBookData,
+ isHighFreqError,
+ sortSubjectsByErrorRate,
+ topWrongQuestions,
+ truncateContent,
+ withStudentRank,
+} from "../transformations";
+
+const baseStats: AdminErrorBookStats = {
+ totalStudents: 100,
+ totalErrorQuestions: 50,
+ totalErrorCount: 200,
+ avgErrorRate: 0.4,
+ bySubject: [
+ {
+ subjectId: "sub-1",
+ subjectName: "数学",
+ errorCount: 80,
+ questionCount: 20,
+ errorRate: 0.8,
+ },
+ {
+ subjectId: "sub-2",
+ subjectName: "语文",
+ errorCount: 60,
+ questionCount: 20,
+ errorRate: 0.5,
+ },
+ {
+ subjectId: "sub-3",
+ subjectName: "英语",
+ errorCount: 40,
+ questionCount: 20,
+ errorRate: 0.2,
+ },
+ ],
+ topStudents: [
+ {
+ studentId: "stu-1",
+ studentName: "张三",
+ className: "三年级1班",
+ errorCount: 30,
+ errorRate: 0.9,
+ },
+ {
+ studentId: "stu-2",
+ studentName: "李四",
+ className: "三年级2班",
+ errorCount: 25,
+ errorRate: 0.7,
+ },
+ ],
+ topWrongQuestions: [
+ {
+ questionId: "q-1",
+ content: "二次函数最值",
+ errorCount: 50,
+ errorRate: 0.85,
+ },
+ {
+ questionId: "q-2",
+ content: "几何证明",
+ errorCount: 30,
+ errorRate: 0.4,
+ },
+ ],
+};
+
+describe("formatErrorCount", () => {
+ it("formats positive counts", () => {
+ expect(formatErrorCount(0)).toBe("0 次");
+ expect(formatErrorCount(5)).toBe("5 次");
+ expect(formatErrorCount(200)).toBe("200 次");
+ });
+
+ it("returns placeholder for null/undefined", () => {
+ expect(formatErrorCount(null)).toBe("--");
+ expect(formatErrorCount(undefined)).toBe("--");
+ });
+
+ it("returns placeholder for negative or non-finite", () => {
+ expect(formatErrorCount(-1)).toBe("--");
+ expect(formatErrorCount(Number.NaN)).toBe("--");
+ });
+});
+
+describe("formatErrorRate", () => {
+ it("formats rate in [0,1] as percentage", () => {
+ expect(formatErrorRate(0)).toBe("0%");
+ expect(formatErrorRate(0.5)).toBe("50%");
+ expect(formatErrorRate(1)).toBe("100%");
+ });
+
+ it("returns placeholder for out-of-range or non-finite", () => {
+ expect(formatErrorRate(-0.1)).toBe("--");
+ expect(formatErrorRate(1.1)).toBe("--");
+ expect(formatErrorRate(Number.NaN)).toBe("--");
+ });
+});
+
+describe("errorRateToColorClass", () => {
+ it("returns destructive for high error rate", () => {
+ expect(errorRateToColorClass(0.6)).toBe("text-destructive");
+ expect(errorRateToColorClass(0.9)).toBe("text-destructive");
+ });
+
+ it("returns amber for medium error rate", () => {
+ expect(errorRateToColorClass(0.3)).toContain("amber");
+ expect(errorRateToColorClass(0.59)).toContain("amber");
+ });
+
+ it("returns emerald for low error rate", () => {
+ expect(errorRateToColorClass(0.1)).toContain("emerald");
+ expect(errorRateToColorClass(0.29)).toContain("emerald");
+ });
+
+ it("returns muted for non-finite or out-of-range input", () => {
+ expect(errorRateToColorClass(Number.NaN)).toBe("text-muted-foreground");
+ expect(errorRateToColorClass(null)).toBe("text-muted-foreground");
+ expect(errorRateToColorClass(undefined)).toBe("text-muted-foreground");
+ expect(errorRateToColorClass(1.5)).toBe("text-muted-foreground");
+ });
+});
+
+describe("isHighFreqError", () => {
+ it("returns true for rate >= 0.6", () => {
+ expect(isHighFreqError(0.6)).toBe(true);
+ expect(isHighFreqError(1)).toBe(true);
+ });
+
+ it("returns false for rate < 0.6", () => {
+ expect(isHighFreqError(0.59)).toBe(false);
+ expect(isHighFreqError(0)).toBe(false);
+ });
+
+ it("returns false for null/undefined/non-finite", () => {
+ expect(isHighFreqError(null)).toBe(false);
+ expect(isHighFreqError(undefined)).toBe(false);
+ expect(isHighFreqError(Number.NaN)).toBe(false);
+ });
+});
+
+describe("computeAvgPerStudent", () => {
+ it("computes avg = totalErrors / totalStudents", () => {
+ expect(computeAvgPerStudent(200, 100)).toBe(2);
+ expect(computeAvgPerStudent(0, 100)).toBe(0);
+ });
+
+ it("returns 0 when totalStudents is 0 or invalid", () => {
+ expect(computeAvgPerStudent(100, 0)).toBe(0);
+ expect(computeAvgPerStudent(100, null)).toBe(0);
+ expect(computeAvgPerStudent(100, -1)).toBe(0);
+ expect(computeAvgPerStudent(100, Number.NaN)).toBe(0);
+ });
+
+ it("returns 0 when totalErrors is invalid", () => {
+ expect(computeAvgPerStudent(null, 100)).toBe(0);
+ expect(computeAvgPerStudent(-5, 100)).toBe(0);
+ });
+});
+
+describe("formatAvgPerStudent", () => {
+ it("formats avg with one decimal", () => {
+ expect(formatAvgPerStudent(200, 100)).toBe("2.0");
+ expect(formatAvgPerStudent(150, 100)).toBe("1.5");
+ });
+
+ it("returns 0.0 when avg is 0 or invalid", () => {
+ expect(formatAvgPerStudent(0, 100)).toBe("0.0");
+ expect(formatAvgPerStudent(100, 0)).toBe("0.0");
+ });
+});
+
+describe("getSubjectTabs", () => {
+ it("extracts subject tabs from stats", () => {
+ const tabs = getSubjectTabs(baseStats);
+ expect(tabs).toHaveLength(3);
+ expect(tabs[0]).toEqual({ subjectId: "sub-1", subjectName: "数学" });
+ });
+
+ it("returns empty array for null/undefined stats", () => {
+ expect(getSubjectTabs(null)).toEqual([]);
+ expect(getSubjectTabs(undefined)).toEqual([]);
+ });
+});
+
+describe("filterStatsBySubject", () => {
+ it("returns original stats when subjectId is empty", () => {
+ const filtered = filterStatsBySubject(baseStats, "");
+ expect(filtered).toBe(baseStats);
+ });
+
+ it("converges totals to the matched subject", () => {
+ const filtered = filterStatsBySubject(baseStats, "sub-1");
+ expect(filtered?.totalErrorCount).toBe(80);
+ expect(filtered?.totalErrorQuestions).toBe(20);
+ expect(filtered?.avgErrorRate).toBe(0.8);
+ });
+
+ it("returns original stats when subject not found", () => {
+ const filtered = filterStatsBySubject(baseStats, "unknown");
+ expect(filtered).toBe(baseStats);
+ });
+
+ it("returns null for null stats", () => {
+ expect(filterStatsBySubject(null, "sub-1")).toBeNull();
+ });
+});
+
+describe("sortSubjectsByErrorRate", () => {
+ it("sorts by errorRate descending", () => {
+ const sorted = sortSubjectsByErrorRate(baseStats);
+ expect(sorted[0]?.subjectId).toBe("sub-1");
+ expect(sorted[1]?.subjectId).toBe("sub-2");
+ expect(sorted[2]?.subjectId).toBe("sub-3");
+ });
+
+ it("returns empty array for null/undefined stats", () => {
+ expect(sortSubjectsByErrorRate(null)).toEqual([]);
+ expect(sortSubjectsByErrorRate(undefined)).toEqual([]);
+ });
+});
+
+describe("withStudentRank", () => {
+ it("injects rank starting from 1", () => {
+ const ranked = withStudentRank(baseStats.topStudents);
+ expect(ranked[0]?.rank).toBe(1);
+ expect(ranked[1]?.rank).toBe(2);
+ expect(ranked[0]?.studentId).toBe("stu-1");
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(withStudentRank([])).toEqual([]);
+ });
+});
+
+describe("topWrongQuestions", () => {
+ it("sorts by errorCount descending and slices to limit", () => {
+ const top = topWrongQuestions(baseStats.topWrongQuestions, 1);
+ expect(top).toHaveLength(1);
+ expect(top[0]?.questionId).toBe("q-1");
+ });
+
+ it("returns empty array for null/undefined input", () => {
+ expect(topWrongQuestions(null)).toEqual([]);
+ expect(topWrongQuestions(undefined)).toEqual([]);
+ });
+
+ it("respects default limit of 10", () => {
+ const many = Array.from({ length: 15 }, (_, i) => ({
+ questionId: `q-${i}`,
+ content: `c-${i}`,
+ errorCount: i,
+ errorRate: 0.1,
+ }));
+ expect(topWrongQuestions(many)).toHaveLength(10);
+ });
+});
+
+describe("countHighFreqErrors", () => {
+ it("counts questions with errorRate >= 0.6", () => {
+ expect(countHighFreqErrors(baseStats.topWrongQuestions)).toBe(1);
+ });
+
+ it("returns 0 for null/undefined", () => {
+ expect(countHighFreqErrors(null)).toBe(0);
+ expect(countHighFreqErrors(undefined)).toBe(0);
+ expect(countHighFreqErrors([])).toBe(0);
+ });
+});
+
+describe("hasErrorBookData", () => {
+ it("returns true when stats has data", () => {
+ expect(hasErrorBookData(baseStats)).toBe(true);
+ });
+
+ it("returns false for null/undefined", () => {
+ expect(hasErrorBookData(null)).toBe(false);
+ expect(hasErrorBookData(undefined)).toBe(false);
+ });
+
+ it("returns false for empty stats", () => {
+ const empty: AdminErrorBookStats = {
+ totalStudents: 0,
+ totalErrorQuestions: 0,
+ totalErrorCount: 0,
+ avgErrorRate: 0,
+ bySubject: [],
+ topStudents: [],
+ topWrongQuestions: [],
+ };
+ expect(hasErrorBookData(empty)).toBe(false);
+ });
+});
+
+describe("truncateContent", () => {
+ it("returns content as-is when shorter than max", () => {
+ expect(truncateContent("短内容", 60)).toBe("短内容");
+ });
+
+ it("truncates and appends ellipsis when longer than max", () => {
+ const long = "这是一段很长的题目内容".repeat(20);
+ const result = truncateContent(long, 10);
+ expect(result.endsWith("...")).toBe(true);
+ expect(result.length).toBe(13);
+ });
+
+ it("returns placeholder for empty/whitespace/null", () => {
+ expect(truncateContent("")).toBe("--");
+ expect(truncateContent(" ")).toBe("--");
+ expect(truncateContent(null)).toBe("--");
+ expect(truncateContent(undefined)).toBe("--");
+ });
+
+ it("uses default max of 60 when not specified", () => {
+ const long = "x".repeat(80);
+ const result = truncateContent(long);
+ expect(result.endsWith("...")).toBe(true);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/error-book/error-book-client.tsx b/apps/portal-shell/src/features/admin/error-book/error-book-client.tsx
new file mode 100644
index 0000000..bc1cfc5
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/error-book/error-book-client.tsx
@@ -0,0 +1,490 @@
+"use client";
+
+/**
+ * 错题本分析页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - adminErrorBookStats():❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * URL 状态:?subjectId=xxx
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { BookX } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useAdminErrorBookStats } from "@/lib/api";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ countHighFreqErrors,
+ errorRateToColorClass,
+ filterStatsBySubject,
+ formatAvgPerStudent,
+ formatErrorCount,
+ formatErrorRate,
+ getSubjectTabs,
+ hasErrorBookData,
+ isHighFreqError,
+ sortSubjectsByErrorRate,
+ topWrongQuestions,
+ truncateContent,
+ withStudentRank,
+} from "@/features/admin/error-book/transformations";
+
+/**
+ * 错题本分析客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function ErrorBookClient(): React.ReactElement {
+ const t = useTranslations("admin.errorBook.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const subjectId = searchParams.get("subjectId") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data: rawStats, loading, error } = useAdminErrorBookStats();
+
+ const stats = useMemo(
+ () => filterStatsBySubject(rawStats ?? null, subjectId),
+ [rawStats, subjectId],
+ );
+
+ const subjectTabs = useMemo(
+ () => getSubjectTabs(rawStats ?? null),
+ [rawStats],
+ );
+
+ const updateSubject = (next: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (next) {
+ params.set("subjectId", next);
+ } else {
+ params.delete("subjectId");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/error-book?${params.toString()}`);
+ });
+ };
+
+ const hasData = hasErrorBookData(stats);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ filters={
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={!hasData && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+ {stats ? (
+
+ ) : null}
+ {t("mswNotice")}
+
+ );
+}
+
+/**
+ * 学科 Tab 列表(全部 + 各学科)。
+ */
+function SubjectTabs({
+ subjectId,
+ tabs,
+ allLabel,
+ onChange,
+}: {
+ subjectId: string;
+ tabs: Array<{ subjectId: string; subjectName: string }>;
+ allLabel: string;
+ onChange: (next: string) => void;
+}): React.ReactElement {
+ return (
+
+ onChange("")}
+ />
+ {tabs.map((tab) => (
+ onChange(tab.subjectId)}
+ />
+ ))}
+
+ );
+}
+
+function TabButton({
+ active,
+ label,
+ onClick,
+}: {
+ active: boolean;
+ label: string;
+ onClick: () => void;
+}): React.ReactElement {
+ return (
+
+ );
+}
+
+/**
+ * 错题本分析主体内容(统计卡片 + 学科分布 + 薄弱点 + Top 学生 + Top 错题)。
+ */
+function ErrorBookContent({
+ stats,
+ highFreqTotal,
+}: {
+ stats: NonNullable["data"]>;
+ highFreqTotal: number;
+}): React.ReactElement {
+ const t = useTranslations("admin.errorBook.list");
+ const avgPerStudent = formatAvgPerStudent(
+ stats.totalErrorCount,
+ stats.totalStudents,
+ );
+
+ const subjectDistribution = sortSubjectsByErrorRate(stats);
+ const rankedStudents = withStudentRank(stats.topStudents).slice(0, 50);
+ const wrongQuestions = topWrongQuestions(stats.topWrongQuestions, 10);
+
+ return (
+
+ {/* 统计卡片 */}
+
+
+
+
+
+
+
+
+ {/* 学科分布 */}
+
+
+
+ {t("distributionTitle")}
+
+ {subjectDistribution.length === 0 ? (
+
+ {t("emptyTitle")}
+
+ ) : (
+
+
+
+
+ |
+ {t("distributionSubject")}
+ |
+
+ {t("distributionCount")}
+ |
+
+ {t("knowledgeWeaknessErrorRate")}
+ |
+
+
+
+ {subjectDistribution.map((s) => (
+
+ | {s.subjectName} |
+
+ {formatErrorCount(s.errorCount)}
+ |
+
+ {formatErrorRate(s.errorRate)}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* 章节薄弱点 + 知识点薄弱点(基于当前契约数据派生) */}
+
+
+
+
+
+ {/* Top 50 学生 */}
+
+
+
+ {t("topStudentsTitle")}
+
+ {rankedStudents.length === 0 ? (
+
+ {t("emptyTitle")}
+
+ ) : (
+
+
+
+
+ |
+ {t("topStudentsRank")}
+ |
+
+ {t("topStudentsName")}
+ |
+
+ {t("knowledgeWeaknessPoint")}
+ |
+
+ {t("topStudentsErrorCount")}
+ |
+
+
+
+ {rankedStudents.map((stu) => (
+
+ |
+ #{stu.rank}
+ |
+ {stu.studentName} |
+
+ {stu.className}
+ |
+
+ {formatErrorCount(stu.errorCount)}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* Top 10 高频错题 */}
+
+
+
+ {t("topWrongQuestionsTitle")}
+
+ {wrongQuestions.length === 0 ? (
+
+ {t("emptyTitle")}
+
+ ) : (
+
+
+
+
+ |
+ {t("topWrongQuestionsContent")}
+ |
+
+ {t("topWrongQuestionsErrorCount")}
+ |
+
+ {t("knowledgeWeaknessErrorRate")}
+ |
+
+
+
+ {wrongQuestions.map((q) => (
+
+ | {truncateContent(q.content, 80)} |
+
+ {formatErrorCount(q.errorCount)}
+ |
+
+ {formatErrorRate(q.errorRate)}
+ {isHighFreqError(q.errorRate) ? (
+
+ {t("statsHighFreqErrors")}
+
+ ) : null}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * 章节薄弱点卡片。
+ *
+ * 当前契约未提供章节维度数据,从 bySubject 派生薄弱学科作为代理视图。
+ */
+function ChapterWeaknessCard({
+ stats,
+}: {
+ stats: NonNullable["data"]>;
+}): React.ReactElement {
+ const t = useTranslations("admin.errorBook.list");
+ const rows = sortSubjectsByErrorRate(stats).slice(0, 5);
+ return (
+
+
+
+ {t("chapterWeaknessTitle")}
+
+ {rows.length === 0 ? (
+
+ {t("emptyTitle")}
+
+ ) : (
+
+
+
+ |
+ {t("chapterWeaknessChapter")}
+ |
+
+ {t("chapterWeaknessErrorRate")}
+ |
+
+
+
+ {rows.map((s) => (
+
+ | {s.subjectName} |
+
+ {formatErrorRate(s.errorRate)}
+ |
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+/**
+ * 知识点薄弱点卡片。
+ *
+ * 当前契约未提供知识点维度数据,从 topWrongQuestions 派生题目级薄弱点。
+ */
+function KnowledgeWeaknessCard({
+ stats,
+}: {
+ stats: NonNullable["data"]>;
+}): React.ReactElement {
+ const t = useTranslations("admin.errorBook.list");
+ const rows = topWrongQuestions(stats.topWrongQuestions, 5);
+ return (
+
+
+
+ {t("knowledgeWeaknessTitle")}
+
+ {rows.length === 0 ? (
+
+ {t("emptyTitle")}
+
+ ) : (
+
+
+
+ |
+ {t("knowledgeWeaknessPoint")}
+ |
+
+ {t("knowledgeWeaknessErrorRate")}
+ |
+
+
+
+ {rows.map((q) => (
+
+ | {truncateContent(q.content, 40)} |
+
+ {formatErrorRate(q.errorRate)}
+ |
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/error-book/transformations.ts b/apps/portal-shell/src/features/admin/error-book/transformations.ts
new file mode 100644
index 0000000..7c443a9
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/error-book/transformations.ts
@@ -0,0 +1,190 @@
+/**
+ * Admin Error Book 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type { AdminErrorBookStats } from "@/lib/api/admin-p5";
+
+/** 学科维度统计项(从 AdminErrorBookStats.bySubject 派生) */
+type SubjectStat = AdminErrorBookStats["bySubject"][number];
+
+/**
+ * 格式化错误次数为展示字符串。
+ * 输入无效返回 "--"。
+ */
+export function formatErrorCount(count: number | null | undefined): string {
+ if (count == null || !Number.isFinite(count) || count < 0) return "--";
+ return `${count} 次`;
+}
+
+/**
+ * 格式化错误率(0-1 浮点)为百分比字符串。
+ * 输入无效或越界返回 "--"。
+ */
+export function formatErrorRate(rate: number | null | undefined): string {
+ if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1)
+ return "--";
+ return `${Math.round(rate * 100)}%`;
+}
+
+/**
+ * 根据错误率(0-1)返回 Tailwind 文本语义类名。
+ * - >= 0.6 → destructive(高错误率)
+ * - >= 0.3 → amber(中错误率)
+ * - 其他 → emerald(低错误率)
+ */
+export function errorRateToColorClass(rate: number | null | undefined): string {
+ if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
+ return "text-muted-foreground";
+ }
+ if (rate >= 0.6) return "text-destructive";
+ if (rate >= 0.3) return "text-amber-600 dark:text-amber-400";
+ return "text-emerald-600 dark:text-emerald-400";
+}
+
+/**
+ * 判断是否为高频错题(错误率 >= 0.6)。
+ */
+export function isHighFreqError(rate: number | null | undefined): boolean {
+ return rate != null && Number.isFinite(rate) && rate >= 0.6;
+}
+
+/**
+ * 计算人均错误次数(总错误次数 / 学生总数)。
+ * 学生数为 0 或无效时返回 0。
+ */
+export function computeAvgPerStudent(
+ totalErrorCount: number | null | undefined,
+ totalStudents: number | null | undefined,
+): number {
+ const errors = totalErrorCount ?? 0;
+ const students = totalStudents ?? 0;
+ if (!Number.isFinite(students) || students <= 0) return 0;
+ if (!Number.isFinite(errors) || errors < 0) return 0;
+ return errors / students;
+}
+
+/**
+ * 格式化人均错误次数为展示字符串(保留 1 位小数)。
+ */
+export function formatAvgPerStudent(
+ totalErrorCount: number | null | undefined,
+ totalStudents: number | null | undefined,
+): string {
+ const avg = computeAvgPerStudent(totalErrorCount, totalStudents);
+ if (!Number.isFinite(avg) || avg <= 0) return "0.0";
+ return avg.toFixed(1);
+}
+
+/**
+ * 从 stats.bySubject 提取学科 Tab 列表(保持原顺序)。
+ */
+export function getSubjectTabs(
+ stats: AdminErrorBookStats | null | undefined,
+): Array<{ subjectId: string; subjectName: string }> {
+ if (!stats?.bySubject) return [];
+ return stats.bySubject.map((s) => ({
+ subjectId: s.subjectId,
+ subjectName: s.subjectName,
+ }));
+}
+
+/**
+ * 按学科 ID 过滤 stats 视图。
+ * subjectId 为空字符串/null 时返回原 stats。
+ * 找不到匹配学科时返回原 stats(保留全局视角)。
+ */
+export function filterStatsBySubject(
+ stats: AdminErrorBookStats | null,
+ subjectId: string | null | undefined,
+): AdminErrorBookStats | null {
+ if (!stats) return null;
+ if (!subjectId) return stats;
+ const matched = stats.bySubject.find((s) => s.subjectId === subjectId);
+ if (!matched) return stats;
+ return {
+ ...stats,
+ // 学科筛选下,总量按该学科口径收敛
+ totalErrorCount: matched.errorCount,
+ totalErrorQuestions: matched.questionCount,
+ avgErrorRate: matched.errorRate,
+ };
+}
+
+/**
+ * 按错误率降序排序的学科分布列表。
+ */
+export function sortSubjectsByErrorRate(
+ stats: AdminErrorBookStats | null | undefined,
+): SubjectStat[] {
+ if (!stats?.bySubject) return [];
+ return [...stats.bySubject].sort((a, b) => b.errorRate - a.errorRate);
+}
+
+/**
+ * 给 topStudents 列表注入排名字段(rank 从 1 开始)。
+ */
+export function withStudentRank(
+ students: readonly T[],
+): Array {
+ return students.map((s, idx) => ({ ...s, rank: idx + 1 }));
+}
+
+/**
+ * 截取前 N 个高频错题(按 errorCount 降序)。
+ */
+export function topWrongQuestions(
+ questions: readonly T[] | null | undefined,
+ limit = 10,
+): T[] {
+ if (!questions || questions.length === 0) return [];
+ const safeLimit = Math.max(0, Math.floor(limit));
+ return [...questions]
+ .sort((a, b) => b.errorCount - a.errorCount)
+ .slice(0, safeLimit);
+}
+
+/**
+ * 统计高频错题数(errorRate >= 0.6)。
+ */
+export function countHighFreqErrors(
+ questions: readonly T[] | null | undefined,
+): number {
+ if (!questions) return 0;
+ return questions.reduce(
+ (acc, q) => acc + (isHighFreqError(q.errorRate) ? 1 : 0),
+ 0,
+ );
+}
+
+/**
+ * 判断 stats 是否有可展示数据(任一核心字段 > 0 即视为有数据)。
+ */
+export function hasErrorBookData(
+ stats: AdminErrorBookStats | null | undefined,
+): boolean {
+ if (!stats) return false;
+ return (
+ stats.totalStudents > 0 ||
+ stats.totalErrorQuestions > 0 ||
+ stats.totalErrorCount > 0 ||
+ (stats.bySubject?.length ?? 0) > 0 ||
+ (stats.topStudents?.length ?? 0) > 0 ||
+ (stats.topWrongQuestions?.length ?? 0) > 0
+ );
+}
+
+/**
+ * 截断题目内容到指定长度并追加省略号。
+ */
+export function truncateContent(
+ content: string | null | undefined,
+ max = 60,
+): string {
+ if (!content || content.trim().length === 0) return "--";
+ const safeMax = Math.max(1, Math.floor(max));
+ if (content.length <= safeMax) return content;
+ return `${content.slice(0, safeMax)}...`;
+}
diff --git a/apps/portal-shell/src/features/admin/files/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/files/__tests__/transformations.test.ts
new file mode 100644
index 0000000..71ea8c9
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/files/__tests__/transformations.test.ts
@@ -0,0 +1,95 @@
+/**
+ * Files 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ MIME_TYPE_LABEL,
+ formatFileDate,
+ formatFileSize,
+ formatMimeType,
+ mimeTypeToCategory,
+} from "../transformations";
+
+describe("formatFileSize", () => {
+ it("formats bytes correctly", () => {
+ expect(formatFileSize(0)).toBe("0 B");
+ expect(formatFileSize(512)).toBe("512 B");
+ expect(formatFileSize(1023)).toBe("1023 B");
+ });
+
+ it("formats kilobytes correctly", () => {
+ expect(formatFileSize(1024)).toBe("1.0 KB");
+ expect(formatFileSize(1536)).toBe("1.5 KB");
+ expect(formatFileSize(10240)).toBe("10 KB");
+ });
+
+ it("formats megabytes correctly", () => {
+ expect(formatFileSize(1048576)).toBe("1.0 MB");
+ expect(formatFileSize(10485760)).toBe("10 MB");
+ });
+
+ it("formats gigabytes correctly", () => {
+ expect(formatFileSize(1073741824)).toBe("1.0 GB");
+ });
+
+ it("returns 0 B for invalid input", () => {
+ expect(formatFileSize(-1)).toBe("0 B");
+ expect(formatFileSize(Number.NaN)).toBe("0 B");
+ expect(formatFileSize(Number.POSITIVE_INFINITY)).toBe("0 B");
+ expect(formatFileSize(Number.NEGATIVE_INFINITY)).toBe("0 B");
+ });
+});
+
+describe("formatFileDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatFileDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatFileDate(null)).toBe("--");
+ expect(formatFileDate(undefined)).toBe("--");
+ expect(formatFileDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatFileDate("not-a-date")).toBe("--");
+ });
+});
+
+describe("formatMimeType", () => {
+ it("maps known MIME types to Chinese labels", () => {
+ expect(formatMimeType("image/png")).toBe("图片");
+ expect(formatMimeType("application/pdf")).toBe("PDF");
+ expect(formatMimeType("text/plain")).toBe("文本");
+ expect(formatMimeType("application/zip")).toBe("压缩包");
+ });
+
+ it("returns original value for unknown MIME type", () => {
+ expect(formatMimeType("application/x-custom")).toBe("application/x-custom");
+ expect(formatMimeType("")).toBe("");
+ });
+
+ it("MIME_TYPE_LABEL covers common types", () => {
+ expect(Object.keys(MIME_TYPE_LABEL).length).toBeGreaterThan(5);
+ });
+});
+
+describe("mimeTypeToCategory", () => {
+ it("extracts main type from MIME", () => {
+ expect(mimeTypeToCategory("image/png")).toBe("image");
+ expect(mimeTypeToCategory("application/pdf")).toBe("application");
+ expect(mimeTypeToCategory("video/mp4")).toBe("video");
+ expect(mimeTypeToCategory("text/plain")).toBe("text");
+ });
+
+ it("returns unknown for empty/invalid input", () => {
+ expect(mimeTypeToCategory("")).toBe("unknown");
+ expect(mimeTypeToCategory("noslash")).toBe("unknown");
+ expect(mimeTypeToCategory("/png")).toBe("unknown");
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/files/files-list-client.tsx b/apps/portal-shell/src/features/admin/files/files-list-client.tsx
new file mode 100644
index 0000000..6d2ba05
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/files/files-list-client.tsx
@@ -0,0 +1,232 @@
+"use client";
+
+/**
+ * 文件管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 fileAttachments(filter) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - 统计查询 fileStats ❌ schema 无 → MSW 兜底
+ *
+ * URL 状态:无(搜索为客户端过滤,mimeType 筛选可通过扩展支持)
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { FileText, Files } from "lucide-react";
+import { useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { useFileAttachments, useFileStats } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import { notify } from "@/shared/lib/notify";
+import {
+ formatFileDate,
+ formatFileSize,
+ formatMimeType,
+} from "@/features/admin/files/transformations";
+
+/** 默认拉取条数(FileFilter.limit) */
+const DEFAULT_LIMIT = 100;
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 不使用,但 StatCard 等组件为 client-only)。
+ */
+export function FilesListClient(): React.ReactElement {
+ const t = useTranslations("admin.files.list");
+ const tCommon = useTranslations("common");
+ const [search, setSearch] = useState("");
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useFileAttachments({
+ limit: DEFAULT_LIMIT,
+ });
+ // @contract-pending:MSW 兜底
+ const { data: stats, loading: statsLoading } = useFileStats();
+
+ const allItems = data?.items ?? [];
+ const filteredItems = useMemo(() => {
+ if (!search.trim()) return allItems;
+ const q = search.trim().toLowerCase();
+ return allItems.filter((f) => f.name.toLowerCase().includes(q));
+ }, [allItems, search]);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredItems.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: filteredItems.length })}
+
+ }
+ >
+
+
+
+
+
+ );
+}
+
+/**
+ * 统计卡片组(总文件数 + 总大小 + 类型分布)。
+ */
+function FilesStatsCards({
+ stats,
+ loading,
+}: {
+ stats:
+ | { totalFiles: number; totalSize: number; byType: Record }
+ | null
+ | undefined;
+ loading: boolean;
+}): React.ReactElement {
+ const t = useTranslations("admin.files.list");
+ const totalFiles = stats?.totalFiles ?? 0;
+ const totalSize = stats?.totalSize ?? 0;
+ const byType = stats?.byType ?? {};
+ const typeCount = Object.keys(byType).length;
+ const typeBreakdown = Object.entries(byType)
+ .map(([type, count]) => `${formatMimeType(type)}: ${count}`)
+ .join(",");
+
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * 文件列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function FilesTable({
+ items,
+}: {
+ items: Array<{
+ id: string;
+ name: string;
+ size: number;
+ mimeType: string;
+ url: string;
+ uploadedBy: string;
+ uploadedAt: string;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("admin.files.list");
+ return (
+
+
+
+
+ | {t("colName")} |
+ {t("colSize")} |
+ {t("colMimeType")} |
+ {t("colUploadedBy")} |
+ {t("colUploadedAt")} |
+ {t("colActions")} |
+
+
+
+ {items.map((item) => (
+
+ | {item.name} |
+
+ {formatFileSize(item.size)}
+ |
+
+ {formatMimeType(item.mimeType)}
+ |
+ {item.uploadedBy} |
+
+ {formatFileDate(item.uploadedAt)}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/files/transformations.ts b/apps/portal-shell/src/features/admin/files/transformations.ts
new file mode 100644
index 0000000..f33176a
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/files/transformations.ts
@@ -0,0 +1,85 @@
+/**
+ * Files 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+/** 文件大小单位(1024 进制,对齐通用文件管理器展示) */
+const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
+
+/**
+ * 格式化文件大小(字节数 → 人类可读字符串)。
+ * - 输入无效(负数、NaN、Infinity)时返回 "0 B"
+ * - 0 字节返回 "0 B"
+ * - 保留 1 位小数(< 10 时),>= 10 单位则取整
+ */
+export function formatFileSize(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
+ if (bytes === 0) return "0 B";
+
+ let size = bytes;
+ let unitIndex = 0;
+ while (size >= 1024 && unitIndex < SIZE_UNITS.length - 1) {
+ size /= 1024;
+ unitIndex += 1;
+ }
+ // < 10 保留 1 位小数,否则取整
+ const formatted = size < 10 ? size.toFixed(1) : Math.round(size).toString();
+ return `${formatted} ${SIZE_UNITS[unitIndex]}`;
+}
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效(null/undefined/空/非法)时返回占位符 "--"。
+ */
+export function formatFileDate(isoDate: string | null | undefined): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/** MIME 类型 → 中文分类标签映射(仅覆盖常见类型,未知回退为原始值) */
+export const MIME_TYPE_LABEL: Record = {
+ "image/png": "图片",
+ "image/jpeg": "图片",
+ "image/gif": "图片",
+ "image/webp": "图片",
+ "application/pdf": "PDF",
+ "text/plain": "文本",
+ "text/csv": "CSV",
+ "application/vnd.ms-excel": "Excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel",
+ "application/msword": "Word",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ "Word",
+ "application/zip": "压缩包",
+ "application/x-zip-compressed": "压缩包",
+ "video/mp4": "视频",
+ "audio/mpeg": "音频",
+};
+
+/**
+ * 将 MIME 类型映射为中文分类标签。未知值回退为原始 MIME 类型。
+ */
+export function formatMimeType(mimeType: string): string {
+ return MIME_TYPE_LABEL[mimeType] ?? mimeType;
+}
+
+/**
+ * 从 MIME 类型提取主类型(如 "image/png" → "image")。
+ * 输入无效返回 "unknown"。
+ */
+export function mimeTypeToCategory(mimeType: string): string {
+ if (!mimeType) return "unknown";
+ const slashIndex = mimeType.indexOf("/");
+ if (slashIndex <= 0) return "unknown";
+ return mimeType.slice(0, slashIndex);
+}
diff --git a/apps/portal-shell/src/features/admin/invitation-codes/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/invitation-codes/__tests__/transformations.test.ts
new file mode 100644
index 0000000..dac7909
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/invitation-codes/__tests__/transformations.test.ts
@@ -0,0 +1,279 @@
+/**
+ * Invitation Codes 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { InvitationCode } from "@/lib/api";
+
+import {
+ ROLE_LABEL,
+ formatInvitationDate,
+ formatInvitationTimestamp,
+ formatUsage,
+ getEffectiveStatus,
+ invitationStatusToBadgeClass,
+ invitationStatusToKey,
+ isInvitationExpired,
+ isInvitationRevocable,
+ isInvitationUsedUp,
+ roleToLabel,
+} from "../transformations";
+
+// ============================================================
+// Fixtures
+// ============================================================
+
+const baseCode: InvitationCode = {
+ id: "inv-001",
+ code: "ABC123XYZ",
+ role: "teacher",
+ status: "active",
+ usedCount: 0,
+ maxUses: 10,
+ expiresAt: "2099-12-31T23:59:59Z",
+ createdAt: "2026-07-22T10:30:00Z",
+ createdBy: "admin-001",
+};
+
+const expiredCode: InvitationCode = {
+ ...baseCode,
+ id: "inv-002",
+ status: "active",
+ expiresAt: "2020-01-01T00:00:00Z",
+};
+
+const usedUpCode: InvitationCode = {
+ ...baseCode,
+ id: "inv-003",
+ status: "used",
+ usedCount: 10,
+ maxUses: 10,
+};
+
+const revokedCode: InvitationCode = {
+ ...baseCode,
+ id: "inv-004",
+ status: "revoked",
+};
+
+// ============================================================
+// formatInvitationTimestamp / formatInvitationDate
+// ============================================================
+
+describe("formatInvitationTimestamp", () => {
+ it("formats valid ISO date string with time", () => {
+ const result = formatInvitationTimestamp("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatInvitationTimestamp(null)).toBe("--");
+ expect(formatInvitationTimestamp(undefined)).toBe("--");
+ expect(formatInvitationTimestamp("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatInvitationTimestamp("not-a-date")).toBe("--");
+ });
+});
+
+describe("formatInvitationDate", () => {
+ it("formats valid ISO date string as date only", () => {
+ const result = formatInvitationDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatInvitationDate(null)).toBe("--");
+ expect(formatInvitationDate(undefined)).toBe("--");
+ expect(formatInvitationDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatInvitationDate("invalid")).toBe("--");
+ });
+});
+
+// ============================================================
+// Status mapping
+// ============================================================
+
+describe("invitationStatusToKey", () => {
+ it("maps active to statusUnused", () => {
+ expect(invitationStatusToKey("active")).toBe("statusUnused");
+ });
+
+ it("maps used to statusUsed", () => {
+ expect(invitationStatusToKey("used")).toBe("statusUsed");
+ });
+
+ it("maps expired to statusExpired", () => {
+ expect(invitationStatusToKey("expired")).toBe("statusExpired");
+ });
+
+ it("maps revoked to statusRevoked", () => {
+ expect(invitationStatusToKey("revoked")).toBe("statusRevoked");
+ });
+
+ it("returns original value for unknown status", () => {
+ expect(invitationStatusToKey("custom")).toBe("custom");
+ });
+});
+
+describe("invitationStatusToBadgeClass", () => {
+ it("returns emerald class for active", () => {
+ expect(invitationStatusToBadgeClass("active")).toContain("emerald");
+ });
+
+ it("returns blue class for used", () => {
+ expect(invitationStatusToBadgeClass("used")).toContain("blue");
+ });
+
+ it("returns amber class for expired", () => {
+ expect(invitationStatusToBadgeClass("expired")).toContain("amber");
+ });
+
+ it("returns muted for revoked", () => {
+ expect(invitationStatusToBadgeClass("revoked")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+
+ it("returns muted for unknown status", () => {
+ expect(invitationStatusToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+// ============================================================
+// Expiration logic
+// ============================================================
+
+describe("isInvitationExpired", () => {
+ it("returns true when status is expired", () => {
+ expect(isInvitationExpired({ ...baseCode, status: "expired" })).toBe(true);
+ });
+
+ it("returns false for used code even if expiresAt is past", () => {
+ expect(
+ isInvitationExpired({ ...usedUpCode, expiresAt: "2020-01-01T00:00:00Z" }),
+ ).toBe(false);
+ });
+
+ it("returns false for revoked code", () => {
+ expect(isInvitationExpired(revokedCode)).toBe(false);
+ });
+
+ it("returns true for active code with past expiresAt", () => {
+ expect(isInvitationExpired(expiredCode)).toBe(true);
+ });
+
+ it("returns false for active code with future expiresAt", () => {
+ expect(isInvitationExpired(baseCode)).toBe(false);
+ });
+
+ it("returns false when expiresAt is empty", () => {
+ expect(isInvitationExpired({ ...baseCode, expiresAt: "" })).toBe(false);
+ });
+
+ it("returns false when expiresAt is invalid", () => {
+ expect(isInvitationExpired({ ...baseCode, expiresAt: "invalid" })).toBe(
+ false,
+ );
+ });
+});
+
+describe("getEffectiveStatus", () => {
+ it("returns expired when active code has past expiresAt", () => {
+ expect(getEffectiveStatus(expiredCode)).toBe("expired");
+ });
+
+ it("returns original status for active code with future expiresAt", () => {
+ expect(getEffectiveStatus(baseCode)).toBe("active");
+ });
+
+ it("returns used for used code", () => {
+ expect(getEffectiveStatus(usedUpCode)).toBe("used");
+ });
+
+ it("returns revoked for revoked code", () => {
+ expect(getEffectiveStatus(revokedCode)).toBe("revoked");
+ });
+});
+
+// ============================================================
+// Usage helpers
+// ============================================================
+
+describe("formatUsage", () => {
+ it("formats valid usage", () => {
+ expect(formatUsage(3, 10)).toBe("3 / 10");
+ expect(formatUsage(0, 5)).toBe("0 / 5");
+ });
+
+ it("returns 0 / 0 for invalid inputs", () => {
+ expect(formatUsage(-1, 10)).toBe("0 / 10");
+ expect(formatUsage(3, -1)).toBe("3 / 0");
+ expect(formatUsage(Number.NaN, 10)).toBe("0 / 10");
+ });
+});
+
+describe("isInvitationUsedUp", () => {
+ it("returns true when usedCount >= maxUses", () => {
+ expect(isInvitationUsedUp(usedUpCode)).toBe(true);
+ });
+
+ it("returns false when usedCount < maxUses", () => {
+ expect(isInvitationUsedUp(baseCode)).toBe(false);
+ });
+
+ it("returns false when maxUses is 0", () => {
+ expect(isInvitationUsedUp({ ...baseCode, maxUses: 0, usedCount: 0 })).toBe(
+ false,
+ );
+ });
+});
+
+describe("isInvitationRevocable", () => {
+ it("returns true for active code", () => {
+ expect(isInvitationRevocable(baseCode)).toBe(true);
+ });
+
+ it("returns false for expired active code (effective status is expired)", () => {
+ expect(isInvitationRevocable(expiredCode)).toBe(false);
+ });
+
+ it("returns false for used code", () => {
+ expect(isInvitationRevocable(usedUpCode)).toBe(false);
+ });
+
+ it("returns false for revoked code", () => {
+ expect(isInvitationRevocable(revokedCode)).toBe(false);
+ });
+});
+
+// ============================================================
+// Role mapping
+// ============================================================
+
+describe("roleToLabel", () => {
+ it("maps known roles to Chinese labels", () => {
+ expect(roleToLabel("admin")).toBe("管理员");
+ expect(roleToLabel("teacher")).toBe("教师");
+ expect(roleToLabel("student")).toBe("学生");
+ expect(roleToLabel("parent")).toBe("家长");
+ });
+
+ it("returns original value for unknown role", () => {
+ expect(roleToLabel("custom")).toBe("custom");
+ });
+
+ it("ROLE_LABEL covers 4 standard roles", () => {
+ expect(Object.keys(ROLE_LABEL)).toHaveLength(4);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/invitation-codes/invitation-codes-list-client.tsx b/apps/portal-shell/src/features/admin/invitation-codes/invitation-codes-list-client.tsx
new file mode 100644
index 0000000..edd4743
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/invitation-codes/invitation-codes-list-client.tsx
@@ -0,0 +1,393 @@
+"use client";
+
+/**
+ * 邀请码管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 invitationCodes(status) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * - 生成 createInvitationCode(input) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - 撤销 revokeInvitationCode(id) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * URL 状态:?status=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Copy, KeyRound, RefreshCw } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useState, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useCreateInvitationCode,
+ useInvitationCodes,
+ useRevokeInvitationCode,
+ type InvitationCode,
+} from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterBar } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ formatInvitationTimestamp,
+ formatUsage,
+ getEffectiveStatus,
+ invitationStatusToBadgeClass,
+ invitationStatusToKey,
+ isInvitationRevocable,
+ roleToLabel,
+} from "@/features/admin/invitation-codes/transformations";
+
+/** 状态筛选选项(与后端 status 字段对齐) */
+const STATUS_OPTIONS = ["active", "used", "expired", "revoked"] as const;
+
+/** 角色选项(与 IAM 角色对齐) */
+const ROLE_OPTIONS = ["admin", "teacher", "student", "parent"] as const;
+
+/** 默认生成参数 */
+const DEFAULT_MAX_USES = 10;
+const DEFAULT_TTL_HOURS = 72;
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function InvitationCodesListClient(): React.ReactElement {
+ const t = useTranslations("admin.invitationCodes.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const statusFilter = searchParams.get("status") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useInvitationCodes(statusFilter || null);
+ const createInvitation = useCreateInvitationCode();
+ const revokeInvitation = useRevokeInvitationCode();
+
+ const [showGenerateForm, setShowGenerateForm] = useState(false);
+ const [pendingRevokeId, setPendingRevokeId] = useState(null);
+
+ const items = data ?? [];
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/invitation-codes?${params.toString()}`);
+ });
+ };
+
+ const handleCopy = async (code: string): Promise => {
+ try {
+ await navigator.clipboard.writeText(code);
+ notify.success(t("copyCode"));
+ } catch (e) {
+ notify.error(tCommon("error.loadFailed", { message: String(e) }));
+ }
+ };
+
+ const handleRevoke = async (id: string): Promise => {
+ setPendingRevokeId(id);
+ try {
+ await revokeInvitation.run(id);
+ notify.success(t("revoke"));
+ } catch (e) {
+ notify.error(tCommon("error.loadFailed", { message: String(e) }));
+ } finally {
+ setPendingRevokeId(null);
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+
+
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={items.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: items.length })}
+
+ }
+ >
+
+ {showGenerateForm ? (
+ {
+ try {
+ await createInvitation.run({ role, maxUses, ttlHours });
+ notify.success(t("generateButton"));
+ setShowGenerateForm(false);
+ } catch (e) {
+ notify.error(
+ tCommon("error.loadFailed", { message: String(e) }),
+ );
+ }
+ }}
+ onCancel={() => setShowGenerateForm(false)}
+ />
+ ) : null}
+
+
+
+ );
+}
+
+/**
+ * 生成邀请码表单(内联展开,对齐 §7.3 列表页 + 行内操作)。
+ */
+function GenerateForm({
+ loading,
+ onSubmit,
+ onCancel,
+}: {
+ loading: boolean;
+ onSubmit: (role: string, maxUses: number, ttlHours: number) => Promise;
+ onCancel: () => void;
+}): React.ReactElement {
+ const tForm = useTranslations("admin.invitationCodes.generateForm");
+ const [role, setRole] = useState("teacher");
+ const [maxUses, setMaxUses] = useState(DEFAULT_MAX_USES);
+ const [ttlHours, setTtlHours] = useState(DEFAULT_TTL_HOURS);
+
+ const handleSubmit = (e: React.FormEvent): void => {
+ e.preventDefault();
+ void onSubmit(role, maxUses, ttlHours);
+ };
+
+ return (
+
+
+ {tForm("title")}
+
+
+
+ );
+}
+
+/**
+ * 邀请码列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function InvitationCodesTable({
+ items,
+ pendingRevokeId,
+ onCopy,
+ onRevoke,
+}: {
+ items: InvitationCode[];
+ pendingRevokeId: string | null;
+ onCopy: (code: string) => void;
+ onRevoke: (id: string) => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.invitationCodes.list");
+ return (
+
+
+
+
+ | {t("colCode")} |
+ {t("colRole")} |
+ {t("colStatus")} |
+ {t("colUsedCount")} |
+ {t("colExpiresAt")} |
+ {t("colCreatedAt")} |
+ {t("colCreatedBy")} |
+ {t("colActions")} |
+
+
+
+ {items.map((item) => {
+ const effectiveStatus = getEffectiveStatus(item);
+ const canRevoke = isInvitationRevocable(item);
+ return (
+
+ | {item.code} |
+
+ {roleToLabel(item.role)}
+ |
+
+
+ |
+
+ {formatUsage(item.usedCount, item.maxUses)}
+ |
+
+ {formatInvitationTimestamp(item.expiresAt)}
+ |
+
+ {formatInvitationTimestamp(item.createdAt)}
+ |
+
+ {item.createdBy}
+ |
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const t = useTranslations("admin.invitationCodes.list");
+ const label = t(invitationStatusToKey(status));
+ const cls = invitationStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/invitation-codes/transformations.ts b/apps/portal-shell/src/features/admin/invitation-codes/transformations.ts
new file mode 100644
index 0000000..f2bf9e6
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/invitation-codes/transformations.ts
@@ -0,0 +1,160 @@
+/**
+ * Invitation Codes 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type { InvitationCode } from "@/lib/api";
+
+// ============================================================
+// 日期格式化
+// ============================================================
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效时返回占位符。
+ */
+export function formatInvitationTimestamp(
+ isoDate: string | null | undefined,
+): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * 格式化日期为纯日期展示(zh-CN,仅年月日)。用于过期时间等。
+ * 输入无效时返回占位符。
+ */
+export function formatInvitationDate(
+ isoDate: string | null | undefined,
+): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+}
+
+// ============================================================
+// 状态映射
+// ============================================================
+
+/**
+ * 将邀请码状态代码映射为 i18n key 后缀。
+ * active → unused(未使用),used → used(已用完),expired → expired(已过期),revoked → revoked(已撤销)
+ * 未知值回退为原始值。
+ */
+export function invitationStatusToKey(status: string): string {
+ switch (status) {
+ case "active":
+ return "statusUnused";
+ case "used":
+ return "statusUsed";
+ case "expired":
+ return "statusExpired";
+ case "revoked":
+ return "statusRevoked";
+ default:
+ return status;
+ }
+}
+
+/**
+ * 根据邀请码状态返回 Tailwind 徽章语义类名。
+ */
+export function invitationStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "active":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "used":
+ return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
+ case "expired":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ case "revoked":
+ return "bg-muted text-muted-foreground";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/**
+ * 判断邀请码是否已过期(基于 expiresAt 与当前时间比较)。
+ * 已撤销或已用完的码不算过期。
+ */
+export function isInvitationExpired(code: InvitationCode): boolean {
+ if (code.status === "expired") return true;
+ if (code.status === "used" || code.status === "revoked") return false;
+ if (!code.expiresAt) return false;
+ const d = new Date(code.expiresAt);
+ if (Number.isNaN(d.getTime())) return false;
+ return d.getTime() < Date.now();
+}
+
+/**
+ * 获取邀请码的有效显示状态。
+ * 优先返回数据库 status,若数据库为 active 但已过期则返回 "expired"。
+ */
+export function getEffectiveStatus(code: InvitationCode): string {
+ if (code.status === "active" && isInvitationExpired(code)) {
+ return "expired";
+ }
+ return code.status;
+}
+
+// ============================================================
+// 使用量格式化
+// ============================================================
+
+/**
+ * 格式化使用量展示(已用/最大)。
+ */
+export function formatUsage(used: number, max: number): string {
+ const safeUsed = Number.isFinite(used) && used >= 0 ? used : 0;
+ const safeMax = Number.isFinite(max) && max >= 0 ? max : 0;
+ return `${safeUsed} / ${safeMax}`;
+}
+
+/**
+ * 判断邀请码是否已用完(usedCount >= maxUses)。
+ */
+export function isInvitationUsedUp(code: InvitationCode): boolean {
+ return code.usedCount >= code.maxUses && code.maxUses > 0;
+}
+
+/**
+ * 判断邀请码是否可撤销(仅 active 状态可撤销)。
+ */
+export function isInvitationRevocable(code: InvitationCode): boolean {
+ return getEffectiveStatus(code) === "active";
+}
+
+// ============================================================
+// 角色映射
+// ============================================================
+
+/** 角色中文标签映射 */
+export const ROLE_LABEL: Record = {
+ admin: "管理员",
+ teacher: "教师",
+ student: "学生",
+ parent: "家长",
+};
+
+/**
+ * 将角色代码映射为中文标签。未知值回退为原始值。
+ */
+export function roleToLabel(role: string): string {
+ return ROLE_LABEL[role] ?? role;
+}
diff --git a/apps/portal-shell/src/features/admin/lesson-plans/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/lesson-plans/__tests__/transformations.test.ts
new file mode 100644
index 0000000..340391f
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/lesson-plans/__tests__/transformations.test.ts
@@ -0,0 +1,134 @@
+/**
+ * Admin Lesson Plans 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ LESSON_PLAN_STATUS_LABEL,
+ formatLessonPlanDate,
+ formatLessonPlanStatus,
+ isLessonPlanArchived,
+ isLessonPlanEditable,
+ isLessonPlanPublished,
+ lessonPlanStatusToBadgeClass,
+ toAdminLessonPlanListItem,
+} from "../transformations";
+
+describe("formatLessonPlanStatus", () => {
+ it("maps known statuses to Chinese labels", () => {
+ expect(formatLessonPlanStatus("DRAFT")).toBe("草稿");
+ expect(formatLessonPlanStatus("PUBLISHED")).toBe("已发布");
+ expect(formatLessonPlanStatus("ARCHIVED")).toBe("已归档");
+ expect(formatLessonPlanStatus("SUBMITTED")).toBe("已提交");
+ });
+
+ it("returns original value for unknown status", () => {
+ expect(formatLessonPlanStatus("UNKNOWN")).toBe("UNKNOWN");
+ expect(formatLessonPlanStatus("")).toBe("");
+ });
+
+ it("LESSON_PLAN_STATUS_LABEL covers 4 standard statuses", () => {
+ expect(Object.keys(LESSON_PLAN_STATUS_LABEL)).toHaveLength(4);
+ });
+});
+
+describe("formatLessonPlanDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatLessonPlanDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatLessonPlanDate(null)).toBe("--");
+ expect(formatLessonPlanDate(undefined)).toBe("--");
+ expect(formatLessonPlanDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatLessonPlanDate("not-a-date")).toBe("--");
+ });
+});
+
+describe("isLessonPlanEditable", () => {
+ it("returns true for DRAFT and PUBLISHED", () => {
+ expect(isLessonPlanEditable("DRAFT")).toBe(true);
+ expect(isLessonPlanEditable("PUBLISHED")).toBe(true);
+ });
+
+ it("returns false for ARCHIVED and unknown", () => {
+ expect(isLessonPlanEditable("ARCHIVED")).toBe(false);
+ expect(isLessonPlanEditable("UNKNOWN")).toBe(false);
+ });
+});
+
+describe("isLessonPlanPublished", () => {
+ it("returns true only for PUBLISHED", () => {
+ expect(isLessonPlanPublished("PUBLISHED")).toBe(true);
+ expect(isLessonPlanPublished("DRAFT")).toBe(false);
+ expect(isLessonPlanPublished("ARCHIVED")).toBe(false);
+ });
+});
+
+describe("isLessonPlanArchived", () => {
+ it("returns true only for ARCHIVED", () => {
+ expect(isLessonPlanArchived("ARCHIVED")).toBe(true);
+ expect(isLessonPlanArchived("PUBLISHED")).toBe(false);
+ expect(isLessonPlanArchived("DRAFT")).toBe(false);
+ });
+});
+
+describe("toAdminLessonPlanListItem", () => {
+ it("extracts list fields from full detail and drops extra fields", () => {
+ const detail = {
+ id: "lp-001",
+ title: "集合的概念",
+ subjectId: "sub-math",
+ subjectName: "数学",
+ teacherId: "usr-001",
+ teacherName: "张老师",
+ classId: "cls-001",
+ className: "高三(1)班",
+ status: "PUBLISHED",
+ createdAt: "2026-07-10T00:00:00Z",
+ updatedAt: "2026-07-15T00:00:00Z",
+ };
+
+ const item = toAdminLessonPlanListItem(detail);
+ expect(item.id).toBe("lp-001");
+ expect(item.title).toBe("集合的概念");
+ expect(item.subjectName).toBe("数学");
+ expect(item.teacherName).toBe("张老师");
+ expect(item.className).toBe("高三(1)班");
+ expect(item.status).toBe("PUBLISHED");
+ expect(item).not.toHaveProperty("textbookId");
+ expect(item).not.toHaveProperty("content");
+ });
+});
+
+describe("lessonPlanStatusToBadgeClass", () => {
+ it("returns primary class for PUBLISHED", () => {
+ expect(lessonPlanStatusToBadgeClass("PUBLISHED")).toContain("primary");
+ });
+
+ it("returns amber class for SUBMITTED", () => {
+ expect(lessonPlanStatusToBadgeClass("SUBMITTED")).toContain("amber");
+ });
+
+ it("returns muted class for DRAFT and ARCHIVED", () => {
+ expect(lessonPlanStatusToBadgeClass("DRAFT")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ expect(lessonPlanStatusToBadgeClass("ARCHIVED")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+
+ it("returns muted class for unknown status", () => {
+ expect(lessonPlanStatusToBadgeClass("UNKNOWN")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/lesson-plans/lesson-plan-view-client.tsx b/apps/portal-shell/src/features/admin/lesson-plans/lesson-plan-view-client.tsx
new file mode 100644
index 0000000..ae06b03
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/lesson-plans/lesson-plan-view-client.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+/**
+ * 教案详情只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 单查 adminLessonPlan(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:DetailPageSkeleton
+ * - error:errorNode 局部降级
+ * - notFound:data 为 null 时显示空态节点
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+import { BookOpen } from "lucide-react";
+import { useParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useEffect } from "react";
+
+import {
+ useAdminLessonPlan,
+ type AdminLessonPlan as AdminLessonPlanData,
+} from "@/lib/api";
+import {
+ DetailPageShell,
+ DetailPageSkeleton,
+ DetailSection,
+ DetailField,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+import {
+ formatLessonPlanDate,
+ formatLessonPlanStatus,
+ lessonPlanStatusToBadgeClass,
+} from "@/features/admin/lesson-plans/transformations";
+
+/**
+ * 只读查看客户端主体。需由 server page 包裹在 中。
+ */
+export function AdminLessonPlanViewClient(): React.ReactElement {
+ const t = useTranslations("admin.lessonPlans.detail");
+ const tCommon = useTranslations("common");
+ const params = useParams<{ planId: string }>();
+ const planId = params?.planId ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminLessonPlan(planId);
+
+ useEffect(() => {
+ if (error) {
+ notify.error(tCommon("error.loadFailed", { message: String(error) }));
+ }
+ }, [error, tCommon]);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ ) : undefined;
+
+ const emptyNode =
+ !loading && !error && !data ? (
+
+ {t("notFound")}
+
+ ) : undefined;
+
+ return (
+ }
+ backHref="/shell/admin/lesson-plans"
+ loading={loading}
+ loadingNode={}
+ errorNode={errorNode}
+ emptyNode={emptyNode}
+ >
+ {data ? : null}
+
+ );
+}
+
+/**
+ * 教案详情主体(基本信息 + 教案内容 + 教学资源)。
+ */
+function AdminLessonPlanViewBody({
+ plan,
+}: {
+ plan: AdminLessonPlanData;
+}): React.ReactElement {
+ const t = useTranslations("admin.lessonPlans.detail");
+ return (
+ <>
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+ {plan.content}
+
+
+
+
+ {t("contractPending")}
+
+ >
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatLessonPlanStatus(status);
+ const cls = lessonPlanStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/lesson-plans/lesson-plans-list-client.tsx b/apps/portal-shell/src/features/admin/lesson-plans/lesson-plans-list-client.tsx
new file mode 100644
index 0000000..1c7a6e4
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/lesson-plans/lesson-plans-list-client.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+/**
+ * 教案管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 adminLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * URL 状态:?status=&q=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { BookOpen } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useAdminLessonPlans, type AdminLessonPlanListItem } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ formatLessonPlanDate,
+ formatLessonPlanStatus,
+ lessonPlanStatusToBadgeClass,
+} from "@/features/admin/lesson-plans/transformations";
+
+/** 可选状态筛选项(与 URL ?status= 对齐) */
+const STATUS_OPTIONS = ["DRAFT", "PUBLISHED", "ARCHIVED", "SUBMITTED"] as const;
+type StatusOption = (typeof STATUS_OPTIONS)[number];
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function AdminLessonPlansListClient(): React.ReactElement {
+ const t = useTranslations("admin.lessonPlans.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const statusParam = searchParams.get("status") ?? "";
+ const status = STATUS_OPTIONS.includes(statusParam as StatusOption)
+ ? (statusParam as StatusOption)
+ : "";
+ const q = searchParams.get("q") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminLessonPlans();
+
+ // 客户端二次筛选(status + q)—— 后端补齐列表查询后改服务端筛选
+ const filteredItems = useMemo(() => {
+ const items = data?.items ?? [];
+ return items.filter((item) => {
+ if (status && item.status !== status) {
+ return false;
+ }
+ if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
+ return false;
+ }
+ return true;
+ });
+ }, [data, status, q]);
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ // 切换筛选时重置页码(暂无分页,保留兼容入口)
+ if (key === "status") {
+ params.delete("page");
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/lesson-plans?${params.toString()}`);
+ });
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+ <>
+ updateQuery("q", v)}
+ />
+
+ >
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredItems.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+ {t("total", { count: filteredItems.length })}
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * 教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function AdminLessonPlansTable({
+ items,
+}: {
+ items: AdminLessonPlanListItem[];
+}): React.ReactElement {
+ const t = useTranslations("admin.lessonPlans.list");
+ return (
+
+
+
+
+ | {t("colTitle")} |
+ {t("colTeacher")} |
+ {t("colSubject")} |
+ {t("colStatus")} |
+ {t("colUpdatedAt")} |
+ {t("colActions")} |
+
+
+
+ {items.map((plan) => (
+
+ |
+
+ {plan.title}
+
+ |
+
+ {plan.teacherName || "-"}
+ |
+
+ {plan.subjectName || plan.subjectId || "-"}
+ |
+
+
+ |
+
+ {formatLessonPlanDate(plan.updatedAt)}
+ |
+
+
+ {t("viewDetail")}
+
+ |
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 教案状态徽章(按状态色阶展示)。
+ */
+function LessonPlanStatusBadge({
+ status,
+}: {
+ status: string;
+}): React.ReactElement {
+ const label = formatLessonPlanStatus(status);
+ const cls = lessonPlanStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/lesson-plans/transformations.ts b/apps/portal-shell/src/features/admin/lesson-plans/transformations.ts
new file mode 100644
index 0000000..2f8f00e
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/lesson-plans/transformations.ts
@@ -0,0 +1,115 @@
+/**
+ * Admin Lesson Plans 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type { AdminLessonPlanListItem } from "@/lib/api/admin-p5";
+
+/** 教案状态中文标签映射 */
+export const LESSON_PLAN_STATUS_LABEL: Record = {
+ DRAFT: "草稿",
+ PUBLISHED: "已发布",
+ ARCHIVED: "已归档",
+ SUBMITTED: "已提交",
+};
+
+/**
+ * 将教案状态枚举值映射为中文标签。
+ * 未知状态回退为原始值。
+ */
+export function formatLessonPlanStatus(status: string): string {
+ return LESSON_PLAN_STATUS_LABEL[status] ?? status;
+}
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效(null/undefined/空/非法)时返回占位符 "--"。
+ */
+export function formatLessonPlanDate(
+ isoDate: string | null | undefined,
+): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * 判断教案是否可编辑(草稿与已发布状态允许编辑,归档后不可编辑)。
+ */
+export function isLessonPlanEditable(status: string): boolean {
+ return status === "DRAFT" || status === "PUBLISHED";
+}
+
+/**
+ * 判断教案是否已发布(PUBLISHED)。
+ */
+export function isLessonPlanPublished(status: string): boolean {
+ return status === "PUBLISHED";
+}
+
+/**
+ * 判断教案是否已归档(ARCHIVED)。
+ */
+export function isLessonPlanArchived(status: string): boolean {
+ return status === "ARCHIVED";
+}
+
+/**
+ * 从教案详情中提取列表项视图模型(裁剪字段)。
+ *
+ * 用于详情→列表裁剪场景;详情字段(textbookId/chapterId/content 等)被剥离。
+ */
+export function toAdminLessonPlanListItem(detail: {
+ id: string;
+ title: string;
+ subjectId: string;
+ subjectName: string;
+ teacherId: string;
+ teacherName: string;
+ classId: string;
+ className: string;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+}): AdminLessonPlanListItem {
+ return {
+ id: detail.id,
+ title: detail.title,
+ subjectId: detail.subjectId,
+ subjectName: detail.subjectName,
+ teacherId: detail.teacherId,
+ teacherName: detail.teacherName,
+ classId: detail.classId,
+ className: detail.className,
+ status: detail.status,
+ createdAt: detail.createdAt,
+ updatedAt: detail.updatedAt,
+ };
+}
+
+/**
+ * 根据教案状态返回 Tailwind 徽章语义类名。
+ */
+export function lessonPlanStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "DRAFT":
+ return "bg-muted text-muted-foreground";
+ case "PUBLISHED":
+ return "bg-primary/10 text-primary";
+ case "ARCHIVED":
+ return "bg-muted text-muted-foreground";
+ case "SUBMITTED":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
diff --git a/apps/portal-shell/src/features/admin/organization/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/organization/__tests__/transformations.test.ts
new file mode 100644
index 0000000..59220b7
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/organization/__tests__/transformations.test.ts
@@ -0,0 +1,193 @@
+/**
+ * Admin Organization 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { OrgNode } from "@/lib/api/admin-p5";
+
+import {
+ ORG_TYPE_LABEL,
+ collectNodeIds,
+ countChildren,
+ formatMemberCount,
+ formatOrgType,
+ isOrgNode,
+ orgTypeToBadgeClass,
+} from "../transformations";
+
+const sampleTree: OrgNode[] = [
+ {
+ id: "org-001",
+ name: "市第一中学",
+ type: "school",
+ parentId: null,
+ memberCount: 1200,
+ children: [
+ {
+ id: "dept-001",
+ name: "数学教研组",
+ type: "department",
+ parentId: "org-001",
+ memberCount: 12,
+ children: [],
+ },
+ {
+ id: "dept-002",
+ name: "语文教研组",
+ type: "department",
+ parentId: "org-001",
+ memberCount: 10,
+ children: [],
+ },
+ ],
+ },
+ {
+ id: "org-002",
+ name: "市第二中学",
+ type: "school",
+ parentId: null,
+ memberCount: 980,
+ children: [],
+ },
+];
+
+describe("formatOrgType", () => {
+ it("maps known types to Chinese labels", () => {
+ expect(formatOrgType("school")).toBe("学校");
+ expect(formatOrgType("department")).toBe("部门");
+ expect(formatOrgType("grade")).toBe("年级");
+ expect(formatOrgType("class")).toBe("班级");
+ });
+
+ it("returns original value for unknown type", () => {
+ expect(formatOrgType("other")).toBe("other");
+ expect(formatOrgType("")).toBe("");
+ });
+
+ it("ORG_TYPE_LABEL covers 4 standard types", () => {
+ expect(Object.keys(ORG_TYPE_LABEL)).toHaveLength(4);
+ });
+});
+
+describe("orgTypeToBadgeClass", () => {
+ it("returns purple class for school", () => {
+ expect(orgTypeToBadgeClass("school")).toContain("purple");
+ });
+
+ it("returns blue class for department", () => {
+ expect(orgTypeToBadgeClass("department")).toContain("blue");
+ });
+
+ it("returns emerald class for grade", () => {
+ expect(orgTypeToBadgeClass("grade")).toContain("emerald");
+ });
+
+ it("returns amber class for class", () => {
+ expect(orgTypeToBadgeClass("class")).toContain("amber");
+ });
+
+ it("returns muted for unknown type", () => {
+ expect(orgTypeToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+describe("formatMemberCount", () => {
+ it("formats valid count", () => {
+ expect(formatMemberCount(0)).toBe("0 人");
+ expect(formatMemberCount(1200)).toBe("1200 人");
+ });
+
+ it("returns 0 人 for invalid input", () => {
+ expect(formatMemberCount(-1)).toBe("0 人");
+ expect(formatMemberCount(Number.NaN)).toBe("0 人");
+ expect(formatMemberCount(Number.POSITIVE_INFINITY)).toBe("0 人");
+ });
+});
+
+describe("countChildren", () => {
+ it("returns direct children count", () => {
+ // 测试样本数据已知存在,使用非空断言绕过 noUncheckedIndexedAccess
+ const school = sampleTree[0]!;
+ const secondSchool = sampleTree[1]!;
+ const dept = school.children[0]!;
+ expect(countChildren(school)).toBe(2);
+ expect(countChildren(secondSchool)).toBe(0);
+ expect(countChildren(dept)).toBe(0);
+ });
+});
+
+describe("collectNodeIds", () => {
+ it("collects all node IDs from nested tree", () => {
+ const ids = collectNodeIds(sampleTree);
+ expect(ids).toHaveLength(4);
+ expect(ids).toContain("org-001");
+ expect(ids).toContain("dept-001");
+ expect(ids).toContain("dept-002");
+ expect(ids).toContain("org-002");
+ });
+
+ it("returns empty array for empty tree", () => {
+ expect(collectNodeIds([])).toEqual([]);
+ });
+
+ it("handles single node without children", () => {
+ const single: OrgNode[] = [
+ {
+ id: "solo",
+ name: "独立节点",
+ type: "school",
+ parentId: null,
+ memberCount: 1,
+ children: [],
+ },
+ ];
+ expect(collectNodeIds(single)).toEqual(["solo"]);
+ });
+});
+
+describe("isOrgNode", () => {
+ it("returns true for valid OrgNode", () => {
+ // 测试样本数据已知存在,使用非空断言绕过 noUncheckedIndexedAccess
+ const school = sampleTree[0]!;
+ const dept = school.children[0]!;
+ expect(isOrgNode(school)).toBe(true);
+ expect(isOrgNode(dept)).toBe(true);
+ });
+
+ it("returns false for null/undefined/primitives", () => {
+ expect(isOrgNode(null)).toBe(false);
+ expect(isOrgNode(undefined)).toBe(false);
+ expect(isOrgNode("string")).toBe(false);
+ expect(isOrgNode(42)).toBe(false);
+ });
+
+ it("returns false for object missing required fields", () => {
+ expect(isOrgNode({ id: "x" })).toBe(false);
+ expect(
+ isOrgNode({
+ id: "x",
+ name: "x",
+ type: "school",
+ parentId: null,
+ memberCount: 1,
+ }),
+ ).toBe(false);
+ });
+
+ it("returns false when children is not an array", () => {
+ expect(
+ isOrgNode({
+ id: "x",
+ name: "x",
+ type: "school",
+ parentId: null,
+ memberCount: 1,
+ children: "not-array",
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/organization/organization-tree-client.tsx b/apps/portal-shell/src/features/admin/organization/organization-tree-client.tsx
new file mode 100644
index 0000000..810062c
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/organization/organization-tree-client.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+/**
+ * 组织架构树页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 组织树查询 organizationTree ❌ schema 无 → MSW 兜底(@contract-pending)
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Building2 } from "lucide-react";
+import Link from "next/link";
+import { useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { useOrganizationTree } from "@/lib/api/admin-p5";
+import type { OrgNode } from "@/lib/api/admin-p5";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ collectNodeIds,
+ formatMemberCount,
+ formatOrgType,
+ orgTypeToBadgeClass,
+} from "@/features/admin/organization/transformations";
+
+/**
+ * 组织架构树客户端主体。无 URL 状态(树形展开/折叠用本地状态)。
+ */
+export function OrganizationTreeClient(): React.ReactElement {
+ const t = useTranslations("admin.organization");
+ const tCommon = useTranslations("common");
+ const { data, loading, error } = useOrganizationTree();
+
+ const tree = data ?? [];
+ const allIds = useMemo(() => collectNodeIds(tree), [tree]);
+
+ const [collapsed, setCollapsed] = useState>(new Set());
+
+ const expandAll = (): void => setCollapsed(new Set());
+ const collapseAll = (): void => setCollapsed(new Set(allIds));
+ const toggleNode = (id: string): void => {
+ setCollapsed((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ {t("list.mswNotice")}
+
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+
+
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={tree.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+
+ );
+}
+
+/**
+ * 组织树表格(递归缩进展示,对齐 §8.2 排版规范)。
+ */
+function OrganizationTreeTable({
+ tree,
+ collapsed,
+ onToggle,
+}: {
+ tree: OrgNode[];
+ collapsed: Set;
+ onToggle: (id: string) => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.organization");
+ return (
+
+
+
+
+ | {t("list.colName")} |
+ {t("list.colType")} |
+
+ {t("list.colMemberCount")}
+ |
+
+ {t("list.colChildren")}
+ |
+
+ {t("list.colActions")}
+ |
+
+
+
+ {tree.map((node) => (
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 递归渲染组织节点行(含子节点缩进)。
+ */
+function OrgNodeRow({
+ node,
+ depth,
+ collapsed,
+ onToggle,
+}: {
+ node: OrgNode;
+ depth: number;
+ collapsed: Set;
+ onToggle: (id: string) => void;
+}): React.ReactElement {
+ const t = useTranslations("admin.organization");
+ const isCollapsed = collapsed.has(node.id);
+ const hasChildren = node.children.length > 0;
+ return (
+ <>
+
+ |
+
+ {hasChildren ? (
+
+ ) : null}
+ {node.name}
+
+ |
+
+
+ |
+
+ {formatMemberCount(node.memberCount)}
+ |
+ {node.children.length} |
+
+
+ {t("list.viewDetail")}
+
+ |
+
+ {!isCollapsed &&
+ node.children.map((child) => (
+
+ ))}
+ >
+ );
+}
+
+/**
+ * 类型徽章(按节点类型色阶展示)。
+ */
+function TypeBadge({ type }: { type: string }): React.ReactElement {
+ const label = formatOrgType(type);
+ const cls = orgTypeToBadgeClass(type);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/organization/transformations.ts b/apps/portal-shell/src/features/admin/organization/transformations.ts
new file mode 100644
index 0000000..fb107da
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/organization/transformations.ts
@@ -0,0 +1,89 @@
+/**
+ * Admin Organization 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type { OrgNode } from "@/lib/api/admin-p5";
+
+/** 组织节点类型代码 → 中文标签映射 */
+export const ORG_TYPE_LABEL: Record = {
+ school: "学校",
+ department: "部门",
+ grade: "年级",
+ class: "班级",
+};
+
+/**
+ * 将组织节点类型代码映射为中文标签。未知值回退为原始值。
+ */
+export function formatOrgType(type: string): string {
+ return ORG_TYPE_LABEL[type] ?? type;
+}
+
+/**
+ * 根据组织节点类型返回 Tailwind 徽章语义类名。
+ */
+export function orgTypeToBadgeClass(type: string): string {
+ switch (type) {
+ case "school":
+ return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
+ case "department":
+ return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
+ case "grade":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "class":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/**
+ * 格式化成员数为展示字符串。
+ * 输入无效返回 "0 人"。
+ */
+export function formatMemberCount(count: number): string {
+ if (!Number.isFinite(count) || count < 0) return "0 人";
+ return `${count} 人`;
+}
+
+/**
+ * 统计节点的直接下级数量。
+ */
+export function countChildren(node: OrgNode): number {
+ return node.children.length;
+}
+
+/**
+ * 递归收集树中所有节点 ID(用于全部展开/折叠)。
+ */
+export function collectNodeIds(nodes: OrgNode[]): string[] {
+ const ids: string[] = [];
+ const walk = (list: OrgNode[]): void => {
+ for (const n of list) {
+ ids.push(n.id);
+ if (n.children.length > 0) {
+ walk(n.children);
+ }
+ }
+ };
+ walk(nodes);
+ return ids;
+}
+
+/**
+ * 类型守卫:判断 unknown 值是否为 OrgNode。
+ */
+export function isOrgNode(value: unknown): value is OrgNode {
+ if (typeof value !== "object" || value === null) return false;
+ const v = value as Record;
+ return (
+ typeof v.id === "string" &&
+ typeof v.name === "string" &&
+ typeof v.type === "string" &&
+ typeof v.memberCount === "number" &&
+ Array.isArray(v.children)
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/permissions/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/permissions/__tests__/transformations.test.ts
new file mode 100644
index 0000000..1ca41de
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/permissions/__tests__/transformations.test.ts
@@ -0,0 +1,170 @@
+/**
+ * Admin Permissions 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { Permission, PermissionRoleCount } from "@/lib/api";
+
+import {
+ buildRoleCountMap,
+ formatPermissionKey,
+ formatRoleCount,
+ groupPermissionsByResource,
+ matchPermissionSearch,
+ permissionToKey,
+ roleCountToBadgeClass,
+} from "../transformations";
+
+const samplePerm = (
+ id: string,
+ resource: string,
+ action: string,
+ name = `${resource}.${action}`,
+): Permission => ({
+ id,
+ name,
+ resource,
+ action,
+ description: `${resource} ${action}`,
+});
+
+const sampleCount = (
+ permissionId: string,
+ resource: string,
+ action: string,
+ roleCount: number,
+): PermissionRoleCount => ({
+ permissionId,
+ permissionName: `${resource}.${action}`,
+ resource,
+ action,
+ roleCount,
+});
+
+describe("matchPermissionSearch", () => {
+ const perm = samplePerm("p1", "user", "read");
+
+ it("returns true when query is empty or whitespace", () => {
+ expect(matchPermissionSearch(perm, "")).toBe(true);
+ expect(matchPermissionSearch(perm, " ")).toBe(true);
+ });
+
+ it("matches by name (case-insensitive)", () => {
+ expect(matchPermissionSearch(perm, "user.read")).toBe(true);
+ expect(matchPermissionSearch(perm, "USER.READ")).toBe(true);
+ });
+
+ it("matches by resource (case-insensitive)", () => {
+ expect(matchPermissionSearch(perm, "user")).toBe(true);
+ expect(matchPermissionSearch(perm, "USER")).toBe(true);
+ });
+
+ it("matches by action (case-insensitive)", () => {
+ expect(matchPermissionSearch(perm, "read")).toBe(true);
+ expect(matchPermissionSearch(perm, "READ")).toBe(true);
+ });
+
+ it("returns false when no match", () => {
+ expect(matchPermissionSearch(perm, "xyz")).toBe(false);
+ expect(matchPermissionSearch(perm, "delete")).toBe(false);
+ });
+});
+
+describe("formatPermissionKey", () => {
+ it("joins resource and action with dot", () => {
+ expect(formatPermissionKey("user", "read")).toBe("user.read");
+ expect(formatPermissionKey("role", "write")).toBe("role.write");
+ });
+});
+
+describe("permissionToKey", () => {
+ it("converts Permission to resource.action", () => {
+ expect(permissionToKey(samplePerm("p1", "class", "read"))).toBe(
+ "class.read",
+ );
+ });
+});
+
+describe("groupPermissionsByResource", () => {
+ it("groups permissions by resource preserving first-seen order", () => {
+ const perms = [
+ samplePerm("p1", "user", "read"),
+ samplePerm("p2", "role", "read"),
+ samplePerm("p3", "user", "write"),
+ ];
+ const groups = groupPermissionsByResource(perms);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.resource).toBe("user");
+ expect(groups[0]?.items).toHaveLength(2);
+ expect(groups[1]?.resource).toBe("role");
+ expect(groups[1]?.items).toHaveLength(1);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(groupPermissionsByResource([])).toEqual([]);
+ });
+});
+
+describe("buildRoleCountMap", () => {
+ it("builds permissionId → roleCount map", () => {
+ const counts = [
+ sampleCount("p1", "user", "read", 3),
+ sampleCount("p2", "role", "read", 1),
+ ];
+ const map = buildRoleCountMap(counts);
+ expect(map.get("p1")).toBe(3);
+ expect(map.get("p2")).toBe(1);
+ });
+
+ it("returns empty map for empty input", () => {
+ expect(buildRoleCountMap([]).size).toBe(0);
+ });
+
+ it("last entry wins on duplicate permissionId", () => {
+ const counts = [
+ sampleCount("p1", "user", "read", 1),
+ sampleCount("p1", "user", "read", 5),
+ ];
+ const map = buildRoleCountMap(counts);
+ expect(map.get("p1")).toBe(5);
+ });
+});
+
+describe("formatRoleCount", () => {
+ it("formats valid count", () => {
+ expect(formatRoleCount(0)).toBe("0");
+ expect(formatRoleCount(3)).toBe("3");
+ });
+
+ it("returns 0 for undefined", () => {
+ expect(formatRoleCount(undefined)).toBe("0");
+ });
+
+ it("returns 0 for invalid input", () => {
+ expect(formatRoleCount(-1)).toBe("0");
+ expect(formatRoleCount(Number.NaN)).toBe("0");
+ expect(formatRoleCount(Number.POSITIVE_INFINITY)).toBe("0");
+ });
+});
+
+describe("roleCountToBadgeClass", () => {
+ it("returns muted class for 0 / undefined / negative", () => {
+ expect(roleCountToBadgeClass(0)).toBe("bg-muted text-muted-foreground");
+ expect(roleCountToBadgeClass(undefined)).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ expect(roleCountToBadgeClass(-1)).toBe("bg-muted text-muted-foreground");
+ });
+
+ it("returns blue class for 1-2", () => {
+ expect(roleCountToBadgeClass(1)).toContain("blue");
+ expect(roleCountToBadgeClass(2)).toContain("blue");
+ });
+
+ it("returns emerald class for >= 3", () => {
+ expect(roleCountToBadgeClass(3)).toContain("emerald");
+ expect(roleCountToBadgeClass(10)).toContain("emerald");
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/permissions/permissions-list-client.tsx b/apps/portal-shell/src/features/admin/permissions/permissions-list-client.tsx
new file mode 100644
index 0000000..e41b6c7
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/permissions/permissions-list-client.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+/**
+ * 权限目录列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 permissions ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * - permissionRoleCounts ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - 契约工单:docs/architecture/issues/contracts/iam_contract.md#permissions
+ *
+ * URL 状态:?search=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { KeyRound } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ usePermissionRoleCounts,
+ usePermissions,
+ type Permission,
+} from "@/lib/api";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ buildRoleCountMap,
+ formatRoleCount,
+ groupPermissionsByResource,
+ matchPermissionSearch,
+ permissionToKey,
+ roleCountToBadgeClass,
+} from "@/features/admin/permissions/transformations";
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function PermissionsListClient(): React.ReactElement {
+ const t = useTranslations("admin.permissions");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const search = searchParams.get("search") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = usePermissions();
+ // @contract-pending:MSW 兜底
+ const { data: roleCounts } = usePermissionRoleCounts();
+
+ const roleCountMap = useMemo(
+ () => buildRoleCountMap(roleCounts ?? []),
+ [roleCounts],
+ );
+
+ const filteredItems = useMemo(() => {
+ const items = data ?? [];
+ return items.filter((p) => matchPermissionSearch(p, search));
+ }, [data, search]);
+
+ const groupedItems = useMemo(
+ () => groupPermissionsByResource(filteredItems),
+ [filteredItems],
+ );
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/permissions?${params.toString()}`);
+ });
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ {t("list.mswNotice")}
+
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ filters={
+ updateQuery("search", v)}
+ />
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredItems.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+
+ );
+}
+
+/**
+ * 按资源分组的权限列表(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function PermissionsGrouped({
+ groups,
+ roleCountMap,
+}: {
+ groups: Array<{ resource: string; items: Permission[] }>;
+ roleCountMap: Map;
+}): React.ReactElement {
+ const t = useTranslations("admin.permissions");
+ return (
+
+ {groups.map((group) => (
+
+
+
+
+ {t("list.groupResource")}:
+ {" "}
+ {group.resource}
+
+
+
+
+
+
+ |
+ {t("list.colPermission")}
+ |
+
+ {t("list.colAction")}
+ |
+
+ {t("list.colRoleCount")}
+ |
+
+
+
+ {group.items.map((perm) => {
+ const count = roleCountMap.get(perm.id);
+ return (
+
+ | {perm.name} |
+
+ {perm.action}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ ))}
+
+ );
+}
+
+/**
+ * 关联角色数徽章。
+ */
+function RoleCountBadge({
+ count,
+}: {
+ count: number | undefined;
+}): React.ReactElement {
+ const cls = roleCountToBadgeClass(count);
+ return (
+
+ {formatRoleCount(count)}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/permissions/transformations.ts b/apps/portal-shell/src/features/admin/permissions/transformations.ts
new file mode 100644
index 0000000..9cd7ad1
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/permissions/transformations.ts
@@ -0,0 +1,98 @@
+/**
+ * Admin Permissions 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type { Permission, PermissionRoleCount } from "@/lib/api";
+
+/**
+ * 模糊匹配权限搜索关键字(按 name / resource / action 命中,大小写不敏感)。
+ * 关键字为空时返回 true。
+ */
+export function matchPermissionSearch(
+ perm: Permission,
+ query: string,
+): boolean {
+ const q = query.trim().toLowerCase();
+ if (!q) return true;
+ if (perm.name.toLowerCase().includes(q)) return true;
+ if (perm.resource.toLowerCase().includes(q)) return true;
+ if (perm.action.toLowerCase().includes(q)) return true;
+ return false;
+}
+
+/**
+ * 组装 resource.action 形式键。
+ */
+export function formatPermissionKey(resource: string, action: string): string {
+ return `${resource}.${action}`;
+}
+
+/**
+ * 从 Permission 组装 resource.action 键。
+ */
+export function permissionToKey(perm: Permission): string {
+ return formatPermissionKey(perm.resource, perm.action);
+}
+
+/**
+ * 按 resource 分组权限列表。
+ * 返回 resource → Permission[] 的有序映射(按资源首次出现顺序)。
+ */
+export function groupPermissionsByResource(
+ perms: Permission[],
+): Array<{ resource: string; items: Permission[] }> {
+ const groups: Array<{ resource: string; items: Permission[] }> = [];
+ const indexByKey = new Map();
+ for (const p of perms) {
+ const existing = indexByKey.get(p.resource);
+ if (existing === undefined) {
+ indexByKey.set(p.resource, groups.length);
+ groups.push({ resource: p.resource, items: [p] });
+ } else {
+ const group = groups[existing];
+ if (group) {
+ group.items.push(p);
+ }
+ }
+ }
+ return groups;
+}
+
+/**
+ * 构造 permissionId → roleCount 的映射,便于列表页快速查询。
+ */
+export function buildRoleCountMap(
+ counts: PermissionRoleCount[],
+): Map {
+ const map = new Map();
+ for (const c of counts) {
+ map.set(c.permissionId, c.roleCount);
+ }
+ return map;
+}
+
+/**
+ * 格式化关联角色数为展示字符串。
+ * 输入无效返回 "0"。
+ */
+export function formatRoleCount(count: number | undefined): string {
+ if (count === undefined || !Number.isFinite(count) || count < 0) return "0";
+ return String(count);
+}
+
+/**
+ * 根据关联角色数返回 Tailwind 徽章语义类名。
+ * 0 → muted;1-2 → blue;≥3 → emerald(高复用权限突出)。
+ */
+export function roleCountToBadgeClass(count: number | undefined): string {
+ if (count === undefined || count <= 0) {
+ return "bg-muted text-muted-foreground";
+ }
+ if (count <= 2) {
+ return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
+ }
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+}
diff --git a/apps/portal-shell/src/features/admin/plugins/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/plugins/__tests__/transformations.test.ts
new file mode 100644
index 0000000..8535aa6
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/plugins/__tests__/transformations.test.ts
@@ -0,0 +1,243 @@
+/**
+ * Plugins 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { RegistryItem } from "@/lib/api";
+
+import {
+ activeToBadgeClass,
+ builtinToBadgeClass,
+ countActivePlugins,
+ countBuiltinPlugins,
+ countTotalPlugins,
+ formatDefaultProps,
+ formatDefaultSize,
+ formatRequiredRoles,
+ groupByCategory,
+ parsePropsJson,
+ truncateText,
+} from "../transformations";
+
+const sampleItem: RegistryItem = {
+ pluginId: "plugin-a",
+ category: "widget",
+ version: "1.0.0",
+ displayName: "Plugin A",
+ description: "A sample plugin for testing",
+ requiredRoles: ["teacher", "admin"],
+ isBuiltin: true,
+ isActive: true,
+ defaultSlot: "main",
+ defaultSize: { colSpan: 2, rowSpan: 1 },
+ defaultProps: { title: "Hello", count: 3 },
+ propsSchema: {},
+};
+
+const sampleItems: RegistryItem[] = [
+ sampleItem,
+ {
+ ...sampleItem,
+ pluginId: "plugin-b",
+ category: "chart",
+ isBuiltin: false,
+ isActive: false,
+ },
+ {
+ ...sampleItem,
+ pluginId: "plugin-c",
+ category: "widget",
+ isBuiltin: false,
+ isActive: true,
+ },
+];
+
+describe("activeToBadgeClass", () => {
+ it("returns emerald class for active", () => {
+ expect(activeToBadgeClass(true)).toContain("emerald");
+ });
+
+ it("returns muted class for inactive", () => {
+ expect(activeToBadgeClass(false)).toBe("bg-muted text-muted-foreground");
+ });
+});
+
+describe("builtinToBadgeClass", () => {
+ it("returns blue class for builtin", () => {
+ expect(builtinToBadgeClass(true)).toContain("blue");
+ });
+
+ it("returns muted class for non-builtin", () => {
+ expect(builtinToBadgeClass(false)).toBe("bg-muted text-muted-foreground");
+ });
+});
+
+describe("formatRequiredRoles", () => {
+ it("joins non-empty array with commas", () => {
+ expect(formatRequiredRoles(["teacher", "admin"])).toBe("teacher, admin");
+ });
+
+ it("returns placeholder for empty array", () => {
+ expect(formatRequiredRoles([])).toBe("--");
+ });
+
+ it("returns placeholder for null/undefined", () => {
+ expect(formatRequiredRoles(null as unknown as string[])).toBe("--");
+ expect(formatRequiredRoles(undefined as unknown as string[])).toBe("--");
+ });
+});
+
+describe("formatDefaultSize", () => {
+ it("formats size as colSpan x rowSpan", () => {
+ expect(formatDefaultSize({ colSpan: 2, rowSpan: 1 })).toBe("2 x 1");
+ });
+
+ it("returns placeholder for null/undefined", () => {
+ expect(
+ formatDefaultSize(
+ null as unknown as { colSpan: number; rowSpan: number },
+ ),
+ ).toBe("--");
+ });
+});
+
+describe("truncateText", () => {
+ it("returns text unchanged when within limit", () => {
+ expect(truncateText("hello", 40)).toBe("hello");
+ });
+
+ it("truncates and appends ellipsis when over limit", () => {
+ const long = "a".repeat(50);
+ const result = truncateText(long, 40);
+ expect(result.endsWith("...")).toBe(true);
+ expect(result.length).toBe(43);
+ });
+
+ it("uses default maxLen of 40", () => {
+ const long = "b".repeat(50);
+ const result = truncateText(long);
+ expect(result.endsWith("...")).toBe(true);
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(truncateText(null)).toBe("--");
+ expect(truncateText(undefined)).toBe("--");
+ expect(truncateText("")).toBe("--");
+ });
+});
+
+describe("formatDefaultProps", () => {
+ it("serializes non-empty object to JSON string", () => {
+ const result = formatDefaultProps({ title: "Hello", count: 3 });
+ expect(result).toContain('"title":"Hello"');
+ expect(result).toContain('"count":3');
+ });
+
+ it("returns placeholder for empty object", () => {
+ expect(formatDefaultProps({})).toBe("--");
+ });
+
+ it("returns placeholder for null/undefined", () => {
+ expect(formatDefaultProps(null)).toBe("--");
+ expect(formatDefaultProps(undefined)).toBe("--");
+ });
+});
+
+describe("parsePropsJson", () => {
+ it("parses valid JSON object", () => {
+ const result = parsePropsJson('{"title":"Hello"}');
+ expect(result.valid).toBe(true);
+ expect(result.value).toEqual({ title: "Hello" });
+ });
+
+ it("returns valid empty object for empty string", () => {
+ const result = parsePropsJson("");
+ expect(result.valid).toBe(true);
+ expect(result.value).toEqual({});
+ });
+
+ it("returns valid empty object for whitespace-only string", () => {
+ const result = parsePropsJson(" ");
+ expect(result.valid).toBe(true);
+ expect(result.value).toEqual({});
+ });
+
+ it("returns invalid for malformed JSON", () => {
+ const result = parsePropsJson("{not valid json}");
+ expect(result.valid).toBe(false);
+ expect(result.value).toBeUndefined();
+ });
+
+ it("returns invalid for JSON array", () => {
+ const result = parsePropsJson("[1, 2, 3]");
+ expect(result.valid).toBe(false);
+ });
+
+ it("returns invalid for JSON primitive", () => {
+ const result = parsePropsJson('"just a string"');
+ expect(result.valid).toBe(false);
+ });
+
+ it("returns invalid for JSON null", () => {
+ const result = parsePropsJson("null");
+ expect(result.valid).toBe(false);
+ });
+});
+
+describe("groupByCategory", () => {
+ it("groups items by category preserving insertion order", () => {
+ const map = groupByCategory(sampleItems);
+ expect(map.size).toBe(2);
+ expect(map.get("widget")).toHaveLength(2);
+ expect(map.get("chart")).toHaveLength(1);
+ });
+
+ it("returns empty map for empty array", () => {
+ const map = groupByCategory([]);
+ expect(map.size).toBe(0);
+ });
+});
+
+describe("countActivePlugins", () => {
+ it("counts only active plugins", () => {
+ expect(countActivePlugins(sampleItems)).toBe(2);
+ });
+
+ it("returns 0 for empty array", () => {
+ expect(countActivePlugins([])).toBe(0);
+ });
+
+ it("returns 0 for null/undefined input", () => {
+ expect(countActivePlugins(null as unknown as RegistryItem[])).toBe(0);
+ });
+});
+
+describe("countTotalPlugins", () => {
+ it("counts all plugins", () => {
+ expect(countTotalPlugins(sampleItems)).toBe(3);
+ });
+
+ it("returns 0 for empty array", () => {
+ expect(countTotalPlugins([])).toBe(0);
+ });
+
+ it("returns 0 for null/undefined input", () => {
+ expect(countTotalPlugins(null as unknown as RegistryItem[])).toBe(0);
+ });
+});
+
+describe("countBuiltinPlugins", () => {
+ it("counts only builtin plugins", () => {
+ expect(countBuiltinPlugins(sampleItems)).toBe(1);
+ });
+
+ it("returns 0 for empty array", () => {
+ expect(countBuiltinPlugins([])).toBe(0);
+ });
+
+ it("returns 0 for null/undefined input", () => {
+ expect(countBuiltinPlugins(null as unknown as RegistryItem[])).toBe(0);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/plugins/plugins-client.tsx b/apps/portal-shell/src/features/admin/plugins/plugins-client.tsx
new file mode 100644
index 0000000..acb7946
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/plugins/plugins-client.tsx
@@ -0,0 +1,399 @@
+"use client";
+
+/**
+ * 插件管理 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / 004 §5.4)
+ *
+ * 数据契约:
+ * - pluginRegistry ✅ schema 已就绪(config-service)
+ * - updatePluginRegistry(pluginId, input) ✅ schema 已就绪
+ *
+ * URL 状态:?category=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Puzzle, Pencil, RefreshCw } from "lucide-react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { useMemo, useTransition, useState, useEffect } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ usePluginRegistry,
+ useUpdatePluginRegistry,
+ type RegistryItem,
+ type PluginRegistryInput,
+} from "@/lib/api";
+import { notify } from "@/shared/lib/notify";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import {
+ activeToBadgeClass,
+ builtinToBadgeClass,
+ formatDefaultSize,
+ formatRequiredRoles,
+ parsePropsJson,
+ truncateText,
+} from "@/features/admin/plugins/transformations";
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function PluginsClient(): React.ReactElement {
+ const t = useTranslations("admin.plugins.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const category = searchParams.get("category") ?? "";
+
+ const { data, loading, error, refetch } = usePluginRegistry();
+ const { run: updatePlugin, loading: submitting } = useUpdatePluginRegistry();
+
+ const plugins = useMemo(() => data ?? [], [data]);
+
+ const [editingId, setEditingId] = useState(null);
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/plugins?${params.toString()}`);
+ });
+ };
+
+ const filteredPlugins = useMemo(() => {
+ if (!category) return plugins;
+ return plugins.filter((p) => p.category === category);
+ }, [plugins, category]);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+
+ ) : undefined;
+
+ const emptyNode = (
+ void refetch(),
+ }}
+ />
+ );
+
+ const handleToggleActive = async (item: RegistryItem): Promise => {
+ try {
+ await updatePlugin(item.pluginId, { isActive: !item.isActive });
+ notify.success(t("saveSuccess"));
+ await refetch();
+ } catch (err) {
+ notify.error(`${t("saveError")}: ${String(err)}`);
+ }
+ };
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+ updateQuery("category", e.target.value)}
+ placeholder="category filter (e.g. widget / chart)"
+ className="h-9 w-64"
+ aria-label="category filter"
+ />
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredPlugins.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+
+
+
+ | {t("colPluginId")} |
+
+ {t("colDisplayName")}
+ |
+ {t("colCategory")} |
+ {t("colVersion")} |
+
+ {t("colDefaultSlot")}
+ |
+
+ {t("colDefaultSize")}
+ |
+
+ {t("colRequiredRoles")}
+ |
+ {t("colIsActive")} |
+ {t("colActions")} |
+
+
+
+ {filteredPlugins.map((item) => {
+ return (
+
+ |
+ {item.pluginId}
+ |
+
+
+ {item.displayName}
+ {item.isBuiltin ? (
+
+ {t("builtin")}
+
+ ) : null}
+
+
+ {truncateText(item.description, 50)}
+
+ |
+ {item.category} |
+
+ v{item.version}
+ |
+
+ {item.defaultSlot}
+ |
+
+ {formatDefaultSize(item.defaultSize)}
+ |
+
+ {formatRequiredRoles(item.requiredRoles)}
+ |
+
+
+ |
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ {t("mswNotice")}
+
+ {editingId ? (
+ p.pluginId === editingId) ?? null}
+ submitting={submitting}
+ onClose={() => setEditingId(null)}
+ onSave={async (input) => {
+ const target = plugins.find((p) => p.pluginId === editingId);
+ if (!target) return;
+ try {
+ await updatePlugin(target.pluginId, input);
+ notify.success(t("saveSuccess"));
+ setEditingId(null);
+ await refetch();
+ } catch (err) {
+ notify.error(`${t("saveError")}: ${String(err)}`);
+ }
+ }}
+ />
+ ) : null}
+
+ );
+}
+
+/**
+ * 插件编辑对话框(行内弹出 Card,编辑 isActive 与 defaultProps)。
+ */
+function PluginEditDialog({
+ plugin,
+ submitting,
+ onClose,
+ onSave,
+}: {
+ plugin: RegistryItem | null;
+ submitting: boolean;
+ onClose: () => void;
+ onSave: (input: PluginRegistryInput) => Promise;
+}): React.ReactElement {
+ const t = useTranslations("admin.plugins.list");
+
+ const [isActive, setIsActive] = useState(false);
+ const [draftProps, setDraftProps] = useState("");
+ const [draftError, setDraftError] = useState("");
+
+ useEffect(() => {
+ if (!plugin) return;
+ setIsActive(plugin.isActive);
+ setDraftProps(JSON.stringify(plugin.defaultProps, null, 2));
+ setDraftError("");
+ }, [plugin]);
+
+ if (!plugin) {
+ return ;
+ }
+
+ const handleSubmit = (): void => {
+ const parsed = parsePropsJson(draftProps);
+ if (!parsed.valid) {
+ setDraftError(t("propsError"));
+ return;
+ }
+ setDraftError("");
+ const input: PluginRegistryInput = {
+ isActive,
+ defaultProps: parsed.value,
+ };
+ void onSave(input);
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * 表单字段容器(label + children)。
+ */
+function FormField({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/plugins/transformations.ts b/apps/portal-shell/src/features/admin/plugins/transformations.ts
new file mode 100644
index 0000000..5a08a36
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/plugins/transformations.ts
@@ -0,0 +1,140 @@
+/**
+ * Plugins 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import type { RegistryItem } from "@/lib/api";
+
+/**
+ * 根据 isActive 返回 Tailwind 徽章语义类名。
+ */
+export function activeToBadgeClass(isActive: boolean): string {
+ return isActive
+ ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
+ : "bg-muted text-muted-foreground";
+}
+
+/**
+ * 根据 isBuiltin 返回 Tailwind 徽章语义类名。
+ */
+export function builtinToBadgeClass(isBuiltin: boolean): string {
+ return isBuiltin
+ ? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
+ : "bg-muted text-muted-foreground";
+}
+
+/**
+ * 将 requiredRoles 数组渲染为逗号分隔字符串。空数组返回 "--"。
+ */
+export function formatRequiredRoles(roles: string[]): string {
+ if (!roles || roles.length === 0) return "--";
+ return roles.join(", ");
+}
+
+/**
+ * 将 defaultSize 对象渲染为 "colSpan x rowSpan" 格式字符串。
+ */
+export function formatDefaultSize(size: {
+ colSpan: number;
+ rowSpan: number;
+}): string {
+ if (!size) return "--";
+ return `${size.colSpan} x ${size.rowSpan}`;
+}
+
+/**
+ * 截断字符串用于表格展示(超过 maxLen 字符时截断并加省略号)。
+ * maxLen 默认 40。
+ */
+export function truncateText(
+ text: string | null | undefined,
+ maxLen = 40,
+): string {
+ if (!text) return "--";
+ const t = text.trim();
+ if (t.length <= maxLen) return t;
+ return `${t.slice(0, maxLen)}...`;
+}
+
+/**
+ * 将 defaultProps 对象序列化为紧凑 JSON 字符串(用于表格展示)。
+ * 空对象或 null 返回 "--"。
+ */
+export function formatDefaultProps(
+ props: Record | null | undefined,
+): string {
+ if (!props) return "--";
+ const keys = Object.keys(props);
+ if (keys.length === 0) return "--";
+ return JSON.stringify(props);
+}
+
+/**
+ * 校验字符串是否为合法 JSON 对象。
+ * 返回 { valid: boolean; value?: Record }。
+ */
+export function parsePropsJson(input: string): {
+ valid: boolean;
+ value?: Record;
+} {
+ if (!input || input.trim().length === 0) {
+ return { valid: true, value: {} };
+ }
+ try {
+ const parsed = JSON.parse(input) as unknown;
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ Array.isArray(parsed)
+ ) {
+ return { valid: false };
+ }
+ return { valid: true, value: parsed as Record };
+ } catch {
+ return { valid: false };
+ }
+}
+
+/**
+ * 按 category 分组插件列表,返回 Map。
+ * 保持插入顺序。
+ */
+export function groupByCategory(
+ items: RegistryItem[],
+): Map {
+ const map = new Map();
+ for (const item of items) {
+ const list = map.get(item.category);
+ if (list) {
+ list.push(item);
+ } else {
+ map.set(item.category, [item]);
+ }
+ }
+ return map;
+}
+
+/**
+ * 统计已启用的插件数。
+ */
+export function countActivePlugins(items: RegistryItem[]): number {
+ if (!items) return 0;
+ return items.filter((i) => i.isActive).length;
+}
+
+/**
+ * 统计插件总数。
+ */
+export function countTotalPlugins(items: RegistryItem[]): number {
+ if (!items) return 0;
+ return items.length;
+}
+
+/**
+ * 统计内置插件数。
+ */
+export function countBuiltinPlugins(items: RegistryItem[]): number {
+ if (!items) return 0;
+ return items.filter((i) => i.isBuiltin).length;
+}
diff --git a/apps/portal-shell/src/features/admin/questions/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/questions/__tests__/transformations.test.ts
new file mode 100644
index 0000000..3924ee0
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/questions/__tests__/transformations.test.ts
@@ -0,0 +1,234 @@
+/**
+ * Admin Questions 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ QUESTION_STATUS_LABEL,
+ QUESTION_TYPE_LABEL,
+ difficultyToColorClass,
+ formatDifficulty,
+ formatQuestionDate,
+ formatQuestionStatus,
+ formatQuestionType,
+ questionStatusToBadgeClass,
+ questionTypeToBadgeClass,
+ toAdminQuestionListItem,
+ truncateContent,
+} from "../transformations";
+
+describe("formatQuestionType", () => {
+ it("maps known types to Chinese labels", () => {
+ expect(formatQuestionType("single_choice")).toBe("单选题");
+ expect(formatQuestionType("multiple_choice")).toBe("多选题");
+ expect(formatQuestionType("fill_blank")).toBe("填空题");
+ expect(formatQuestionType("short_answer")).toBe("简答题");
+ expect(formatQuestionType("essay")).toBe("论述题");
+ expect(formatQuestionType("true_false")).toBe("判断题");
+ });
+
+ it("returns original value for unknown type", () => {
+ expect(formatQuestionType("unknown_type")).toBe("unknown_type");
+ expect(formatQuestionType("")).toBe("");
+ });
+
+ it("QUESTION_TYPE_LABEL covers 6 standard types", () => {
+ expect(Object.keys(QUESTION_TYPE_LABEL)).toHaveLength(6);
+ });
+});
+
+describe("formatQuestionStatus", () => {
+ it("maps known statuses to Chinese labels", () => {
+ expect(formatQuestionStatus("DRAFT")).toBe("草稿");
+ expect(formatQuestionStatus("PUBLISHED")).toBe("已发布");
+ expect(formatQuestionStatus("ARCHIVED")).toBe("已归档");
+ });
+
+ it("returns original value for unknown status", () => {
+ expect(formatQuestionStatus("UNKNOWN")).toBe("UNKNOWN");
+ expect(formatQuestionStatus("")).toBe("");
+ });
+
+ it("QUESTION_STATUS_LABEL covers all standard statuses", () => {
+ expect(Object.keys(QUESTION_STATUS_LABEL)).toHaveLength(3);
+ });
+});
+
+describe("formatDifficulty", () => {
+ it("maps string enums to Chinese labels", () => {
+ expect(formatDifficulty("easy")).toBe("简单");
+ expect(formatDifficulty("medium")).toBe("中等");
+ expect(formatDifficulty("hard")).toBe("困难");
+ });
+
+ it("maps numeric values by thresholds", () => {
+ expect(formatDifficulty(0)).toBe("简单");
+ expect(formatDifficulty(0.4)).toBe("简单");
+ expect(formatDifficulty(0.41)).toBe("中等");
+ expect(formatDifficulty(0.7)).toBe("中等");
+ expect(formatDifficulty(0.71)).toBe("困难");
+ expect(formatDifficulty(1)).toBe("困难");
+ });
+
+ it("returns placeholder for non-finite numeric input", () => {
+ expect(formatDifficulty(Number.NaN)).toBe("--");
+ expect(formatDifficulty(Number.POSITIVE_INFINITY)).toBe("--");
+ });
+
+ it("returns original string for unknown enum", () => {
+ expect(formatDifficulty("extreme")).toBe("extreme");
+ });
+});
+
+describe("formatQuestionDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatQuestionDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatQuestionDate(null)).toBe("--");
+ expect(formatQuestionDate(undefined)).toBe("--");
+ expect(formatQuestionDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatQuestionDate("not-a-date")).toBe("--");
+ });
+});
+
+describe("truncateContent", () => {
+ it("returns text unchanged when within limit", () => {
+ expect(truncateContent("短题干", 10)).toBe("短题干");
+ });
+
+ it("truncates and appends ellipsis when over limit", () => {
+ const long = "a".repeat(80);
+ const result = truncateContent(long, 60);
+ expect(result.endsWith("...")).toBe(true);
+ expect(result.length).toBe(63);
+ });
+
+ it("collapses whitespace", () => {
+ expect(truncateContent("题干\n带\n换行", 60)).toBe("题干 带 换行");
+ });
+
+ it("uses default maxLen of 60", () => {
+ const long = "b".repeat(70);
+ const result = truncateContent(long);
+ expect(result.endsWith("...")).toBe(true);
+ });
+});
+
+describe("toAdminQuestionListItem", () => {
+ it("extracts list fields from full question and coerces difficulty to string", () => {
+ const q = {
+ id: "q-001",
+ type: "single_choice",
+ content: "下列哪个是质数?",
+ difficulty: 0.3,
+ status: "PUBLISHED",
+ createdAt: "2026-07-20T00:00:00Z",
+ };
+
+ const item = toAdminQuestionListItem(q);
+ expect(item.id).toBe("q-001");
+ expect(item.type).toBe("single_choice");
+ expect(item.content).toBe("下列哪个是质数?");
+ expect(item.difficulty).toBe("0.3");
+ expect(item.status).toBe("PUBLISHED");
+ expect(item.subjectId).toBe("");
+ expect(item.subjectName).toBe("");
+ expect(item.textbookId).toBe("");
+ expect(item.createdBy).toBe("");
+ expect(item).not.toHaveProperty("answer");
+ });
+
+ it("handles string difficulty without changing it", () => {
+ const item = toAdminQuestionListItem({
+ id: "q-002",
+ type: "essay",
+ content: "论述题",
+ difficulty: "hard",
+ status: "DRAFT",
+ createdAt: "2026-07-21T00:00:00Z",
+ });
+ expect(item.difficulty).toBe("hard");
+ });
+});
+
+describe("questionTypeToBadgeClass", () => {
+ it("returns blue class for choice types", () => {
+ expect(questionTypeToBadgeClass("single_choice")).toContain("blue");
+ expect(questionTypeToBadgeClass("multiple_choice")).toContain("blue");
+ });
+
+ it("returns emerald class for fill_blank", () => {
+ expect(questionTypeToBadgeClass("fill_blank")).toContain("emerald");
+ });
+
+ it("returns amber class for short_answer and essay", () => {
+ expect(questionTypeToBadgeClass("short_answer")).toContain("amber");
+ expect(questionTypeToBadgeClass("essay")).toContain("amber");
+ });
+
+ it("returns purple class for true_false", () => {
+ expect(questionTypeToBadgeClass("true_false")).toContain("purple");
+ });
+
+ it("returns muted for unknown type", () => {
+ expect(questionTypeToBadgeClass("unknown")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
+
+describe("difficultyToColorClass", () => {
+ it("returns emerald for easy (string)", () => {
+ expect(difficultyToColorClass("easy")).toBe("text-emerald-600");
+ });
+
+ it("returns amber for medium (string)", () => {
+ expect(difficultyToColorClass("medium")).toBe("text-amber-600");
+ });
+
+ it("returns destructive for hard (string)", () => {
+ expect(difficultyToColorClass("hard")).toBe("text-destructive");
+ });
+
+ it("returns emerald for low numeric difficulty", () => {
+ expect(difficultyToColorClass(0.2)).toBe("text-emerald-600");
+ });
+
+ it("returns destructive for high numeric difficulty", () => {
+ expect(difficultyToColorClass(0.9)).toBe("text-destructive");
+ });
+
+ it("returns muted for unknown string", () => {
+ expect(difficultyToColorClass("extreme")).toBe("text-muted-foreground");
+ });
+});
+
+describe("questionStatusToBadgeClass", () => {
+ it("returns primary for PUBLISHED", () => {
+ expect(questionStatusToBadgeClass("PUBLISHED")).toContain("primary");
+ });
+
+ it("returns muted for DRAFT and ARCHIVED", () => {
+ expect(questionStatusToBadgeClass("DRAFT")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ expect(questionStatusToBadgeClass("ARCHIVED")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+
+ it("returns muted for unknown status", () => {
+ expect(questionStatusToBadgeClass("UNKNOWN")).toBe(
+ "bg-muted text-muted-foreground",
+ );
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/questions/questions-list-client.tsx b/apps/portal-shell/src/features/admin/questions/questions-list-client.tsx
new file mode 100644
index 0000000..98f127d
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/questions/questions-list-client.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+/**
+ * 题库管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 adminQuestions(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
+ *
+ * URL 状态:?type=&difficulty=&subjectId=&q=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { HelpCircle } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useAdminQuestions, type AdminQuestionListItem } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ difficultyToColorClass,
+ formatDifficulty,
+ formatQuestionDate,
+ formatQuestionStatus,
+ formatQuestionType,
+ questionStatusToBadgeClass,
+ questionTypeToBadgeClass,
+ truncateContent,
+} from "@/features/admin/questions/transformations";
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function AdminQuestionsListClient(): React.ReactElement {
+ const t = useTranslations("admin.questions.list");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const typeFilter = searchParams.get("type") ?? "";
+ const difficultyFilter = searchParams.get("difficulty") ?? "";
+ const subjectId = searchParams.get("subjectId") ?? "";
+ const q = searchParams.get("q") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminQuestions({
+ type: typeFilter || null,
+ difficulty: difficultyFilter || null,
+ subjectId: subjectId || null,
+ q: q || null,
+ });
+
+ // 客户端二次筛选兜底(q 在 MSW 已支持,此处保留以备切换真实 fetcher)
+ const filteredItems = useMemo(() => {
+ const items = data?.items ?? [];
+ if (!q) return items;
+ const lower = q.toLowerCase();
+ return items.filter((item) => item.content.toLowerCase().includes(lower));
+ }, [data, q]);
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/questions?${params.toString()}`);
+ });
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ filters={
+ <>
+ updateQuery("q", v)}
+ />
+
+
+ updateQuery("subjectId", e.target.value)}
+ placeholder={t("filterSubject")}
+ className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
+ aria-label={t("filterSubject")}
+ />
+ >
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredItems.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ pagination={
+
+
+ {t("total", { count: data?.total ?? filteredItems.length })}
+
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * 题目列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function AdminQuestionsTable({
+ items,
+}: {
+ items: AdminQuestionListItem[];
+}): React.ReactElement {
+ const t = useTranslations("admin.questions.list");
+ return (
+
+
+
+
+ | {t("colContent")} |
+ {t("colType")} |
+ {t("colDifficulty")} |
+ {t("colSubject")} |
+ {t("colStatus")} |
+ {t("colCreatedAt")} |
+ {t("colCreatedBy")} |
+ {t("colActions")} |
+
+
+
+ {items.map((q) => (
+
+ |
+
+ {truncateContent(q.content)}
+
+ |
+
+
+ |
+
+
+ {formatDifficulty(q.difficulty)}
+
+ |
+
+ {q.subjectName || q.subjectId || "-"}
+ |
+
+
+ |
+
+ {formatQuestionDate(q.createdAt)}
+ |
+
+ {q.createdBy || "-"}
+ |
+
+
+ {t("viewDetail")}
+
+ |
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 题型徽章(按题型色阶展示)。
+ */
+function QuestionTypeBadge({ type }: { type: string }): React.ReactElement {
+ const label = formatQuestionType(type);
+ const cls = questionTypeToBadgeClass(type);
+ return (
+
+ {label}
+
+ );
+}
+
+/**
+ * 题目状态徽章(按状态色阶展示)。
+ */
+function QuestionStatusBadge({
+ status,
+}: {
+ status: string;
+}): React.ReactElement {
+ const label = formatQuestionStatus(status);
+ const cls = questionStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/questions/transformations.ts b/apps/portal-shell/src/features/admin/questions/transformations.ts
new file mode 100644
index 0000000..9107f40
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/questions/transformations.ts
@@ -0,0 +1,178 @@
+/**
+ * Admin Questions 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type { AdminQuestionListItem } from "@/lib/api/admin-p5";
+
+/** 题型中文标签映射(对齐 schema Question.type 字符串语义) */
+export const QUESTION_TYPE_LABEL: Record = {
+ single_choice: "单选题",
+ multiple_choice: "多选题",
+ fill_blank: "填空题",
+ short_answer: "简答题",
+ essay: "论述题",
+ true_false: "判断题",
+};
+
+/** 题目状态中文标签映射 */
+export const QUESTION_STATUS_LABEL: Record = {
+ DRAFT: "草稿",
+ PUBLISHED: "已发布",
+ ARCHIVED: "已归档",
+};
+
+/**
+ * 将题型映射为中文标签。未知题型回退为原始值。
+ */
+export function formatQuestionType(type: string): string {
+ return QUESTION_TYPE_LABEL[type] ?? type;
+}
+
+/**
+ * 将题目状态映射为中文标签。未知状态回退为原始值。
+ */
+export function formatQuestionStatus(status: string): string {
+ return QUESTION_STATUS_LABEL[status] ?? status;
+}
+
+/**
+ * 格式化难度。
+ *
+ * admin AdminQuestionListItem.difficulty 为字符串枚举(easy/medium/hard)。
+ * 为兼容数值输入(schema Question.difficulty 为 Float),同时支持数值:
+ * - 字符串枚举:直接映射("easy" → "简单")
+ * - 数值:0~0.4 → 简单,0.4~0.7 → 中等,0.7~1.0 → 困难
+ * - 其他:回退原始字符串
+ */
+export function formatDifficulty(difficulty: string | number): string {
+ if (typeof difficulty === "number") {
+ if (!Number.isFinite(difficulty)) return "--";
+ if (difficulty <= 0.4) return "简单";
+ if (difficulty <= 0.7) return "中等";
+ return "困难";
+ }
+ switch (difficulty) {
+ case "easy":
+ return "简单";
+ case "medium":
+ return "中等";
+ case "hard":
+ return "困难";
+ default:
+ return difficulty;
+ }
+}
+
+/**
+ * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
+ * 输入无效时返回占位符。
+ */
+export function formatQuestionDate(isoDate: string | null | undefined): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * 截断题干文本用于列表展示。
+ * - 超过 maxLen 字符时截断并加省略号
+ * - 折叠空白(列表单行展示)
+ * - maxLen 默认 60
+ */
+export function truncateContent(content: string, maxLen = 60): string {
+ const text = content.replace(/\s+/g, " ").trim();
+ if (text.length <= maxLen) return text;
+ return `${text.slice(0, maxLen)}...`;
+}
+
+/**
+ * 从题目详情中提取列表项视图模型(裁剪字段)。
+ *
+ * 注:admin 题目详情契约尚未就绪(@contract-pending),此处保留裁剪入口,
+ * subjectName 等列表扩展字段在详情→列表裁剪时置为空串。
+ */
+export function toAdminQuestionListItem(question: {
+ id: string;
+ type: string;
+ content: string;
+ difficulty: string | number;
+ status: string;
+ createdAt: string;
+}): AdminQuestionListItem {
+ return {
+ id: question.id,
+ type: question.type,
+ content: question.content,
+ difficulty: String(question.difficulty),
+ subjectId: "",
+ subjectName: "",
+ textbookId: "",
+ status: question.status,
+ createdAt: question.createdAt,
+ createdBy: "",
+ };
+}
+
+/**
+ * 根据题型返回 Tailwind 徽章语义类名。
+ */
+export function questionTypeToBadgeClass(type: string): string {
+ switch (type) {
+ case "single_choice":
+ case "multiple_choice":
+ return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
+ case "fill_blank":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "short_answer":
+ case "essay":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ case "true_false":
+ return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/**
+ * 根据难度返回 Tailwind 文本语义类名。
+ * 支持字符串枚举与数值输入。
+ */
+export function difficultyToColorClass(difficulty: string | number): string {
+ const label = formatDifficulty(difficulty);
+ switch (label) {
+ case "简单":
+ return "text-emerald-600";
+ case "中等":
+ return "text-amber-600";
+ case "困难":
+ return "text-destructive";
+ default:
+ return "text-muted-foreground";
+ }
+}
+
+/**
+ * 根据题目状态返回 Tailwind 徽章语义类名。
+ */
+export function questionStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "DRAFT":
+ return "bg-muted text-muted-foreground";
+ case "PUBLISHED":
+ return "bg-primary/10 text-primary";
+ case "ARCHIVED":
+ return "bg-muted text-muted-foreground";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
diff --git a/apps/portal-shell/src/features/admin/roles/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/roles/__tests__/transformations.test.ts
new file mode 100644
index 0000000..a63346b
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/roles/__tests__/transformations.test.ts
@@ -0,0 +1,177 @@
+/**
+ * Admin Roles 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import type { Role, RolePermission } from "@/lib/api";
+
+import {
+ countRolePermissions,
+ formatPermissionCount,
+ formatPermissionKey,
+ formatRoleDescription,
+ groupPermissionsByResource,
+ hasPermissions,
+ isRoleLocked,
+ lockedToBadgeClass,
+ matchRoleSearch,
+ permissionToKey,
+} from "../transformations";
+
+const samplePerm = (resource: string, action: string): RolePermission => ({
+ id: `${resource}.${action}`,
+ name: `${resource}.${action}`,
+ resource,
+ action,
+});
+
+const sampleRole: Role = {
+ id: "role-001",
+ name: "admin",
+ permissions: [
+ samplePerm("user", "read"),
+ samplePerm("user", "write"),
+ samplePerm("role", "read"),
+ ],
+ description: "系统管理员",
+ isLocked: true,
+};
+
+describe("formatPermissionCount", () => {
+ it("formats valid count", () => {
+ expect(formatPermissionCount(0)).toBe("0 个");
+ expect(formatPermissionCount(3)).toBe("3 个");
+ });
+
+ it("returns 0 个 for invalid input", () => {
+ expect(formatPermissionCount(-1)).toBe("0 个");
+ expect(formatPermissionCount(Number.NaN)).toBe("0 个");
+ expect(formatPermissionCount(Number.POSITIVE_INFINITY)).toBe("0 个");
+ });
+});
+
+describe("formatPermissionKey", () => {
+ it("joins resource and action with dot", () => {
+ expect(formatPermissionKey("user", "read")).toBe("user.read");
+ expect(formatPermissionKey("role", "write")).toBe("role.write");
+ });
+});
+
+describe("permissionToKey", () => {
+ it("converts RolePermission to resource.action", () => {
+ expect(permissionToKey(samplePerm("class", "read"))).toBe("class.read");
+ });
+});
+
+describe("countRolePermissions", () => {
+ it("counts permissions on role", () => {
+ expect(countRolePermissions(sampleRole)).toBe(3);
+ });
+
+ it("returns 0 for role without permissions", () => {
+ const empty: Role = {
+ id: "r",
+ name: "empty",
+ permissions: [],
+ };
+ expect(countRolePermissions(empty)).toBe(0);
+ });
+});
+
+describe("hasPermissions", () => {
+ it("returns true when role has permissions", () => {
+ expect(hasPermissions(sampleRole)).toBe(true);
+ });
+
+ it("returns false when role has no permissions", () => {
+ expect(hasPermissions({ id: "r", name: "empty", permissions: [] })).toBe(
+ false,
+ );
+ });
+});
+
+describe("isRoleLocked", () => {
+ it("returns true when isLocked === true", () => {
+ expect(isRoleLocked({ ...sampleRole, isLocked: true })).toBe(true);
+ });
+
+ it("returns false when isLocked is false", () => {
+ expect(isRoleLocked({ ...sampleRole, isLocked: false })).toBe(false);
+ });
+
+ it("returns false when isLocked is undefined (list MSW fallback)", () => {
+ expect(isRoleLocked({ id: "r", name: "n", permissions: [] })).toBe(false);
+ });
+});
+
+describe("lockedToBadgeClass", () => {
+ it("returns amber class for locked", () => {
+ expect(lockedToBadgeClass(true)).toContain("amber");
+ });
+
+ it("returns muted class for unlocked", () => {
+ expect(lockedToBadgeClass(false)).toBe("bg-muted text-muted-foreground");
+ });
+});
+
+describe("groupPermissionsByResource", () => {
+ it("groups permissions by resource preserving first-seen order", () => {
+ const perms = [
+ samplePerm("user", "read"),
+ samplePerm("role", "read"),
+ samplePerm("user", "write"),
+ ];
+ const groups = groupPermissionsByResource(perms);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.resource).toBe("user");
+ expect(groups[0]?.items).toHaveLength(2);
+ expect(groups[1]?.resource).toBe("role");
+ expect(groups[1]?.items).toHaveLength(1);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(groupPermissionsByResource([])).toEqual([]);
+ });
+});
+
+describe("formatRoleDescription", () => {
+ it("returns description when present", () => {
+ expect(formatRoleDescription("系统管理员")).toBe("系统管理员");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatRoleDescription(null)).toBe("--");
+ expect(formatRoleDescription(undefined)).toBe("--");
+ expect(formatRoleDescription("")).toBe("--");
+ });
+});
+
+describe("matchRoleSearch", () => {
+ it("returns true when query is empty or whitespace", () => {
+ expect(matchRoleSearch(sampleRole, "")).toBe(true);
+ expect(matchRoleSearch(sampleRole, " ")).toBe(true);
+ });
+
+ it("matches by name (case-insensitive)", () => {
+ expect(matchRoleSearch(sampleRole, "admin")).toBe(true);
+ expect(matchRoleSearch(sampleRole, "ADMIN")).toBe(true);
+ expect(matchRoleSearch(sampleRole, "ad")).toBe(true);
+ });
+
+ it("matches by description (case-insensitive)", () => {
+ expect(matchRoleSearch(sampleRole, "系统")).toBe(true);
+ expect(matchRoleSearch(sampleRole, "管理员")).toBe(true);
+ });
+
+ it("returns false when no match", () => {
+ expect(matchRoleSearch(sampleRole, "teacher")).toBe(false);
+ expect(matchRoleSearch(sampleRole, "xyz")).toBe(false);
+ });
+
+ it("returns false when description is undefined and query misses name", () => {
+ const r: Role = { id: "r", name: "empty", permissions: [] };
+ expect(matchRoleSearch(r, "系统")).toBe(false);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/roles/role-detail-client.tsx b/apps/portal-shell/src/features/admin/roles/role-detail-client.tsx
new file mode 100644
index 0000000..81eb929
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/roles/role-detail-client.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+/**
+ * 角色详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 单查 role(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - mutation updateRole / deleteRole ❌ → MSW 兜底
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:DetailPageSkeleton
+ * - error:errorNode 局部降级
+ * - notFound:data 为 null 时显示空态节点
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+import { ShieldCheck } from "lucide-react";
+import { useParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { useRole, type RoleDetail } from "@/lib/api";
+import {
+ DetailPageShell,
+ DetailPageSkeleton,
+ DetailSection,
+ DetailField,
+} from "@/shared/components/page-templates";
+import {
+ groupPermissionsByResource,
+ isRoleLocked,
+ lockedToBadgeClass,
+ permissionToKey,
+} from "@/features/admin/roles/transformations";
+
+/**
+ * 详情客户端主体。需由 server page 包裹在 中。
+ */
+export function RoleDetailClient(): React.ReactElement {
+ const t = useTranslations("admin.roles.detail");
+ const tCommon = useTranslations("common");
+ const params = useParams<{ id: string }>();
+ const roleId = params?.id ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useRole(roleId);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ ) : undefined;
+
+ return (
+ }
+ backHref="/shell/admin/roles"
+ loading={loading}
+ loadingNode={}
+ errorNode={errorNode}
+ emptyNode={
+ !loading && !error && !data ? (
+
+ {t("notFound")}
+
+ ) : undefined
+ }
+ >
+ {data ? : null}
+
+ );
+}
+
+/**
+ * 详情内容区(基本信息 + 权限矩阵)。
+ */
+function RoleDetailBody({ role }: { role: RoleDetail }): React.ReactElement {
+ const t = useTranslations("admin.roles.detail");
+ const locked = isRoleLocked(role);
+ return (
+ <>
+
+ {locked ? (
+
+ {t("lockedNotice")}
+
+ ) : null}
+
+
+ }
+ />
+
+
+
+ {role.permissions.length === 0 ? (
+ {t("noPermissions")}
+ ) : (
+
+ )}
+
+ >
+ );
+}
+
+/**
+ * 权限矩阵(按资源分组的权限列表)。
+ */
+function PermissionsMatrix({
+ permissions,
+}: {
+ permissions: RoleDetail["permissions"];
+}): React.ReactElement {
+ const groups = groupPermissionsByResource(permissions);
+ return (
+
+ {groups.map((group) => (
+
+
+ {group.resource}
+
+
+ {group.items.map((perm) => (
+
+ {perm.action}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+/**
+ * 系统锁定徽章。
+ */
+function LockedBadge({ locked }: { locked: boolean }): React.ReactElement {
+ const t = useTranslations("admin.roles");
+ const cls = lockedToBadgeClass(locked);
+ return (
+
+ {locked ? t("list.lockedRole") : "--"}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/roles/roles-list-client.tsx b/apps/portal-shell/src/features/admin/roles/roles-list-client.tsx
new file mode 100644
index 0000000..5245641
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/roles/roles-list-client.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+/**
+ * 角色管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 roles ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * - 契约工单:docs/architecture/issues/contracts/iam_contract.md#roles
+ *
+ * URL 状态:?search=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { ShieldCheck } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import { useRoles, type Role } from "@/lib/api";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ countRolePermissions,
+ formatPermissionCount,
+ formatRoleDescription,
+ isRoleLocked,
+ lockedToBadgeClass,
+ matchRoleSearch,
+} from "@/features/admin/roles/transformations";
+
+/**
+ * 列表客户端主体。需由 server page 包裹在 中
+ * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
+ */
+export function RolesListClient(): React.ReactElement {
+ const t = useTranslations("admin.roles");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const search = searchParams.get("search") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useRoles();
+
+ const filteredItems = useMemo(() => {
+ const items = data ?? [];
+ return items.filter((r) => matchRoleSearch(r, search));
+ }, [data, search]);
+
+ const updateQuery = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ startTransition(() => {
+ router.push(`/shell/admin/roles?${params.toString()}`);
+ });
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+ {t("list.mswNotice")}
+
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ filters={
+ updateQuery("search", v)}
+ />
+ }
+ loading={loading}
+ loadingNode={}
+ empty={filteredItems.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+
+ );
+}
+
+/**
+ * 角色列表表格(纯展示组件,对齐 §8.2 排版规范)。
+ */
+function RolesTable({ items }: { items: Role[] }): React.ReactElement {
+ const t = useTranslations("admin.roles");
+ return (
+
+
+
+
+ | {t("list.colName")} |
+
+ {t("list.colDescription")}
+ |
+
+ {t("list.colIsLocked")}
+ |
+
+ {t("list.colPermissions")}
+ |
+
+ {t("list.colActions")}
+ |
+
+
+
+ {items.map((r) => {
+ const locked = isRoleLocked(r);
+ const permCount = countRolePermissions(r);
+ return (
+
+ | {r.name} |
+
+ {formatRoleDescription(r.description)}
+ |
+
+
+ |
+
+ {formatPermissionCount(permCount)}
+ |
+
+
+
+ {t("list.viewDetail")}
+
+
+ {t("list.editPermissions")}
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+/**
+ * 系统锁定徽章。
+ */
+function LockedBadge({ locked }: { locked: boolean }): React.ReactElement {
+ const t = useTranslations("admin.roles");
+ const cls = lockedToBadgeClass(locked);
+ return (
+
+ {locked ? t("list.lockedRole") : "--"}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/roles/transformations.ts b/apps/portal-shell/src/features/admin/roles/transformations.ts
new file mode 100644
index 0000000..5ce688e
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/roles/transformations.ts
@@ -0,0 +1,110 @@
+/**
+ * Admin Roles 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+import type { Role, RolePermission } from "@/lib/api";
+
+/**
+ * 格式化权限数为展示字符串。
+ * 输入无效返回 "0 个"。
+ */
+export function formatPermissionCount(count: number): string {
+ if (!Number.isFinite(count) || count < 0) return "0 个";
+ return `${count} 个`;
+}
+
+/**
+ * 将权限组装为 resource.action 形式键。
+ */
+export function formatPermissionKey(resource: string, action: string): string {
+ return `${resource}.${action}`;
+}
+
+/**
+ * 从 RolePermission 组装 resource.action 键。
+ */
+export function permissionToKey(perm: RolePermission): string {
+ return formatPermissionKey(perm.resource, perm.action);
+}
+
+/**
+ * 统计角色权限数。
+ */
+export function countRolePermissions(role: Role): number {
+ return role.permissions.length;
+}
+
+/**
+ * 判断角色是否已分配权限。
+ */
+export function hasPermissions(role: Role): boolean {
+ return role.permissions.length > 0;
+}
+
+/**
+ * 判断角色是否为系统锁定角色。
+ * 缺省(undefined)视为非锁定(列表 MSW 兜底可能缺省)。
+ */
+export function isRoleLocked(role: Role): boolean {
+ return role.isLocked === true;
+}
+
+/**
+ * 根据 isLocked 返回 Tailwind 徽章语义类名。
+ */
+export function lockedToBadgeClass(locked: boolean): string {
+ return locked
+ ? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
+ : "bg-muted text-muted-foreground";
+}
+
+/**
+ * 按资源分组权限列表。
+ * 返回 resource → RolePermission[] 的有序映射(按资源首次出现顺序)。
+ */
+export function groupPermissionsByResource(
+ perms: RolePermission[],
+): Array<{ resource: string; items: RolePermission[] }> {
+ const groups: Array<{ resource: string; items: RolePermission[] }> = [];
+ const indexByKey = new Map();
+ for (const p of perms) {
+ const existing = indexByKey.get(p.resource);
+ if (existing === undefined) {
+ indexByKey.set(p.resource, groups.length);
+ groups.push({ resource: p.resource, items: [p] });
+ } else {
+ const group = groups[existing];
+ if (group) {
+ group.items.push(p);
+ }
+ }
+ }
+ return groups;
+}
+
+/**
+ * 格式化角色描述,缺省返回占位符。
+ */
+export function formatRoleDescription(
+ description: string | null | undefined,
+): string {
+ if (!description) return "--";
+ return description;
+}
+
+/**
+ * 模糊匹配角色搜索关键字(按 name / description 命中,大小写不敏感)。
+ * 关键字为空时返回 true。
+ */
+export function matchRoleSearch(role: Role, query: string): boolean {
+ const q = query.trim().toLowerCase();
+ if (!q) return true;
+ if (role.name.toLowerCase().includes(q)) return true;
+ if (role.description && role.description.toLowerCase().includes(q)) {
+ return true;
+ }
+ return false;
+}
diff --git a/apps/portal-shell/src/features/admin/scheduling/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/scheduling/__tests__/transformations.test.ts
new file mode 100644
index 0000000..54892ec
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/scheduling/__tests__/transformations.test.ts
@@ -0,0 +1,157 @@
+/**
+ * Admin Scheduling 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ countConflicts,
+ formatGeneratedEntries,
+ formatScheduleDate,
+ hasConflicts,
+ parseSlot,
+ scheduleChangeStatusToBadgeClass,
+ scheduleChangeStatusToKey,
+ scheduleStatusToBadgeClass,
+ scheduleStatusToKey,
+} from "../transformations";
+
+describe("scheduleStatusToKey", () => {
+ it("returns known statuses as-is", () => {
+ expect(scheduleStatusToKey("pending")).toBe("pending");
+ expect(scheduleStatusToKey("scheduled")).toBe("scheduled");
+ expect(scheduleStatusToKey("failed")).toBe("failed");
+ });
+
+ it("falls back to pending for unknown", () => {
+ expect(scheduleStatusToKey("unknown")).toBe("pending");
+ expect(scheduleStatusToKey("")).toBe("pending");
+ });
+});
+
+describe("scheduleStatusToBadgeClass", () => {
+ it("returns emerald for scheduled", () => {
+ expect(scheduleStatusToBadgeClass("scheduled")).toContain("emerald");
+ });
+
+ it("returns destructive for failed", () => {
+ expect(scheduleStatusToBadgeClass("failed")).toContain("destructive");
+ });
+
+ it("returns amber for pending", () => {
+ expect(scheduleStatusToBadgeClass("pending")).toContain("amber");
+ });
+
+ it("returns amber for unknown (fallback)", () => {
+ expect(scheduleStatusToBadgeClass("unknown")).toContain("amber");
+ });
+});
+
+describe("formatGeneratedEntries", () => {
+ it("formats positive counts as string", () => {
+ expect(formatGeneratedEntries(0)).toBe("0");
+ expect(formatGeneratedEntries(35)).toBe("35");
+ });
+
+ it("returns placeholder for null/undefined/non-finite/negative", () => {
+ expect(formatGeneratedEntries(null)).toBe("--");
+ expect(formatGeneratedEntries(undefined)).toBe("--");
+ expect(formatGeneratedEntries(Number.NaN)).toBe("--");
+ expect(formatGeneratedEntries(-1)).toBe("--");
+ });
+});
+
+describe("hasConflicts", () => {
+ it("returns true for non-empty conflict array", () => {
+ expect(hasConflicts(["MON_1 conflict"])).toBe(true);
+ expect(hasConflicts(["a", "b"])).toBe(true);
+ });
+
+ it("returns false for empty/null/undefined", () => {
+ expect(hasConflicts([])).toBe(false);
+ expect(hasConflicts(null)).toBe(false);
+ expect(hasConflicts(undefined)).toBe(false);
+ });
+});
+
+describe("countConflicts", () => {
+ it("counts conflicts", () => {
+ expect(countConflicts(["a", "b", "c"])).toBe(3);
+ });
+
+ it("returns 0 for null/undefined/empty", () => {
+ expect(countConflicts(null)).toBe(0);
+ expect(countConflicts(undefined)).toBe(0);
+ expect(countConflicts([])).toBe(0);
+ });
+});
+
+describe("scheduleChangeStatusToKey", () => {
+ it("returns known statuses as-is", () => {
+ expect(scheduleChangeStatusToKey("pending")).toBe("pending");
+ expect(scheduleChangeStatusToKey("approved")).toBe("approved");
+ expect(scheduleChangeStatusToKey("rejected")).toBe("rejected");
+ });
+
+ it("falls back to pending for unknown", () => {
+ expect(scheduleChangeStatusToKey("unknown")).toBe("pending");
+ });
+});
+
+describe("scheduleChangeStatusToBadgeClass", () => {
+ it("returns emerald for approved", () => {
+ expect(scheduleChangeStatusToBadgeClass("approved")).toContain("emerald");
+ });
+
+ it("returns destructive for rejected", () => {
+ expect(scheduleChangeStatusToBadgeClass("rejected")).toContain(
+ "destructive",
+ );
+ });
+
+ it("returns amber for pending", () => {
+ expect(scheduleChangeStatusToBadgeClass("pending")).toContain("amber");
+ });
+});
+
+describe("parseSlot", () => {
+ it("parses XXX_N format (e.g. MON_1)", () => {
+ const r = parseSlot("MON_1");
+ expect(r.day).toBe("MON");
+ expect(r.period).toBe(1);
+ });
+
+ it("parses 周X第N节 format", () => {
+ const r = parseSlot("周一第3节");
+ expect(r.day).toBe("周一");
+ expect(r.period).toBe(3);
+ });
+
+ it("returns placeholder for null/empty", () => {
+ expect(parseSlot(null)).toEqual({ day: "--", period: 0 });
+ expect(parseSlot(undefined)).toEqual({ day: "--", period: 0 });
+ expect(parseSlot("")).toEqual({ day: "--", period: 0 });
+ });
+
+ it("returns raw slot as day with period 0 for unparseable", () => {
+ const r = parseSlot("some random slot");
+ expect(r.day).toBe("some random slot");
+ expect(r.period).toBe(0);
+ });
+});
+
+describe("formatScheduleDate", () => {
+ it("formats valid ISO date string", () => {
+ const result = formatScheduleDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty/invalid", () => {
+ expect(formatScheduleDate(null)).toBe("--");
+ expect(formatScheduleDate(undefined)).toBe("--");
+ expect(formatScheduleDate("")).toBe("--");
+ expect(formatScheduleDate("not-a-date")).toBe("--");
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx b/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx
new file mode 100644
index 0000000..8c82ce8
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+/**
+ * 自动排课页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - adminClasses():❌ schema 无 → MSW 兜底(@contract-pending)
+ * - autoSchedule(classId) mutation:❌ schema 无 → MSW 兜底
+ *
+ * URL 状态:无(操作型页面,状态本地维护)
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { CalendarClock } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { useAdminClasses, useAutoSchedule } from "@/lib/api";
+import { notify } from "@/shared/lib/notify";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ formatGeneratedEntries,
+ hasConflicts,
+ scheduleStatusToBadgeClass,
+ scheduleStatusToKey,
+ type ScheduleStatus,
+} from "@/features/admin/scheduling/transformations";
+
+/** 班级排课结果本地状态 */
+interface ClassScheduleResult {
+ status: ScheduleStatus;
+ generatedEntries?: number;
+ conflicts?: string[];
+}
+
+/**
+ * 自动排课客户端主体。需由 server page 包裹在 中。
+ */
+export function AutoScheduleClient(): React.ReactElement {
+ const t = useTranslations("admin.scheduling.auto");
+ const tCommon = useTranslations("common");
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error } = useAdminClasses();
+ const { run: autoSchedule, loading: running } = useAutoSchedule();
+
+ const [results, setResults] = useState>(
+ {},
+ );
+
+ const classes = data ?? [];
+
+ const handleStart = async (classId: string): Promise => {
+ if (!window.confirm(t("startConfirm"))) return;
+ // 标记为 pending(覆盖之前结果)
+ setResults((prev) => ({
+ ...prev,
+ [classId]: { status: "pending" },
+ }));
+ try {
+ const result = await autoSchedule(classId);
+ const nextStatus: ScheduleStatus = hasConflicts(result.conflicts)
+ ? "failed"
+ : "scheduled";
+ setResults((prev) => ({
+ ...prev,
+ [classId]: {
+ status: nextStatus,
+ generatedEntries: result.generatedEntries,
+ conflicts: result.conflicts,
+ },
+ }));
+ if (nextStatus === "scheduled") {
+ notify.success(t("startSuccess", { count: result.generatedEntries }));
+ } else {
+ notify.error(t("startError"));
+ }
+ } catch (err) {
+ setResults((prev) => ({
+ ...prev,
+ [classId]: { status: "failed", conflicts: [String(err)] },
+ }));
+ notify.error(`${t("startError")}: ${String(err)}`);
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ actions={
+
+ }
+ loading={loading}
+ loadingNode={}
+ empty={classes.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+ {t("mswNotice")}
+
+ );
+}
+
+/**
+ * 班级自动排课表格。
+ */
+function AutoScheduleTable({
+ classes,
+ results,
+ running,
+ onStart,
+}: {
+ classes: NonNullable["data"]>;
+ results: Record;
+ running: boolean;
+ onStart: (classId: string) => Promise;
+}): React.ReactElement {
+ const t = useTranslations("admin.scheduling.auto");
+ return (
+
+
+
+
+ | {t("colClassName")} |
+ {t("colGrade")} |
+ {t("colTeacher")} |
+
+ {t("colSubjectCount")}
+ |
+ {t("colStatus")} |
+ {t("startButton")} |
+
+
+
+ {classes.map((cls) => {
+ const result = results[cls.id];
+ const statusKey = scheduleStatusToKey(result?.status ?? "pending");
+ const statusLabel = t(
+ `status${statusKey.charAt(0).toUpperCase()}${statusKey.slice(1)}` as
+ "statusPending" | "statusScheduled" | "statusFailed",
+ );
+ return (
+
+ | {cls.name} |
+ {cls.gradeName} |
+
+ {cls.headTeacherName || "--"}
+ |
+ {cls.subjectCount} |
+
+
+ {result?.generatedEntries != null ? (
+
+ ({formatGeneratedEntries(result.generatedEntries)})
+
+ ) : null}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+/**
+ * 排课状态徽章。
+ */
+function StatusBadge({
+ status,
+ label,
+}: {
+ status: ScheduleStatus;
+ label: string;
+}): React.ReactElement {
+ const cls = scheduleStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx b/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx
new file mode 100644
index 0000000..9cdc286
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx
@@ -0,0 +1,385 @@
+"use client";
+
+/**
+ * 排课变更审批页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - adminScheduleChanges():❌ schema 无 → MSW 兜底(@contract-pending)
+ * - adminScheduleEntries():❌ schema 无 → MSW 兜底
+ * - approveScheduleChange(id) / rejectScheduleChange(id, reason) mutation:❌ → MSW 兜底
+ *
+ * URL 状态:无(操作型页面)
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { ClipboardCheck } from "lucide-react";
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAdminScheduleChanges,
+ useAdminScheduleEntries,
+ useApproveScheduleChange,
+ useRejectScheduleChange,
+} from "@/lib/api";
+import { notify } from "@/shared/lib/notify";
+import { Button } from "@/shared/components/ui/button";
+import { Card, CardContent } from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ formatScheduleDate,
+ parseSlot,
+ scheduleChangeStatusToBadgeClass,
+ scheduleChangeStatusToKey,
+} from "@/features/admin/scheduling/transformations";
+
+/**
+ * 排课变更审批客户端主体。需由 server page 包裹在 中。
+ */
+export function ScheduleChangesClient(): React.ReactElement {
+ const t = useTranslations("admin.scheduling.changes");
+ const tCommon = useTranslations("common");
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error, refetch } = useAdminScheduleChanges();
+ const { data: entries } = useAdminScheduleEntries();
+ const { run: approveChange, loading: approving } = useApproveScheduleChange();
+ const { run: rejectChange, loading: rejecting } = useRejectScheduleChange();
+
+ const changes = data?.items ?? [];
+ const entriesList = entries ?? [];
+
+ // 冲突检测:requestedSlot 与该班级已有 entries 的 slot 重合视为冲突
+ const conflictMap = useMemo(() => {
+ const map = new Map();
+ for (const change of changes) {
+ const conflicts: string[] = [];
+ const classEntries = entriesList.filter(
+ (e) => e.classId === change.classId,
+ );
+ for (const entry of classEntries) {
+ for (const slot of entry.entries) {
+ if (slot.slot === change.requestedSlot) {
+ conflicts.push(
+ `${slot.subjectName} - ${slot.teacherName} (${slot.slot})`,
+ );
+ }
+ }
+ }
+ // 同时检测同教师在同时段是否已被占用
+ for (const entry of entriesList) {
+ for (const slot of entry.entries) {
+ if (
+ slot.slot === change.requestedSlot &&
+ slot.teacherId === change.teacherId &&
+ entry.classId !== change.classId
+ ) {
+ conflicts.push(
+ `${slot.teacherName} 在 ${entry.className} 同时段已排课`,
+ );
+ }
+ }
+ }
+ map.set(change.id, conflicts);
+ }
+ return map;
+ }, [changes, entriesList]);
+
+ const handleApprove = async (id: string): Promise => {
+ if (!window.confirm(t("approveConfirm"))) return;
+ try {
+ await approveChange(id);
+ notify.success(t("approveSuccess"));
+ await refetch();
+ } catch (err) {
+ notify.error(`${t("approveError")}: ${String(err)}`);
+ }
+ };
+
+ const handleReject = async (id: string): Promise => {
+ const reason = window.prompt(t("rejectConfirm"));
+ if (reason === null) return;
+ const trimmed = reason.trim();
+ if (!trimmed) {
+ notify.error(t("rejectError"));
+ return;
+ }
+ try {
+ await rejectChange(id, trimmed);
+ notify.success(t("rejectSuccess"));
+ await refetch();
+ } catch (err) {
+ notify.error(`${t("rejectError")}: ${String(err)}`);
+ }
+ };
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ loading={loading}
+ loadingNode={}
+ empty={changes.length === 0 && !loading}
+ emptyNode={emptyNode}
+ errorNode={errorNode}
+ >
+
+
+ {t("mswNotice")}
+
+ );
+}
+
+/**
+ * 调课变更表格 + 冲突检测展示。
+ */
+function ChangesTable({
+ changes,
+ conflictMap,
+ approving,
+ rejecting,
+ onApprove,
+ onReject,
+}: {
+ changes: NonNullable<
+ ReturnType["data"]
+ >["items"];
+ conflictMap: Map;
+ approving: boolean;
+ rejecting: boolean;
+ onApprove: (id: string) => Promise;
+ onReject: (id: string) => Promise;
+}): React.ReactElement {
+ const t = useTranslations("admin.scheduling.changes");
+ return (
+
+
+ {t("conflictTitle")}
+
+
+
+
+ |
+ {t("colClassName")}
+ |
+ {t("colDay")} |
+ {t("colPeriod")} |
+ {t("colTeacher")} |
+
+ {t("rejectButton")}
+ |
+ {t("noConflict")} |
+
+ {t("approveButton")}/{t("rejectButton")}
+ |
+
+
+
+ {changes.map((change) => {
+ const conflicts = conflictMap.get(change.id) ?? [];
+ const statusKey = scheduleChangeStatusToKey(change.status);
+ const statusLabel = t(
+ statusKey === "approved"
+ ? "approveButton"
+ : statusKey === "rejected"
+ ? "rejectButton"
+ : "noConflict",
+ );
+ const requested = parseSlot(change.requestedSlot);
+ const isPending = statusKey === "pending";
+ return (
+
+ |
+ {change.className}
+
+ {change.teacherName}
+
+ |
+
+ {requested.day}
+ |
+
+ {requested.period > 0 ? `#${requested.period}` : "--"}
+ |
+
+ {change.teacherName}
+ |
+
+ {change.reason || "--"}
+ |
+
+
+ {conflicts.length > 0 ? (
+
+ {t("conflictDetect", { count: conflicts.length })}
+
+ ) : isPending ? (
+
+ {t("noConflict")}
+
+ ) : null}
+ |
+
+ {isPending ? (
+
+
+
+
+ ) : (
+
+ {formatScheduleDate(change.submittedAt)}
+
+ )}
+ |
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+/**
+ * 课表网格视图(按班级展示已有 entries)。
+ */
+function ScheduleGridCard({
+ entries,
+}: {
+ entries: NonNullable["data"]>;
+}): React.ReactElement {
+ const t = useTranslations("admin.scheduling.changes");
+ if (entries.length === 0) {
+ return (
+
+
+
+ {t("scheduleGridTitle")}
+
+
+ {t("emptyTitle")}
+
+
+
+ );
+ }
+ return (
+
+
+ {t("scheduleGridTitle")}
+
+
+
+
+ |
+ {t("colClassName")}
+ |
+ {t("colDay")} |
+ {t("colPeriod")} |
+ {t("colSubject")} |
+ {t("colTeacher")} |
+
+
+
+ {entries.flatMap((entry) =>
+ entry.entries.map((slot, idx) => {
+ const parsed = parseSlot(slot.slot);
+ const key = `${entry.classId}-${idx}-${slot.slot}`;
+ return (
+
+ {idx === 0 ? (
+ |
+ {entry.className}
+ |
+ ) : null}
+
+ {parsed.day}
+ |
+
+ {parsed.period > 0 ? `#${parsed.period}` : "--"}
+ |
+ {slot.subjectName} |
+
+ {slot.teacherName}
+ |
+
+ );
+ }),
+ )}
+
+
+
+
+
+ );
+}
+
+/**
+ * 变更状态徽章。
+ */
+function StatusBadge({
+ status,
+ label,
+}: {
+ status: "pending" | "approved" | "rejected";
+ label: string;
+}): React.ReactElement {
+ const cls = scheduleChangeStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/scheduling/scheduling-rules-client.tsx b/apps/portal-shell/src/features/admin/scheduling/scheduling-rules-client.tsx
new file mode 100644
index 0000000..f9f4ffd
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/scheduling/scheduling-rules-client.tsx
@@ -0,0 +1,372 @@
+"use client";
+
+/**
+ * 排课规则配置页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.4 / §10 P5 / admin-NeedTodo §四)
+ *
+ * 数据契约:
+ * - schedulingRules():❌ schema 无 → MSW 兜底(@contract-pending)
+ * - updateSchedulingRules(input[]) mutation:❌ schema 无 → MSW 兜底
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底)
+ * - error:errorSummary 表单级错误
+ * - success:notify.success + refetch
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { SlidersHorizontal } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAdminSchedulingRules,
+ useUpdateSchedulingRules,
+ type AdminSchedulingRule,
+} from "@/lib/api/admin-p5";
+import { notify } from "@/shared/lib/notify";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import {
+ FormPageShell,
+ FormPageSkeleton,
+} from "@/shared/components/page-templates";
+
+/** 已知的 8 个规则槽位:key → 分区 + i18n 标签 key + 类型 */
+interface RuleSlot {
+ key: string;
+ section: "general" | "constraints" | "preferences";
+ labelKey: string;
+ type: "number" | "checkbox";
+ defaultNumber: number;
+ defaultEnabled: boolean;
+}
+
+const RULE_SLOTS: readonly RuleSlot[] = [
+ {
+ key: "max_hours_per_day",
+ section: "general",
+ labelKey: "generalMaxHoursPerDay",
+ type: "number",
+ defaultNumber: 8,
+ defaultEnabled: true,
+ },
+ {
+ key: "min_break_between_classes",
+ section: "general",
+ labelKey: "generalMinBreakBetweenClasses",
+ type: "number",
+ defaultNumber: 10,
+ defaultEnabled: true,
+ },
+ {
+ key: "max_consecutive_classes",
+ section: "general",
+ labelKey: "generalMaxConsecutiveClasses",
+ type: "number",
+ defaultNumber: 3,
+ defaultEnabled: true,
+ },
+ {
+ key: "no_consecutive_same_subject",
+ section: "constraints",
+ labelKey: "constraintsNoConsecutiveSameSubject",
+ type: "checkbox",
+ defaultNumber: 0,
+ defaultEnabled: true,
+ },
+ {
+ key: "avoid_first_period",
+ section: "constraints",
+ labelKey: "constraintsAvoidFirstPeriod",
+ type: "checkbox",
+ defaultNumber: 0,
+ defaultEnabled: false,
+ },
+ {
+ key: "free_afternoon",
+ section: "constraints",
+ labelKey: "constraintsFreeAfternoon",
+ type: "checkbox",
+ defaultNumber: 0,
+ defaultEnabled: false,
+ },
+ {
+ key: "balance_teacher_load",
+ section: "preferences",
+ labelKey: "preferencesBalanceTeacherLoad",
+ type: "checkbox",
+ defaultNumber: 0,
+ defaultEnabled: true,
+ },
+ {
+ key: "cluster_by_subject",
+ section: "preferences",
+ labelKey: "preferencesClusterBySubject",
+ type: "checkbox",
+ defaultNumber: 0,
+ defaultEnabled: false,
+ },
+] as const;
+
+/** 将 rule.name 归一化为 key(小写、空格/连字符转下划线) */
+function normalizeRuleName(name: string | null | undefined): string {
+ if (!name) return "";
+ return name.toLowerCase().replace(/[\s-]+/g, "_");
+}
+
+/** 从 rule.config 提取数值(兼容 value 字段) */
+function extractConfigNumber(
+ config: Record | null | undefined,
+): number | null {
+ if (!config) return null;
+ const v = config.value ?? config.max ?? config.count;
+ if (typeof v === "number" && Number.isFinite(v)) return v;
+ if (typeof v === "string") {
+ const n = Number(v);
+ if (Number.isFinite(n)) return n;
+ }
+ return null;
+}
+
+/**
+ * 表单客户端主体。需由 server page 包裹在 中。
+ */
+export function SchedulingRulesClient(): React.ReactElement {
+ const t = useTranslations("admin.scheduling.rules");
+ const tCommon = useTranslations("common");
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error, refetch } = useAdminSchedulingRules();
+ const { run: updateRules, loading: submitting } = useUpdateSchedulingRules();
+
+ // 将加载到的规则按 key 索引,便于按槽位填充
+ const ruleByKey = useMemo(() => {
+ const map = new Map();
+ for (const rule of data ?? []) {
+ const key = normalizeRuleName(rule.name);
+ if (key) map.set(key, rule);
+ }
+ return map;
+ }, [data]);
+
+ const [numberValues, setNumberValues] = useState>({});
+ const [enabledMap, setEnabledMap] = useState>({});
+ const [formError, setFormError] = useState(null);
+
+ // 同步加载到的规则到本地状态
+ useEffect(() => {
+ if (!data || data.length === 0) return;
+ const nextNumbers: Record = {};
+ const nextEnabled: Record = {};
+ for (const slot of RULE_SLOTS) {
+ const matched = ruleByKey.get(slot.key);
+ if (matched) {
+ nextEnabled[slot.key] = matched.isEnabled;
+ const cfgNum = extractConfigNumber(matched.config);
+ nextNumbers[slot.key] =
+ cfgNum != null ? String(cfgNum) : String(slot.defaultNumber);
+ }
+ }
+ setNumberValues((prev) => ({ ...prev, ...nextNumbers }));
+ setEnabledMap((prev) => ({ ...prev, ...nextEnabled }));
+ }, [data, ruleByKey]);
+
+ const getEnabled = (slot: RuleSlot): boolean => {
+ if (slot.key in enabledMap)
+ return enabledMap[slot.key] ?? slot.defaultEnabled;
+ return slot.defaultEnabled;
+ };
+
+ const getNumber = (slot: RuleSlot): string => {
+ if (slot.key in numberValues) return numberValues[slot.key] ?? "";
+ return String(slot.defaultNumber);
+ };
+
+ const handleToggle = (key: string, next: boolean): void => {
+ setEnabledMap((prev) => ({ ...prev, [key]: next }));
+ };
+
+ const handleNumberChange = (key: string, value: string): void => {
+ setNumberValues((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const handleReset = (): void => {
+ if (!window.confirm(t("resetConfirm"))) return;
+ setNumberValues({});
+ setEnabledMap({});
+ void refetch();
+ };
+
+ const handleSubmit = async (): Promise => {
+ setFormError(null);
+ if (!data || data.length === 0) {
+ const msg = t("saveError");
+ setFormError(msg);
+ notify.error(msg);
+ return;
+ }
+ try {
+ const input = (data ?? []).map((rule) => {
+ const key = normalizeRuleName(rule.name);
+ const nextEnabled =
+ key in enabledMap ? enabledMap[key] : rule.isEnabled;
+ return { id: rule.id, isEnabled: Boolean(nextEnabled) };
+ });
+ await updateRules(input);
+ notify.success(t("saveSuccess"));
+ await refetch();
+ } catch (err) {
+ const msg = `${t("saveError")}: ${String(err)}`;
+ setFormError(msg);
+ notify.error(msg);
+ }
+ };
+
+ if (loading) {
+ return (
+ }
+ loading
+ loadingNode={}
+ />
+ );
+ }
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
{t("mswNotice")}
+
+
+ ) : undefined;
+
+ return (
+ }
+ onSubmit={() => void handleSubmit()}
+ submitting={submitting}
+ submitLabel={t("saveButton")}
+ cancelLabel={t("resetButton")}
+ onCancel={handleReset}
+ errorSummary={
+ formError ? (
+ {formError}
+ ) : (
+ errorNode
+ )
+ }
+ >
+
+ {RULE_SLOTS.filter((s) => s.section === "general").map((slot) => {
+ const matched = ruleByKey.get(slot.key);
+ const enabled = getEnabled(slot);
+ return (
+
+ );
+ })}
+
+
+
+ {RULE_SLOTS.filter((s) => s.section === "constraints").map((slot) => (
+ handleToggle(slot.key, next)}
+ />
+ ))}
+
+
+
+ {RULE_SLOTS.filter((s) => s.section === "preferences").map((slot) => (
+ handleToggle(slot.key, next)}
+ />
+ ))}
+
+
+ {t("mswNotice")}
+
+ );
+}
+
+/**
+ * Checkbox 字段(label + 原生 input[type=checkbox])。
+ */
+function CheckboxField({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (next: boolean) => void;
+}): React.ReactElement {
+ return (
+
+ onChange(e.target.checked)}
+ className="size-4 cursor-pointer rounded border border-input"
+ />
+ {label}
+
+ );
+}
+
+/**
+ * 分区容器(标题 + 字段列表)。
+ */
+function SectionBlock({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/scheduling/transformations.ts b/apps/portal-shell/src/features/admin/scheduling/transformations.ts
new file mode 100644
index 0000000..4d7ba4a
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/scheduling/transformations.ts
@@ -0,0 +1,145 @@
+/**
+ * Admin Scheduling 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
+ *
+ * 所有格式化/映射函数均为纯函数,便于 vitest 单测。
+ * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+
+/** 排课状态枚举(与 i18n admin.scheduling.auto.status* 对齐) */
+export type ScheduleStatus = "pending" | "scheduled" | "failed";
+
+/**
+ * 将排课状态映射为 i18n key 后缀(pending/scheduled/failed)。
+ * 未知状态回退为 "pending"。
+ */
+export function scheduleStatusToKey(status: string): ScheduleStatus {
+ if (status === "scheduled" || status === "failed" || status === "pending") {
+ return status;
+ }
+ return "pending";
+}
+
+/**
+ * 根据排课状态返回 Tailwind 徽章语义类名。
+ */
+export function scheduleStatusToBadgeClass(status: string): string {
+ switch (scheduleStatusToKey(status)) {
+ case "scheduled":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "failed":
+ return "bg-destructive/10 text-destructive";
+ case "pending":
+ default:
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ }
+}
+
+/**
+ * 格式化生成的课表条数为展示字符串。
+ * 输入无效返回 "--"。
+ */
+export function formatGeneratedEntries(
+ count: number | null | undefined,
+): string {
+ if (count == null || !Number.isFinite(count) || count < 0) return "--";
+ return `${count}`;
+}
+
+/**
+ * 判断排课结果是否包含冲突。
+ */
+export function hasConflicts(
+ conflicts: ReadonlyArray | null | undefined,
+): boolean {
+ return Boolean(conflicts && conflicts.length > 0);
+}
+
+/**
+ * 将冲突列表格式化为展示用项目符号串(每条一行)。
+ * 输入空返回空数组。
+ */
+export function formatConflictsList(
+ conflicts: ReadonlyArray | null | undefined,
+): string[] {
+ if (!conflicts) return [];
+ return conflicts.map((c) => c);
+}
+
+/**
+ * 统计冲突数量。
+ */
+export function countConflicts(
+ conflicts: ReadonlyArray | null | undefined,
+): number {
+ return conflicts?.length ?? 0;
+}
+
+/**
+ * 将调课申请状态映射为 i18n key 后缀。
+ * 已知值:pending / approved / rejected;未知回退为 "pending"。
+ */
+export function scheduleChangeStatusToKey(
+ status: string,
+): "pending" | "approved" | "rejected" {
+ if (status === "approved" || status === "rejected") return status;
+ return "pending";
+}
+
+/**
+ * 根据调课申请状态返回 Tailwind 徽章语义类名。
+ */
+export function scheduleChangeStatusToBadgeClass(status: string): string {
+ switch (scheduleChangeStatusToKey(status)) {
+ case "approved":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "rejected":
+ return "bg-destructive/10 text-destructive";
+ case "pending":
+ default:
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ }
+}
+
+/**
+ * 将 slot 字符串(如 "MON_1" / "周一第1节")解析为展示用 { day, period }。
+ * 支持两种格式:
+ * - "MON_1" → { day: "MON", period: 1 }
+ * - "周一第1节" → { day: "周一", period: 1 }
+ * 解析失败返回原始 slot 作为 day,period 为 0。
+ */
+export function parseSlot(slot: string | null | undefined): {
+ day: string;
+ period: number;
+} {
+ if (!slot) return { day: "--", period: 0 };
+ // 格式1: XXX_N (如 MON_1)
+ const underscoreMatch = slot.match(/^([A-Za-z]+)_(\d+)$/);
+ if (underscoreMatch) {
+ return {
+ day: underscoreMatch[1] ?? "",
+ period: Number(underscoreMatch[2] ?? 0),
+ };
+ }
+ // 格式2: 周X第N节
+ const cnMatch = slot.match(/^(周[一二三四五六日日])第?(\d+)节?$/);
+ if (cnMatch) {
+ return { day: cnMatch[1] ?? "", period: Number(cnMatch[2] ?? 0) };
+ }
+ return { day: slot, period: 0 };
+}
+
+/**
+ * 格式化提交时间为本地化日期字符串。
+ */
+export function formatScheduleDate(isoDate: string | null | undefined): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
diff --git a/apps/portal-shell/src/features/admin/school/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/school/__tests__/transformations.test.ts
new file mode 100644
index 0000000..4b1de71
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/school/__tests__/transformations.test.ts
@@ -0,0 +1,135 @@
+/**
+ * School admin 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
+ *
+ * 覆盖 schools / grades 等子页面使用的纯函数。
+ *
+ * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ activeToBadgeClass,
+ formatCount,
+ formatScore,
+ formatSchoolDate,
+ formatSchoolDay,
+ isEmptyText,
+ truncateText,
+} from "../transformations";
+
+describe("formatSchoolDate", () => {
+ it("formats valid ISO date string with time", () => {
+ const result = formatSchoolDate("2026-07-22T10:30:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("07");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatSchoolDate(null)).toBe("--");
+ expect(formatSchoolDate(undefined)).toBe("--");
+ expect(formatSchoolDate("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatSchoolDate("not-a-date")).toBe("--");
+ });
+});
+
+describe("formatSchoolDay", () => {
+ it("formats valid ISO date string as date only", () => {
+ const result = formatSchoolDay("2026-09-01T00:00:00Z");
+ expect(result).toContain("2026");
+ expect(result).toContain("09");
+ });
+
+ it("returns placeholder for null/undefined/empty", () => {
+ expect(formatSchoolDay(null)).toBe("--");
+ expect(formatSchoolDay(undefined)).toBe("--");
+ expect(formatSchoolDay("")).toBe("--");
+ });
+
+ it("returns placeholder for invalid date", () => {
+ expect(formatSchoolDay("invalid")).toBe("--");
+ });
+});
+
+describe("truncateText", () => {
+ it("returns text unchanged when within limit", () => {
+ expect(truncateText("第一小学", 30)).toBe("第一小学");
+ });
+
+ it("truncates and appends ellipsis when over limit", () => {
+ const long = "a".repeat(40);
+ const result = truncateText(long, 30);
+ expect(result.endsWith("...")).toBe(true);
+ expect(result.length).toBe(33);
+ });
+
+ it("collapses whitespace", () => {
+ expect(truncateText("第一 小学\n校区", 30)).toBe("第一 小学 校区");
+ });
+
+ it("uses default maxLen of 30", () => {
+ const long = "b".repeat(40);
+ const result = truncateText(long);
+ expect(result.endsWith("...")).toBe(true);
+ });
+});
+
+describe("activeToBadgeClass", () => {
+ it("returns emerald class for active", () => {
+ expect(activeToBadgeClass(true)).toContain("emerald");
+ });
+
+ it("returns muted class for inactive", () => {
+ expect(activeToBadgeClass(false)).toBe("bg-muted text-muted-foreground");
+ });
+});
+
+describe("formatScore", () => {
+ it("formats valid score with one decimal", () => {
+ expect(formatScore(85)).toBe("85.0");
+ expect(formatScore(85.56)).toBe("85.6");
+ });
+
+ it("returns placeholder for null/undefined", () => {
+ expect(formatScore(null)).toBe("--");
+ expect(formatScore(undefined)).toBe("--");
+ });
+
+ it("returns placeholder for non-finite values", () => {
+ expect(formatScore(Number.NaN)).toBe("--");
+ expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
+ });
+});
+
+describe("formatCount", () => {
+ it("formats valid count", () => {
+ expect(formatCount(0)).toBe("0");
+ expect(formatCount(38)).toBe("38");
+ });
+
+ it("returns 0 for null/undefined", () => {
+ expect(formatCount(null)).toBe("0");
+ expect(formatCount(undefined)).toBe("0");
+ });
+
+ it("returns 0 for invalid input", () => {
+ expect(formatCount(-1)).toBe("0");
+ expect(formatCount(Number.NaN)).toBe("0");
+ });
+});
+
+describe("isEmptyText", () => {
+ it("returns true for null/undefined/empty/whitespace", () => {
+ expect(isEmptyText(null)).toBe(true);
+ expect(isEmptyText(undefined)).toBe(true);
+ expect(isEmptyText("")).toBe(true);
+ expect(isEmptyText(" ")).toBe(true);
+ });
+
+ it("returns false for non-empty string", () => {
+ expect(isEmptyText("第一小学")).toBe(false);
+ expect(isEmptyText(" a ")).toBe(false);
+ });
+});
diff --git a/apps/portal-shell/src/features/admin/school/academic-year-client.tsx b/apps/portal-shell/src/features/admin/school/academic-year-client.tsx
new file mode 100644
index 0000000..fe9107b
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/school/academic-year-client.tsx
@@ -0,0 +1,447 @@
+"use client";
+
+/**
+ * 学年管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 列表查询 academicYears():❌ schema 无 → MSW 兜底(@contract-pending)
+ * - createAcademicYear / updateAcademicYear / deleteAcademicYear:❌ → MSW 兜底(@contract-pending)
+ *
+ * URL 状态:?schoolId=&q=
+ *
+ * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import { Calendar, Pencil, Plus, Trash2 } from "lucide-react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { useEffect, useMemo, useState, useTransition } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useAcademicYears,
+ useCreateAcademicYear,
+ useDeleteAcademicYear,
+ useSchools,
+ useUpdateAcademicYear,
+ type AcademicYear,
+ type AcademicYearInput,
+ type SchoolListItem,
+} from "@/lib/api";
+import { notify } from "@/shared/lib/notify";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
+import { Input } from "@/shared/components/ui/input";
+import {
+ ListPageShell,
+ ListPageSkeleton,
+} from "@/shared/components/page-templates";
+import {
+ activeToBadgeClass,
+ formatSchoolDay,
+ truncateText,
+} from "@/features/admin/school/transformations";
+import {
+ DeleteConfirmDialog,
+ FormField,
+} from "@/features/admin/school/schools-client";
+
+/**
+ * 学年列表客户端主体。需由 server page 包裹在 中。
+ */
+export function AcademicYearClient(): React.ReactElement {
+ const t = useTranslations("admin.school.academicYear");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const q = searchParams.get("q") ?? "";
+ const schoolId = searchParams.get("schoolId") ?? "";
+
+ // @contract-pending:MSW 兜底
+ const { data, loading, error, refetch } = useAcademicYears();
+ const { data: schools } = useSchools();
+ const createMutation = useCreateAcademicYear();
+ const updateMutation = useUpdateAcademicYear();
+ const deleteMutation = useDeleteAcademicYear();
+
+ const [formOpen, setFormOpen] = useState(false);
+ const [editTarget, setEditTarget] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const schoolNameMap = useMemo