@@ -197,7 +197,7 @@ function ClassComparisonSkeleton(): React.ReactElement {
diff --git a/apps/portal-shell/src/features/admin/attendance/export-utils.ts b/apps/portal-shell/src/features/admin/attendance/export-utils.ts
new file mode 100644
index 0000000..3b1f306
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/attendance/export-utils.ts
@@ -0,0 +1,97 @@
+/**
+ * Admin Attendance 导出工具(ARCHITECTURE.md §9.4 / §10 P5)
+ *
+ * 前端构造 CSV 并触发下载(@contract-pending,无后端导出契约时使用)。
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import type { AdminAttendanceRecord } from "@/lib/api/admin-p5";
+import { downloadBlob } from "@/shared/lib/download";
+
+export type AttendanceExportColumnKey =
+ "studentName" | "className" | "date" | "status" | "note" | "recordedBy";
+
+export interface AttendanceColumnLabels {
+ studentName: string;
+ className: string;
+ date: string;
+ status: string;
+ note: string;
+ recordedBy: string;
+ statusPresent: string;
+ statusAbsent: string;
+ statusLate: string;
+ statusLeave: string;
+}
+
+export interface AttendanceExportColumn {
+ key: AttendanceExportColumnKey;
+ label: string;
+}
+
+export type AttendanceExportRow = Record
;
+
+function formatStatus(status: string, labels: AttendanceColumnLabels): string {
+ switch (status) {
+ case "present":
+ return labels.statusPresent;
+ case "absent":
+ return labels.statusAbsent;
+ case "late":
+ return labels.statusLate;
+ case "leave":
+ return labels.statusLeave;
+ default:
+ return status;
+ }
+}
+
+export function buildAttendanceExportColumns(
+ labels: AttendanceColumnLabels,
+): readonly AttendanceExportColumn[] {
+ return [
+ { key: "studentName", label: labels.studentName },
+ { key: "className", label: labels.className },
+ { key: "date", label: labels.date },
+ { key: "status", label: labels.status },
+ { key: "note", label: labels.note },
+ { key: "recordedBy", label: labels.recordedBy },
+ ];
+}
+
+export function attendanceRecordToExportRow(
+ record: AdminAttendanceRecord,
+ labels: AttendanceColumnLabels,
+): AttendanceExportRow {
+ return {
+ studentName: record.studentName ?? "",
+ className: record.className ?? "",
+ date: record.date ?? "",
+ status: formatStatus(record.status, labels),
+ note: record.note ?? "",
+ recordedBy: record.recordedBy ?? "",
+ };
+}
+
+function escapeCsvField(value: string): string {
+ if (value === "") return "";
+ const needsQuote = /[",\n\r]/.test(value);
+ const escaped = value.replace(/"/g, '""');
+ return needsQuote ? '"' + escaped + '"' : escaped;
+}
+
+export function exportAttendanceToCsv(
+ filename: string,
+ records: AdminAttendanceRecord[],
+ labels: AttendanceColumnLabels,
+): void {
+ const columns = buildAttendanceExportColumns(labels);
+ const header = columns.map((c) => escapeCsvField(c.label)).join(",");
+ const rows = records.map((record) => {
+ const row = attendanceRecordToExportRow(record, labels);
+ return columns.map((c) => escapeCsvField(row[c.key])).join(",");
+ });
+ const csv = "\uFEFF" + [header, ...rows].join("\r\n");
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
+ downloadBlob(blob, filename + ".csv");
+}
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
index 2edb45b..5da1354 100644
--- 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
@@ -293,7 +293,7 @@ function LoginLogsTable({
{log.ip}
{log.userAgent}
diff --git a/apps/portal-shell/src/features/admin/course-plans/course-plan-item-editor.tsx b/apps/portal-shell/src/features/admin/course-plans/course-plan-item-editor.tsx
index a3e2662..ee86599 100644
--- a/apps/portal-shell/src/features/admin/course-plans/course-plan-item-editor.tsx
+++ b/apps/portal-shell/src/features/admin/course-plans/course-plan-item-editor.tsx
@@ -200,7 +200,7 @@ export function CoursePlanItemEditor({
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("contentPlaceholder")}
- className="min-h-[100px]"
+ className="min-h-24"
/>
diff --git a/apps/portal-shell/src/features/admin/error-book/error-book-detail-dialog.tsx b/apps/portal-shell/src/features/admin/error-book/error-book-detail-dialog.tsx
index b0321b2..8c30288 100644
--- a/apps/portal-shell/src/features/admin/error-book/error-book-detail-dialog.tsx
+++ b/apps/portal-shell/src/features/admin/error-book/error-book-detail-dialog.tsx
@@ -4,7 +4,7 @@
* 错题详情对话框(迁移自 CICD error-book-detail-dialog.tsx)
*
* 适配 portal-shell:
- * - 用原生轻量模态(fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
+ * - 使用 shadcn Dialog 组件
* - 数据通过 useErrorBookDetail hook(@contract-pending MSW 兜底)拉取
* - 仅展示详情(无 archive/delete/saveNote 等 server action)
* - 错误处理走 notify.error()
@@ -28,6 +28,14 @@ import { useErrorBookDetail } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
import { Separator } from "@/shared/components/ui/separator";
export interface ErrorBookDetailDialogProps {
@@ -58,65 +66,31 @@ export function ErrorBookDetailDialog({
}
}, [error, tCommon]);
- // ESC 键关闭
- useEffect(() => {
- if (!open) return;
- const handleKeyDown = (e: KeyboardEvent): void => {
- if (e.key === "Escape") onOpenChange(false);
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [open, onOpenChange]);
-
- if (!open) return <>>;
-
return (
- onOpenChange(false)}
- >
- e.stopPropagation()}
- >
- {/* 头部 */}
-
-
-
-
- {t("title")}
-
- {data ? (
-
- {data.subjectName ? (
- {data.subjectName}
- ) : null}
- {data.knowledgePointTitle ? (
- {data.knowledgePointTitle}
- ) : null}
- {data.className ? (
-
- {data.className}
-
- ) : null}
-
- ) : null}
-
-
-
+
-
+
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/files/file-batch-operations.tsx b/apps/portal-shell/src/features/admin/files/file-batch-operations.tsx
index cba5ecb..9d7926e 100644
--- a/apps/portal-shell/src/features/admin/files/file-batch-operations.tsx
+++ b/apps/portal-shell/src/features/admin/files/file-batch-operations.tsx
@@ -14,11 +14,21 @@
*
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5
*/
-import { Trash2, X } from "lucide-react";
+import { Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useBatchDeleteFiles } from "@/lib/api";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
@@ -155,50 +165,27 @@ function ConfirmDialog({
onCancel,
onConfirm,
}: ConfirmDialogProps): React.ReactElement {
- useEffect(() => {
- const handler = (e: KeyboardEvent): void => {
- if (e.key === "Escape" && !loading) onCancel();
- };
- window.addEventListener("keydown", handler);
- return () => window.removeEventListener("keydown", handler);
- }, [loading, onCancel]);
-
return (
- {
- if (!loading) onCancel();
+ {
+ if (!o && !loading) onCancel();
}}
- role="dialog"
- aria-modal="true"
- aria-label={title}
>
- e.stopPropagation()}
- >
-
- {title}
-
-
- {description}
-
-
-
-
-
-
+
+
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx b/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx
index b016a3a..a2390bb 100644
--- a/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx
+++ b/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx
@@ -4,12 +4,11 @@
* 教案软删除确认对话框(基于 shadcn AlertDialog,ARCHITECTURE.md §7.3 / §9.4)
*
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
- * 结构:AlertDialog + AlertDialogContent。
- * 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
+ * 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体(由 Radix Dialog 提供)。
*
* 关联:ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
*/
-import { AlertTriangle, Loader2 } from "lucide-react";
+import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import {
@@ -30,7 +29,7 @@ export interface DeleteConfirmDialogProps {
onConfirm: () => void;
/** 取消回调(点击遮罩 / ESC / 取消按钮) */
onCancel: () => void;
- /** 删除进行中(禁用按钮、隐藏 spinner) */
+ /** 删除进行中(禁用按钮、显示 spinner) */
loading?: boolean;
}
@@ -44,39 +43,32 @@ export function DeleteConfirmDialog({
onConfirm,
onCancel,
loading = false,
-}: DeleteConfirmDialogProps): React.ReactElement | null {
+}: DeleteConfirmDialogProps): React.ReactElement {
const t = useTranslations("admin.lessonPlans.delete");
+ const handleOpenChange = (next: boolean): void => {
+ // 删除进行中时禁止关闭(与原 ESC 行为一致)
+ if (loading) return;
+ if (!next) onCancel();
+ };
+
+ const handleConfirm = (e: React.MouseEvent): void => {
+ e.preventDefault();
+ onConfirm();
+ };
+
return (
- {
- if (!nextOpen && !loading) {
- onCancel();
- }
- }}
- >
+
-
-
-
-
-
- {t("title")}
- {t("description")}
-
-
+
+ {t("title")}
+ {t("description")}
+
-
+
{t("cancel")}
- {
- e.preventDefault();
- onConfirm();
- }}
- disabled={loading}
- >
+
{loading ? (
<>
diff --git a/apps/portal-shell/src/features/admin/lesson-plans/export-utils.ts b/apps/portal-shell/src/features/admin/lesson-plans/export-utils.ts
new file mode 100644
index 0000000..7f275f7
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/lesson-plans/export-utils.ts
@@ -0,0 +1,116 @@
+/**
+ * Admin Lesson Plans 导出工具(ARCHITECTURE.md §9.4 / §10 P5)
+ *
+ * 前端构造 CSV 并触发下载(@contract-pending,无后端导出契约时使用)。
+ * 后端补齐导出契约后,可改为调用后端接口获取 CSV。
+ *
+ * 设计原则:
+ * - `lessonPlanToExportRow` 为纯函数,便于单测;不直接依赖 i18n,
+ * 状态文本由调用方通过 columnLabels 传入
+ * - `exportLessonPlansToCsv` 执行客户端下载,调用方处理 notify
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4
+ */
+import type { AdminLessonPlanListItem } from "@/lib/api";
+import { downloadBlob } from "@/shared/lib/download";
+
+/** 导出列标识(与 LessonPlanExportRow 的 key 对应) */
+export type LessonPlanExportColumnKey =
+ | "title"
+ | "subject"
+ | "grade"
+ | "className"
+ | "status"
+ | "teacher"
+ | "createdAt";
+
+/** 列标签映射(由调用方传入已本地化的字符串) */
+export interface LessonPlanColumnLabels {
+ title: string;
+ subject: string;
+ grade: string;
+ className: string;
+ status: string;
+ teacher: string;
+ createdAt: string;
+ statusDraft: string;
+ statusPublished: string;
+ statusArchived: string;
+ statusSubmitted: string;
+}
+
+/** 导出列定义 */
+export interface LessonPlanExportColumn {
+ key: LessonPlanExportColumnKey;
+ label: string;
+}
+
+/** 导出行(键值对,键对应 LessonPlanExportColumn.key) */
+export type LessonPlanExportRow = Record;
+
+function formatStatus(status: string, labels: LessonPlanColumnLabels): string {
+ switch (status) {
+ case "DRAFT":
+ return labels.statusDraft;
+ case "PUBLISHED":
+ return labels.statusPublished;
+ case "ARCHIVED":
+ return labels.statusArchived;
+ case "SUBMITTED":
+ return labels.statusSubmitted;
+ default:
+ return status;
+ }
+}
+
+export function buildLessonPlanExportColumns(
+ labels: LessonPlanColumnLabels,
+): readonly LessonPlanExportColumn[] {
+ return [
+ { key: "title", label: labels.title },
+ { key: "subject", label: labels.subject },
+ { key: "grade", label: labels.grade },
+ { key: "className", label: labels.className },
+ { key: "status", label: labels.status },
+ { key: "teacher", label: labels.teacher },
+ { key: "createdAt", label: labels.createdAt },
+ ];
+}
+
+export function lessonPlanToExportRow(
+ plan: AdminLessonPlanListItem,
+ labels: LessonPlanColumnLabels,
+): LessonPlanExportRow {
+ return {
+ title: plan.title ?? "",
+ subject: plan.subjectName || plan.subjectId || "",
+ grade: "",
+ className: plan.className ?? "",
+ status: formatStatus(plan.status, labels),
+ teacher: plan.teacherName ?? "",
+ createdAt: plan.createdAt ?? "",
+ };
+}
+
+function escapeCsvField(value: string): string {
+ if (value === "") return "";
+ const needsQuote = /[",\n\r]/.test(value);
+ const escaped = value.replace(/"/g, '""');
+ return needsQuote ? `"${escaped}"` : escaped;
+}
+
+export function exportLessonPlansToCsv(
+ filename: string,
+ plans: AdminLessonPlanListItem[],
+ labels: LessonPlanColumnLabels,
+): void {
+ const columns = buildLessonPlanExportColumns(labels);
+ const header = columns.map((c) => escapeCsvField(c.label)).join(",");
+ const rows = plans.map((plan) => {
+ const row = lessonPlanToExportRow(plan, labels);
+ return columns.map((c) => escapeCsvField(row[c.key])).join(",");
+ });
+ const csv = "\uFEFF" + [header, ...rows].join("\r\n");
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
+ downloadBlob(blob, `${filename}.csv`);
+}
diff --git a/apps/portal-shell/src/features/admin/organization/organization-detail-dialog.tsx b/apps/portal-shell/src/features/admin/organization/organization-detail-dialog.tsx
new file mode 100644
index 0000000..8bc17c0
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/organization/organization-detail-dialog.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+/**
+ * 组织节点详情对话框(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 详情数据复用树中的 OrgNode 对象(@contract-pending:单查契约待补齐)
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+import { Building2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import type { OrgNode } from "@/lib/api/admin-p5";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { DetailField, DetailSection } from "@/shared/components/page-templates";
+import {
+ countChildren,
+ formatMemberCount,
+ formatOrgType,
+ orgTypeToBadgeClass,
+} from "@/features/admin/organization/transformations";
+
+/**
+ * 组织节点详情对话框。受控组件,由父组件管理 open 状态与选中数据。
+ */
+export function OrganizationDetailDialog({
+ open,
+ onOpenChange,
+ node,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: OrgNode | null;
+}): React.ReactElement {
+ const t = useTranslations("admin.organization.detail");
+
+ return (
+
+ );
+}
+
+/**
+ * 详情内容区(基本信息 + 结构信息)。
+ */
+function OrganizationDetailBody({
+ node,
+}: {
+ node: OrgNode;
+}): React.ReactElement {
+ const t = useTranslations("admin.organization.detail");
+
+ return (
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 类型徽章(按节点类型色阶展示)。
+ */
+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/plugins/plugins-client.tsx b/apps/portal-shell/src/features/admin/plugins/plugins-client.tsx
index acb7946..0dfc103 100644
--- a/apps/portal-shell/src/features/admin/plugins/plugins-client.tsx
+++ b/apps/portal-shell/src/features/admin/plugins/plugins-client.tsx
@@ -33,11 +33,13 @@ import {
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@/shared/components/ui/card";
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
import {
activeToBadgeClass,
builtinToBadgeClass,
@@ -318,12 +320,20 @@ function PluginEditDialog({
};
return (
-
-
-
- {t("editTitle")}
-
-
+
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/questions/batch-operations.tsx b/apps/portal-shell/src/features/admin/questions/batch-operations.tsx
index 239086a..bf818b5 100644
--- a/apps/portal-shell/src/features/admin/questions/batch-operations.tsx
+++ b/apps/portal-shell/src/features/admin/questions/batch-operations.tsx
@@ -23,6 +23,16 @@ import { useState } from "react";
import { useTranslations } from "next-intl";
import { useBatchDeleteQuestions } from "@/lib/api";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
@@ -132,37 +142,28 @@ function ConfirmDeleteDialog({
}): React.ReactElement {
const t = useTranslations("admin.questions.batch");
return (
- {
+ if (!o && !loading) onCancel();
+ }}
>
- e.stopPropagation()}
- >
- {t("confirmTitle")}
-
- {t("confirmDescription", { count })}
-
-
-
-
-
-
-
+
+
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx b/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx
index 70c9730..9961bc2 100644
--- a/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx
+++ b/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx
@@ -23,6 +23,14 @@ import { useTranslations } from "next-intl";
import { useCreateQuestion } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
@@ -127,16 +135,17 @@ export function CreateQuestionDialog({
};
return (
- {
+ if (!o) handleClose();
+ }}
>
- e.stopPropagation()}
- >
- {t("title")}
- {t("description")}
+
+
+ {t("title")}
+ {t("description")}
+
-
-
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/questions/question-detail-dialog.tsx b/apps/portal-shell/src/features/admin/questions/question-detail-dialog.tsx
index 92d5e97..d31a391 100644
--- a/apps/portal-shell/src/features/admin/questions/question-detail-dialog.tsx
+++ b/apps/portal-shell/src/features/admin/questions/question-detail-dialog.tsx
@@ -7,7 +7,7 @@
* - adminQuestion(id):❌ schema 无 → MSW 兜底(@contract-pending)
*
* 适配 portal-shell:
- * - 用原生轻量模态(fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
+ * - 使用 shadcn Dialog 组件
* - 数据通过 useAdminQuestion hook 拉取
* - 错误处理走 notify.error()
*
@@ -31,6 +31,14 @@ import { useAdminQuestion } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
import { Separator } from "@/shared/components/ui/separator";
import {
difficultyToColorClass,
@@ -69,63 +77,31 @@ export function QuestionDetailDialog({
}
}, [error, tCommon]);
- // ESC 键关闭
- useEffect(() => {
- if (!open) return;
- const handleKeyDown = (e: KeyboardEvent): void => {
- if (e.key === "Escape") onOpenChange(false);
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [open, onOpenChange]);
-
- if (!open) return <>>;
-
return (
- onOpenChange(false)}
- >
- e.stopPropagation()}
- >
- {/* 头部 */}
-
-
-
-
- {t("title")}
-
- {data ? (
-
- {formatQuestionType(data.type)}
-
- {formatQuestionStatus(data.status)}
-
- {data.subjectName ? (
-
- {data.subjectName}
-
- ) : null}
-
- ) : null}
-
-
-
+
-
+
+
+
);
}
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
index ab77802..b2e18d8 100644
--- a/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx
+++ b/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx
@@ -38,6 +38,7 @@ import {
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
+import { Textarea } from "@/shared/components/ui/textarea";
import {
ListPageShell,
ListPageSkeleton,
@@ -61,6 +62,8 @@ export function ScheduleChangesClient(): React.ReactElement {
const { data: entries } = useAdminScheduleEntries();
const { run: approveChange, loading: approving } = useApproveScheduleChange();
const { run: rejectChange, loading: rejecting } = useRejectScheduleChange();
+ const [rejectId, setRejectId] = useState(null);
+ const [rejectReason, setRejectReason] = useState("");
const changes = data?.items ?? [];
const entriesList = entries ?? [];
@@ -112,19 +115,26 @@ export function ScheduleChangesClient(): React.ReactElement {
};
const handleReject = async (id: string): Promise => {
- const reason = window.prompt(t("rejectConfirm"));
- if (reason === null) return;
- const trimmed = reason.trim();
+ setRejectId(id);
+ setRejectReason("");
+ };
+
+ const confirmReject = async (): Promise => {
+ if (!rejectId) return;
+ const trimmed = rejectReason.trim();
if (!trimmed) {
notify.error(t("rejectError"));
return;
}
try {
- await rejectChange(id, trimmed);
+ await rejectChange(rejectId, trimmed);
notify.success(t("rejectSuccess"));
await refetch();
} catch (err) {
notify.error(`${t("rejectError")}: ${String(err)}`);
+ } finally {
+ setRejectId(null);
+ setRejectReason("");
}
};
@@ -166,6 +176,45 @@ export function ScheduleChangesClient(): React.ReactElement {
/>
{t("mswNotice")}
+ {
+ if (!open) {
+ setRejectId(null);
+ setRejectReason("");
+ }
+ }}
+ >
+
+
+ {t("rejectButton")}
+
+ {t("rejectConfirm")}
+
+
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/school/class-invitation-manager.tsx b/apps/portal-shell/src/features/admin/school/class-invitation-manager.tsx
index f066fc7..3f5ef19 100644
--- a/apps/portal-shell/src/features/admin/school/class-invitation-manager.tsx
+++ b/apps/portal-shell/src/features/admin/school/class-invitation-manager.tsx
@@ -26,8 +26,26 @@ import {
type ClassInvitationCode,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/components/ui/alert-dialog";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { FormField } from "@/features/admin/school/schools-client";
@@ -97,23 +115,25 @@ export function ClassInvitationManagerDialog({
if (!open) return null;
return (
- {
+ if (!o) onClose();
+ }}
>
- e.stopPropagation()}
- >
-
-
- {t("title")}
- {className ? (
-
- {t("classLabel")}: {className}
-
- ) : null}
-
+
+
+ {t("title")}
+
+ {t("title")}
+
+ {className ? (
+
+ {t("classLabel")}: {className}
+
+ ) : null}
+
+
|
-
+ |
{record.note ?? "-"}
|
@@ -211,11 +231,11 @@ export function ClassInvitationManagerDialog({
)}
-
+
-
+
{revokeTarget ? (
- setRevokeTarget(null)}
+ {
+ if (!o && !revokeMutation.loading) setRevokeTarget(null);
+ }}
>
- e.stopPropagation()}
- >
- {t("revoke")}
-
- {t("revokeConfirm")}
-
-
-
-
-
-
-
+
+
+
+
) : null}
-
-
+
+
);
}
@@ -344,20 +361,19 @@ function GenerateCodeDialog({
if (!open) return null;
return (
- {
+ if (!o) onClose();
+ }}
>
- e.stopPropagation()}
- >
-
- {t("generateWithCustom")}
-
-
- {t("defaultDuration")} · {t("defaultMaxUses")}
-
+
+
+ {t("generateWithCustom")}
+
+ {t("defaultDuration")} · {t("defaultMaxUses")}
+
+
-
-
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/school/class-schedule-dialog.tsx b/apps/portal-shell/src/features/admin/school/class-schedule-dialog.tsx
index 8e41368..242e54c 100644
--- a/apps/portal-shell/src/features/admin/school/class-schedule-dialog.tsx
+++ b/apps/portal-shell/src/features/admin/school/class-schedule-dialog.tsx
@@ -21,7 +21,25 @@ import {
type ClassScheduleItem,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { FormField } from "@/features/admin/school/schools-client";
@@ -91,23 +109,25 @@ export function ScheduleManagerDialog({
if (!open) return null;
return (
- {
+ if (!o) onClose();
+ }}
>
- e.stopPropagation()}
- >
-
-
- {t("manager.title")}
- {className ? (
-
- {t("form.classLabel")}: {className}
-
- ) : null}
-
+
+
+ {t("manager.title")}
+
+ {t("manager.title")}
+
+ {className ? (
+
+ {t("form.classLabel")}: {className}
+
+ ) : null}
+
+
-
+
-
+
) : null}
-
-
+
+
);
}
@@ -329,20 +349,22 @@ function ScheduleFormDialog({
const title = mode === "edit" ? t("form.titleEdit") : t("form.titleCreate");
return (
- {
+ if (!o) onClose();
+ }}
>
- e.stopPropagation()}
- >
- {title}
- {className ? (
-
- {t("form.classLabel")}: {className}
-
- ) : null}
+
+
+ {title}
+ {title}
+ {className ? (
+
+ {t("form.classLabel")}: {className}
+
+ ) : null}
+
-
-
+
+
);
}
@@ -464,41 +486,38 @@ function ScheduleDeleteDialog({
};
return (
- {
+ if (!o && !deleteMutation.loading) onClose();
+ }}
>
- e.stopPropagation()}
- >
- {t("form.deleteTitle")}
-
- {t("form.deleteMessage", {
- weekday: t(`weekday.${target.weekday}`),
- period: target.period,
- subject: target.subjectName,
- })}
-
-
-
-
-
-
-
+
+
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/school/departments-client.tsx b/apps/portal-shell/src/features/admin/school/departments-client.tsx
index e96905e..ae44930 100644
--- a/apps/portal-shell/src/features/admin/school/departments-client.tsx
+++ b/apps/portal-shell/src/features/admin/school/departments-client.tsx
@@ -30,6 +30,14 @@ import {
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input";
@@ -373,17 +381,21 @@ function DepartmentFormDialog({
};
return (
- {
+ if (!o) onClose();
+ }}
>
- e.stopPropagation()}
- >
-
- {editTarget ? t("form.titleEdit") : t("form.titleCreate")}
-
+
+
+
+ {editTarget ? t("form.titleEdit") : t("form.titleCreate")}
+
+
+ {editTarget ? t("form.titleEdit") : t("form.titleCreate")}
+
+
-
-
+
+
);
}
diff --git a/apps/portal-shell/src/features/admin/school/grade-insights-client.tsx b/apps/portal-shell/src/features/admin/school/grade-insights-client.tsx
index 7d38320..d4dbd4e 100644
--- a/apps/portal-shell/src/features/admin/school/grade-insights-client.tsx
+++ b/apps/portal-shell/src/features/admin/school/grade-insights-client.tsx
@@ -321,7 +321,7 @@ export function GradeInsightsClient(): React.ReactElement {
icon={BarChart3}
title={t("classRanking.empty")}
description={t("classRanking.emptyDescription")}
- className="min-h-[240px] bg-transparent"
+ className="min-h-60 bg-transparent"
/>
) : (
diff --git a/apps/portal-shell/src/features/admin/students/student-detail-dialog.tsx b/apps/portal-shell/src/features/admin/students/student-detail-dialog.tsx
new file mode 100644
index 0000000..1457c40
--- /dev/null
+++ b/apps/portal-shell/src/features/admin/students/student-detail-dialog.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+/**
+ * 学生详情对话框(ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5)
+ *
+ * 数据契约:
+ * - 详情数据复用列表中的 AdminStudent 对象(@contract-pending:单查契约待补齐)
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
+ */
+import { GraduationCap } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import type { AdminStudent } from "@/lib/api/admin-p5";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { DetailField, DetailSection } from "@/shared/components/page-templates";
+import {
+ formatStudentDate,
+ formatStudentStatus,
+ studentStatusToBadgeClass,
+} from "@/features/admin/students/transformations";
+
+/**
+ * 学生详情对话框。受控组件,由父组件管理 open 状态与选中数据。
+ */
+export function StudentDetailDialog({
+ open,
+ onOpenChange,
+ student,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ student: AdminStudent | null;
+}): React.ReactElement {
+ const t = useTranslations("admin.students.detail");
+
+ return (
+
+ );
+}
+
+/**
+ * 详情内容区(基本信息 + 学籍信息)。
+ */
+function StudentDetailBody({
+ student,
+}: {
+ student: AdminStudent;
+}): React.ReactElement {
+ const t = useTranslations("admin.students.detail");
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 状态徽章(按状态色阶展示)。
+ */
+function StatusBadge({ status }: { status: string }): React.ReactElement {
+ const label = formatStudentStatus(status);
+ const cls = studentStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/admin/students/students-list-client.tsx b/apps/portal-shell/src/features/admin/students/students-list-client.tsx
index 4de6d4b..6fdb8a9 100644
--- a/apps/portal-shell/src/features/admin/students/students-list-client.tsx
+++ b/apps/portal-shell/src/features/admin/students/students-list-client.tsx
@@ -4,18 +4,20 @@
* 学生管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
*
* 数据契约:
- * - 列表查询 adminStudents(filter, pagination) ❌ schema 无 → MSW 兜底(@contract-pending)
+ * - 列表查询 adminStudents(filter) ❌ schema 无 → MSW 兜底(@contract-pending)
* - 年级/班级选项 grades/adminClasses ❌ schema 无 → MSW 兜底(@contract-pending)
*
* URL 状态:?search=&gradeId=&classId=&page=
*
+ * 分页:全客户端切片(与 announcements 模块对齐,@contract-pending: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 { GraduationCap } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
-import { useMemo, useTransition } from "react";
+import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
@@ -32,7 +34,6 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
-import { notify } from "@/shared/lib/notify";
import {
formatStudentDate,
formatStudentStatus,
@@ -40,9 +41,10 @@ import {
studentStatusToBadgeClass,
truncateStudentName,
} from "@/features/admin/students/transformations";
+import { StudentDetailDialog } from "@/features/admin/students/student-detail-dialog";
-/** 每页条数(@contract-pending:MSW 兜底,真实分页待契约就绪) */
-const PAGE_SIZE = 20;
+/** 客户端分页每页条数(@contract-pending:MSW 兜底,真实分页待契约就绪) */
+const PAGE_SIZE = 10;
/**
* 列表客户端主体。需由 server page 包裹在 中
@@ -54,13 +56,18 @@ export function StudentsListClient(): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [selectedStudent, setSelectedStudent] = useState(
+ null,
+ );
const search = searchParams.get("search") ?? "";
const gradeId = searchParams.get("gradeId") ?? "";
const classId = searchParams.get("classId") ?? "";
const pageParam = Number(searchParams.get("page") ?? "1");
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
- // @contract-pending:MSW 兜底
+
+ // @contract-pending:MSW 兜底,全量拉取后客户端切片
const { data, loading, error } = useAdminStudents({
gradeId: gradeId || null,
classId: classId || null,
@@ -76,14 +83,18 @@ export function StudentsListClient(): React.ReactElement {
return all.filter((c) => c.gradeId === gradeId);
}, [classesData, gradeId]);
+ // 客户端二次过滤 + 切片(@contract-pending:MSW 兜底,真实分页待契约就绪)
const filteredItems = useMemo(() => {
const items = data?.items ?? [];
return items.filter((s) => matchAdminStudentSearch(s, search));
}, [data, search]);
- const total = data?.total ?? filteredItems.length;
- const hasNext = page * PAGE_SIZE < total;
- const hasPrev = page > 1;
+ const pagedItems = useMemo(
+ () => filteredItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
+ [filteredItems, page],
+ );
+
+ const total = filteredItems.length;
const updateQuery = (key: string, value: string, resetPage = false): void => {
const params = new URLSearchParams(searchParams.toString());
@@ -126,6 +137,11 @@ export function StudentsListClient(): React.ReactElement {
});
};
+ const openDetail = (student: AdminStudent): void => {
+ setSelectedStudent(student);
+ setDetailOpen(true);
+ };
+
const errorNode = error ? (
@@ -165,23 +181,21 @@ export function StudentsListClient(): React.ReactElement {
value={gradeId}
onValueChange={(v) => handleGradeChange(v)}
aria-label={t("list.gradeFilter")}
+ className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
options={[
{ value: "", label: t("list.allGrades") },
...gradeOptions.map((g) => ({ value: g.id, label: g.name })),
]}
- placeholder={t("list.allGrades")}
- className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
/>
+
+ );
+}
+
+/**
+ * 角色徽章(admin/teacher/student/parent)。
+ */
+function RoleBadge({ role }: { role: string }): React.ReactElement {
+ const t = useTranslations("shared.profile");
+ const label = roleToLabel(role, t);
+ const cls = roleToBadgeClass(role);
+ return (
+
+ {label}
+
+ );
+}
+
+/** 角色枚举 → i18n 标签。未知角色回退原始值。 */
+function roleToLabel(
+ role: string,
+ t: ReturnType,
+): string {
+ switch (role) {
+ case "admin":
+ return t("roleAdmin");
+ case "teacher":
+ return t("roleTeacher");
+ case "student":
+ return t("roleStudent");
+ case "parent":
+ return t("roleParent");
+ default:
+ return role;
+ }
+}
+
+/** 角色 → Tailwind 徽章类名。 */
+function roleToBadgeClass(role: string): string {
+ switch (role) {
+ case "admin":
+ return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
+ case "teacher":
+ return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
+ case "student":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "parent":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/** 性别枚举 → i18n 标签。 */
+function renderGender(
+ gender: string,
+ t: ReturnType,
+): string {
+ switch (gender) {
+ case "male":
+ return t("genderMale");
+ case "female":
+ return t("genderFemale");
+ case "other":
+ return t("genderOther");
+ default:
+ return gender || "--";
+ }
+}
+
+/** ISO 时间字符串 → 本地化日期时间展示。 */
+function formatDateTime(iso: string): string {
+ if (!iso) return "--";
+ const d = new Date(iso);
+ 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/student/dashboard/dashboard-client.tsx b/apps/portal-shell/src/features/student/dashboard/dashboard-client.tsx
new file mode 100644
index 0000000..719be86
--- /dev/null
+++ b/apps/portal-shell/src/features/student/dashboard/dashboard-client.tsx
@@ -0,0 +1,686 @@
+"use client";
+
+/**
+ * 学生仪表盘首页 - 客户端组件(ARCHITECTURE.md §7.1 / §10 P1-2 / P3)
+ *
+ * 数据契约:studentDashboard 真实聚合查询(data-ana subgraph)。
+ * P3 扩展字段(enrolled_classes_count / grades / upcoming_assignments /
+ * today_schedule)尚未在 subgraph SDL 中定义,由 MSW 兜底
+ * (@contract-pending)。
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:StatCard 骨架 + DashboardSection 骨架
+ * - error:局部降级 Card + 文案
+ * - empty:EmptyState 组件
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §7.1 / §10 P1-2 / §10 P3 / §11.3 / §11.4
+ */
+import {
+ BookOpen,
+ CalendarDays,
+ CheckCircle,
+ GraduationCap,
+ PenTool,
+ TriangleAlert,
+ Trophy,
+ TrendingUp,
+} from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import type { StudentDashboard } from "@/lib/api";
+import { useStudentDashboard } from "@/lib/api";
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
+import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
+import {
+ GradeTrendChart,
+ type GradeTrendPoint,
+} from "@/shared/components/charts";
+
+/** 根据当前小时返回问候语 i18n 键。 */
+function getGreetingKey(
+ hour: number,
+):
+ | "greetingMorning"
+ | "greetingNoon"
+ | "greetingAfternoon"
+ | "greetingEvening"
+ | "greetingNight" {
+ if (hour >= 5 && hour < 11) return "greetingMorning";
+ if (hour >= 11 && hour < 13) return "greetingNoon";
+ if (hour >= 13 && hour < 18) return "greetingAfternoon";
+ if (hour >= 18 && hour < 23) return "greetingEvening";
+ return "greetingNight";
+}
+
+/** 判断作业是否已逾期(未提交且截止时间已过)。 */
+function isAssignmentOverdue(
+ dueAt: string | null | undefined,
+ status: string,
+): boolean {
+ if (!dueAt || status === "graded") return false;
+ const due = new Date(dueAt).getTime();
+ if (Number.isNaN(due)) return false;
+ return due < Date.now();
+}
+
+/** 判断作业是否在 N 天内到期(未逾期且未提交)。 */
+function isAssignmentDueSoon(
+ dueAt: string | null | undefined,
+ status: string,
+ days: number,
+): boolean {
+ if (!dueAt || status === "graded") return false;
+ const due = new Date(dueAt).getTime();
+ if (Number.isNaN(due)) return false;
+ const diff = due - Date.now();
+ return diff >= 0 && diff <= days * 24 * 60 * 60 * 1000;
+}
+
+/** 将 "HH:MM" 时间字符串转为分钟数。 */
+function parseTimeToMinutes(time: string | null | undefined): number | null {
+ if (!time) return null;
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time);
+ if (!match) return null;
+ return parseInt(match[1] ?? "0", 10) * 60 + parseInt(match[2] ?? "0", 10);
+}
+
+/** 学生仪表盘客户端主体。 */
+export function StudentDashboardClient(): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+ const tCommon = useTranslations("common");
+ const { data, loading, error } = useStudentDashboard();
+
+ if (loading) {
+ return ;
+ }
+
+ if (error || !data) {
+ return (
+
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+
+
+ );
+ }
+
+ return ;
+}
+
+/** 仪表盘主体(已有数据)。 */
+function StudentDashboardBody({
+ data,
+}: {
+ data: StudentDashboard;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+
+ const ranking = data.grades?.ranking ?? null;
+ const upcomingAssignments = data.upcoming_assignments ?? [];
+ const todaySchedule = data.today_schedule ?? [];
+ const gradesTrend = data.grades?.trend ?? [];
+ const gradesRecent = data.grades?.recent ?? [];
+
+ const now = new Date();
+ const greeting = t(getGreetingKey(now.getHours()));
+ const studentName = data.student_name ?? "";
+ const todayDate = now.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ weekday: "long",
+ });
+
+ return (
+
+ }
+ >
+
+
+
+
+
+ {t("legendScore")}
+
+
+
+ {t("legendClassAvg")}
+
+
+ }
+ >
+ ({
+ label: item.assignment_title ?? "",
+ score: item.percentage ?? 0,
+ classAvg: item.class_avg ?? null,
+ }))}
+ emptyMessage={t("emptyGradeTrend")}
+ />
+
+
+
+ {t("actionViewAll")}
+
+ }
+ >
+
+
+
+
+ {t("actionViewAll")}
+
+ }
+ >
+
+
+
+
+
+
+
+ {t("actionViewSchedule")}
+
+
+ }
+ >
+
+
+
+
+
+ );
+}
+
+/** 6 StatCard 网格(对齐 CICD student-stats-grid.tsx)。 */
+function StudentStatsGrid({
+ enrolledClassesCount,
+ gradedCount,
+ dueSoonCount,
+ overdueCount,
+ ranking,
+}: {
+ enrolledClassesCount: number;
+ gradedCount: number;
+ dueSoonCount: number;
+ overdueCount: number;
+ ranking: {
+ rank?: number | null;
+ class_size?: number | null;
+ percentage?: number | null;
+ } | null;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+
+ return (
+ <>
+
+
+
+
+ 0}
+ valueClassName={
+ dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums"
+ }
+ />
+ 0}
+ valueClassName={
+ overdueCount > 0 ? "text-red-500 tabular-nums" : "tabular-nums"
+ }
+ />
+ >
+ );
+}
+
+/** 待办作业列表。 */
+function UpcomingAssignmentsList({
+ assignments,
+}: {
+ assignments: Array<{
+ id?: string | null;
+ title?: string | null;
+ subject_name?: string | null;
+ due_at?: string | null;
+ progress_status?: string | null;
+ latest_score?: number | null;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+
+ if (assignments.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ |
+ {t("colTitle")}
+ |
+
+ {t("colSubject")}
+ |
+
+ {t("colStatus")}
+ |
+
+ {t("colDue")}
+ |
+
+ {t("colScore")}
+ |
+
+ {t("colAction")}
+ |
+
+
+
+ {assignments.map((item) => {
+ const status = item.progress_status ?? "not_started";
+ const href = `/shell/student/homework/${item.id ?? ""}`;
+ const urgency = isAssignmentOverdue(item.due_at, status)
+ ? "overdue"
+ : isAssignmentDueSoon(item.due_at, status, 3)
+ ? "urgent"
+ : "normal";
+ const dueClassName =
+ urgency === "overdue"
+ ? "p-3 font-medium text-red-500 tabular-nums"
+ : urgency === "urgent"
+ ? "p-3 font-medium text-orange-500 tabular-nums"
+ : "p-3 text-muted-foreground tabular-nums";
+ return (
+
+ |
+
+ {item.title ?? "--"}
+
+ |
+
+ {item.subject_name ?? "--"}
+ |
+
+
+
+ {urgency === "overdue" ? (
+
+ {t("badgeLate")}
+
+ ) : urgency === "urgent" ? (
+
+ {t("labelUrgent")}
+
+ ) : null}
+
+ |
+ {item.due_at ?? "--"} |
+
+ {item.latest_score ?? "--"}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+/** 进度状态徽章。 */
+function ProgressStatusBadge({
+ status,
+}: {
+ status: string;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+
+ if (status === "graded") {
+ return (
+ {t("badgeGraded")}
+ );
+ }
+ if (status === "in_progress") {
+ return {t("badgeSubmitted")};
+ }
+ return {t("badgeNotStarted")};
+}
+
+/** 根据进度状态返回操作类型键。 */
+function getProgressActionKey(status: string): "start" | "continue" | "review" {
+ if (status === "graded") return "review";
+ if (status === "in_progress") return "continue";
+ return "start";
+}
+
+/** 近期成绩表格。 */
+function RecentGradesTable({
+ trend,
+ recent,
+}: {
+ trend: Array<{
+ assignment_id?: string | null;
+ assignment_title?: string | null;
+ score?: number | null;
+ max_score?: number | null;
+ percentage?: number | null;
+ submitted_at?: string | null;
+ }>;
+ recent: Array<{
+ assignment_id?: string | null;
+ assignment_title?: string | null;
+ score?: number | null;
+ max_score?: number | null;
+ percentage?: number | null;
+ submitted_at?: string | null;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+
+ const source = recent.length > 0 ? recent : trend;
+ if (source.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ |
+ {t("colAssignment")}
+ |
+
+ {t("colScore")}
+ |
+
+ {t("colWhen")}
+ |
+
+
+
+ {source.map((item, idx) => {
+ const key = item.assignment_id ?? `grade-${idx}`;
+ return (
+
+ |
+
+ {item.assignment_title ?? "--"}
+
+ |
+
+ {item.score ?? "--"}/{item.max_score ?? "--"}{" "}
+
+ ({Math.round(item.percentage ?? 0)}%)
+
+ |
+
+ {item.submitted_at ?? "--"}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+/** 今日课表列表。 */
+function TodayScheduleList({
+ items,
+}: {
+ items: Array<{
+ id?: string | null;
+ class_id?: string | null;
+ class_name?: string | null;
+ course?: string | null;
+ start_time?: string | null;
+ end_time?: string | null;
+ location?: string | null;
+ }>;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ const nowMinutes = parseTimeToMinutes(new Date().toTimeString().slice(0, 5));
+ let nextClassIdx = -1;
+ const currentClassIdx = items.findIndex((item) => {
+ const start = parseTimeToMinutes(item.start_time);
+ const end = parseTimeToMinutes(item.end_time);
+ return (
+ start !== null &&
+ end !== null &&
+ nowMinutes !== null &&
+ nowMinutes >= start &&
+ nowMinutes < end
+ );
+ });
+ if (currentClassIdx === -1) {
+ items.forEach((item, idx) => {
+ const start = parseTimeToMinutes(item.start_time);
+ if (
+ nextClassIdx === -1 &&
+ start !== null &&
+ nowMinutes !== null &&
+ start > nowMinutes
+ ) {
+ nextClassIdx = idx;
+ }
+ });
+ }
+
+ return (
+
+
+
+
+ {t("sectionTodaySchedule")}
+
+
+
+ {items.map((item, idx) => {
+ const key = item.id ?? `sch-${idx}`;
+ const isCurrent = idx === currentClassIdx;
+ const isNext = idx === nextClassIdx;
+ const itemClassName = isCurrent
+ ? "flex items-center justify-between rounded-md border border-emerald-500 bg-emerald-50 p-3 dark:bg-emerald-950/30"
+ : isNext
+ ? "flex items-center justify-between rounded-md border border-blue-500 bg-blue-50 p-3 dark:bg-blue-950/30"
+ : "flex items-center justify-between rounded-md border p-3";
+ return (
+
+
+
+ {item.course ?? "--"}
+ {isCurrent ? (
+
+ {t("badgeInProgress")}
+
+ ) : isNext ? (
+ {t("badgeUpNext")}
+ ) : null}
+
+
+ {item.class_name ?? "--"} · {t("colLocation")}:
+ {item.location ?? "--"}
+
+
+
+
+ {item.start_time ?? "--"} - {item.end_time ?? "--"}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+/** 仪表盘骨架屏。 */
+function StudentDashboardSkeleton(): React.ReactElement {
+ const t = useTranslations("studentDomain.dashboard.home");
+
+ return (
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/student/diagnostic/diagnostic-client.tsx b/apps/portal-shell/src/features/student/diagnostic/diagnostic-client.tsx
new file mode 100644
index 0000000..1e0c2c3
--- /dev/null
+++ b/apps/portal-shell/src/features/student/diagnostic/diagnostic-client.tsx
@@ -0,0 +1,645 @@
+"use client";
+
+/**
+ * 学生自我诊断报告页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.2 / §10 P3)
+ *
+ * 数据契约:studentDiagnostic ❌ schema 无此字段 → MSW 兜底(@contract-pending)
+ * 契约工单:docs/architecture/issues/contracts/data-ana_contract.md#diagnostic-student-detail
+ *
+ * 三态规范(§11.3 DoD):
+ * - loading:DetailPageSkeleton
+ * - error:errorNode 局部降级 + notify.error()
+ * - empty:EmptyState 组件
+ *
+ * 页面结构(DetailPageShell):
+ * - 4 个概览卡片(StatCard):学生姓名 / 总体掌握率 / 优势数 / 弱势数
+ * - 知识点掌握度雷达图(纯 SVG 自研,无 recharts 依赖)
+ * - 优势知识点列表 Card(含掌握率 Badge)
+ * - 弱势知识点列表 Card(含掌握率 Badge + 练习跳转按钮)
+ * - 最新诊断报告 Card(状态徽章 / 置信度 Badge + Tooltip / 报告期 / 得分 / 摘要 / 推荐建议)
+ * - 历史报告 Card(已发布报告列表)
+ * - SectionErrorBoundary 包裹各区块
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.2 / §10 P3 / §11.3 / §11.4
+ */
+import {
+ Activity,
+ ArrowRight,
+ CheckCircle,
+ FileText,
+ GraduationCap,
+ TrendingDown,
+ TrendingUp,
+} from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { useEffect } from "react";
+
+import type {
+ StudentSelfDiagnostic,
+ StudentSelfDiagnosticReport,
+ StudentMasteryPoint,
+} from "@/lib/api";
+import { useStudentSelfDiagnostic } from "@/lib/api";
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/shared/components/ui/tooltip";
+import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
+import {
+ DetailPageShell,
+ DetailPageSkeleton,
+} from "@/shared/components/page-templates";
+import { notify } from "@/shared/lib/notify";
+
+/** 学生自我诊断客户端主体。需由 server page 包裹在 中。 */
+export function StudentSelfDiagnosticClient(): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+ const tCommon = useTranslations("common");
+
+ // @contract-pending MSW 兜底
+ const { data, loading, error } = useStudentSelfDiagnostic();
+
+ useEffect(() => {
+ if (error) {
+ notify.error(tCommon("error.loadFailed", { message: String(error) }));
+ }
+ }, [error, tCommon]);
+
+ const errorNode = error ? (
+
+
+ {tCommon("error.loadFailed", { message: String(error) })}
+
+ {t("mswNotice")}
+
+ ) : undefined;
+
+ const emptyNode = (
+
+ );
+
+ return (
+ }
+ backHref="/shell/student"
+ loading={loading}
+ loadingNode={}
+ errorNode={errorNode}
+ emptyNode={!loading && !error && !data ? emptyNode : undefined}
+ >
+ {data ? : null}
+
+ );
+}
+
+/** 诊断主体(已有数据)。 */
+function DiagnosticBody({
+ data,
+}: {
+ data: StudentSelfDiagnostic;
+}): React.ReactElement {
+ const { summary, masteryPoints, reports } = data;
+ const strengths = masteryPoints.filter((p) => p.isStrength);
+ const weaknesses = masteryPoints.filter((p) => !p.isStrength);
+ const latestReport = reports[0] ?? null;
+ const historyReports = reports.slice(1);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {latestReport ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+/** 4 个概览卡片。 */
+function OverviewStats({
+ summary,
+}: {
+ summary: StudentSelfDiagnostic["summary"];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+ const masteryPct = `${Math.round(summary.overallMastery * 100)}%`;
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** 知识点掌握度雷达图 Card(纯 SVG 自研)。 */
+function MasteryRadarCard({
+ points,
+}: {
+ points: StudentMasteryPoint[];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+
+ if (points.length < 3) {
+ return (
+
+
+
+
+ {t("sectionMasteryRadar")}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t("sectionMasteryRadar")}
+
+
+
+
+
+
+ );
+}
+
+/** 纯 SVG 雷达图(无 recharts 依赖,参考 portal-shell TrendChart 自研模式)。 */
+function MasteryRadarChart({
+ points,
+}: {
+ points: StudentMasteryPoint[];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+ const size = 400;
+ const cx = size / 2;
+ const cy = size / 2;
+ const R = 130;
+ const labelOffset = 28;
+ const n = points.length;
+
+ const angleFor = (i: number): number => -Math.PI / 2 + (i * 2 * Math.PI) / n;
+
+ const pointAt = (i: number, frac: number): { x: number; y: number } => ({
+ x: cx + frac * R * Math.cos(angleFor(i)),
+ y: cy + frac * R * Math.sin(angleFor(i)),
+ });
+
+ const gridLevels = [0.25, 0.5, 0.75, 1];
+
+ const toPolyPoints = (frac: number): string =>
+ Array.from({ length: n }, (_, i) => {
+ const p = pointAt(i, frac);
+ return `${p.x.toFixed(1)},${p.y.toFixed(1)}`;
+ }).join(" ");
+
+ const dataPolyPoints = points
+ .map((p, i) => {
+ const mastery = Math.max(0, Math.min(1, p.masteryRate));
+ const pt = pointAt(i, mastery);
+ return `${pt.x.toFixed(1)},${pt.y.toFixed(1)}`;
+ })
+ .join(" ");
+
+ return (
+
+
+
+ );
+}
+
+/** 优势知识点列表 Card。 */
+function StrengthListCard({
+ points,
+}: {
+ points: StudentMasteryPoint[];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+
+ return (
+
+
+
+
+ {t("sectionStrengths")}
+
+
+
+ {points.length === 0 ? (
+
+ ) : (
+
+ {points.map((p) => (
+ -
+ {p.name}
+
+ {formatMastery(p.masteryRate)}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+/** 弱势知识点列表 Card(含练习跳转按钮)。 */
+function WeaknessListCard({
+ points,
+}: {
+ points: StudentMasteryPoint[];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+
+ return (
+
+
+
+
+ {t("sectionWeakness")}
+
+
+
+ {points.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+/** 最新诊断报告 Card。 */
+function LatestReportCard({
+ report,
+}: {
+ report: StudentSelfDiagnosticReport;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+
+ return (
+
+
+
+
+ {t("sectionLatestReport")}
+
+
+
+
+
+ {reportStatusToLabel(report.status, t)}
+
+
+
+
+
+ {t("confidenceLabel")}:{formatConfidence(report.confidence)}
+
+
+
+ {t("confidenceTooltip")}
+
+
+
+
+
+
+
+ {t("fieldPeriod")}
+ {report.period}
+
+
+ {t("fieldScore")}
+
+ {report.score}
+
+
+
+
+
+ {t("fieldSummary")}
+ {report.summary}
+
+
+ {report.recommendations.length > 0 ? (
+
+
+ {t("fieldRecommendations")}
+
+
+ {report.recommendations.map((rec, idx) => (
+ -
+ •
+ {rec}
+
+ ))}
+
+
+ ) : null}
+
+
+ );
+}
+
+/** 历史报告 Card。 */
+function HistoryReportsCard({
+ reports,
+}: {
+ reports: StudentSelfDiagnosticReport[];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+
+ return (
+
+
+
+
+ {t("sectionHistoryReports")}
+
+
+
+ {reports.length === 0 ? (
+
+ ) : (
+
+ {reports.map((report) => (
+ -
+
+
+
+ {report.period}
+
+
+ {t("fieldScore")}:{report.score}
+
+
+
+ {report.summary}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+/** 格式化 0-1 的掌握度为百分比字符串。 */
+function formatMastery(mastery: number): string {
+ if (!Number.isFinite(mastery) || mastery < 0 || mastery > 1) return "--";
+ return `${(mastery * 100).toFixed(0)}%`;
+}
+
+/** 格式化 0-1 的置信度为百分比字符串。 */
+function formatConfidence(confidence: number): string {
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
+ return "--";
+ return `${(confidence * 100).toFixed(0)}%`;
+}
+
+/** 根据掌握度返回 Tailwind 徽章语义类名。 */
+function masteryToBadgeClass(mastery: number): string {
+ if (!Number.isFinite(mastery) || mastery < 0 || mastery > 1) {
+ return "text-muted-foreground";
+ }
+ if (mastery >= 0.8) return "text-emerald-600";
+ if (mastery >= 0.6) return "text-amber-600";
+ return "text-destructive";
+}
+
+/** 根据报告状态返回 Tailwind 徽章语义类名。 */
+function reportStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "published":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "generated":
+ return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
+ case "draft":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/** 报告状态徽章(含 i18n 文案 + 语义化颜色)。 */
+function ReportStatusBadge({ status }: { status: string }): React.ReactElement {
+ const t = useTranslations("studentDomain.diagnostic");
+ const label = reportStatusToLabel(status, t);
+ return (
+
+ {label}
+
+ );
+}
+
+/** 根据报告状态返回 i18n 文案。 */
+function reportStatusToLabel(
+ status: string,
+ t: (key: string) => string,
+): string {
+ switch (status) {
+ case "published":
+ return t("badgePublished");
+ case "generated":
+ return t("badgeGenerated");
+ case "draft":
+ return t("badgeDraft");
+ case "archived":
+ return t("badgeArchived");
+ default:
+ return status;
+ }
+}
diff --git a/apps/portal-shell/src/features/student/error-book/error-book-detail-dialog.tsx b/apps/portal-shell/src/features/student/error-book/error-book-detail-dialog.tsx
new file mode 100644
index 0000000..5b1c362
--- /dev/null
+++ b/apps/portal-shell/src/features/student/error-book/error-book-detail-dialog.tsx
@@ -0,0 +1,323 @@
+"use client";
+/**
+ * 错题详情对话框(学生端)
+ * 用原生轻量模态替代 shadcn Dialog。
+ * AI 分析区块 @contract-pending ai-analysis,待 AI 服务接入后调用真实 API。
+ */
+import {
+ BookOpen,
+ Calendar,
+ GraduationCap,
+ Hash,
+ Lightbulb,
+ Loader2,
+ NotebookPen,
+ RefreshCw,
+ Sparkles,
+ Target,
+ TrendingUp,
+} from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+
+import { useStartPracticeSession, type StudentErrorBookItem } from "@/lib/api";
+import { notify } from "@/shared/lib/notify";
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import { Separator } from "@/shared/components/ui/separator";
+
+export interface ErrorBookDetailDialogProps {
+ item: StudentErrorBookItem | null;
+ onOpenChange: (open: boolean) => void;
+ onMarkMastered: (id: string) => Promise;
+ marking: boolean;
+}
+
+export function ErrorBookDetailDialog({
+ item,
+ onOpenChange,
+ onMarkMastered,
+ marking,
+}: ErrorBookDetailDialogProps): React.ReactElement {
+ const t = useTranslations("studentDomain.errorBook");
+ const router = useRouter();
+ const open = item !== null;
+ const [practicing, setPracticing] = useState(false);
+ const { run: startPractice } = useStartPracticeSession();
+
+ useEffect(() => {
+ if (!open) return;
+ const handleKeyDown = (e: KeyboardEvent): void => {
+ if (e.key === "Escape") onOpenChange(false);
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [open, onOpenChange]);
+
+ if (!open || !item) return <>>;
+
+ const handleMarkMastered = async (): Promise => {
+ await onMarkMastered(item.id);
+ onOpenChange(false);
+ };
+
+ const handleVariantPractice = async (): Promise => {
+ setPracticing(true);
+ try {
+ // @contract-pending: kpId derived from error book item id, replace with real kpId after AI service integration
+ const kpId = "variant:" + item.id;
+ const sessionId = await startPractice([kpId]);
+ notify.success(t("practiceStarted"));
+ router.push("/shell/student/practice/" + sessionId);
+ onOpenChange(false);
+ } catch (err) {
+ notify.error(t("practiceStartError"));
+ console.error("[student.error-book] startVariantPractice failed:", err);
+ } finally {
+ setPracticing(false);
+ }
+ };
+
+ const handleEditNote = (): void => {
+ // @contract-pending: note editing pending backend contract
+ notify.info(t("noteEditPending"));
+ };
+
+ return (
+ onOpenChange(false)}
+ >
+ e.stopPropagation()}
+ >
+
+
+
+
+ {t("detailTitle")}
+
+
+ {item.subject ? (
+ {item.subject}
+ ) : null}
+ {item.difficulty ? (
+
+ ) : null}
+ {(item.errorTags ?? []).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+
+
+ }
+ label={t("colErrorCount")}
+ value={String(item.errorCount)}
+ />
+ }
+ label={t("reviewCount")}
+ value={String(item.reviewCount ?? 0)}
+ />
+ }
+ label={t("masteryLevel")}
+ value={formatMastery(item.masteryLevel)}
+ />
+
+
+
+ {t("colQuestion")}
+
+
+ {item.questionDetail?.content ?? item.question}
+
+
+
+
+ {item.questionDetail?.answer ? (
+
+
+
+ {t("correctAnswer")}
+
+
+
+ {item.questionDetail.answer}
+
+
+
+ ) : null}
+ {item.note ? (
+
+ ) : null}
+ {/* @contract-pending ai-analysis: placeholder, replace with real AI service call after integration */}
+
+
+
+ {t("aiAnalysisTitle")}
+
+
+
+
+ {t("aiErrorCategory")}: {item.errorTags?.[0] ?? "--"}
+
+
+
+ {t("aiKnowledgePoint")}: {item.subject}
+
+
+
+ {t("aiLearningPath")}: {t("aiLearningPathDesc")}
+
+
+
+
+
+
+ {t("createdAt")}: {formatDate(item.createdAt)}
+
+
+
+ {t("colLastErrorAt")}: {formatDate(item.lastErrorAt)}
+
+ {item.nextReviewAt ? (
+
+
+ {t("nextReviewAt")}: {formatDate(item.nextReviewAt)}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function InfoItem({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+}): React.ReactElement {
+ return (
+
+ {icon}
+
+ {label}
+ {value || "--"}
+
+
+ );
+}
+
+function DifficultyBadge({
+ difficulty,
+}: {
+ difficulty: "easy" | "medium" | "hard";
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.errorBook");
+ const labelKey =
+ difficulty === "easy"
+ ? "difficultyEasy"
+ : difficulty === "medium"
+ ? "difficultyMedium"
+ : "difficultyHard";
+ const cls =
+ difficulty === "easy"
+ ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
+ : difficulty === "medium"
+ ? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
+ : "bg-destructive/10 text-destructive";
+ return (
+
+ {t(labelKey)}
+
+ );
+}
+
+function formatMastery(level?: number): string {
+ if (level === undefined || level === null) return "--";
+ return level + " / 5";
+}
+
+function formatDate(iso?: string): string {
+ if (!iso) return "--";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleDateString("zh-CN");
+}
diff --git a/apps/portal-shell/src/features/student/grades/components/growth-archive-card.tsx b/apps/portal-shell/src/features/student/grades/components/growth-archive-card.tsx
new file mode 100644
index 0000000..ca3a726
--- /dev/null
+++ b/apps/portal-shell/src/features/student/grades/components/growth-archive-card.tsx
@@ -0,0 +1,248 @@
+"use client";
+
+/**
+ * 成长档案卡(ARCHITECTURE.md §7.3 / §9.1 / §10 P2)
+ *
+ * 纯 SVG 多折线图,展示跨学年/学期的各学科成绩变化。
+ * 每个学科一条折线 + 图例,数据点标注分数。
+ *
+ * 数据契约:StudentGrowthArchivePoint[](@contract-pending,MSW 兜底)
+ * 设计令牌:stroke-primary / stroke-blue-500 / stroke-emerald-500 / stroke-amber-500
+ * (Tailwind 调色板,非 hex 字面量,符合 §3.10 设计令牌规范)
+ * 响应式:viewBox + preserveAspectRatio,小屏自适应宽度
+ */
+import { LineChart } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import type { StudentGrowthArchivePoint } from "@/lib/api";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+
+interface GrowthArchiveCardProps {
+ points: StudentGrowthArchivePoint[] | undefined;
+}
+
+/** 多学科折线颜色序列(Tailwind 调色板,循环使用)。 */
+const SUBJECT_STROKE_CLASSES = [
+ "stroke-primary",
+ "stroke-blue-500",
+ "stroke-emerald-500",
+ "stroke-amber-500",
+ "stroke-purple-500",
+] as const;
+
+/** 多学科数据点/图例 fill 颜色序列(与 STROKE 对齐)。 */
+const SUBJECT_FILL_CLASSES = [
+ "fill-primary",
+ "fill-blue-500",
+ "fill-emerald-500",
+ "fill-amber-500",
+ "fill-purple-500",
+] as const;
+
+/** 成长档案卡:无数据时降级为 EmptyState,有数据时渲染 SVG 多折线图。 */
+export function GrowthArchiveCard({
+ points,
+}: GrowthArchiveCardProps): React.ReactElement {
+ const t = useTranslations("studentDomain.grades.list");
+
+ if (!points || points.length === 0) {
+ return (
+
+
+
+ {t("sectionGrowthArchive")}
+
+
+
+
+
+ {t("growthEmptyTitle")}
+
+
+ {t("growthEmptyDescription")}
+
+
+
+
+ );
+ }
+
+ const subjects = collectSubjects(points);
+
+ return (
+
+
+
+
+ {t("sectionGrowthArchive")}
+
+
+
+
+
+ {t("growthArchiveSubjectLabel")}:
+ {subjects.map((subj, i) => {
+ const fillClass =
+ SUBJECT_FILL_CLASSES[i % SUBJECT_FILL_CLASSES.length]!;
+ return (
+
+
+ {subj}
+
+ );
+ })}
+
+
+
+ );
+}
+/** 成长档案 SVG 多折线图(每学科一条线)。 */
+function GrowthArchiveSvg({
+ points,
+ subjects,
+}: {
+ points: StudentGrowthArchivePoint[];
+ subjects: string[];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.grades.list");
+ const width = 480;
+ const height = 220;
+ const padX = 40;
+ const padY = 24;
+ const innerW = width - padX * 2;
+ const innerH = height - padY * 2;
+ const n = points.length;
+
+ const yMin = 0;
+ const yMax = 100;
+ const yRange = yMax - yMin;
+
+ const xStep = n > 1 ? innerW / (n - 1) : 0;
+ const toX = (i: number): number => padX + i * xStep;
+ const toY = (score: number): number =>
+ padY + innerH - ((score - yMin) / yRange) * innerH;
+
+ const yTicks = [0, 50, 100];
+
+ const series = subjects.map((subj, idx) => {
+ const strokeClass =
+ SUBJECT_STROKE_CLASSES[idx % SUBJECT_STROKE_CLASSES.length]!;
+ const fillClass = SUBJECT_FILL_CLASSES[idx % SUBJECT_FILL_CLASSES.length]!;
+ const coords = points
+ .map((p, i) => {
+ const scoreEntry = p.subjectScores.find((s) => s.subject === subj);
+ if (!scoreEntry) return null;
+ return {
+ x: toX(i),
+ y: toY(scoreEntry.score),
+ score: scoreEntry.score,
+ i,
+ };
+ })
+ .filter(
+ (c): c is { x: number; y: number; score: number; i: number } =>
+ c !== null,
+ );
+ const pathD = coords
+ .map((c, i) => `${i === 0 ? "M" : "L"} ${c.x} ${c.y}`)
+ .join(" ");
+ return { subj, strokeClass, fillClass, coords, pathD };
+ });
+ return (
+
+
+
+ );
+}
+
+/** 从所有数据点中收集学科列表(保持出现顺序,去重)。 */
+function collectSubjects(points: StudentGrowthArchivePoint[]): string[] {
+ const seen = new Set();
+ const result: string[] = [];
+ for (const p of points) {
+ for (const s of p.subjectScores) {
+ if (!seen.has(s.subject)) {
+ seen.add(s.subject);
+ result.push(s.subject);
+ }
+ }
+ }
+ return result;
+}
diff --git a/apps/portal-shell/src/features/student/grades/components/ranking-trend-card.tsx b/apps/portal-shell/src/features/student/grades/components/ranking-trend-card.tsx
new file mode 100644
index 0000000..4349611
--- /dev/null
+++ b/apps/portal-shell/src/features/student/grades/components/ranking-trend-card.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+/**
+ * 排名趋势卡(ARCHITECTURE.md §7.3 / §9.1 / §10 P2)
+ *
+ * 纯 SVG 折线图,展示最近若干次考试的班级排名变化。
+ * Y 轴反向(排名 1 在顶部,排名越大越靠下),数据点标注排名数字。
+ *
+ * 数据契约:StudentRankingTrendPoint[](@contract-pending,MSW 兜底)
+ * 设计令牌:stroke-primary / fill-primary / fill-muted-foreground / stroke-muted
+ * 响应式:viewBox + preserveAspectRatio,小屏自适应宽度
+ */
+import { TrendingUp } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import type { StudentRankingTrendPoint } from "@/lib/api";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+
+interface RankingTrendCardProps {
+ points: StudentRankingTrendPoint[] | undefined;
+}
+
+/** 排名趋势卡:无数据时降级为 EmptyState,有数据时渲染 SVG 折线图。 */
+export function RankingTrendCard({
+ points,
+}: RankingTrendCardProps): React.ReactElement {
+ const t = useTranslations("studentDomain.grades.list");
+
+ if (!points || points.length === 0) {
+ return (
+
+
+
+ {t("sectionRankingTrend")}
+
+
+
+
+
+ {t("rankingEmptyTitle")}
+
+
+ {t("rankingEmptyDescription")}
+
+
+
+
+ );
+ }
+
+ const lastPoint = points[points.length - 1];
+
+ return (
+
+
+
+
+ {t("sectionRankingTrend")}
+
+
+
+
+
+
+
+ {t("rankingLegend")}
+
+ {lastPoint ? (
+
+ {t("rankingTotalStudents", { total: lastPoint.totalStudents })}
+
+ ) : null}
+
+
+
+ );
+}
+/** 排名趋势 SVG 折线图(Y 轴反向:排名 1 在顶部)。 */
+function RankingTrendSvg({
+ points,
+}: {
+ points: StudentRankingTrendPoint[];
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.grades.list");
+ const width = 480;
+ const height = 200;
+ const padX = 40;
+ const padY = 24;
+ const innerW = width - padX * 2;
+ const innerH = height - padY * 2;
+ const n = points.length;
+
+ const ranks = points.map((p) => p.rank);
+ const maxRank = Math.max(...ranks, 1);
+ const minRank = 1;
+ // Y 轴范围至少为 1,避免所有排名相同时除零
+ const yRange = Math.max(maxRank - minRank, 1);
+
+ const xStep = n > 1 ? innerW / (n - 1) : 0;
+ const toX = (i: number): number => padX + i * xStep;
+ // 反向 Y:rank 1 → 顶部(padY),maxRank → 底部(padY + innerH)
+ const toY = (rank: number): number =>
+ padY + ((rank - minRank) / yRange) * innerH;
+
+ const pathD =
+ n === 1
+ ? `M ${toX(0)} ${toY(points[0]!.rank)}`
+ : points
+ .map((p, i) => `${i === 0 ? "M" : "L"} ${toX(i)} ${toY(p.rank)}`)
+ .join(" ");
+
+ // Y 轴刻度:顶部(#1)、中部、底部(#maxRank)
+ const yTicks = [minRank, Math.round((minRank + maxRank) / 2), maxRank];
+
+ return (
+
+
+
+ );
+}
+
+/** 格式化周期标签:ISO 日期 → MM-DD,否则原样返回。 */
+function formatPeriod(period: string): string {
+ const match = period.match(/^\d{4}-(\d{2})-(\d{2})$/);
+ if (match) {
+ return `${match[1]}-${match[2]}`;
+ }
+ return period;
+}
diff --git a/apps/portal-shell/src/features/student/grades/components/score-distribution-card.tsx b/apps/portal-shell/src/features/student/grades/components/score-distribution-card.tsx
new file mode 100644
index 0000000..93ec51b
--- /dev/null
+++ b/apps/portal-shell/src/features/student/grades/components/score-distribution-card.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+/**
+ * 班级分布卡(ARCHITECTURE.md §7.3 / §9.1 / §10 P2)
+ *
+ * 纯 SVG 柱状图,展示当前考试/学科的班级分数段分布。
+ * 高亮当前学生所在分数段(primary 色),其余段用 muted 色。
+ *
+ * 数据契约:StudentScoreDistribution(@contract-pending,MSW 兜底)
+ * 设计令牌:fill-primary / fill-muted / stroke-muted-foreground
+ * 响应式:viewBox + preserveAspectRatio,小屏自适应宽度
+ */
+import { BarChart3 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import type { StudentScoreDistribution } from "@/lib/api";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+
+interface ScoreDistributionCardProps {
+ distribution: StudentScoreDistribution | undefined;
+}
+
+/** 班级分布卡:无数据时降级为 EmptyState,有数据时渲染 SVG 柱状图。 */
+export function ScoreDistributionCard({
+ distribution,
+}: ScoreDistributionCardProps): React.ReactElement {
+ const t = useTranslations("studentDomain.grades.list");
+
+ if (!distribution || distribution.ranges.length === 0) {
+ return (
+
+
+
+ {t("sectionDistribution")}
+
+
+
+
+
+ {t("distributionEmptyTitle")}
+
+
+ {t("distributionEmptyDescription")}
+
+
+
+
+ );
+ }
+
+ const studentBucket = distribution.ranges.find((r) => r.isStudentIn);
+
+ return (
+
+
+
+
+ {t("sectionDistribution")}
+
+
+
+
+ {studentBucket ? (
+
+ {t("distributionStudentPosition", {
+ bucket: studentBucket.range,
+ rank: distribution.studentRank,
+ })}
+
+ ) : null}
+
+
+ );
+}
+/** 班级分布 SVG 柱状图(高亮当前学生所在分数段)。 */
+function ScoreDistributionSvg({
+ distribution,
+}: {
+ distribution: StudentScoreDistribution;
+}): React.ReactElement {
+ const t = useTranslations("studentDomain.grades.list");
+ const width = 480;
+ const height = 200;
+ const padX = 40;
+ const padY = 24;
+ const innerW = width - padX * 2;
+ const innerH = height - padY * 2;
+ const ranges = distribution.ranges;
+ const n = ranges.length;
+
+ const maxCount = Math.max(...ranges.map((r) => r.count), 1);
+ const barGap = 12;
+ const barWidth = n > 0 ? (innerW - barGap * (n - 1)) / n : 0;
+
+ const toX = (i: number): number => padX + i * (barWidth + barGap);
+ const barTop = (count: number): number =>
+ padY + innerH - (count / maxCount) * innerH;
+
+ // Y 轴刻度:0、中部、最大值
+ const yTicks = [0, Math.round(maxCount / 2), maxCount];
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/student/grades/grades-list-client.tsx b/apps/portal-shell/src/features/student/grades/grades-list-client.tsx
index ff41715..d067e30 100644
--- a/apps/portal-shell/src/features/student/grades/grades-list-client.tsx
+++ b/apps/portal-shell/src/features/student/grades/grades-list-client.tsx
@@ -290,7 +290,7 @@ function TrendCard({ points }: { points: TrendPoint[] }): React.ReactElement {
-
+
{t("trendEmptyTitle")}
diff --git a/apps/portal-shell/src/features/student/grades/report-card-print.css b/apps/portal-shell/src/features/student/grades/report-card-print.css
new file mode 100644
index 0000000..54823f2
--- /dev/null
+++ b/apps/portal-shell/src/features/student/grades/report-card-print.css
@@ -0,0 +1,88 @@
+/**
+ * 学生成绩报告卡 A4 打印样式(ARCHITECTURE.md §7.3 / §10 P2)
+ *
+ * 仅在 @media print 下生效,控制 A4 纸张布局:
+ * - 隐藏导航 / 侧边栏 / 顶栏 / 打印按钮等非打印元素
+ * - 强制黑白文本,保证打印可读性
+ * - A4 容器不被分页切割
+ *
+ * 关联:report-card-client.tsx 中 .report-card-a4 容器
+ */
+
+@media print {
+ /* 隐藏非打印元素:侧边栏、顶栏、面包屑、打印按钮、切换器 */
+ [data-slot="sidebar"],
+ [data-slot="sidebar-trigger"],
+ [data-slot="topbar"],
+ [data-slot="page-header"],
+ .no-print,
+ .report-card-toolbar,
+ .report-card-period-selector {
+ display: none !important;
+ }
+
+ /* 重置页面边距,由 A4 容器 padding 控制 */
+ @page {
+ size: A4;
+ margin: 0;
+ }
+
+ html,
+ body {
+ margin: 0 !important;
+ padding: 0 !important;
+ background: #ffffff !important;
+ }
+
+ /* A4 容器:去除屏幕态阴影,撑满页面 */
+ .report-card-a4 {
+ box-shadow: none !important;
+ margin: 0 !important;
+ width: 210mm !important;
+ min-height: 297mm !important;
+ padding: 15mm !important;
+ background: #ffffff !important;
+ color: #000000 !important;
+ }
+
+ /* 强制黑色文本(打印友好) */
+ .report-card-a4,
+ .report-card-a4 * {
+ color: #000000 !important;
+ background: #ffffff !important;
+ border-color: #000000 !important;
+ box-shadow: none !important;
+ text-shadow: none !important;
+ }
+
+ /* 表格边框在打印下加粗为黑色细线 */
+ .report-card-a4 table {
+ border-collapse: collapse !important;
+ }
+
+ .report-card-a4 th,
+ .report-card-a4 td {
+ border: 1px solid #000000 !important;
+ }
+
+ /* 签名区虚线在打印下可见 */
+ .report-card-signature-line {
+ border-bottom: 1px dashed #000000 !important;
+ }
+
+ /* 避免行内分割:同学科行尽量保持在同一页 */
+ .report-card-a4 tr,
+ .report-card-a4 thead,
+ .report-card-a4 tfoot {
+ page-break-inside: avoid !important;
+ break-inside: avoid !important;
+ }
+
+ /* 综合统计与评语区不跨页 */
+ .report-card-summary,
+ .report-card-comments,
+ .report-card-signatures {
+ page-break-inside: avoid !important;
+ break-inside: avoid !important;
+ }
+}
diff --git a/apps/portal-shell/src/features/student/leave/leave-request-form.tsx b/apps/portal-shell/src/features/student/leave/leave-request-form.tsx
new file mode 100644
index 0000000..90d0fe7
--- /dev/null
+++ b/apps/portal-shell/src/features/student/leave/leave-request-form.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+/**
+ * 学生请假 - 表单组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3)
+ *
+ * 从 leave-client.tsx 抽取,单一职责:表单渲染 + 校验 + 提交。
+ * - 接收 defaultStudentId / defaultClassId 作为初始值(可选)
+ * - 通过 useStudentClasses 获取班级下拉数据
+ * - 自动写入 defaultClassId 或第一个活跃班级
+ * - 提交成功后调用 onSubmitted 回调(父组件刷新列表)
+ *
+ * 数据契约(@contract-pending 全 MSW):
+ * - studentClasses / submitLeaveRequest:❌ schema 无此字段 → MSW 兜底
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ useStudentClasses,
+ useSubmitLeaveRequest,
+ type LeaveRequestInput,
+} from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { notify } from "@/shared/lib/notify";
+
+export interface LeaveRequestFormProps {
+ /** 默认学生 ID(预留:当前契约 studentId 由后端从上下文推断) */
+ defaultStudentId?: string;
+ /** 默认班级 ID(优先于"第一个活跃班级"自动选择) */
+ defaultClassId?: string;
+ /** 提交成功回调(父组件用于刷新列表 / 重置分页) */
+ onSubmitted?: () => void;
+}
+
+/**
+ * 请假申请表单。受控组件,内部维护 5 个字段 + 校验态。
+ */
+export function LeaveRequestForm({
+ defaultStudentId: _defaultStudentId,
+ defaultClassId,
+ onSubmitted,
+}: LeaveRequestFormProps): React.ReactElement {
+ const t = useTranslations("studentDomain.leave");
+
+ // @contract-pending:MSW 兜底(班级下拉数据源)
+ const { data: classes } = useStudentClasses();
+ // @contract-pending:MSW 兜底
+ const { run: submitLeave, loading: submitting } = useSubmitLeaveRequest();
+
+ const [classId, setClassId] = useState(defaultClassId ?? "");
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+ const [type, setType] = useState("");
+ const [reason, setReason] = useState("");
+ const [formError, setFormError] = useState (null);
+
+ // 默认选择 defaultClassId 或第一个活跃班级
+ useEffect(() => {
+ if (!classId && classes) {
+ if (defaultClassId) {
+ setClassId(defaultClassId);
+ return;
+ }
+ const firstActive = classes.find((c) => c.isActive);
+ if (firstActive) setClassId(firstActive.id);
+ }
+ }, [classes, classId, defaultClassId]);
+
+ const hasActiveClass = (classes ?? []).some((c) => c.isActive);
+
+ const handleFormSubmit = (): void => {
+ setFormError(null);
+
+ if (!classId) {
+ setFormError(t("validation.selectClass"));
+ return;
+ }
+ if (!startDate) {
+ setFormError(t("validation.selectStartDate"));
+ return;
+ }
+ if (!endDate) {
+ setFormError(t("validation.selectEndDate"));
+ return;
+ }
+ if (!type) {
+ setFormError(t("validation.selectType"));
+ return;
+ }
+ if (!reason.trim()) {
+ setFormError(t("validation.fillReason"));
+ return;
+ }
+ if (endDate < startDate) {
+ setFormError(t("validation.endDateBeforeStart"));
+ return;
+ }
+
+ const input: LeaveRequestInput = {
+ classId,
+ startDate,
+ endDate,
+ reason: reason.trim(),
+ type,
+ };
+
+ void (async (): Promise => {
+ try {
+ await submitLeave(input);
+ notify.success(t("submitSuccess"));
+ // 重置表单(保留班级选择),通知父组件刷新
+ setStartDate("");
+ setEndDate("");
+ setType("");
+ setReason("");
+ onSubmitted?.();
+ } catch {
+ notify.error(t("submitError"));
+ }
+ })();
+ };
+
+ return (
+
+ {!hasActiveClass ? (
+ {t("noActiveClass")}
+ ) : null}
+
+
+ setClassId(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ disabled={!hasActiveClass}
+ >
+
+ {(classes ?? []).map((cls) => (
+
+ ))}
+
+
+
+
+ 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"
+ />
+
+
+
+ setType(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ >
+
+
+
+
+
+
+
+
+
+
+ {formError ? (
+ {formError}
+ ) : null}
+
+
+
+ {t("mswNotice")}
+
+ );
+}
+
+/**
+ * 表单字段容器(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/student/leave/leave-request-list.tsx b/apps/portal-shell/src/features/student/leave/leave-request-list.tsx
new file mode 100644
index 0000000..34f4c3f
--- /dev/null
+++ b/apps/portal-shell/src/features/student/leave/leave-request-list.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+/**
+ * 学生请假 - 历史列表组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3)
+ *
+ * 从 leave-client.tsx 抽取,单一职责:列表渲染 + 分页控件 + 空态。
+ * - 接收 emptyTitle / emptyDescription 控制空态文案
+ * - 显示分页信息(总数、当前页范围)
+ * - 上一页 / 下一页 / 页码跳转
+ *
+ * 数据契约(@contract-pending 全 MSW):
+ * - studentLeave:❌ schema 无此字段 → MSW 兜底
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
+ */
+import { CalendarClock, ChevronLeft, ChevronRight } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import type { StudentLeaveItem } from "@/lib/api";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { Button } from "@/shared/components/ui/button";
+import { cn } from "@/shared/lib/utils";
+
+export interface LeaveRequestListProps {
+ /** 当前页数据项 */
+ items: StudentLeaveItem[];
+ /** 总记录数 */
+ total: number;
+ /** 当前页码(1-based) */
+ page: number;
+ /** 每页大小 */
+ pageSize: number;
+ /** 加载态 */
+ loading?: boolean;
+ /** 翻页回调 */
+ onPageChange: (page: number) => void;
+ /** 空态标题(可选,默认走 i18n) */
+ emptyTitle?: string;
+ /** 空态描述(可选,默认走 i18n) */
+ emptyDescription?: string;
+}
+
+/**
+ * 请假历史列表 + 分页控件。
+ */
+export function LeaveRequestList({
+ items,
+ total,
+ page,
+ pageSize,
+ loading = false,
+ onPageChange,
+ emptyTitle,
+ emptyDescription,
+}: LeaveRequestListProps): React.ReactElement {
+ const t = useTranslations("studentDomain.leave");
+
+ if (!loading && items.length === 0) {
+ return (
+
+ );
+ }
+
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ const startIndex = total === 0 ? 0 : (page - 1) * pageSize + 1;
+ const endIndex = Math.min(page * pageSize, total);
+
+ // 页码按钮:显示当前页 ± 2,首尾固定
+ const pageNumbers = buildPageNumbers(page, totalPages);
+
+ return (
+
+ {/* 范围信息 */}
+
+
+ {t("paginationRange", {
+ start: startIndex,
+ end: endIndex,
+ total,
+ })}
+
+
+ {t("paginationPage", { page, totalPages })}
+
+
+
+ {/* 表格 */}
+
+
+
+
+ | {t("colStartDate")} |
+ {t("colEndDate")} |
+ {t("colType")} |
+ {t("colReason")} |
+ {t("colStatus")} |
+
+
+
+ {items.map((item) => (
+
+ |
+ {formatDate(item.startDate)}
+ |
+
+ {formatDate(item.endDate)}
+ |
+ {formatLeaveType(item.type, t)} |
+
+ {item.reason}
+ |
+
+
+ |
+
+ ))}
+
+
+
+
+ {/* 分页控件 */}
+ {totalPages > 1 ? (
+
+
+ {pageNumbers.map((p, idx) =>
+ p === null ? (
+
+ …
+
+ ) : (
+
+ ),
+ )}
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * 请假状态徽章(pending 琥珀 / approved 翡翠 / rejected 危险色)。
+ */
+function LeaveStatusBadge({ status }: { status: string }): React.ReactElement {
+ const t = useTranslations("studentDomain.leave");
+ const label = leaveStatusToLabel(status, t);
+ const cls = leaveStatusToBadgeClass(status);
+ return (
+
+ {label}
+
+ );
+}
+
+/** 将请假状态枚举值映射为 i18n 标签。未知状态回退为原始值。 */
+function leaveStatusToLabel(
+ status: string,
+ t: ReturnType,
+): string {
+ switch (status) {
+ case "pending":
+ return t("statusPending");
+ case "approved":
+ return t("statusApproved");
+ case "rejected":
+ return t("statusRejected");
+ default:
+ return status;
+ }
+}
+
+/** 根据请假状态返回 Tailwind 徽章类名。 */
+function leaveStatusToBadgeClass(status: string): string {
+ switch (status) {
+ case "pending":
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
+ case "approved":
+ return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+ case "rejected":
+ return "bg-destructive/10 text-destructive";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+/** 将请假类型枚举值映射为 i18n 标签。未知类型回退为原始值。 */
+function formatLeaveType(
+ type: string,
+ t: ReturnType,
+): string {
+ switch (type) {
+ case "personal":
+ return t("typePersonal");
+ case "sick":
+ return t("typeSick");
+ case "other":
+ return t("typeOther");
+ default:
+ return type;
+ }
+}
+
+/** 格式化 ISO 日期字符串为本地化展示。 */
+function formatDate(isoDate: string): string {
+ if (!isoDate) return "--";
+ const d = new Date(isoDate);
+ if (Number.isNaN(d.getTime())) return "--";
+ return d.toLocaleDateString("zh-CN");
+}
+
+/**
+ * 构造分页页码数组。返回 null 表示省略号占位。
+ * - 当前页 ± 2
+ * - 首尾固定显示
+ * - 中间用 null(…)填充
+ */
+function buildPageNumbers(
+ current: number,
+ total: number,
+): Array {
+ if (total <= 7) {
+ return Array.from({ length: total }, (_, i) => i + 1);
+ }
+ const pages: Array = [1];
+ const start = Math.max(2, current - 1);
+ const end = Math.min(total - 1, current + 1);
+ if (start > 2) pages.push(null);
+ for (let i = start; i <= end; i++) pages.push(i);
+ if (end < total - 1) pages.push(null);
+ pages.push(total);
+ return pages;
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-assistant-widget-inner.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-assistant-widget-inner.tsx
new file mode 100644
index 0000000..8167f12
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-assistant-widget-inner.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { usePathname } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Bot, X, Sparkles, RotateCcw, ChevronLeft } from "lucide-react";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@/shared/components/ui/sheet";
+import { AiChatPanel } from "./ai-chat-panel";
+import { useAiClientOptional } from "../context/ai-client-provider";
+import { useFloatingBall } from "../hooks/use-floating-ball";
+
+/**
+ * 上下文感知规则
+ *
+ * 根据当前路由推断用户上下文,动态生成 systemPrompt 和 contextMessage。
+ */
+type AiContextConfig = {
+ systemPrompt: string;
+ contextMessage: string;
+ suggestedPrompts?: string[];
+};
+
+/**
+ * 全局 AI 助手悬浮球(360 悬浮球风格)— 内部实现
+ *
+ * 特性:
+ * - 可拖拽移动,松手吸附到最近屏幕边缘
+ * - 拖到边缘自动半隐藏(只露出一小部分)
+ * - 鼠标悬停时恢复显示
+ * - 位置持久化到 localStorage
+ * - 点击打开侧边抽屉,内嵌 AiChatPanel
+ * - 上下文感知:根据当前路由自动推断用户场景
+ *
+ * 该组件被打包进独立 chunk(由 ai-assistant-widget.tsx 通过 next/dynamic 以 ssr:false 懒加载),
+ * 以避免 AiChatPanel 依赖的 AI SDK / Markdown 渲染等重型依赖被打入首屏 chunk。
+ *
+ * 使用:在 dashboard layout 中引入即可全局生效。
+ * 需要 AiClientProvider 包裹(可选,未注入时按钮不显示)。
+ */
+export function AiAssistantWidgetInner(): React.ReactNode {
+ const t = useTranslations("ai");
+ const pathname = usePathname();
+ const aiClient = useAiClientOptional();
+ const [open, setOpen] = useState(false);
+ const [chatKey, setChatKey] = useState(0);
+
+ const handleBallClick = () => setOpen(true);
+ const ball = useFloatingBall(handleBallClick);
+
+ // 根据路由推断上下文
+ const contextConfig = useMemo(() => {
+ return inferContextFromPath(pathname, t);
+ }, [pathname, t]);
+
+ // 如果未注入 AI 客户端服务,不显示悬浮按钮
+ if (!aiClient) {
+ return null;
+ }
+
+ const {
+ position,
+ hidden,
+ dragging,
+ hovered,
+ hiddenOffset,
+ handlers,
+ show,
+ resetPosition,
+ } = ball;
+
+ // 首次渲染时 position 为占位值(屏幕外),避免闪烁
+ const isReady = position.x < 9999;
+
+ return (
+ <>
+ {/* 悬浮球 */}
+ {isReady ? (
+
+ ) : null}
+
+ {/* 半隐藏时的提示条 */}
+ {hidden && isReady ? (
+
+ ) : null}
+
+ {/* 侧边抽屉 */}
+
+
+
+
+
+
+
+
+
+
+ {t("widget.title")}
+
+
+
+ {t("widget.online")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+const BALL_SIZE = 56;
+
+/**
+ * 根据路由推断 AI 上下文
+ */
+function inferContextFromPath(
+ pathname: string,
+ t: ReturnType,
+): AiContextConfig {
+ // 教师批改
+ if (pathname.includes("/teacher/homework/submissions")) {
+ return {
+ systemPrompt:
+ "You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
+ contextMessage: t("chat.contextMessage.teacherGrading"),
+ suggestedPrompts: [
+ t("chat.suggestedPrompts.teacher.0"),
+ t("chat.suggestedPrompts.context.teacherGrading.0"),
+ t("chat.suggestedPrompts.context.teacherGrading.1"),
+ ],
+ };
+ }
+
+ // 教师备课
+ if (pathname.includes("/teacher/lesson-plans")) {
+ return {
+ systemPrompt:
+ "You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
+ contextMessage: t("chat.contextMessage.teacherLesson"),
+ suggestedPrompts: [
+ t("chat.suggestedPrompts.teacher.1"),
+ t("chat.suggestedPrompts.context.teacherLesson.0"),
+ t("chat.suggestedPrompts.context.teacherLesson.1"),
+ ],
+ };
+ }
+
+ // 教师试卷
+ if (pathname.includes("/teacher/exams")) {
+ return {
+ systemPrompt:
+ "You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
+ contextMessage: t("chat.contextMessage.teacherExam"),
+ suggestedPrompts: [
+ t("chat.suggestedPrompts.teacher.2"),
+ t("chat.suggestedPrompts.context.teacherExam.0"),
+ t("chat.suggestedPrompts.context.teacherExam.1"),
+ ],
+ };
+ }
+
+ // 学生错题本
+ if (pathname.includes("/student/error-book")) {
+ return {
+ systemPrompt:
+ "You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
+ contextMessage: t("chat.contextMessage.studentErrorBook"),
+ suggestedPrompts: [
+ t("chat.suggestedPrompts.student.0"),
+ t("chat.suggestedPrompts.student.1"),
+ t("chat.suggestedPrompts.student.2"),
+ ],
+ };
+ }
+
+ // 学生作业
+ if (
+ pathname.includes("/student/homework") ||
+ pathname.includes("/student/learning")
+ ) {
+ return {
+ systemPrompt:
+ "You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
+ contextMessage: t("chat.contextMessage.studentHomework"),
+ suggestedPrompts: [
+ t("chat.suggestedPrompts.student.0"),
+ t("chat.suggestedPrompts.context.studentHomework.0"),
+ t("chat.suggestedPrompts.context.studentHomework.1"),
+ ],
+ };
+ }
+
+ // 家长面板
+ if (pathname.includes("/parent")) {
+ return {
+ systemPrompt:
+ "You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
+ contextMessage: t("chat.contextMessage.parent"),
+ suggestedPrompts: [
+ t("chat.suggestedPrompts.parent.0"),
+ t("chat.suggestedPrompts.parent.1"),
+ ],
+ };
+ }
+
+ // 管理员面板
+ if (pathname.includes("/admin")) {
+ return {
+ systemPrompt:
+ "You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
+ contextMessage: t("chat.contextMessage.admin"),
+ suggestedPrompts: [
+ t("chat.suggestedPrompts.admin.0"),
+ t("chat.suggestedPrompts.admin.1"),
+ ],
+ };
+ }
+
+ // 默认
+ return {
+ systemPrompt:
+ "You are a helpful AI assistant for a K12 school management system.",
+ contextMessage: "",
+ suggestedPrompts: undefined,
+ };
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-assistant-widget.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-assistant-widget.tsx
new file mode 100644
index 0000000..1ad58ee
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-assistant-widget.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { Skeleton } from "@/shared/components/ui/skeleton";
+
+/**
+ * 全局 AI 助手悬浮球 —— 懒加载入口(Phase 4.7)
+ *
+ * 通过 next/dynamic 以 ssr:false 懒加载 AiAssistantWidgetInner,
+ * 使其依赖的 AI SDK / Markdown 渲染等重型依赖被打入独立 chunk,
+ * 避免污染首屏 bundle。加载完成前展示 Skeleton 占位骨架。
+ */
+const LazyAiAssistantWidgetInner = dynamic(
+ () =>
+ import("./ai-assistant-widget-inner").then((m) => m.AiAssistantWidgetInner),
+ {
+ ssr: false,
+ loading: () => ,
+ },
+);
+
+export function AiAssistantWidget(): React.ReactNode {
+ return ;
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-chart-renderer.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-chart-renderer.tsx
new file mode 100644
index 0000000..6f62b71
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-chart-renderer.tsx
@@ -0,0 +1,374 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ Legend,
+ Line,
+ LineChart,
+ Pie,
+ PieChart,
+ PolarAngleAxis,
+ PolarGrid,
+ PolarRadiusAxis,
+ Radar,
+ RadarChart,
+ XAxis,
+ YAxis,
+} from "recharts";
+
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/shared/components/ui/chart";
+import { cn } from "@/shared/lib/utils";
+import { AiChartSpecSchema } from "../schema";
+
+/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
+const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 };
+const CARTESIAN_GRID_PROPS = {
+ vertical: false,
+ strokeDasharray: "4 4",
+ strokeOpacity: 0.4,
+} as const;
+const POLAR_GRID_PROPS = { strokeOpacity: 0.4 } as const;
+const BAR_X_AXIS_PROPS = {
+ tickLine: false,
+ axisLine: false,
+ tickMargin: 8,
+} as const;
+const BAR_Y_AXIS_PROPS = {
+ allowDecimals: true,
+ tickLine: false,
+ axisLine: false,
+ width: 36,
+} as const;
+const LINE_Y_AXIS_PROPS = {
+ tickLine: false,
+ axisLine: false,
+ width: 36,
+} as const;
+const LINE_TOOLTIP_CURSOR = {
+ stroke: "hsl(var(--muted-foreground))",
+ strokeWidth: 1,
+ strokeDasharray: "4 4",
+} as const;
+const LINE_ACTIVE_DOT = { r: 5, strokeWidth: 0 } as const;
+const POLAR_ANGLE_TICK = { fontSize: 12 } as const;
+const POLAR_RADIUS_TICK = { fontSize: 10 } as const;
+const BAR_RADIUS_TOP: [number, number, number, number] = [4, 4, 0, 0];
+const DEFAULT_Y_DOMAIN: [number, number] = [0, 100];
+
+function formatPercentTick(value: number): string {
+ return `${value}%`;
+}
+
+function formatPieLabel(entry: { name?: string; value?: number }): string {
+ return `${entry.name ?? ""}: ${entry.value ?? ""}`;
+}
+
+/**
+ * AI 图表渲染器
+ *
+ * 将 AI 返回的图表 JSON 规格渲染为 recharts 图表。
+ * 支持 4 种图表类型:bar / line / pie / radar。
+ *
+ * AI 通过在 Markdown 中返回特殊代码块触发渲染:
+ *
+ * ```chart:bar
+ * { "title": "...", "data": [...], "series": [...] }
+ * ```
+ *
+ * 规格格式(通用):
+ * {
+ * "title": "图表标题", // 可选
+ * "description": "说明文字", // 可选
+ * "data": [ // 数据数组
+ * { "name": "数学", "score": 85, "fullTitle": "数学科目" }
+ * ],
+ * "xKey": "name", // X 轴字段(bar/line)
+ * "series": [ // 系列配置
+ * { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" }
+ * ],
+ * "yDomain": [0, 100], // 可选,Y 轴定义域
+ * "height": 280 // 可选,高度 px
+ * }
+ *
+ * pie 图特有:
+ * {
+ * "data": [{ "name": "及格", "value": 30 }],
+ * "series": [{ "name": "分布" }]
+ * }
+ */
+
+export type AiChartType = "bar" | "line" | "pie" | "radar";
+
+export interface AiChartSeries {
+ dataKey: string;
+ name: string;
+ color?: string;
+ /** pie: 填充透明度;radar: 填充透明度 */
+ fillOpacity?: number;
+ /** radar: 线宽 */
+ strokeWidth?: number;
+ /** radar: 虚线 */
+ strokeDasharray?: string;
+}
+
+export interface AiChartSpec {
+ title?: string;
+ description?: string;
+ type?: AiChartType;
+ data: Array>;
+ xKey?: string;
+ angleKey?: string;
+ series: AiChartSeries[];
+ yDomain?: [number, number];
+ height?: number;
+ showLegend?: boolean;
+}
+
+/** 默认调色板(色盲友好) */
+const DEFAULT_PALETTE = [
+ "hsl(221, 83%, 53%)", // 蓝
+ "hsl(142, 71%, 45%)", // 绿
+ "hsl(43, 96%, 56%)", // 黄
+ "hsl(0, 84%, 60%)", // 红
+ "hsl(271, 76%, 53%)", // 紫
+ "hsl(199, 89%, 48%)", // 青
+ "hsl(25, 95%, 53%)", // 橙
+ "hsl(280, 65%, 60%)", // 品红
+];
+
+interface AiChartRendererProps {
+ /** 图表类型 */
+ type: AiChartType;
+ /** JSON 规格字符串 */
+ spec: string;
+ className?: string;
+}
+
+/**
+ * 解析 JSON 规格,失败时返回 null
+ *
+ * 使用 Zod schema 校验,避免 as 断言。
+ */
+function parseSpec(spec: string): AiChartSpec | null {
+ try {
+ const parsed: unknown = JSON.parse(spec);
+ const result = AiChartSpecSchema.safeParse(parsed);
+ if (!result.success) return null;
+ return result.data;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * 为 series 补充默认颜色
+ */
+function withDefaultColors(series: AiChartSeries[]): AiChartSeries[] {
+ return series.map((s, i) => ({
+ ...s,
+ color: s.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length],
+ }));
+}
+
+export function AiChartRenderer({
+ type,
+ spec,
+ className,
+}: AiChartRendererProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const parsed = useMemo(() => parseSpec(spec), [spec]);
+
+ if (!parsed) {
+ return (
+
+ {t("chart.parseError")}
+
+ );
+ }
+
+ const series = withDefaultColors(parsed.series);
+ const height = parsed.height ?? 280;
+
+ // 构建 ChartConfig
+ const chartConfig: ChartConfig = {};
+ for (const s of series) {
+ chartConfig[s.dataKey] = {
+ label: s.name,
+ color: s.color,
+ };
+ }
+
+ return (
+
+ {parsed.title ? (
+
+ {parsed.title}
+ {parsed.description ? (
+
+ {parsed.description}
+
+ ) : null}
+
+ ) : null}
+
+ {renderChart(type, parsed, series)}
+
+
+ );
+}
+
+function renderChart(
+ type: AiChartType,
+ spec: AiChartSpec,
+ series: AiChartSeries[],
+): React.ReactNode {
+ switch (type) {
+ case "bar":
+ return renderBarChart(spec, series);
+ case "line":
+ return renderLineChart(spec, series);
+ case "pie":
+ return renderPieChart(spec, series);
+ case "radar":
+ return renderRadarChart(spec, series);
+ default:
+ return null;
+ }
+}
+
+function renderBarChart(
+ spec: AiChartSpec,
+ series: AiChartSeries[],
+): React.ReactNode {
+ const xKey = spec.xKey ?? "name";
+ return (
+
+
+
+
+ } />
+ {spec.showLegend ? : null}
+ {series.map((s) => (
+
+ ))}
+
+ );
+}
+
+function renderLineChart(
+ spec: AiChartSpec,
+ series: AiChartSeries[],
+): React.ReactNode {
+ const xKey = spec.xKey ?? "name";
+ return (
+
+
+
+
+ {/* arbitrary-value: chart canvas fixed size */}
+ }
+ />
+ {spec.showLegend ? : null}
+ {series.map((s) => (
+
+ ))}
+
+ );
+}
+
+function renderPieChart(
+ spec: AiChartSpec,
+ series: AiChartSeries[],
+): React.ReactNode {
+ // Pie 图:data 中每项 { name, value },series 仅取第一个作为图例名
+ const colors = series.map((s) => s.color ?? DEFAULT_PALETTE[0]);
+ const dataKey = series[0]?.dataKey ?? "value";
+ return (
+
+ } />
+ {spec.showLegend ? : null}
+
+ {spec.data.map((_, index) => (
+ |
+ ))}
+
+
+ );
+}
+
+function renderRadarChart(
+ spec: AiChartSpec,
+ series: AiChartSeries[],
+): React.ReactNode {
+ const angleKey = spec.angleKey ?? spec.xKey ?? "name";
+ return (
+
+
+
+
+ } />
+ {spec.showLegend ? : null}
+ {series.map((s) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-chat-input.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-chat-input.tsx
new file mode 100644
index 0000000..8a2c578
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-chat-input.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Send, Square } from "lucide-react";
+
+import { Button } from "@/shared/components/ui/button";
+import { Textarea } from "@/shared/components/ui/textarea";
+
+type AiChatInputProps = {
+ value: string;
+ onChange: (value: string) => void;
+ onKeyDown: (e: React.KeyboardEvent) => void;
+ streaming: boolean;
+ onSend: () => void;
+ onStop: () => void;
+ maxReached: boolean;
+ variant: "card" | "widget";
+ placeholder?: string;
+};
+
+/**
+ * AI 聊天输入框
+ *
+ * 渲染文本输入区域与发送/停止按钮,支持 card / widget 两种视觉变体。
+ * 纯展示组件,所有状态由容器通过 props 注入。
+ */
+export function AiChatInput({
+ value,
+ onChange,
+ onKeyDown,
+ streaming,
+ onSend,
+ onStop,
+ maxReached,
+ variant,
+ placeholder,
+}: AiChatInputProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const isWidget = variant === "widget";
+
+ if (isWidget) {
+ return (
+
+
+
+ {maxReached ? (
+
+ {t("chat.maxReached")}
+
+ ) : null}
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {maxReached ? (
+
+ {t("chat.maxReached")}
+
+ ) : null}
+ >
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-chat-messages.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-chat-messages.tsx
new file mode 100644
index 0000000..e1d1b15
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-chat-messages.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import type { RefObject } from "react";
+import { useTranslations } from "next-intl";
+import { Bot, User, Sparkles } from "lucide-react";
+
+import { cn } from "@/shared/lib/utils";
+import { Button } from "@/shared/components/ui/button";
+import { ScrollArea } from "@/shared/components/ui/scroll-area";
+import { AiMarkdownRenderer } from "./ai-markdown-renderer";
+import type { AiChatMessage } from "../types";
+
+type AiChatMessagesProps = {
+ messages: AiChatMessage[];
+ streaming: boolean;
+ variant: "card" | "widget";
+ scrollRef: RefObject;
+ suggestedPrompts: string[];
+ onSuggestedPrompt: (prompt: string) => void;
+};
+
+/**
+ * AI 聊天消息列表:渲染消息气泡、流式指示器、空状态建议提示词。
+ * 支持 card / widget 两种视觉变体。纯展示组件,状态由容器注入。
+ */
+export function AiChatMessages({
+ messages,
+ streaming,
+ variant,
+ scrollRef,
+ suggestedPrompts,
+ onSuggestedPrompt,
+}: AiChatMessagesProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const isWidget = variant === "widget";
+
+ if (messages.length > 0) {
+ if (isWidget) {
+ return (
+
+
+ {messages.map((message, index) => (
+
+ {message.role === "assistant" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {message.role === "assistant" ? (
+
+ ) : (
+ {message.content}
+ )}
+
+
+ ))}
+ {streaming ? (
+
+ ) : null}
+
+
+ );
+ }
+
+ return (
+
+
+ {messages.map((message, index) => (
+
+ {message.role === "assistant" ? (
+
+ ) : (
+
+ )}
+
+ {message.role === "assistant" ? (
+
+ ) : (
+ {message.content}
+ )}
+
+
+ ))}
+ {streaming ? (
+
+
+
+ {t("chat.streaming")}
+
+
+
+ ) : null}
+
+
+ );
+ }
+
+ // 空状态:建议提示词
+ if (isWidget) {
+ return (
+
+
+
+
+ {t("widget.welcome")}
+
+ {t("widget.welcomeDesc")}
+
+
+ {suggestedPrompts.map((prompt, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("chat.suggestedPrompts.title")}
+
+
+ {suggestedPrompts.map((prompt, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-chat-panel.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-chat-panel.tsx
new file mode 100644
index 0000000..70d1f1b
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-chat-panel.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import { useTranslations } from "next-intl";
+import { Bot, Trash2 } from "lucide-react";
+import { notify } from "@/shared/lib/notify";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/components/ui/alert-dialog";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { SkeletonCard } from "@/shared/components/ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/shared/components/ui/tooltip";
+import { AiChatMessages } from "./ai-chat-messages";
+import { AiChatInput } from "./ai-chat-input";
+import { useAiChatStream } from "../hooks/use-ai-chat-stream";
+import type { AiChatMessage } from "../types";
+
+type AiChatPanelProps = {
+ /** 初始系统提示词 */
+ systemPrompt?: string;
+ /** 上下文信息(注入到 user message 前面) */
+ contextMessage?: string;
+ /** 占位提示文本 */
+ placeholder?: string;
+ /** 标题 */
+ title?: string;
+ /** 最大消息数 */
+ maxMessages?: number;
+ /** 建议提示词列表(空状态展示) */
+ suggestedPrompts?: string[];
+ /** 视觉变体:card(默认卡片)/ widget(悬浮球内嵌,无边框,撑满容器) */
+ variant?: "card" | "widget";
+};
+
+/**
+ * AI 聊天面板
+ *
+ * 通用 AI 对话组件,可嵌入任何页面。
+ * V2 增强:
+ * - 流式响应(SSE)逐 token 渲染
+ * - Markdown 渲染(代码块、表格、列表)
+ * - 复制按钮
+ * - 停止生成按钮
+ * - 清除对话按钮
+ * - 建议提示词
+ * - aria-live 无障碍
+ * - 对话历史持久化(localStorage,防抖写入)
+ *
+ * 容器职责:持有 useAiChatStream(流式状态机 + AbortController ref)、
+ * 输入状态、事件编排。渲染委托给 AiChatMessages / AiChatInput 子组件。
+ */
+export function AiChatPanel({
+ systemPrompt,
+ contextMessage,
+ placeholder,
+ title,
+ maxMessages = 50,
+ suggestedPrompts,
+ variant = "card",
+}: AiChatPanelProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const { messages, streaming, error, send, stop, clear } = useAiChatStream();
+ const [input, setInput] = useState("");
+ const [confirmClear, setConfirmClear] = useState(false);
+ const scrollRef = useRef(null);
+ const isWidget = variant === "widget";
+ const maxReached = messages.length >= maxMessages;
+
+ // 自动滚动到底部
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, [messages]);
+
+ const handleSend = useCallback(
+ async (content?: string): Promise => {
+ const trimmed = (content ?? input).trim();
+ if (!trimmed || streaming || maxReached) return;
+
+ // 上下文信息合并到 systemPrompt 发送给 AI,用户气泡只显示真实输入
+ const fullSystemPrompt = contextMessage
+ ? `${systemPrompt ?? ""}\n\n[Page Context]\n${contextMessage}`.trim()
+ : systemPrompt;
+
+ const requestMessages: AiChatMessage[] = [
+ ...messages,
+ { role: "user", content: trimmed },
+ ];
+
+ setInput("");
+ await send(requestMessages, { systemPrompt: fullSystemPrompt });
+ },
+ [
+ input,
+ streaming,
+ messages,
+ maxReached,
+ systemPrompt,
+ contextMessage,
+ send,
+ ],
+ );
+
+ const handleKeyDown = (e: React.KeyboardEvent): void => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ void handleSend();
+ }
+ };
+
+ const handleClear = (): void => {
+ setConfirmClear(true);
+ };
+
+ const handleConfirmClear = (): void => {
+ clear();
+ setConfirmClear(false);
+ notify.success(t("chat.clear"));
+ };
+
+ if (streaming && messages.length === 0) {
+ return ;
+ }
+
+ const defaultSuggestedPrompts = suggestedPrompts ?? [
+ t("chat.suggestedPrompts.teacher.0"),
+ t("chat.suggestedPrompts.teacher.1"),
+ t("chat.suggestedPrompts.teacher.2"),
+ ];
+
+ if (isWidget) {
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ void handleSend(prompt)}
+ />
+ void handleSend()}
+ onStop={stop}
+ maxReached={maxReached}
+ variant={variant}
+ placeholder={placeholder}
+ />
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {title ?? t("chat.title")}
+
+ {messages.length > 0 ? (
+
+
+
+
+
+ {t("chat.clear")}
+
+
+ ) : null}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ void handleSend(prompt)}
+ />
+ void handleSend()}
+ onStop={stop}
+ maxReached={maxReached}
+ variant={variant}
+ placeholder={placeholder}
+ />
+
+
+
+
+
+ {t("chat.clearConfirm")}
+
+ {t("chat.clearConfirm")}
+
+
+
+ {t("chat.clear")}
+
+ {t("chat.clear")}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-error-book-analysis.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-error-book-analysis.tsx
new file mode 100644
index 0000000..4d1cdaf
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-error-book-analysis.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Sparkles, Lightbulb, BookOpen, TrendingDown } from "lucide-react";
+import { notify } from "@/shared/lib/notify";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/shared/components/ui/card";
+import { Badge } from "@/shared/components/ui/badge";
+import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
+import { SkeletonCard } from "@/shared/components/ui/skeleton";
+import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
+import type {
+ WeaknessAnalysisResult,
+ SimilarQuestionResult,
+} from "@/features/teacher/ai/types";
+
+type AiErrorBookAnalysisProps = {
+ /** 错题列表(用于薄弱点分析) */
+ errorItems: Array<{
+ questionText: string;
+ questionType: string;
+ knowledgePointIds?: string[];
+ errorCount: number;
+ masteryLevel: number;
+ }>;
+ /** 学生 ID */
+ studentId: string;
+ /** 学科 ID */
+ subjectId?: string;
+ /** 当前错题的题目文本(用于相似题推荐) */
+ currentQuestionText?: string;
+ /** 当前题目类型 */
+ currentQuestionType?: string;
+ /** 选中相似题后的回调 */
+ onSelectSimilarQuestion?: (question: SimilarQuestionResult) => void;
+};
+
+/**
+ * 错题本 AI 分析组件
+ *
+ * 集成两个 AI 能力:
+ * 1. 相似题推荐:根据当前错题生成同类练习
+ * 2. 薄弱点分析:分析错题分布,生成学习建议
+ *
+ * 通过 AiClientProvider 注入服务,不直接 import actions。
+ */
+export function AiErrorBookAnalysis({
+ errorItems,
+ studentId,
+ subjectId,
+ currentQuestionText,
+ currentQuestionType,
+ onSelectSimilarQuestion,
+}: AiErrorBookAnalysisProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const aiClient = useAiClient();
+ const [similarLoading, setSimilarLoading] = useState(false);
+ const [weaknessLoading, setWeaknessLoading] = useState(false);
+ const [similarQuestions, setSimilarQuestions] = useState<
+ SimilarQuestionResult[]
+ >([]);
+ const [weaknessResult, setWeaknessResult] =
+ useState(null);
+
+ const handleGenerateSimilar = async (): Promise => {
+ if (!currentQuestionText || !currentQuestionType) return;
+ setSimilarLoading(true);
+ try {
+ const result = await aiClient.suggestSimilarQuestions({
+ questionText: currentQuestionText,
+ questionType: currentQuestionType,
+ subject: subjectId,
+ count: 3,
+ });
+ if (result.success && result.data) {
+ setSimilarQuestions(result.data);
+ notify.success(t("suggestion.loaded"));
+ } else {
+ notify.error(result.message ?? t("suggestion.error"));
+ }
+ } catch {
+ notify.error(t("suggestion.error"));
+ } finally {
+ setSimilarLoading(false);
+ }
+ };
+
+ const handleAnalyzeWeakness = async (): Promise => {
+ if (errorItems.length === 0) return;
+ setWeaknessLoading(true);
+ try {
+ const result = await aiClient.analyzeWeakness({
+ studentId,
+ subjectId,
+ errorItems,
+ });
+ if (result.success && result.data) {
+ setWeaknessResult(result.data);
+ notify.success(t("errorBook.weaknessAnalysis"));
+ } else {
+ notify.error(result.message ?? t("error.analysisFailed"));
+ }
+ } catch {
+ notify.error(t("error.analysisFailed"));
+ } finally {
+ setWeaknessLoading(false);
+ }
+ };
+
+ const severityVariant = (
+ severity: "high" | "medium" | "low",
+ ): "destructive" | "secondary" | "outline" => {
+ if (severity === "high") return "destructive";
+ if (severity === "medium") return "secondary";
+ return "outline";
+ };
+
+ return (
+
+
+ {/* 相似题推荐 */}
+ {currentQuestionText ? (
+
+
+
+
+ {t("errorBook.similarQuestions")}
+
+ {t("suggestion.title")}
+
+
+ {similarLoading ? (
+
+ ) : similarQuestions.length > 0 ? (
+ <>
+ {similarQuestions.map((question, index) => (
+
+ {question.text}
+ {question.difficulty ? (
+
+ {t("suggestion.difficulty")}: {question.difficulty}
+
+ ) : null}
+ {question.options && question.options.length > 0 ? (
+
+ {question.options.map((opt, optIndex) => (
+ -
+ {opt.id}.{" "}
+ {opt.text}
+
+ ))}
+
+ ) : null}
+ {question.explanation ? (
+
+ {question.explanation}
+
+ ) : null}
+ {onSelectSimilarQuestion ? (
+
+ ) : null}
+
+ ))}
+
+ >
+ ) : (
+
+ )}
+
+
+ ) : null}
+
+ {/* 薄弱点分析 */}
+
+
+
+
+ {t("errorBook.weaknessAnalysis")}
+
+ {t("errorBook.weakAreas")}
+
+
+ {weaknessLoading ? (
+
+ ) : weaknessResult ? (
+
+
+
+ {t("errorBook.weakAreas")}
+
+ {weaknessResult.weakAreas.map((area, index) => (
+
+
+ {area.area}
+
+ {t(`errorBook.severity.${area.severity}`)}
+
+
+
+ {area.suggestion}
+
+
+ ))}
+
+
+
+
+ {t("errorBook.studyPlan")}
+
+
+ {weaknessResult.studyPlan}
+
+
+ {weaknessResult.recommendedResources.length > 0 ? (
+
+
+
+ {t("errorBook.recommendedResources")}
+
+
+ {weaknessResult.recommendedResources.map(
+ (resource, index) => (
+ - {resource}
+ ),
+ )}
+
+
+ ) : null}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-grading-assist.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-grading-assist.tsx
new file mode 100644
index 0000000..2af657d
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-grading-assist.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Sparkles, Check } from "lucide-react";
+import { notify } from "@/shared/lib/notify";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/shared/components/ui/card";
+import { Badge } from "@/shared/components/ui/badge";
+import { Progress } from "@/shared/components/ui/progress";
+import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
+import { SkeletonCard } from "@/shared/components/ui/skeleton";
+import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
+import type { GradingSuggestion } from "@/features/teacher/ai/types";
+
+type AiGradingAssistProps = {
+ /** 题目文本 */
+ questionText: string;
+ /** 题目类型 */
+ questionType: string;
+ /** 学生答案 */
+ studentAnswer: string;
+ /** 正确答案(可选) */
+ correctAnswer?: string;
+ /** 最大分值 */
+ maxScore: number;
+ /** 学科 */
+ subject?: string;
+ /** 应用建议分数 */
+ onApplyScore?: (score: number) => void;
+ /** 应用建议反馈 */
+ onApplyFeedback?: (feedback: string) => void;
+};
+
+/**
+ * AI 批改辅助组件
+ *
+ * 为教师提供 AI 预评分与反馈建议。
+ * 仅用于主观题(text/essay),客观题由系统自动判分。
+ */
+export function AiGradingAssist({
+ questionText,
+ questionType,
+ studentAnswer,
+ correctAnswer,
+ maxScore,
+ subject,
+ onApplyScore,
+ onApplyFeedback,
+}: AiGradingAssistProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const aiClient = useAiClient();
+ const [loading, setLoading] = useState(false);
+ const [suggestion, setSuggestion] = useState(null);
+
+ // 仅对主观题提供 AI 批改
+ const isAutoGradable =
+ questionType === "single_choice" ||
+ questionType === "multiple_choice" ||
+ questionType === "judgment";
+ if (isAutoGradable) {
+ return null;
+ }
+
+ const handleGenerate = async (): Promise => {
+ setLoading(true);
+ try {
+ const result = await aiClient.suggestGrading({
+ questionText,
+ questionType,
+ studentAnswer,
+ correctAnswer,
+ maxScore,
+ subject,
+ });
+ if (result.success && result.data) {
+ setSuggestion(result.data);
+ notify.success(t("grading.title"));
+ } else {
+ notify.error(result.message ?? t("grading.error"));
+ }
+ } catch {
+ notify.error(t("grading.error"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const confidencePercent = suggestion
+ ? Math.round(suggestion.confidence * 100)
+ : 0;
+
+ return (
+
+
+
+
+
+ {t("grading.title")}
+
+ {t("grading.description")}
+
+
+ {loading ? (
+
+ ) : suggestion ? (
+
+
+
+
+ {t("grading.suggestedScore")}
+
+
+ {suggestion.suggestedScore} / {maxScore}
+
+
+
+
+ {t("grading.confidence")}
+ {confidencePercent}%
+
+
+
+
+
+ {t("grading.feedback")}
+
+ {suggestion.feedback}
+
+
+
+
+ {t("grading.reasoning")}
+
+
+ {suggestion.reasoning}
+
+
+
+ {onApplyScore ? (
+
+ ) : null}
+ {onApplyFeedback ? (
+
+ ) : null}
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-lesson-content-generator.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-lesson-content-generator.tsx
new file mode 100644
index 0000000..e35d044
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-lesson-content-generator.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import {
+ Sparkles,
+ BookOpen,
+ Lightbulb,
+ HelpCircle,
+ FileText,
+} from "lucide-react";
+import { notify } from "@/shared/lib/notify";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/shared/components/ui/card";
+import { Textarea } from "@/shared/components/ui/textarea";
+import { Badge } from "@/shared/components/ui/badge";
+import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
+import { SkeletonCard } from "@/shared/components/ui/skeleton";
+import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
+import type { LessonContentResult } from "@/features/teacher/ai/types";
+
+type ContentType = "activity" | "assessment" | "question" | "material";
+
+type AiLessonContentGeneratorProps = {
+ /** 备课主题 */
+ topic: string;
+ /** 学科 */
+ subject?: string;
+ /** 年级 */
+ grade?: string;
+ /** 教材 ID */
+ textbookId?: string;
+ /** 章节 ID */
+ chapterId?: string;
+ /** 生成内容后的回调 */
+ onInsertContent?: (result: LessonContentResult) => void;
+};
+
+/**
+ * AI 备课内容生成器
+ *
+ * 为教师提供 AI 生成教学活动、评估题、讨论题、教学素材的能力。
+ * 通过 AiClientProvider 注入服务,不直接 import actions。
+ *
+ * 使用场景:在备课编辑器侧边栏中作为辅助工具使用。
+ */
+export function AiLessonContentGenerator({
+ topic,
+ subject,
+ grade,
+ textbookId,
+ chapterId,
+ onInsertContent,
+}: AiLessonContentGeneratorProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const aiClient = useAiClient();
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [activeType, setActiveType] = useState("activity");
+ const [additionalContext, setAdditionalContext] = useState("");
+
+ const handleGenerate = async (): Promise => {
+ if (!topic.trim()) {
+ notify.error(t("lessonPrep.error"));
+ return;
+ }
+ setLoading(true);
+ try {
+ const response = await aiClient.generateLessonContent({
+ topic,
+ subject,
+ grade,
+ textbookId,
+ chapterId,
+ contentType: activeType,
+ additionalContext: additionalContext.trim() || undefined,
+ });
+ if (response.success && response.data) {
+ setResult(response.data);
+ notify.success(t("lessonPrep.generateContent"));
+ } else {
+ notify.error(response.message ?? t("lessonPrep.error"));
+ }
+ } catch {
+ notify.error(t("lessonPrep.error"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const contentTypes: Array<{
+ type: ContentType;
+ label: string;
+ icon: typeof Sparkles;
+ }> = [
+ {
+ type: "activity",
+ label: t("lessonPrep.generateActivity"),
+ icon: Lightbulb,
+ },
+ {
+ type: "assessment",
+ label: t("lessonPrep.generateAssessment"),
+ icon: FileText,
+ },
+ {
+ type: "question",
+ label: t("lessonPrep.generateQuestion"),
+ icon: HelpCircle,
+ },
+ {
+ type: "material",
+ label: t("lessonPrep.generateContent"),
+ icon: BookOpen,
+ },
+ ];
+
+ return (
+
+
+
+
+
+ {t("lessonPrep.generateContent")}
+
+ {t("lessonPrep.description")}
+
+
+ {/* 内容类型选择 */}
+
+ {contentTypes.map(({ type, label, icon: Icon }) => (
+
+ ))}
+
+
+ {/* 附加上下文 */}
+
+
+
+
+ {/* 生成按钮 */}
+
+
+ {/* 生成结果 */}
+ {loading ? (
+
+ ) : result ? (
+
+
+ {result.title}
+
+ {activeType}
+
+
+
+ {result.content}
+
+ {onInsertContent ? (
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-markdown-renderer.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-markdown-renderer.tsx
new file mode 100644
index 0000000..7365496
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-markdown-renderer.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { memo, useState, useCallback } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { Copy, Check } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { notify } from "@/shared/lib/notify";
+
+import { Button } from "@/shared/components/ui/button";
+import { cn } from "@/shared/lib/utils";
+import { AiChartRenderer, type AiChartType } from "./ai-chart-renderer";
+
+type AiMarkdownRendererProps = {
+ content: string;
+ /** 是否显示复制按钮 */
+ showCopyButton?: boolean;
+ /** 自定义类名 */
+ className?: string;
+};
+
+/** 支持的图表类型映射:language → chart type */
+const CHART_LANG_PREFIX = "chart:";
+const CHART_TYPES: Record = {
+ "chart:bar": "bar",
+ "chart:line": "line",
+ "chart:pie": "pie",
+ "chart:radar": "radar",
+};
+
+/**
+ * 类型守卫:判断字符串是否为合法的 AiChartType
+ */
+function isAiChartType(value: string): value is AiChartType {
+ return (
+ value === "bar" || value === "line" || value === "pie" || value === "radar"
+ );
+}
+
+/**
+ * 从 language 标识中解析图表类型
+ */
+function resolveChartType(lang: string): AiChartType | undefined {
+ // 优先查表(兼容 "chart:bar" 形式)
+ const fromTable = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`];
+ if (fromTable) return fromTable;
+ // 兼容 "chart:bar" 前缀形式
+ if (lang.startsWith(CHART_LANG_PREFIX)) {
+ const suffix = lang.slice(CHART_LANG_PREFIX.length);
+ return isAiChartType(suffix) ? suffix : undefined;
+ }
+ return undefined;
+}
+
+/**
+ * AI Markdown 渲染器
+ *
+ * 将 AI 回复渲染为富文本 Markdown,支持:
+ * - GFM(表格、删除线、任务列表)
+ * - 代码块语法高亮
+ * - 图表渲染(```chart:bar|line|pie|radar + JSON)
+ * - 复制按钮
+ *
+ * 安全:react-markdown 默认不执行 HTML,防止 XSS。
+ */
+function AiMarkdownRendererImpl({
+ content,
+ showCopyButton = true,
+ className,
+}: AiMarkdownRendererProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = useCallback(async (): Promise => {
+ try {
+ await navigator.clipboard.writeText(content);
+ setCopied(true);
+ notify.success(t("chat.copied"));
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ notify.error(t("error.chatFailed"));
+ }
+ }, [content, t]);
+
+ return (
+
+
+
+ {children}
+
+ );
+ }
+
+ // 检测图表代码块:language-chart:bar / chart:line / chart:pie / chart:radar
+ const lang = codeClass?.replace("language-", "").trim() ?? "";
+ const chartType = resolveChartType(lang);
+
+ if (chartType) {
+ const raw = String(children).replace(/\n$/, "");
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+ a({ children, ...props }) {
+ return (
+
+ {children}
+
+ );
+ },
+ }}
+ >
+ {content}
+
+
+ {showCopyButton ? (
+
+ ) : null}
+
+ );
+}
+
+export const AiMarkdownRenderer = memo(AiMarkdownRendererImpl);
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-provider-selector.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-provider-selector.tsx
new file mode 100644
index 0000000..3567e13
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-provider-selector.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Label } from "@/shared/components/ui/label";
+import { Select, type SelectOption } from "@/shared/components/ui/select";
+
+/** AI Provider 摘要信息(与 settings 模块类型兼容) */
+export type AiProviderOption = {
+ id: string;
+ provider: string;
+ model: string;
+ isDefault: boolean;
+};
+
+type AiProviderSelectorProps = {
+ /** 当前选中的 provider ID */
+ value: string;
+ /** 值变更回调 */
+ onChange: (value: string) => void;
+ /** Provider 列表 */
+ providers: AiProviderOption[];
+ /** 是否加载中 */
+ loading?: boolean;
+ /** Provider 标签映射 */
+ providerLabels?: Record;
+ /** 字段 ID */
+ id?: string;
+};
+
+/**
+ * AI Provider 选择器
+ *
+ * 可复用的受控选择组件,用于选择 AI Provider。
+ * portal-shell 适配:使用原生 Select(无 react-hook-form 依赖)。
+ */
+export function AiProviderSelector({
+ value,
+ onChange,
+ providers,
+ loading = false,
+ providerLabels,
+ id = "ai-provider",
+}: AiProviderSelectorProps): React.ReactNode {
+ const t = useTranslations("ai");
+
+ const options: SelectOption[] = providers.map((item) => ({
+ value: item.id,
+ label: `${providerLabels?.[item.provider] ?? item.provider} · ${item.model}${item.isDefault ? ` (${t("provider.default")})` : ""}`,
+ }));
+
+ return (
+
+
+
+
+ {t("provider.description")}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-question-variant-generator.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-question-variant-generator.tsx
new file mode 100644
index 0000000..e2f7014
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-question-variant-generator.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Sparkles, RefreshCw, Plus } from "lucide-react";
+import { notify } from "@/shared/lib/notify";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/shared/components/ui/card";
+import { Badge } from "@/shared/components/ui/badge";
+import { Select, type SelectOption } from "@/shared/components/ui/select";
+import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
+import { SkeletonCard } from "@/shared/components/ui/skeleton";
+import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
+import type { QuestionVariantResult } from "@/features/teacher/ai/types";
+
+type VariantType =
+ "same_knowledge_point" | "different_difficulty" | "different_format";
+
+const VARIANT_TYPES: readonly string[] = [
+ "same_knowledge_point",
+ "different_difficulty",
+ "different_format",
+];
+
+const isVariantType = (value: string): value is VariantType => {
+ return VARIANT_TYPES.includes(value);
+};
+
+type AiQuestionVariantGeneratorProps = {
+ /** 原始题目 */
+ originalQuestion: {
+ text: string;
+ type: string;
+ difficulty?: number;
+ options?: Array<{ id: string; text: string; isCorrect?: boolean }>;
+ answer?: string;
+ };
+ /** 学科 */
+ subject?: string;
+ /** 生成变体后的回调 */
+ onAddVariant?: (variant: QuestionVariantResult) => void;
+};
+
+/**
+ * AI 题目变体生成器
+ *
+ * 为教师提供从现有题目生成变体的能力:
+ * - same_knowledge_point: 同知识点不同表述
+ * - different_difficulty: 调整难度
+ * - different_format: 转换题型
+ *
+ * 通过 AiClientProvider 注入服务,不直接 import actions。
+ */
+export function AiQuestionVariantGenerator({
+ originalQuestion,
+ subject,
+ onAddVariant,
+}: AiQuestionVariantGeneratorProps): React.ReactNode {
+ const t = useTranslations("ai");
+ const aiClient = useAiClient();
+ const [loading, setLoading] = useState(false);
+ const [variant, setVariant] = useState(null);
+ const [variantType, setVariantType] = useState(
+ "same_knowledge_point",
+ );
+
+ const handleGenerate = async (): Promise => {
+ if (!originalQuestion.text.trim()) {
+ notify.error(t("error.invalidInput"));
+ return;
+ }
+ setLoading(true);
+ try {
+ const result = await aiClient.generateQuestionVariant({
+ originalQuestion,
+ subject,
+ variantType,
+ });
+ if (result.success && result.data) {
+ setVariant(result.data);
+ notify.success(t("exam.generate"));
+ } else {
+ notify.error(result.message ?? t("error.variantFailed"));
+ }
+ } catch {
+ notify.error(t("error.variantFailed"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const variantTypeLabels: Record = {
+ same_knowledge_point: t("exam.variantType.same_knowledge_point"),
+ different_difficulty: t("exam.variantType.different_difficulty"),
+ different_format: t("exam.variantType.different_format"),
+ };
+
+ return (
+
+
+
+
+
+ {t("capability.questionVariant")}
+
+ {t("exam.variantType.label")}
+
+
+ {/* 变体类型选择 */}
+
+
+ {
+ if (isVariantType(value)) {
+ setVariantType(value);
+ }
+ }}
+ options={VARIANT_TYPES.map((v) => ({
+ value: v,
+ label: variantTypeLabels[v as VariantType],
+ }))}
+ placeholder={t("exam.generate")}
+ />
+
+
+ {/* 生成按钮 */}
+
+
+ {/* 生成结果 */}
+ {loading ? (
+
+ ) : variant ? (
+
+
+ {variant.text}
+
+ {t("suggestion.difficulty")}: {variant.difficulty}
+
+
+ {variant.options && variant.options.length > 0 ? (
+
+ {variant.options.map((opt, index) => (
+ -
+ {opt.id}.
+ {opt.text}
+ {opt.isCorrect ? (
+
+ ✓
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+ {variant.answer ? (
+
+ {t("exam.sourceText")}:{" "}
+
+ {variant.answer}
+
+
+ ) : null}
+ {variant.explanation ? (
+
+ {variant.explanation}
+
+ ) : null}
+
+ {onAddVariant ? (
+
+ ) : null}
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/components/ai-usage-dashboard.tsx b/apps/portal-shell/src/features/teacher/ai/components/ai-usage-dashboard.tsx
new file mode 100644
index 0000000..c919b3d
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/components/ai-usage-dashboard.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { useTranslations } from "next-intl";
+import { Activity, Users, AlertTriangle, Clock } from "lucide-react";
+import { notify } from "@/shared/lib/notify";
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/shared/components/ui/card";
+import { Badge } from "@/shared/components/ui/badge";
+import { Progress } from "@/shared/components/ui/progress";
+import { Button } from "@/shared/components/ui/button";
+import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
+import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
+import type { AiUsageStats } from "@/features/teacher/ai/types";
+
+/**
+ * 管理员 AI 使用统计仪表盘
+ *
+ * 参考 Khanmigo district dashboard 和 Century Tech 全校视图。
+ * 展示:
+ * - 总调用数 / 今日 / 本周
+ * - 活跃用户数
+ * - 错误率
+ * - 平均耗时
+ * - 按能力分类
+ * - 按角色分类
+ * - 高频用户
+ * - 最近活动
+ */
+export function AiUsageDashboard(): React.ReactNode {
+ const t = useTranslations("ai");
+ const aiClient = useAiClient();
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const loadStats = useCallback(async (): Promise => {
+ if (!aiClient.getAiUsageStats) return;
+ setLoading(true);
+ try {
+ const result = await aiClient.getAiUsageStats();
+ if (result.success && result.data) {
+ setStats(result.data);
+ } else {
+ notify.error(result.message ?? t("error.statsFailed"));
+ }
+ } catch {
+ notify.error(t("error.statsFailed"));
+ } finally {
+ setLoading(false);
+ }
+ }, [aiClient, t]);
+
+ useEffect(() => {
+ void loadStats();
+ }, [loadStats]);
+
+ const statCards = stats
+ ? [
+ {
+ label: t("admin.totalCalls"),
+ value: stats.totalCalls.toString(),
+ icon: Activity,
+ color: "text-blue-500",
+ },
+ {
+ label: t("admin.callsToday"),
+ value: stats.callsToday.toString(),
+ icon: Clock,
+ color: "text-green-500",
+ },
+ {
+ label: t("admin.activeUsers"),
+ value: stats.activeUsers.toString(),
+ icon: Users,
+ color: "text-purple-500",
+ },
+ {
+ label: t("admin.errorRate"),
+ value: `${(stats.errorRate * 100).toFixed(1)}%`,
+ icon: AlertTriangle,
+ color: stats.errorRate > 0.05 ? "text-red-500" : "text-green-500",
+ },
+ ]
+ : [];
+
+ return (
+
+
+
+
+
+
+
+ {t("admin.usageDashboard")}
+
+
+ {t("admin.dashboardDescription")}
+
+
+
+
+
+
+ {loading && !stats ? (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : stats ? (
+ <>
+ {/* 统计卡片 */}
+
+ {statCards.map((card, index) => {
+ const Icon = card.icon;
+ return (
+
+
+
+ {card.label}
+
+
+
+ {card.value}
+
+ );
+ })}
+
+
+ {/* 按能力分类 */}
+ {stats.byCapability.length > 0 ? (
+
+
+ {t("admin.byCapability")}
+
+
+ {stats.byCapability.map((item, index) => {
+ const maxCount = Math.max(
+ ...stats.byCapability.map((c) => c.count),
+ 1,
+ );
+ const percent = (item.count / maxCount) * 100;
+ return (
+
+
+
+ {item.capability}
+
+ {item.count}
+
+
+
+ );
+ })}
+
+
+ ) : null}
+
+ {/* 按角色分类 */}
+ {stats.byRole.length > 0 ? (
+
+ {t("admin.byRole")}
+
+ {stats.byRole.map((item, index) => (
+
+ {item.role}: {item.count}
+
+ ))}
+
+
+ ) : null}
+
+ {/* 高频用户 */}
+ {stats.topUsers.length > 0 ? (
+
+ {t("admin.topUsers")}
+
+ {stats.topUsers.slice(0, 5).map((user, index) => (
+
+
+ {user.userId}
+
+ {user.count}
+
+ ))}
+
+
+ ) : null}
+
+ {/* 最近活动 */}
+ {stats.recentActivity.length > 0 ? (
+
+
+ {t("admin.recentActivity")}
+
+
+ {stats.recentActivity
+ .slice(0, 10)
+ .map((activity, index) => (
+
+
+ {activity.capability}
+
+
+
+ {activity.success ? "✓" : "✗"}
+
+
+ {activity.durationMs}ms
+
+
+
+ ))}
+
+
+ ) : null}
+
+ {stats.totalCalls === 0 ? (
+
+ {t("admin.noData")}
+
+ ) : null}
+ >
+ ) : (
+
+ {t("admin.noData")}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/context/ai-client-provider.tsx b/apps/portal-shell/src/features/teacher/ai/context/ai-client-provider.tsx
new file mode 100644
index 0000000..080b030
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/context/ai-client-provider.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { createContext, useContext, type ReactNode } from "react";
+
+import type { AiClientService } from "../types";
+
+/**
+ * AI 客户端服务 Context
+ *
+ * 通过 React Context 注入 AiClientService(Server Action 引用集合),
+ * 客户端组件通过 useAiClient() 消费,不直接 import actions。
+ *
+ * 遵循 settings 模块的依赖注入模式:
+ * - 页面层(Server Component)创建 service 对象并注入 Provider
+ * - 组件层通过 Hook 消费
+ * - 测试时可注入 mock service
+ */
+
+// 重新导出 AiClientService 类型,方便调用方从单一入口导入
+export type { AiClientService } from "../types";
+
+const AiClientContext = createContext(null);
+
+export function AiClientProvider({
+ children,
+ service,
+}: {
+ children: ReactNode;
+ service: AiClientService;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * 获取 AI 客户端服务
+ *
+ * 必须在 AiClientProvider 内部使用。
+ * 若未注入,抛出错误以防止静默失败。
+ */
+export function useAiClient(): AiClientService {
+ const service = useContext(AiClientContext);
+ if (!service) {
+ throw new Error(
+ "useAiClient must be used within an AiClientProvider. " +
+ "Wrap your component tree with .",
+ );
+ }
+ return service;
+}
+
+/**
+ * 安全获取 AI 客户端服务(未注入时返回 null)
+ *
+ * 用于可选 AI 功能的场景,组件需自行处理 null 情况。
+ */
+export function useAiClientOptional(): AiClientService | null {
+ return useContext(AiClientContext);
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/context/create-ai-client-service.ts b/apps/portal-shell/src/features/teacher/ai/context/create-ai-client-service.ts
new file mode 100644
index 0000000..7af4560
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/context/create-ai-client-service.ts
@@ -0,0 +1,101 @@
+"use client";
+
+/**
+ * 创建 AI 客户端服务(portal-shell 适配版)
+ *
+ * 从 CICD src/modules/ai/context/create-ai-client-service.ts 迁移。
+ * 适配:CICD 通过 Server Action 引用调用 AI,portal-shell 为客户端微前端,
+ * 改为通过 fetch 调用 BFF 路由(/api/ai/*)。
+ *
+ * 返回 ActionState(与 CICD Server Action 模式结构一致),便于上层组件统一处理。
+ */
+
+import type {
+ ActionState,
+ AiChatResult,
+ AiClientService,
+ ExplainErrorResult,
+ GradingSuggestion,
+ LessonContentResult,
+ QuestionVariantResult,
+ SimilarQuestionResult,
+ WeaknessAnalysisResult,
+} from "../types";
+
+/**
+ * 通用 fetch POST 封装,统一返回 ActionState
+ *
+ * BFF 路由约定返回 { success: boolean; data?: T; message?: string } 结构。
+ */
+async function postJson(
+ url: string,
+ body: unknown,
+): Promise> {
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ return {
+ success: false,
+ message: `AI BFF ${response.status}: ${text || response.statusText}`,
+ };
+ }
+ const json = (await response.json()) as ActionState;
+ return json;
+ } catch (err) {
+ return {
+ success: false,
+ message: err instanceof Error ? err.message : String(err),
+ };
+ }
+}
+
+/**
+ * 创建完整的 AI 客户端服务(含全部能力,通过 BFF fetch)
+ *
+ * 用于全局 layout 或需要全部 AI 能力的页面。
+ * 通过 React Context 注入,客户端组件通过 useAiClient() 消费。
+ */
+export function createFullAiClientService(): AiClientService {
+ return {
+ chat: (input) => postJson("/api/ai/chat", input),
+ suggestSimilarQuestions: (input) =>
+ postJson("/api/ai/similar-questions", input),
+ suggestGrading: (input) =>
+ postJson("/api/ai/grading", input),
+ generateLessonContent: (input) =>
+ postJson("/api/ai/lesson-content", input),
+ generateQuestionVariant: (input) =>
+ postJson("/api/ai/question-variant", input),
+ analyzeWeakness: (input) =>
+ postJson("/api/ai/weakness-analysis", input),
+ explainError: (input) =>
+ postJson("/api/ai/explain-error", input),
+ };
+}
+
+/**
+ * 创建核心 AI 客户端服务(仅 6 个常用能力)
+ *
+ * 用于只需要 AI 业务能力(不含错题解释/统计等可选能力)的页面。
+ * 可选字段不注入,调用方组件需自行处理 undefined 情况。
+ */
+export function createCoreAiClientService(): AiClientService {
+ return {
+ chat: (input) => postJson("/api/ai/chat", input),
+ suggestSimilarQuestions: (input) =>
+ postJson("/api/ai/similar-questions", input),
+ suggestGrading: (input) =>
+ postJson("/api/ai/grading", input),
+ generateLessonContent: (input) =>
+ postJson("/api/ai/lesson-content", input),
+ generateQuestionVariant: (input) =>
+ postJson("/api/ai/question-variant", input),
+ analyzeWeakness: (input) =>
+ postJson("/api/ai/weakness-analysis", input),
+ };
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/hooks/stream-utils.ts b/apps/portal-shell/src/features/teacher/ai/hooks/stream-utils.ts
new file mode 100644
index 0000000..334281c
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/hooks/stream-utils.ts
@@ -0,0 +1,141 @@
+/**
+ * SSE 流式响应解析与处理工具
+ *
+ * 从 use-ai-chat-stream hook 中抽取的纯函数,便于单独测试与复用。
+ */
+
+import type { AiChatMessage } from "../types";
+
+/** SSE 事件类型 */
+export type SseEvent =
+ | { type: "token"; content: string }
+ | { type: "error"; message: string }
+ | { type: "filtered" }
+ | { type: "socratic_warning"; message: string };
+
+/**
+ * 从 Response 中读取并解析 SSE 事件流
+ *
+ * @param response - fetch 返回的 Response 对象
+ * @param onEvent - 每个解析出的事件回调
+ */
+export async function consumeSseStream(
+ response: Response,
+ onEvent: (event: SseEvent) => void,
+): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) return;
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ const data = line.slice(6).trim();
+ if (data === "[DONE]") continue;
+
+ try {
+ const parsed = JSON.parse(data) as {
+ type: "token" | "error" | "filtered" | "socratic_warning";
+ content?: string;
+ message?: string;
+ };
+
+ if (parsed.type === "token" && parsed.content) {
+ onEvent({ type: "token", content: parsed.content });
+ } else if (parsed.type === "error") {
+ onEvent({
+ type: "error",
+ message: parsed.message ?? "Unknown error",
+ });
+ } else if (parsed.type === "filtered") {
+ onEvent({ type: "filtered" });
+ } else if (parsed.type === "socratic_warning") {
+ onEvent({
+ type: "socratic_warning",
+ message: parsed.message ?? "Socratic warning",
+ });
+ }
+ } catch {
+ // 忽略解析错误
+ }
+ }
+ }
+}
+
+/** 流式错误对应的 i18n key(相对于 "ai" 命名空间) */
+export type StreamErrorKey =
+ | "safety.dailyLimit"
+ | "error.unauthorized"
+ | "safety.blocked"
+ | "error.chatFailed";
+
+/**
+ * 根据 HTTP 状态码映射错误消息 i18n key
+ *
+ * 返回值用于 next-intl 的 t() 调用(相对于 "ai" 命名空间)。
+ */
+export function getStreamErrorKey(status: number): StreamErrorKey {
+ if (status === 429) return "safety.dailyLimit";
+ if (status === 403) return "error.unauthorized";
+ if (status === 400) return "safety.blocked";
+ return "error.chatFailed";
+}
+
+/**
+ * 从错误响应体中提取错误消息
+ */
+export async function extractErrorMessage(
+ response: Response,
+ fallback: string,
+): Promise {
+ try {
+ const errorText = await response.text();
+ const errorData = JSON.parse(errorText) as { message?: string };
+ return errorData.message ?? fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+/**
+ * 移除消息列表末尾空的 assistant 消息
+ *
+ * 用于流式失败/中止时清理占位消息。
+ */
+export function removeTrailingEmptyAssistant(
+ messages: AiChatMessage[],
+): AiChatMessage[] {
+ const filtered = [...messages];
+ const last = filtered[filtered.length - 1];
+ if (last && last.role === "assistant" && last.content === "") {
+ filtered.pop();
+ }
+ return filtered;
+}
+
+/**
+ * 向消息列表末尾追加 token 到 assistant 消息
+ */
+export function appendTokenToLastAssistant(
+ messages: AiChatMessage[],
+ token: string,
+): AiChatMessage[] {
+ const updated = [...messages];
+ const last = updated[updated.length - 1];
+ if (last && last.role === "assistant") {
+ updated[updated.length - 1] = {
+ ...last,
+ content: last.content + token,
+ };
+ }
+ return updated;
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/hooks/use-ai-chat-stream.ts b/apps/portal-shell/src/features/teacher/ai/hooks/use-ai-chat-stream.ts
new file mode 100644
index 0000000..c6040f6
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/hooks/use-ai-chat-stream.ts
@@ -0,0 +1,177 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useTranslations } from "next-intl";
+import type { AiChatMessage } from "../types";
+import {
+ consumeSseStream,
+ getStreamErrorKey,
+ extractErrorMessage,
+ removeTrailingEmptyAssistant,
+ appendTokenToLastAssistant,
+} from "./stream-utils";
+
+const HISTORY_STORAGE_KEY = "ai-chat-history";
+const MAX_HISTORY = 20;
+
+/**
+ * AI 流式聊天 Hook
+ *
+ * 通过 SSE 端点消费流式 AI 回复。
+ * 支持:逐 token 渲染、停止生成(AbortController)、错误处理、历史持久化恢复。
+ */
+type UseAiChatStreamReturn = {
+ messages: AiChatMessage[];
+ streaming: boolean;
+ error: string | null;
+ send: (
+ messages: AiChatMessage[],
+ options?: { systemPrompt?: string; providerId?: string },
+ ) => Promise;
+ stop: () => void;
+ clear: () => void;
+};
+
+function isAiChatMessage(value: unknown): value is AiChatMessage {
+ if (value === null || typeof value !== "object") return false;
+ return (
+ "role" in value &&
+ (value.role === "system" ||
+ value.role === "user" ||
+ value.role === "assistant") &&
+ "content" in value &&
+ typeof value.content === "string"
+ );
+}
+
+function loadHistory(): AiChatMessage[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(HISTORY_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ // 从 unknown 转换:localStorage 数据不可信,用类型守卫过滤不合法的项
+ return parsed.filter(isAiChatMessage);
+ } catch {
+ return [];
+ }
+}
+
+export function useAiChatStream(): UseAiChatStreamReturn {
+ const t = useTranslations("ai");
+ // 懒初始化:从 localStorage 恢复历史
+ const [messages, setMessages] = useState(() =>
+ loadHistory(),
+ );
+ const [streaming, setStreaming] = useState(false);
+ const [error, setError] = useState(null);
+ const abortControllerRef = useRef(null);
+
+ // 持久化(防抖)
+ const persistTimerRef = useRef | null>(null);
+ useEffect(() => {
+ if (streaming) return;
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
+ persistTimerRef.current = setTimeout(() => {
+ try {
+ if (messages.length === 0) {
+ localStorage.removeItem(HISTORY_STORAGE_KEY);
+ } else {
+ localStorage.setItem(
+ HISTORY_STORAGE_KEY,
+ JSON.stringify(messages.slice(-MAX_HISTORY)),
+ );
+ }
+ } catch {
+ // ignore
+ }
+ }, 500);
+ return () => {
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
+ };
+ }, [messages, streaming]);
+
+ const send = useCallback(
+ async (
+ inputMessages: AiChatMessage[],
+ options?: { systemPrompt?: string; providerId?: string },
+ ): Promise => {
+ if (streaming) return;
+ setStreaming(true);
+ setError(null);
+
+ const userMessage = inputMessages[inputMessages.length - 1];
+ if (userMessage && userMessage.role === "user") {
+ setMessages((prev) => [...prev, userMessage]);
+ }
+ setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
+
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ try {
+ const response = await fetch("/api/ai/chat/stream", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: inputMessages,
+ systemPrompt: options?.systemPrompt,
+ providerId: options?.providerId,
+ }),
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ const fallback = t(getStreamErrorKey(response.status));
+ const errorMessage = await extractErrorMessage(response, fallback);
+ setError(errorMessage);
+ setMessages((prev) => removeTrailingEmptyAssistant(prev));
+ return;
+ }
+
+ await consumeSseStream(response, (event) => {
+ if (event.type === "token") {
+ setMessages((prev) =>
+ appendTokenToLastAssistant(prev, event.content),
+ );
+ } else if (event.type === "error") {
+ setError(event.message);
+ setMessages((prev) => removeTrailingEmptyAssistant(prev));
+ } else if (event.type === "filtered") {
+ setError(t("safety.contentFiltered"));
+ } else if (event.type === "socratic_warning") {
+ // 苏格拉底式辅导警告:不阻断,仅提示
+ // 可通过 toast 或 UI 标记展示,此处暂存 error 供组件判断
+ setError(event.message);
+ }
+ });
+ } catch (err) {
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ } finally {
+ setStreaming(false);
+ abortControllerRef.current = null;
+ setMessages((prev) => removeTrailingEmptyAssistant(prev));
+ }
+ },
+ [streaming, t],
+ );
+
+ const stop = useCallback((): void => {
+ abortControllerRef.current?.abort();
+ }, []);
+
+ const clear = useCallback((): void => {
+ setMessages([]);
+ setError(null);
+ try {
+ localStorage.removeItem(HISTORY_STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+ }, []);
+
+ return { messages, streaming, error, send, stop, clear };
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/hooks/use-ai-suggestion.ts b/apps/portal-shell/src/features/teacher/ai/hooks/use-ai-suggestion.ts
new file mode 100644
index 0000000..0b3175c
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/hooks/use-ai-suggestion.ts
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { useAiClient } from "../context/ai-client-provider";
+import type {
+ SimilarQuestionInput,
+ SimilarQuestionResult,
+ GradingInput,
+ GradingSuggestion,
+} from "../types";
+
+/**
+ * AI 建议 Hook
+ *
+ * 封装 AI 建议调用逻辑(相似题、批改建议等),与 UI 分离。
+ */
+export function useAiSuggestion(): {
+ loading: boolean;
+ error: string | null;
+ suggestSimilarQuestions: (
+ input: SimilarQuestionInput,
+ ) => Promise;
+ suggestGrading: (input: GradingInput) => Promise;
+} {
+ const aiClient = useAiClient();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const suggestSimilarQuestions = useCallback(
+ async (
+ input: SimilarQuestionInput,
+ ): Promise => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await aiClient.suggestSimilarQuestions(input);
+ if (result.success && result.data) {
+ return result.data;
+ }
+ setError(result.message ?? "AI suggestion failed");
+ return null;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [aiClient],
+ );
+
+ const suggestGrading = useCallback(
+ async (input: GradingInput): Promise => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await aiClient.suggestGrading(input);
+ if (result.success && result.data) {
+ return result.data;
+ }
+ setError(result.message ?? "AI grading failed");
+ return null;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [aiClient],
+ );
+
+ return { loading, error, suggestSimilarQuestions, suggestGrading };
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/hooks/use-drag-position.ts b/apps/portal-shell/src/features/teacher/ai/hooks/use-drag-position.ts
new file mode 100644
index 0000000..cc73688
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/hooks/use-drag-position.ts
@@ -0,0 +1,130 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import type { Position } from "./use-position-persistence";
+import { clampPosition } from "./use-position-persistence";
+
+type DragState = {
+ active: boolean;
+ moved: boolean;
+ startX: number;
+ startY: number;
+ originX: number;
+ originY: number;
+ pointerId: number;
+};
+
+type DragCallbacks = {
+ /** 拖拽开始时触发(pointer down 后) */
+ onDragStart: () => void;
+ /** 拖拽释放时触发,moved 表示是否发生了实际移动 */
+ onRelease: (moved: boolean) => void;
+};
+
+/**
+ * 拖拽位置 Hook
+ *
+ * 处理 pointer 事件,跟踪拖拽状态与位置变化。
+ * 不处理边缘吸附、持久化等业务逻辑,通过回调委托给调用方。
+ */
+export function useDragPosition(
+ position: Position,
+ setPosition: React.Dispatch>,
+ callbacks: DragCallbacks,
+): {
+ dragging: boolean;
+ handlers: {
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerMove: (e: React.PointerEvent) => void;
+ onPointerUp: (e: React.PointerEvent) => void;
+ onPointerCancel: () => void;
+ };
+} {
+ const [dragging, setDragging] = useState(false);
+ const dragStateRef = useRef({
+ active: false,
+ moved: false,
+ startX: 0,
+ startY: 0,
+ originX: 0,
+ originY: 0,
+ pointerId: -1,
+ });
+ const callbacksRef = useRef(callbacks);
+ useEffect(() => {
+ callbacksRef.current = callbacks;
+ }, [callbacks]);
+
+ const onPointerDown = useCallback(
+ (e: React.PointerEvent): void => {
+ // 仅主键响应拖拽
+ if (e.button !== 0 && e.pointerType === "mouse") return;
+ const s = dragStateRef.current;
+ s.active = true;
+ s.moved = false;
+ s.startX = e.clientX;
+ s.startY = e.clientY;
+ s.originX = position.x;
+ s.originY = position.y;
+ s.pointerId = e.pointerId;
+ try {
+ e.currentTarget.setPointerCapture(e.pointerId);
+ } catch {
+ // ignore
+ }
+ setDragging(true);
+ callbacksRef.current.onDragStart();
+ },
+ [position],
+ );
+
+ const onPointerMove = useCallback(
+ (e: React.PointerEvent): void => {
+ const s = dragStateRef.current;
+ if (!s.active || e.pointerId !== s.pointerId) return;
+ const dx = e.clientX - s.startX;
+ const dy = e.clientY - s.startY;
+ // 阈值过滤微抖动
+ if (!s.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
+ s.moved = true;
+ const next = clampPosition({
+ x: s.originX + dx,
+ y: s.originY + dy,
+ });
+ setPosition(next);
+ },
+ [setPosition],
+ );
+
+ const onPointerUp = useCallback(
+ (e: React.PointerEvent): void => {
+ const s = dragStateRef.current;
+ if (!s.active || e.pointerId !== s.pointerId) return;
+ s.active = false;
+ setDragging(false);
+ try {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ } catch {
+ // ignore
+ }
+ callbacksRef.current.onRelease(s.moved);
+ },
+ [],
+ );
+
+ const onPointerCancel = useCallback((): void => {
+ dragStateRef.current.active = false;
+ setDragging(false);
+ }, []);
+
+ return {
+ dragging,
+ handlers: {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onPointerCancel,
+ },
+ };
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/hooks/use-floating-ball.ts b/apps/portal-shell/src/features/teacher/ai/hooks/use-floating-ball.ts
new file mode 100644
index 0000000..6007b45
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/hooks/use-floating-ball.ts
@@ -0,0 +1,165 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import {
+ BALL_SIZE,
+ HIDE_THRESHOLD,
+ MARGIN,
+ type Position,
+ clampPosition,
+ getDefaultPosition,
+ savePosition,
+ usePositionPersistence,
+} from "./use-position-persistence";
+import { useDragPosition } from "./use-drag-position";
+
+/**
+ * 计算吸附到最近边缘后的 X 坐标
+ */
+function snapToEdge(x: number): number {
+ if (typeof window === "undefined") return x;
+ const w = window.innerWidth;
+ const centerX = x + BALL_SIZE / 2;
+ const distanceToLeft = centerX;
+ const distanceToRight = w - centerX;
+ return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - MARGIN;
+}
+
+/**
+ * 计算半隐藏时的视觉偏移量
+ */
+function calculateHiddenOffset(
+ position: Position,
+ hidden: boolean,
+ hovered: boolean,
+ dragging: boolean,
+): number {
+ if (!hidden || hovered || dragging) return 0;
+ return position.x <= MARGIN + 2
+ ? -(BALL_SIZE * HIDE_THRESHOLD)
+ : BALL_SIZE * HIDE_THRESHOLD;
+}
+
+/**
+ * 可拖拽悬浮球 Hook(360 悬浮球风格)
+ *
+ * 特性:
+ * - 鼠标/触摸拖拽,松手后吸附到最近屏幕边缘
+ * - 拖到边缘超过阈值时半隐藏(只露出一小部分)
+ * - 单击(未发生拖动)触发 onClick
+ * - 位置持久化到 localStorage
+ * - 窗口 resize 时自动校正位置
+ *
+ * V3:拆分为 use-position-persistence + use-drag-position + 本 hook 组合
+ */
+export function useFloatingBall(onClick: () => void): {
+ position: Position;
+ hidden: boolean;
+ dragging: boolean;
+ hovered: boolean;
+ hiddenOffset: number;
+ handlers: {
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerMove: (e: React.PointerEvent) => void;
+ onPointerUp: (e: React.PointerEvent) => void;
+ onPointerCancel: () => void;
+ onMouseEnter: () => void;
+ onMouseLeave: () => void;
+ };
+ show: () => void;
+ resetPosition: () => void;
+} {
+ const { position, setPosition } = usePositionPersistence();
+ const [hidden, setHidden] = useState(false);
+ const [hovered, setHovered] = useState(false);
+ // 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
+ const justHiddenRef = useRef(false);
+
+ const onClickRef = useRef(onClick);
+ useEffect(() => {
+ onClickRef.current = onClick;
+ }, [onClick]);
+
+ const handleDragStart = useCallback((): void => {
+ setHidden(false);
+ }, []);
+
+ const handleRelease = useCallback(
+ (moved: boolean): void => {
+ // 未移动 → 视为点击
+ if (!moved) {
+ onClickRef.current();
+ return;
+ }
+ // 移动了 → 吸附到最近边缘
+ const snappedX = snapToEdge(position.x);
+ const finalPos = clampPosition({ x: snappedX, y: position.y });
+ setPosition(finalPos);
+ savePosition(finalPos);
+ setHidden(true);
+ // 标记刚隐藏,阻止后续 mouseEnter 立即展开
+ justHiddenRef.current = true;
+ // 清除 hovered,确保 hiddenOffset 生效
+ setHovered(false);
+ },
+ [position, setPosition],
+ );
+
+ const { dragging, handlers: dragHandlers } = useDragPosition(
+ position,
+ setPosition,
+ { onDragStart: handleDragStart, onRelease: handleRelease },
+ );
+
+ const handleMouseEnter = useCallback((): void => {
+ // 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
+ if (justHiddenRef.current) {
+ justHiddenRef.current = false;
+ return;
+ }
+ setHovered(true);
+ if (hidden) setHidden(false);
+ }, [hidden]);
+
+ const handleMouseLeave = useCallback((): void => {
+ setHovered(false);
+ // 离开后清除 justHidden 标记,下次进入可正常展开
+ justHiddenRef.current = false;
+ }, []);
+
+ const show = useCallback((): void => {
+ justHiddenRef.current = false;
+ setHidden(false);
+ }, []);
+
+ const resetPosition = useCallback((): void => {
+ justHiddenRef.current = false;
+ const fresh = getDefaultPosition();
+ setPosition(fresh);
+ savePosition(fresh);
+ setHidden(false);
+ }, [setPosition]);
+
+ const hiddenOffset = calculateHiddenOffset(
+ position,
+ hidden,
+ hovered,
+ dragging,
+ );
+
+ return {
+ position,
+ hidden,
+ dragging,
+ hovered,
+ hiddenOffset,
+ handlers: {
+ ...dragHandlers,
+ onMouseEnter: handleMouseEnter,
+ onMouseLeave: handleMouseLeave,
+ },
+ show,
+ resetPosition,
+ };
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/hooks/use-position-persistence.ts b/apps/portal-shell/src/features/teacher/ai/hooks/use-position-persistence.ts
new file mode 100644
index 0000000..405b62b
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/hooks/use-position-persistence.ts
@@ -0,0 +1,106 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export type Position = { x: number; y: number };
+
+export const STORAGE_KEY = "ai-widget-position";
+export const HIDE_THRESHOLD = 0.55;
+export const BALL_SIZE = 56;
+export const MARGIN = 16;
+
+/**
+ * 将位置限制在视口内
+ */
+export function clampPosition(pos: Position): Position {
+ if (typeof window === "undefined") return pos;
+ const maxX = window.innerWidth - BALL_SIZE - MARGIN;
+ const maxY = window.innerHeight - BALL_SIZE - MARGIN;
+ return {
+ x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
+ y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
+ };
+}
+
+/**
+ * 从 localStorage 加载位置,失败时返回默认右下角位置
+ */
+export function loadPosition(): Position {
+ if (typeof window === "undefined") {
+ return { x: 9999, y: 9999 };
+ }
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const parsed: unknown = JSON.parse(raw);
+ // 从 unknown 转换:localStorage 数据不可信,做字段类型校验
+ if (
+ typeof parsed === "object" &&
+ parsed !== null &&
+ "x" in parsed &&
+ typeof parsed.x === "number" &&
+ "y" in parsed &&
+ typeof parsed.y === "number"
+ ) {
+ return clampPosition({ x: parsed.x, y: parsed.y });
+ }
+ }
+ } catch {
+ // ignore
+ }
+ const x = window.innerWidth - BALL_SIZE - MARGIN * 2;
+ const y = window.innerHeight - BALL_SIZE - MARGIN * 4;
+ return clampPosition({ x, y });
+}
+
+/**
+ * 持久化位置到 localStorage
+ */
+export function savePosition(pos: Position): void {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(pos));
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * 默认位置(右下角)
+ */
+export function getDefaultPosition(): Position {
+ if (typeof window === "undefined") return { x: 9999, y: 9999 };
+ return clampPosition({
+ x: window.innerWidth - BALL_SIZE - MARGIN * 2,
+ y: window.innerHeight - BALL_SIZE - MARGIN * 4,
+ });
+}
+
+/**
+ * 位置持久化 Hook
+ *
+ * 管理 position 状态,mount 时从 localStorage 加载,resize 时校正。
+ * 服务端与客户端首次渲染一致(position 在屏幕外),避免 hydration mismatch。
+ */
+export function usePositionPersistence(): {
+ position: Position;
+ setPosition: React.Dispatch>;
+} {
+ const [position, setPosition] = useState({ x: 9999, y: 9999 });
+
+ // 初始化位置:在客户端 mount 后加载真实位置
+ useEffect(() => {
+ setPosition(loadPosition());
+ }, []);
+
+ // 窗口 resize 时校正
+ useEffect(() => {
+ const handleResize = (): void => {
+ setPosition((prev) => clampPosition(prev));
+ };
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return { position, setPosition };
+}
diff --git a/apps/portal-shell/src/features/teacher/ai/schema.ts b/apps/portal-shell/src/features/teacher/ai/schema.ts
new file mode 100644
index 0000000..d84cffc
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/schema.ts
@@ -0,0 +1,280 @@
+import { z } from "zod";
+
+// ---------------------------------------------------------------------------
+// 基础校验
+// ---------------------------------------------------------------------------
+
+export const AiChatMessageSchema = z.object({
+ role: z.enum(["system", "user", "assistant"]),
+ content: z.string().min(1).max(8000),
+});
+
+export const AiChatInputSchema = z.object({
+ messages: z.array(AiChatMessageSchema).min(1).max(50),
+ providerId: z.string().min(1).optional(),
+});
+
+// ---------------------------------------------------------------------------
+// 业务场景校验
+// ---------------------------------------------------------------------------
+
+export const SimilarQuestionInputSchema = z.object({
+ questionText: z.string().min(1).max(4000),
+ questionType: z.string().min(1),
+ subject: z.string().optional(),
+ knowledgePointIds: z.array(z.string()).optional(),
+ count: z.number().int().min(1).max(10).optional(),
+});
+
+export const GradingInputSchema = z.object({
+ questionText: z.string().min(1).max(4000),
+ questionType: z.string().min(1),
+ studentAnswer: z.string().min(1).max(8000),
+ correctAnswer: z.string().optional(),
+ maxScore: z.number().int().min(1).max(100),
+ subject: z.string().optional(),
+});
+
+export const LessonContentInputSchema = z.object({
+ topic: z.string().min(1).max(500),
+ subject: z.string().optional(),
+ grade: z.string().optional(),
+ textbookId: z.string().optional(),
+ chapterId: z.string().optional(),
+ contentType: z.enum(["activity", "assessment", "question", "material"]),
+ additionalContext: z.string().max(2000).optional(),
+});
+
+export const QuestionVariantInputSchema = z.object({
+ originalQuestion: z.object({
+ text: z.string().min(1).max(4000),
+ type: z.string().min(1),
+ difficulty: z.number().int().min(1).max(5).optional(),
+ options: z
+ .array(
+ z.object({
+ id: z.string().min(1),
+ text: z.string().min(1),
+ isCorrect: z.boolean().optional(),
+ }),
+ )
+ .optional(),
+ answer: z.string().optional(),
+ }),
+ subject: z.string().optional(),
+ variantType: z.enum([
+ "same_knowledge_point",
+ "different_difficulty",
+ "different_format",
+ ]),
+});
+
+export const WeaknessAnalysisInputSchema = z.object({
+ studentId: z.string().min(1),
+ subjectId: z.string().optional(),
+ errorItems: z
+ .array(
+ z.object({
+ questionText: z.string().min(1),
+ questionType: z.string().min(1),
+ knowledgePointIds: z.array(z.string()).optional(),
+ errorCount: z.number().int().min(1),
+ masteryLevel: z.number().int().min(0).max(5),
+ }),
+ )
+ .min(1)
+ .max(100),
+});
+
+// ---------------------------------------------------------------------------
+// AI 返回结果校验(用于解析 AI JSON 输出)
+// ---------------------------------------------------------------------------
+
+export const SimilarQuestionResultSchema = z.object({
+ text: z.string().min(1),
+ type: z.string().min(1),
+ difficulty: z.number().int().min(1).max(5).optional(),
+ options: z.array(z.object({ id: z.string(), text: z.string() })).optional(),
+ answer: z.string().optional(),
+ explanation: z.string().optional(),
+});
+
+export const SimilarQuestionListSchema = z.array(SimilarQuestionResultSchema);
+
+export const GradingSuggestionSchema = z.object({
+ suggestedScore: z.number().min(0),
+ confidence: z.number().min(0).max(1),
+ feedback: z.string(),
+ reasoning: z.string(),
+});
+
+export const LessonContentResultSchema = z.object({
+ title: z.string().min(1),
+ content: z.string().min(1),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+});
+
+export const QuestionVariantResultSchema = z.object({
+ text: z.string().min(1),
+ type: z.string().min(1),
+ difficulty: z.number().int().min(1).max(5),
+ options: z
+ .array(
+ z.object({ id: z.string(), text: z.string(), isCorrect: z.boolean() }),
+ )
+ .optional(),
+ answer: z.string().optional(),
+ explanation: z.string().optional(),
+});
+
+export const WeaknessAnalysisResultSchema = z.object({
+ weakAreas: z.array(
+ z.object({
+ area: z.string().min(1),
+ severity: z.enum(["high", "medium", "low"]),
+ suggestion: z.string().min(1),
+ }),
+ ),
+ studyPlan: z.string().min(1),
+ recommendedResources: z.array(z.string()),
+});
+
+// ---------------------------------------------------------------------------
+// 家长学情摘要校验
+// ---------------------------------------------------------------------------
+
+export const ChildSummaryInputSchema = z.object({
+ studentId: z.string().min(1),
+ studentName: z.string().optional(),
+ grade: z.string().optional(),
+ recentGrades: z
+ .array(
+ z.object({
+ subject: z.string().min(1),
+ score: z.number(),
+ maxScore: z.number(),
+ trend: z.enum(["up", "down", "stable"]),
+ }),
+ )
+ .optional(),
+ attendanceRate: z.number().min(0).max(1).optional(),
+ errorBookSummary: z
+ .object({
+ totalErrors: z.number().int().min(0),
+ topWeakSubjects: z.array(z.string()),
+ masteryTrend: z.enum(["improving", "declining", "stable"]),
+ })
+ .optional(),
+ homeworkCompletionRate: z.number().min(0).max(1).optional(),
+});
+
+export const ChildSummaryResultSchema = z.object({
+ overallAssessment: z.string().min(1),
+ strengths: z.array(z.string()),
+ areasForImprovement: z.array(z.string()),
+ familyTutoringSuggestions: z.array(z.string()),
+ nextSteps: z.array(z.string()),
+});
+
+// ---------------------------------------------------------------------------
+// 学习路径推荐校验
+// ---------------------------------------------------------------------------
+
+export const StudyPathInputSchema = z.object({
+ studentId: z.string().min(1),
+ subject: z.string().optional(),
+ currentMastery: z
+ .array(
+ z.object({
+ knowledgePoint: z.string().min(1),
+ masteryLevel: z.number().min(0).max(5),
+ errorCount: z.number().int().min(0),
+ }),
+ )
+ .optional(),
+ learningGoal: z.string().optional(),
+ /** 教材 ID(传入后 action 层自动获取知识图谱注入) */
+ textbookId: z.string().optional(),
+ /** 知识图谱(可直接传入,优先于 textbookId 自动获取) */
+ knowledgeGraph: z
+ .object({
+ nodes: z.array(
+ z.object({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ level: z.number().int().min(0),
+ masteryLevel: z.number().optional(),
+ }),
+ ),
+ edges: z.array(
+ z.object({
+ from: z.string().min(1),
+ to: z.string().min(1),
+ type: z.literal("prerequisite"),
+ }),
+ ),
+ })
+ .optional(),
+});
+
+export const StudyPathResultSchema = z.object({
+ currentLevel: z.string().min(1),
+ learningPath: z.array(
+ z.object({
+ step: z.number().int().min(1),
+ knowledgePoint: z.string().min(1),
+ status: z.enum(["mastered", "in_progress", "needs_work"]),
+ recommendedAction: z.string().min(1),
+ estimatedTime: z.string().min(1),
+ }),
+ ),
+ summary: z.string().min(1),
+ motivation: z.string().min(1),
+});
+
+// ---------------------------------------------------------------------------
+// 错题 AI 解释校验
+// ---------------------------------------------------------------------------
+
+export const ExplainErrorInputSchema = z.object({
+ questionText: z.string().min(1).max(4000),
+ questionType: z.string().min(1),
+ studentAnswer: z.string().min(1).max(8000),
+ correctAnswer: z.string().optional(),
+ subject: z.string().optional(),
+ knowledgePointIds: z.array(z.string()).optional(),
+});
+
+export const ExplainErrorResultSchema = z.object({
+ errorAnalysis: z.string().min(1),
+ correctApproach: z.string().min(1),
+ keyConcepts: z.array(z.string().min(1)),
+ preventionTips: z.array(z.string().min(1)),
+ practiceSuggestion: z.string().min(1),
+});
+
+// ---------------------------------------------------------------------------
+// AI 图表规格校验(用于 ai-chart-renderer.tsx 解析 AI 返回的图表 JSON)
+// ---------------------------------------------------------------------------
+
+export const AiChartSeriesSchema = z.object({
+ dataKey: z.string().min(1),
+ name: z.string().min(1),
+ color: z.string().optional(),
+ fillOpacity: z.number().optional(),
+ strokeWidth: z.number().optional(),
+ strokeDasharray: z.string().optional(),
+});
+
+export const AiChartSpecSchema = z.object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ type: z.enum(["bar", "line", "pie", "radar"]).optional(),
+ data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
+ xKey: z.string().optional(),
+ angleKey: z.string().optional(),
+ series: z.array(AiChartSeriesSchema),
+ yDomain: z.tuple([z.number(), z.number()]).optional(),
+ height: z.number().optional(),
+ showLegend: z.boolean().optional(),
+});
diff --git a/apps/portal-shell/src/features/teacher/ai/services/ai-service.ts b/apps/portal-shell/src/features/teacher/ai/services/ai-service.ts
new file mode 100644
index 0000000..ac0c990
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/services/ai-service.ts
@@ -0,0 +1,559 @@
+/**
+ * portal-shell 适配:env 在客户端微前端不存在(env.mjs 为服务端模块),
+ * 实际 AI 调用由 BFF 处理;此处仅保留默认占位值,避免客户端 bundle 引入服务端依赖。
+ */
+const env: { AI_MODEL?: string } = { AI_MODEL: "gpt-4o-mini" };
+/**
+ * portal-shell 适配:createAiChatCompletion/getAiErrorMessage 在客户端微前端不存在,
+ * 由 BFF 调用真实 AI SDK;此处保留为 stub,被调用时抛错提示应通过 BFF 路由。
+ */
+const createAiChatCompletion = async (
+ _options: Record,
+): Promise<{ content: string; usage?: unknown }> => {
+ throw new Error(
+ "createAiChatCompletion is server-only; portal-shell should call BFF via fetch instead",
+ );
+};
+const getAiErrorMessage = (error: unknown): string => {
+ if (error instanceof Error) return error.message;
+ return String(error);
+};
+
+import {
+ GRADING_ASSIST_SYSTEM_PROMPT,
+ LESSON_CONTENT_SYSTEM_PROMPT,
+ QUESTION_VARIANT_SYSTEM_PROMPT,
+ SIMILAR_QUESTION_SYSTEM_PROMPT,
+ WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
+ CHILD_SUMMARY_SYSTEM_PROMPT,
+ STUDY_PATH_SYSTEM_PROMPT,
+ EXPLAIN_ERROR_SYSTEM_PROMPT,
+} from "./prompt-templates";
+import { withAiTracking } from "./usage-tracker";
+import {
+ GradingSuggestionSchema,
+ LessonContentResultSchema,
+ QuestionVariantResultSchema,
+ SimilarQuestionListSchema,
+ WeaknessAnalysisResultSchema,
+ ChildSummaryResultSchema,
+ StudyPathResultSchema,
+ ExplainErrorResultSchema,
+} from "../schema";
+import type {
+ AiChatMessage,
+ AiChatOptions,
+ AiChatResult,
+ AiService,
+ GradingInput,
+ GradingSuggestion,
+ LessonContentInput,
+ LessonContentResult,
+ QuestionVariantInput,
+ QuestionVariantResult,
+ SimilarQuestionInput,
+ SimilarQuestionResult,
+ WeaknessAnalysisInput,
+ WeaknessAnalysisResult,
+ ChildSummaryInput,
+ ChildSummaryResult,
+ StudyPathInput,
+ StudyPathResult,
+ ExplainErrorInput,
+ ExplainErrorResult,
+} from "../types";
+
+// ---------------------------------------------------------------------------
+// JSON 提取工具(从 AI 返回文本中提取 JSON)
+// ---------------------------------------------------------------------------
+
+const extractBalancedJsonSegment = (value: string): string | null => {
+ const startBrace = value.indexOf("{");
+ const startBracket = value.indexOf("[");
+ const start =
+ startBrace === -1
+ ? startBracket
+ : startBracket === -1
+ ? startBrace
+ : Math.min(startBrace, startBracket);
+ if (start === -1) return null;
+ const opening = value[start];
+ const closing = opening === "{" ? "}" : "]";
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ for (let i = start; i < value.length; i += 1) {
+ const char = value[i];
+ if (inString) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === '"') {
+ inString = false;
+ }
+ continue;
+ }
+ if (char === '"') {
+ inString = true;
+ continue;
+ }
+ if (char === opening) {
+ depth += 1;
+ continue;
+ }
+ if (char === closing) {
+ depth -= 1;
+ if (depth === 0) {
+ return value.slice(start, i + 1);
+ }
+ }
+ }
+ return null;
+};
+
+const tryParseJson = (value: string): unknown | null => {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+};
+
+const extractJson = (raw: string): unknown => {
+ const trimmed = raw.trim();
+ const candidates: string[] = [];
+ const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
+ if (fencedMatches.length > 0) {
+ candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()));
+ }
+ candidates.push(trimmed);
+ for (const candidate of candidates) {
+ const direct = tryParseJson(candidate);
+ if (direct !== null) return direct;
+ const segment = extractBalancedJsonSegment(candidate);
+ if (!segment) continue;
+ const parsed = tryParseJson(segment);
+ if (parsed !== null) return parsed;
+ }
+ throw new Error("Invalid AI response: cannot parse JSON");
+};
+
+// ---------------------------------------------------------------------------
+// AiService 实现
+// ---------------------------------------------------------------------------
+
+const DEFAULT_MODEL = () => String(env.AI_MODEL ?? "gpt-4o-mini");
+
+const buildChatMessages = (
+ systemPrompt: string,
+ userContent: string,
+): AiChatMessage[] => [
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userContent },
+];
+
+const callAi = async (
+ messages: AiChatMessage[],
+ options?: AiChatOptions,
+): Promise<{ content: string; model?: string; tokenUsage?: number }> => {
+ const result = await createAiChatCompletion({
+ messages,
+ model: options?.model ?? DEFAULT_MODEL(),
+ temperature: options?.temperature ?? 0.3,
+ ...(typeof options?.maxTokens === "number"
+ ? { maxTokens: options.maxTokens }
+ : {}),
+ ...(options?.providerId ? { providerId: options.providerId } : {}),
+ });
+ // 从 unknown 类型安全提取 total_tokens(避免 as 断言)
+ const tokenUsage =
+ result.usage &&
+ typeof result.usage === "object" &&
+ "total_tokens" in result.usage
+ ? Number(result.usage.total_tokens ?? 0)
+ : undefined;
+ return { content: result.content, tokenUsage };
+};
+
+/**
+ * 默认 AI 服务实现
+ *
+ * 封装 shared/lib/ai 的底层 SDK 调用,提供业务语义化接口。
+ * 所有业务模块通过此服务调用 AI,不直接 import shared/lib/ai。
+ */
+export class DefaultAiService implements AiService {
+ constructor(private readonly userId: string) {}
+
+ async chat(
+ messages: AiChatMessage[],
+ options?: AiChatOptions,
+ ): Promise {
+ return withAiTracking(
+ this.userId,
+ "chat",
+ options?.providerId,
+ async () => {
+ const { content, tokenUsage } = await callAi(messages, {
+ ...options,
+ temperature: options?.temperature ?? 0.7,
+ });
+ // usage 字段返回 token 用量对象(unknown 类型),便于调用方按需类型缩小
+ return {
+ result: {
+ content,
+ usage:
+ tokenUsage !== undefined ? { total_tokens: tokenUsage } : null,
+ },
+ tokenUsage,
+ };
+ },
+ );
+ }
+
+ async suggestSimilarQuestions(
+ input: SimilarQuestionInput,
+ ): Promise {
+ return withAiTracking(
+ this.userId,
+ "similar_question",
+ undefined,
+ async () => {
+ const count = input.count ?? 3;
+ const userLines = [
+ `Question Type: ${input.questionType}`,
+ input.subject ? `Subject: ${input.subject}` : "",
+ input.knowledgePointIds?.length
+ ? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
+ : "",
+ `Generate ${count} similar questions.`,
+ `Original Question:\n${input.questionText}`,
+ ].filter((line) => line.length > 0);
+ const { content } = await callAi(
+ buildChatMessages(
+ SIMILAR_QUESTION_SYSTEM_PROMPT,
+ userLines.join("\n\n"),
+ ),
+ { temperature: 0.5, maxTokens: 3000 },
+ );
+ const parsed = extractJson(content);
+ // 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
+ const list =
+ parsed && typeof parsed === "object" && "questions" in parsed
+ ? parsed.questions
+ : parsed;
+ const validated = SimilarQuestionListSchema.safeParse(list);
+ if (!validated.success) return { result: [] };
+ return { result: validated.data };
+ },
+ );
+ }
+
+ async suggestGrading(input: GradingInput): Promise {
+ return withAiTracking(
+ this.userId,
+ "grading_assist",
+ undefined,
+ async () => {
+ const userLines = [
+ `Question Type: ${input.questionType}`,
+ `Max Score: ${input.maxScore}`,
+ input.subject ? `Subject: ${input.subject}` : "",
+ `Question:\n${input.questionText}`,
+ `Student Answer:\n${input.studentAnswer}`,
+ input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
+ ].filter((line) => line.length > 0);
+ const { content } = await callAi(
+ buildChatMessages(
+ GRADING_ASSIST_SYSTEM_PROMPT,
+ userLines.join("\n\n"),
+ ),
+ { temperature: 0.2, maxTokens: 1000 },
+ );
+ const parsed = extractJson(content);
+ const validated = GradingSuggestionSchema.safeParse(parsed);
+ if (!validated.success) {
+ return {
+ result: {
+ suggestedScore: 0,
+ confidence: 0,
+ feedback: "AI grading unavailable",
+ reasoning: "AI response format invalid",
+ },
+ };
+ }
+ const data = validated.data;
+ return {
+ result: {
+ suggestedScore: Math.min(
+ Math.max(data.suggestedScore, 0),
+ input.maxScore,
+ ),
+ confidence: data.confidence,
+ feedback: data.feedback,
+ reasoning: data.reasoning,
+ },
+ };
+ },
+ );
+ }
+
+ async generateLessonContent(
+ input: LessonContentInput,
+ ): Promise {
+ return withAiTracking(
+ this.userId,
+ "lesson_content",
+ undefined,
+ async () => {
+ const userLines = [
+ `Topic: ${input.topic}`,
+ `Content Type: ${input.contentType}`,
+ input.subject ? `Subject: ${input.subject}` : "",
+ input.grade ? `Grade: ${input.grade}` : "",
+ input.additionalContext
+ ? `Additional Context:\n${input.additionalContext}`
+ : "",
+ ].filter((line) => line.length > 0);
+ const { content } = await callAi(
+ buildChatMessages(
+ LESSON_CONTENT_SYSTEM_PROMPT,
+ userLines.join("\n\n"),
+ ),
+ { temperature: 0.7, maxTokens: 4000 },
+ );
+ const parsed = extractJson(content);
+ const validated = LessonContentResultSchema.safeParse(parsed);
+ if (!validated.success) {
+ return {
+ result: {
+ title: input.topic,
+ content: content,
+ },
+ };
+ }
+ return { result: validated.data };
+ },
+ );
+ }
+
+ async generateQuestionVariant(
+ input: QuestionVariantInput,
+ ): Promise {
+ return withAiTracking(
+ this.userId,
+ "question_variant",
+ undefined,
+ async () => {
+ const userLines = [
+ `Variant Type: ${input.variantType}`,
+ input.subject ? `Subject: ${input.subject}` : "",
+ `Original Question:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
+ ].filter((line) => line.length > 0);
+ const { content } = await callAi(
+ buildChatMessages(
+ QUESTION_VARIANT_SYSTEM_PROMPT,
+ userLines.join("\n\n"),
+ ),
+ { temperature: 0.6, maxTokens: 2000 },
+ );
+ const parsed = extractJson(content);
+ const validated = QuestionVariantResultSchema.safeParse(parsed);
+ if (!validated.success) {
+ throw new Error("AI question variant format invalid");
+ }
+ return { result: validated.data };
+ },
+ );
+ }
+
+ async analyzeWeakness(
+ input: WeaknessAnalysisInput,
+ ): Promise {
+ return withAiTracking(
+ this.userId,
+ "weakness_analysis",
+ undefined,
+ async () => {
+ const userLines = [
+ `Student ID: ${input.studentId}`,
+ input.subjectId ? `Subject ID: ${input.subjectId}` : "",
+ `Error Items (${input.errorItems.length}):`,
+ JSON.stringify(
+ input.errorItems.map((item) => ({
+ questionText: item.questionText,
+ questionType: item.questionType,
+ errorCount: item.errorCount,
+ masteryLevel: item.masteryLevel,
+ })),
+ null,
+ 2,
+ ),
+ ].filter((line) => line.length > 0);
+ const { content } = await callAi(
+ buildChatMessages(
+ WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
+ userLines.join("\n\n"),
+ ),
+ { temperature: 0.3, maxTokens: 2000 },
+ );
+ const parsed = extractJson(content);
+ const validated = WeaknessAnalysisResultSchema.safeParse(parsed);
+ if (!validated.success) {
+ return {
+ result: {
+ weakAreas: [],
+ studyPlan: "Analysis unavailable",
+ recommendedResources: [],
+ },
+ };
+ }
+ return { result: validated.data };
+ },
+ );
+ }
+
+ async generateChildSummary(
+ input: ChildSummaryInput,
+ ): Promise {
+ return withAiTracking(this.userId, "child_summary", undefined, async () => {
+ // PII 最小化:不传学生真实姓名,用 ID 替代(COPPA/FERPA 合规)
+ const userLines = [
+ `Student ID: ${input.studentId}`,
+ input.grade ? `Grade: ${input.grade}` : "",
+ input.recentGrades && input.recentGrades.length > 0
+ ? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
+ : "",
+ input.attendanceRate !== undefined
+ ? `Attendance Rate: ${(input.attendanceRate * 100).toFixed(1)}%`
+ : "",
+ input.errorBookSummary
+ ? `Error Book Summary:\n${JSON.stringify(input.errorBookSummary, null, 2)}`
+ : "",
+ input.homeworkCompletionRate !== undefined
+ ? `Homework Completion Rate: ${(input.homeworkCompletionRate * 100).toFixed(1)}%`
+ : "",
+ ].filter((line) => line.length > 0);
+ const { content } = await callAi(
+ buildChatMessages(CHILD_SUMMARY_SYSTEM_PROMPT, userLines.join("\n\n")),
+ { temperature: 0.4, maxTokens: 2000 },
+ );
+ const parsed = extractJson(content);
+ const validated = ChildSummaryResultSchema.safeParse(parsed);
+ if (!validated.success) {
+ return {
+ result: {
+ overallAssessment: "Unable to generate summary at this time.",
+ strengths: [],
+ areasForImprovement: [],
+ familyTutoringSuggestions: [],
+ nextSteps: [],
+ },
+ };
+ }
+ return { result: validated.data };
+ });
+ }
+
+ async recommendStudyPath(input: StudyPathInput): Promise {
+ return withAiTracking(this.userId, "study_path", undefined, async () => {
+ const userLines = [
+ `Student ID: ${input.studentId}`,
+ input.subject ? `Subject: ${input.subject}` : "",
+ input.currentMastery && input.currentMastery.length > 0
+ ? `Current Mastery:\n${JSON.stringify(input.currentMastery, null, 2)}`
+ : "",
+ input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
+ ].filter((line) => line.length > 0);
+
+ // 知识图谱上下文注入(V3:对标 Squirrel AI 纳米级知识图谱)
+ if (input.knowledgeGraph && input.knowledgeGraph.nodes.length > 0) {
+ const graphLines = [
+ "Knowledge Graph:",
+ "Nodes (id | name | level | mastery 0-100):",
+ ...input.knowledgeGraph.nodes.map(
+ (n) =>
+ ` ${n.id} | ${n.name} | L${n.level} | ${n.masteryLevel ?? "unassessed"}`,
+ ),
+ "Prerequisite edges (from -> to, meaning 'from' must be mastered before 'to'):",
+ ...input.knowledgeGraph.edges.map((e) => ` ${e.from} -> ${e.to}`),
+ ];
+ userLines.push(graphLines.join("\n"));
+ }
+
+ const { content } = await callAi(
+ buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
+ { temperature: 0.5, maxTokens: 2000 },
+ );
+ const parsed = extractJson(content);
+ const validated = StudyPathResultSchema.safeParse(parsed);
+ if (!validated.success) {
+ return {
+ result: {
+ currentLevel: "Analysis unavailable",
+ learningPath: [],
+ summary: "Unable to generate learning path at this time.",
+ motivation: "Keep learning!",
+ },
+ };
+ }
+ return { result: validated.data };
+ });
+ }
+
+ async explainError(input: ExplainErrorInput): Promise {
+ return withAiTracking(this.userId, "explain_error", undefined, async () => {
+ const userLines = [
+ `Question Type: ${input.questionType}`,
+ input.subject ? `Subject: ${input.subject}` : "",
+ input.knowledgePointIds?.length
+ ? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
+ : "",
+ `Question:\n${input.questionText}`,
+ `Student Answer:\n${input.studentAnswer}`,
+ input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
+ ].filter((line) => line.length > 0);
+ const { content } = await callAi(
+ buildChatMessages(EXPLAIN_ERROR_SYSTEM_PROMPT, userLines.join("\n\n")),
+ { temperature: 0.4, maxTokens: 2000 },
+ );
+ const parsed = extractJson(content);
+ const validated = ExplainErrorResultSchema.safeParse(parsed);
+ if (!validated.success) {
+ return {
+ result: {
+ errorAnalysis: "Unable to analyze the error at this time.",
+ correctApproach: "Please consult your teacher for help.",
+ keyConcepts: [],
+ preventionTips: [],
+ practiceSuggestion: "Review the relevant chapter and try again.",
+ },
+ };
+ }
+ return { result: validated.data };
+ });
+ }
+}
+
+/**
+ * 创建 AI 服务实例
+ *
+ * 在 Server Action 中调用,传入当前用户 ID。
+ * 测试时可替换为 mock 实现。
+ */
+export const createAiService = (userId: string): AiService =>
+ new DefaultAiService(userId);
+
+/**
+ * 安全执行 AI 调用,捕获异常并返回错误消息
+ */
+export const safeAiCall = async (
+ fn: () => Promise,
+): Promise<{ ok: true; data: T } | { ok: false; message: string }> => {
+ try {
+ const data = await fn();
+ return { ok: true, data };
+ } catch (error) {
+ return { ok: false, message: getAiErrorMessage(error) };
+ }
+};
diff --git a/apps/portal-shell/src/features/teacher/ai/services/content-safety.ts b/apps/portal-shell/src/features/teacher/ai/services/content-safety.ts
new file mode 100644
index 0000000..0acad2e
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/services/content-safety.ts
@@ -0,0 +1,299 @@
+/**
+ * AI 内容安全过滤
+ *
+ * 多层防护:
+ * 1. 输入过滤:检查用户输入是否包含不当内容
+ * 2. 输出过滤:检查 AI 回复是否包含不当内容
+ * 3. 每日限制:按用户 + 日期计数(原子操作,防 TOCTOU 竞态)
+ *
+ * 参考 Khanmigo 的多层 moderation 模式。
+ *
+ * 注意:当前为内存实现,多实例部署需替换为 Redis(INCR + EXPIRE)。
+ */
+
+// ---------------------------------------------------------------------------
+// 不当内容关键词(基础过滤,生产环境应接入专业 Moderation API)
+// ---------------------------------------------------------------------------
+
+const BLOCKED_INPUT_PATTERNS: readonly RegExp[] = [
+ /\b(violence|kill|murder|suicide|self[- ]?harm|cut myself)\b/i,
+ /\b(porn|sex|nude|nsfw|explicit)\b/i,
+ /\b(drug|cocaine|heroin|weed|marijuana)\b/i,
+ /\b(hack|exploit|malware|virus|phishing)\b/i,
+ // PII 请求
+ /\b(your (password|credit card|ssn|social security|bank account))\b/i,
+ /\b(home address|phone number|real name)\b/i,
+];
+
+const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
+ /\b(violence|kill|murder|suicide|self[- ]?harm)\b/i,
+ /\b(porn|sex|nude|nsfw|explicit)\b/i,
+ /\b(drug|cocaine|heroin)\b/i,
+];
+
+const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
+ // 学生侧额外限制:禁止直接给出作业答案
+ /\b(here is the (complete )?answer|the answer is:?)\b/i,
+ // 强化:匹配"答案是 X" / "正确答案是 X" / "final answer: X"
+ /\b(the (correct )?answer is\s*[::]?\s*[A-Z\d])/i,
+ /\bfinal answer[::]\s*\S+/i,
+ /\b答案(是|应该为|为)\s*[::]?\s*[A-F\d]/i,
+];
+
+// ---------------------------------------------------------------------------
+// 输入过滤
+// ---------------------------------------------------------------------------
+
+export type SafetyFilterResult = {
+ blocked: boolean;
+ reason?: string;
+};
+
+export const filterUserInput = (
+ content: string,
+ options?: { isStudent?: boolean },
+): SafetyFilterResult => {
+ const text = String(content ?? "");
+
+ for (const pattern of BLOCKED_INPUT_PATTERNS) {
+ if (pattern.test(text)) {
+ return {
+ blocked: true,
+ reason: "Input contains inappropriate content",
+ };
+ }
+ }
+
+ if (options?.isStudent) {
+ for (const pattern of STUDENT_BLOCKED_PATTERNS) {
+ if (pattern.test(text)) {
+ return {
+ blocked: true,
+ reason: "Student input blocked by safety filter",
+ };
+ }
+ }
+ }
+
+ return { blocked: false };
+};
+
+// ---------------------------------------------------------------------------
+// 输出过滤
+// ---------------------------------------------------------------------------
+
+export const filterAiOutput = (
+ content: string,
+ options?: { isStudent?: boolean },
+): SafetyFilterResult => {
+ const text = String(content ?? "");
+
+ for (const pattern of BLOCKED_OUTPUT_PATTERNS) {
+ if (pattern.test(text)) {
+ return {
+ blocked: true,
+ reason: "AI output contains inappropriate content",
+ };
+ }
+ }
+
+ if (options?.isStudent) {
+ for (const pattern of STUDENT_BLOCKED_PATTERNS) {
+ if (pattern.test(text)) {
+ return {
+ blocked: true,
+ reason: "AI output blocked for student safety",
+ };
+ }
+ }
+ }
+
+ return { blocked: false };
+};
+
+// ---------------------------------------------------------------------------
+// 每日限制(原子操作,防 TOCTOU 竞态)
+// ---------------------------------------------------------------------------
+
+const DAILY_LIMITS: Record = {
+ student: 50,
+ teacher: 200,
+ parent: 30,
+ admin: 500,
+};
+
+export const getDailyLimit = (role: string): number => {
+ return DAILY_LIMITS[role] ?? 50;
+};
+
+/**
+ * 每日使用计数(内存实现,多实例需替换为 Redis)
+ *
+ * 注意:当前为单实例内存映射,多实例部署下每个实例独立计数,
+ * 实际可用次数 = 限额 × 实例数。生产环境应接入 Redis INCR + EXPIRE。
+ */
+const dailyUsageMap = new Map();
+
+export const checkDailyLimit = (
+ userId: string,
+ role: string,
+): SafetyFilterResult => {
+ const today = new Date().toISOString().slice(0, 10);
+ const key = `${userId}:${today}`;
+ const limit = getDailyLimit(role);
+ const current = dailyUsageMap.get(key);
+
+ if (!current) {
+ return { blocked: false };
+ }
+
+ if (current.count >= limit) {
+ return {
+ blocked: true,
+ reason: `Daily limit reached (${current.count}/${limit})`,
+ };
+ }
+
+ return { blocked: false };
+};
+
+export const incrementDailyUsage = (userId: string): void => {
+ const today = new Date().toISOString().slice(0, 10);
+ const key = `${userId}:${today}`;
+ const current = dailyUsageMap.get(key);
+
+ if (current && current.date === today) {
+ current.count += 1;
+ } else {
+ dailyUsageMap.set(key, { date: today, count: 1 });
+ }
+
+ // 清理过期条目(防止内存泄漏)
+ if (dailyUsageMap.size > 10000) {
+ for (const [k, v] of dailyUsageMap.entries()) {
+ if (v.date !== today) {
+ dailyUsageMap.delete(k);
+ }
+ }
+ }
+};
+
+/**
+ * 原子化检查并递增每日使用计数
+ *
+ * 解决 checkDailyLimit + incrementDailyUsage 分离导致的 TOCTOU 竞态:
+ * 并发请求在限额临界点同时通过检查,导致超额。
+ *
+ * 此函数在一次调用内完成「递增 + 判断是否超限」,
+ * 若递增后超过限额,回滚计数并返回 blocked。
+ *
+ * @returns { blocked, currentCount, limit } — blocked 为 true 表示已超限
+ */
+export const tryConsumeDailyQuota = (
+ userId: string,
+ role: string,
+): { blocked: boolean; currentCount: number; limit: number } => {
+ const today = new Date().toISOString().slice(0, 10);
+ const key = `${userId}:${today}`;
+ const limit = getDailyLimit(role);
+ const current = dailyUsageMap.get(key);
+
+ // 原子递增
+ const newCount = current && current.date === today ? current.count + 1 : 1;
+ dailyUsageMap.set(key, { date: today, count: newCount });
+
+ // 清理过期条目
+ if (dailyUsageMap.size > 10000) {
+ for (const [k, v] of dailyUsageMap.entries()) {
+ if (v.date !== today) {
+ dailyUsageMap.delete(k);
+ }
+ }
+ }
+
+ if (newCount > limit) {
+ // 超限,回滚计数(不惩罚用户因竞态多出的尝试)
+ dailyUsageMap.set(key, { date: today, count: limit });
+ return { blocked: true, currentCount: limit, limit };
+ }
+
+ return { blocked: false, currentCount: newCount, limit };
+};
+
+/**
+ * 回退每日使用计数(当 AI 调用失败或内容被过滤时调用)
+ *
+ * 确保用户不会因 AI 输出被过滤或调用失败而损失配额。
+ */
+export const refundDailyQuota = (userId: string): void => {
+ const today = new Date().toISOString().slice(0, 10);
+ const key = `${userId}:${today}`;
+ const current = dailyUsageMap.get(key);
+
+ if (current && current.date === today && current.count > 0) {
+ current.count -= 1;
+ }
+};
+
+// ---------------------------------------------------------------------------
+// 苏格拉底式辅导输出校验
+// ---------------------------------------------------------------------------
+
+export type SocraticValidationResult = {
+ valid: boolean;
+ reason?: string;
+};
+
+/**
+ * 校验 AI 回复是否符合苏格拉底式辅导原则
+ *
+ * 规则:
+ * 1. 回复必须以问号结尾(中英文均可)
+ * 2. 不得包含超过 2 句连续陈述句而不提问
+ * 3. 不得直接给出最终答案(复用 STUDENT_BLOCKED_PATTERNS)
+ *
+ * 用于学生侧 AI 对话,强制引导式教学。
+ */
+export const validateSocraticOutput = (
+ content: string,
+): SocraticValidationResult => {
+ const text = String(content ?? "").trim();
+
+ if (!text) {
+ return { valid: false, reason: "Empty response" };
+ }
+
+ // 检查是否直接给出答案
+ for (const pattern of STUDENT_BLOCKED_PATTERNS) {
+ if (pattern.test(text)) {
+ return { valid: false, reason: "Response contains direct answer" };
+ }
+ }
+
+ // 检查是否以问号结尾
+ if (!/[??]$/.test(text)) {
+ return { valid: false, reason: "Response must end with a question" };
+ }
+
+ // 检查连续陈述句数量(按句号/感叹号分割)
+ const sentences = text
+ .split(/[。!?.!?]/)
+ .filter((s) => s.trim().length > 0);
+ let consecutiveStatements = 0;
+ for (const sentence of sentences) {
+ // 如果句子本身是疑问句(包含 ? 或 ?),重置计数
+ if (/[??]/.test(sentence)) {
+ consecutiveStatements = 0;
+ } else {
+ consecutiveStatements += 1;
+ if (consecutiveStatements > 2) {
+ return {
+ valid: false,
+ reason: "Too many consecutive statements without a question",
+ };
+ }
+ }
+ }
+
+ return { valid: true };
+};
diff --git a/apps/portal-shell/src/features/teacher/ai/services/prompt-templates.ts b/apps/portal-shell/src/features/teacher/ai/services/prompt-templates.ts
new file mode 100644
index 0000000..6acac67
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/services/prompt-templates.ts
@@ -0,0 +1,308 @@
+/**
+ * AI Prompt 模板
+ *
+ * 从 CICD src/modules/ai/services/prompt-templates.ts 迁移。
+ * 集中管理所有业务场景的 Prompt,便于版本管理与调优。
+ * 所有 Prompt 使用英文以获得最佳模型兼容性,业务文本通过 user message 注入。
+ *
+ * 适配说明:portal-shell 为客户端微前端,本文件为纯常量定义,
+ * 可在客户端安全引用(实际由 BFF/服务端使用)。
+ */
+
+// ---------------------------------------------------------------------------
+// 相似题推荐
+// ---------------------------------------------------------------------------
+
+export const SIMILAR_QUESTION_SYSTEM_PROMPT = [
+ "You are an expert K12 education question generator.",
+ "Given a question, generate similar practice questions that test the same knowledge points.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "questions": [',
+ " {",
+ ' "text": "question text",',
+ ' "type": "single_choice | multiple_choice | judgment | text",',
+ ' "difficulty": 3,',
+ ' "options": [{ "id": "A", "text": "option text" }],',
+ ' "answer": "correct answer",',
+ ' "explanation": "brief explanation"',
+ " }",
+ " ]",
+ "}",
+ "Rules:",
+ "- Generate 1-5 similar questions based on the count parameter.",
+ "- Keep the same knowledge points but vary the context and numbers.",
+ "- For choice questions, always include 4 options.",
+ "- For text questions, omit options and include the answer.",
+ "- Difficulty should be 1-5, matching the original.",
+ "Never output placeholders like ..., [...], or {...}.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// AI 辅助批改
+// ---------------------------------------------------------------------------
+
+export const GRADING_ASSIST_SYSTEM_PROMPT = [
+ "You are an expert K12 teacher assistant for grading subjective questions.",
+ "Given a question, the student's answer, and the correct answer (if available),",
+ "evaluate the student's answer and suggest a score with feedback.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "suggestedScore": 4,',
+ ' "confidence": 0.85,',
+ ' "feedback": "constructive feedback in the student\'s language",',
+ ' "reasoning": "why this score was assigned"',
+ "}",
+ "Rules:",
+ "- suggestedScore must be between 0 and maxScore.",
+ "- confidence is between 0 and 1 (higher means more certain).",
+ "- feedback should be encouraging and specific.",
+ "- If the answer is completely wrong, suggestedScore should be 0.",
+ "- If the answer is partially correct, give partial credit.",
+ "- Consider alternative correct answers if the question allows.",
+ "Never output placeholders.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 备课内容生成
+// ---------------------------------------------------------------------------
+
+export const LESSON_CONTENT_SYSTEM_PROMPT = [
+ "You are an expert K12 instructional designer.",
+ "Generate teaching content based on the given topic and context.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "title": "content title",',
+ ' "content": "detailed content in markdown format",',
+ ' "metadata": { "duration": "15 min", "materials": ["..."] }',
+ "}",
+ "Rules:",
+ "- Content should be age-appropriate for the specified grade.",
+ "- For 'activity' type: generate an interactive classroom activity.",
+ "- For 'assessment' type: generate a formative assessment.",
+ "- For 'question' type: generate discussion questions.",
+ "- For 'material' type: generate teaching material outline.",
+ "- Content should align with the subject curriculum.",
+ "Never output placeholders.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 题目变体生成
+// ---------------------------------------------------------------------------
+
+export const QUESTION_VARIANT_SYSTEM_PROMPT = [
+ "You are an expert K12 question variation generator.",
+ "Given an original question, generate a variant based on the specified type.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "text": "variant question text",',
+ ' "type": "single_choice | multiple_choice | judgment | text",',
+ ' "difficulty": 3,',
+ ' "options": [{ "id": "A", "text": "option", "isCorrect": true }],',
+ ' "answer": "correct answer",',
+ ' "explanation": "brief explanation"',
+ "}",
+ "Variant types:",
+ "- same_knowledge_point: test the same concept with different context.",
+ "- different_difficulty: make it easier or harder.",
+ "- different_format: change the question type (e.g., choice to text).",
+ "Rules:",
+ "- For choice questions, always include 4 options with exactly one correct.",
+ "- Difficulty must be 1-5.",
+ "Never output placeholders.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 薄弱点分析
+// ---------------------------------------------------------------------------
+
+export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
+ "You are an expert K12 learning analyst.",
+ "Analyze the student's error patterns and identify weak areas.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "weakAreas": [',
+ " {",
+ ' "area": "knowledge area name",',
+ ' "severity": "high | medium | low",',
+ ' "rootCause": "underlying reason, e.g. missing prerequisite",',
+ ' "suggestion": "specific improvement suggestion"',
+ " }",
+ " ],",
+ ' "studyPlan": "personalized study plan summary",',
+ ' "recommendedResources": ["resource 1", "resource 2"]',
+ "}",
+ "Rules:",
+ "- Identify 2-5 weak areas based on error frequency and mastery level.",
+ "- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
+ "- If prerequisite knowledge is provided and a prerequisite mastery < 2, list the prerequisite as the rootCause.",
+ "- Suggestions should be actionable and specific.",
+ "- Study plan should be concise (3-5 sentences).",
+ "- Recommended resources can be topic names or study strategies.",
+ "Never output placeholders.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 通用 JSON 提取提示词(用于修复 AI 返回的无效 JSON)
+// ---------------------------------------------------------------------------
+
+export const JSON_REPAIR_SYSTEM_PROMPT = [
+ "You are a JSON repair engine.",
+ "Fix the provided invalid JSON into valid JSON only.",
+ "Keep the original structure and values as much as possible.",
+ "Do not use placeholders such as ... or [...].",
+ "Return JSON only without markdown.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 通用聊天(全局 AI 助手)
+// ---------------------------------------------------------------------------
+
+export const CHAT_SYSTEM_PROMPT = [
+ "You are a helpful K12 education assistant for the Next_Edu school management system.",
+ "You assist teachers, students, parents, and administrators with their daily tasks.",
+ "Respond in the user's language (Chinese by default).",
+ "Use Markdown formatting for structured content (lists, tables, code blocks).",
+ "Be concise, accurate, and pedagogically sound.",
+ "",
+ "## Data Visualization",
+ "When presenting quantitative data, trends, comparisons, or distributions, ALWAYS render a chart using a fenced code block with one of these languages:",
+ "- ```chart:bar — for comparing categories or showing distributions",
+ "- ```chart:line — for trends over time",
+ "- ```chart:pie — for part-to-whole / percentage breakdown",
+ "- ```chart:radar — for multi-dimensional comparison (e.g. subject mastery)",
+ "",
+ "Chart spec format (JSON inside the code block):",
+ "```",
+ "{",
+ ' "title": "图表标题",',
+ ' "description": "可选说明",',
+ ' "data": [',
+ ' { "name": "数学", "score": 85, "fullTitle": "数学科目" }',
+ " ],",
+ ' "xKey": "name", // bar/line: X 轴字段;pie: nameKey',
+ ' "angleKey": "name", // radar: 角度轴字段(可选,默认同 xKey)',
+ ' "series": [',
+ ' { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" }',
+ " ],",
+ ' "yDomain": [0, 100], // 可选,Y 轴范围',
+ ' "height": 280, // 可选,高度 px',
+ ' "showLegend": true // 可选,多系列时建议 true',
+ "}",
+ "```",
+ "",
+ "Chart rules:",
+ "- Only use charts when data is genuinely quantitative (numbers, percentages, counts).",
+ "- Keep data arrays small (≤ 12 items) for readability.",
+ '- For pie charts, each data item must have a `name` and a `value` field; set series[0].dataKey to "value".',
+ "- For radar charts, set `angleKey` to the dimension name field and provide one series per metric.",
+ "- Colors are optional; if omitted, a colorblind-friendly palette is applied.",
+ "- Always provide a concise text explanation alongside the chart.",
+ "- Do NOT wrap the JSON in any other markdown; output the raw JSON inside the fenced block.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 苏格拉底式辅导(学生专用,强制引导式教学)
+// ---------------------------------------------------------------------------
+
+export const SOCRATIC_TUTOR_SYSTEM_PROMPT = [
+ "You are a Socratic tutor for K12 students.",
+ "STRICT RULES (never violate):",
+ "- NEVER output the final answer directly.",
+ "- NEVER output more than 2 consecutive sentences without asking a question.",
+ "- Use a 3-tier hint escalation: Tier 1 (conceptual question) → Tier 2 (concrete hint) → Tier 3 (worked example without the final step).",
+ "- If the student asks for the answer 3+ times, explain why guided discovery is better for learning.",
+ "- Always end your response with a question that moves the student forward.",
+ "- Track the student's reasoning and point out the exact step where they went wrong.",
+ "- Respond in the student's language (Chinese by default).",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 家长学情摘要
+// ---------------------------------------------------------------------------
+
+export const CHILD_SUMMARY_SYSTEM_PROMPT = [
+ "You are an expert K12 family education advisor.",
+ "Analyze the student's learning data and generate a summary for parents.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "overallAssessment": "brief overall assessment in parent-friendly language",',
+ ' "strengths": ["strength 1", "strength 2"],',
+ ' "areasForImprovement": ["area 1", "area 2"],',
+ ' "familyTutoringSuggestions": ["suggestion 1", "suggestion 2"],',
+ ' "nextSteps": ["actionable next step 1", "actionable next step 2"]',
+ "}",
+ "Rules:",
+ "- Use encouraging and constructive tone.",
+ "- Focus on actionable advice parents can follow at home.",
+ "- Avoid educational jargon; use plain language.",
+ "- Consider cultural sensitivity in family education.",
+ "- If data is limited, provide general guidance.",
+ "Never output placeholders.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 学习路径推荐
+// ---------------------------------------------------------------------------
+
+export const STUDY_PATH_SYSTEM_PROMPT = [
+ "You are an expert K12 adaptive learning path designer.",
+ "Based on the student's current mastery levels and knowledge graph, recommend a personalized learning path.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "currentLevel": "brief description of current level",',
+ ' "learningPath": [',
+ " {",
+ ' "step": 1,',
+ ' "knowledgePoint": "knowledge point name",',
+ ' "status": "mastered | in_progress | needs_work",',
+ ' "recommendedAction": "specific action to take",',
+ ' "estimatedTime": "15 min"',
+ " }",
+ " ],",
+ ' "summary": "brief summary of the learning path",',
+ ' "motivation": "encouraging message for the student"',
+ "}",
+ "Rules:",
+ "- Order learning path from foundational to advanced.",
+ "- MUST follow prerequisite chains: if a knowledge point has unmastered prerequisites, list the prerequisites first.",
+ "- Prioritize weak areas (mastery < 2) first, but only after their prerequisites are addressed.",
+ "- Include 3-7 steps in the learning path.",
+ "- estimatedTime should be realistic (5-30 min per step).",
+ "- motivation should be age-appropriate and encouraging.",
+ "Never output placeholders.",
+].join("\n");
+
+// ---------------------------------------------------------------------------
+// 错题 AI 解释
+// ---------------------------------------------------------------------------
+
+export const EXPLAIN_ERROR_SYSTEM_PROMPT = [
+ "You are an expert K12 tutor specializing in helping students understand their mistakes.",
+ "Analyze the student's error and provide a clear, encouraging explanation.",
+ "Return JSON only without markdown.",
+ "Output schema:",
+ "{",
+ ' "errorAnalysis": "detailed analysis of why the student made this error",',
+ ' "correctApproach": "step-by-step correct solution approach",',
+ ' "keyConcepts": ["list of key concepts the student needs to review"],',
+ ' "preventionTips": ["tips to avoid similar mistakes in the future"],',
+ ' "practiceSuggestion": "specific practice recommendation"',
+ "}",
+ "Rules:",
+ "- Use age-appropriate language for K12 students.",
+ "- Be encouraging and constructive, never dismissive.",
+ "- errorAnalysis should identify the specific misconception, not just say 'wrong'.",
+ "- correctApproach should be step-by-step and easy to follow.",
+ "- keyConcepts should list 2-5 fundamental concepts.",
+ "- preventionTips should be actionable and specific.",
+ "- practiceSuggestion should recommend a specific type of practice problem.",
+ "Never output placeholders.",
+].join("\n");
diff --git a/apps/portal-shell/src/features/teacher/ai/services/usage-tracker.ts b/apps/portal-shell/src/features/teacher/ai/services/usage-tracker.ts
new file mode 100644
index 0000000..546e00f
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/services/usage-tracker.ts
@@ -0,0 +1,132 @@
+/**
+ * portal-shell 适配:trackEvent/EventName 在客户端微前端不存在,
+ * 服务端埋点由 BFF 负责;此处保留为 no-op 占位以保持类型契约。
+ */
+type EventName = string;
+const trackEvent = async (_event: {
+ event: EventName;
+ userId: string;
+ targetType?: string;
+ properties?: Record;
+}): Promise => {
+ /* no-op: portal-shell client-side stub */
+};
+/**
+ * portal-shell 适配:data-access/recordAiEvent 在客户端微前端不存在,
+ * 由 BFF 维护事件存储;此处保留为 no-op 占位。
+ */
+const recordAiEvent = (_event: {
+ userId: string;
+ capability: string;
+ success: boolean;
+ durationMs: number;
+ timestamp: number;
+ errorMessage?: string;
+}): void => {
+ /* no-op: portal-shell client-side stub */
+};
+
+export type AiUsageEvent = {
+ userId: string;
+ capability:
+ | "chat"
+ | "similar_question"
+ | "grading_assist"
+ | "lesson_content"
+ | "question_variant"
+ | "weakness_analysis"
+ | "child_summary"
+ | "study_path"
+ | "explain_error";
+ providerId?: string;
+ model?: string;
+ success: boolean;
+ durationMs: number;
+ tokenUsage?: number;
+ errorMessage?: string;
+};
+
+const AI_EVENT_MAP: Record = {
+ chat: "ai.chat",
+ similar_question: "ai.similar_question",
+ grading_assist: "ai.grading_assist",
+ lesson_content: "ai.lesson_content",
+ question_variant: "ai.question_variant",
+ weakness_analysis: "ai.weakness_analysis",
+ child_summary: "ai.child_summary",
+ study_path: "ai.study_path",
+ explain_error: "ai.explain_error",
+};
+
+/**
+ * AI 使用埋点
+ *
+ * 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
+ * 同时写入 data-access 层的内存事件存储(供管理员仪表盘聚合查询)。
+ * 非阻塞,失败不影响主流程。
+ */
+export const trackAiUsage = (event: AiUsageEvent): void => {
+ const eventName = AI_EVENT_MAP[event.capability];
+
+ // 写入 data-access 层(供 getAiUsageStats 聚合)
+ recordAiEvent({
+ userId: event.userId,
+ capability: event.capability,
+ success: event.success,
+ durationMs: event.durationMs,
+ timestamp: Date.now(),
+ errorMessage: event.errorMessage,
+ });
+
+ // 写入全局 trackEvent(供外部监控系统)
+ void trackEvent({
+ event: eventName,
+ userId: event.userId,
+ targetType: event.capability,
+ properties: {
+ providerId: event.providerId,
+ model: event.model,
+ success: event.success,
+ durationMs: event.durationMs,
+ tokenUsage: event.tokenUsage,
+ errorMessage: event.errorMessage,
+ },
+ }).catch(() => {
+ // 静默失败:埋点不应影响业务流程
+ });
+};
+
+/**
+ * 测量 AI 调用耗时并自动埋点
+ */
+export const withAiTracking = async (
+ userId: string,
+ capability: AiUsageEvent["capability"],
+ providerId: string | undefined,
+ fn: () => Promise<{ result: T; model?: string; tokenUsage?: number }>,
+): Promise => {
+ const start = Date.now();
+ try {
+ const { result, model, tokenUsage } = await fn();
+ trackAiUsage({
+ userId,
+ capability,
+ providerId,
+ model,
+ success: true,
+ durationMs: Date.now() - start,
+ tokenUsage,
+ });
+ return result;
+ } catch (error) {
+ trackAiUsage({
+ userId,
+ capability,
+ providerId,
+ success: false,
+ durationMs: Date.now() - start,
+ errorMessage: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+};
diff --git a/apps/portal-shell/src/features/teacher/ai/types.ts b/apps/portal-shell/src/features/teacher/ai/types.ts
new file mode 100644
index 0000000..2b1c059
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/ai/types.ts
@@ -0,0 +1,332 @@
+/**
+ * AI 模块类型定义
+ *
+ * 从 CICD src/modules/ai/types.ts 迁移。
+ * 适配:ActionState 在 portal-shell 未提供,本文件内联定义。
+ */
+
+// ---------------------------------------------------------------------------
+// ActionState(portal-shell 未提供,本模块内联;结构与 CICD @/shared/types/action-state 一致)
+// ---------------------------------------------------------------------------
+
+export type ActionState = {
+ success: boolean;
+ data?: T;
+ message?: string;
+};
+
+// ---------------------------------------------------------------------------
+// 基础类型
+// ---------------------------------------------------------------------------
+
+export type AiChatRole = "system" | "user" | "assistant";
+
+export type AiChatMessage = {
+ role: AiChatRole;
+ content: string;
+};
+
+export type AiChatOptions = {
+ providerId?: string;
+ temperature?: number;
+ maxTokens?: number;
+ model?: string;
+};
+
+export type AiChatResult = {
+ content: string;
+ usage: unknown;
+};
+
+// ---------------------------------------------------------------------------
+// 业务场景类型
+// ---------------------------------------------------------------------------
+
+export type SimilarQuestionInput = {
+ questionText: string;
+ questionType: string;
+ subject?: string;
+ knowledgePointIds?: string[];
+ count?: number;
+};
+
+export type SimilarQuestionResult = {
+ text: string;
+ type: string;
+ difficulty?: number;
+ options?: Array<{ id: string; text: string }>;
+ answer?: string;
+ explanation?: string;
+};
+
+export type GradingInput = {
+ questionText: string;
+ questionType: string;
+ studentAnswer: string;
+ correctAnswer?: string;
+ maxScore: number;
+ subject?: string;
+};
+
+export type GradingSuggestion = {
+ suggestedScore: number;
+ confidence: number;
+ feedback: string;
+ reasoning: string;
+};
+
+export type LessonContentInput = {
+ topic: string;
+ subject?: string;
+ grade?: string;
+ textbookId?: string;
+ chapterId?: string;
+ contentType: "activity" | "assessment" | "question" | "material";
+ additionalContext?: string;
+};
+
+export type LessonContentResult = {
+ title: string;
+ content: string;
+ metadata?: Record;
+};
+
+export type QuestionVariantInput = {
+ originalQuestion: {
+ text: string;
+ type: string;
+ difficulty?: number;
+ options?: Array<{ id: string; text: string; isCorrect?: boolean }>;
+ answer?: string;
+ };
+ subject?: string;
+ variantType:
+ "same_knowledge_point" | "different_difficulty" | "different_format";
+};
+
+export type QuestionVariantResult = {
+ text: string;
+ type: string;
+ difficulty: number;
+ options?: Array<{ id: string; text: string; isCorrect: boolean }>;
+ answer?: string;
+ explanation?: string;
+};
+
+export type WeaknessAnalysisInput = {
+ studentId: string;
+ subjectId?: string;
+ errorItems: Array<{
+ questionText: string;
+ questionType: string;
+ knowledgePointIds?: string[];
+ errorCount: number;
+ masteryLevel: number;
+ }>;
+};
+
+export type WeaknessAnalysisResult = {
+ weakAreas: Array<{
+ area: string;
+ severity: "high" | "medium" | "low";
+ suggestion: string;
+ }>;
+ studyPlan: string;
+ recommendedResources: string[];
+};
+
+// ---------------------------------------------------------------------------
+// 家长学情摘要类型
+// ---------------------------------------------------------------------------
+
+export type ChildSummaryInput = {
+ studentId: string;
+ studentName?: string;
+ grade?: string;
+ recentGrades?: Array<{
+ subject: string;
+ score: number;
+ maxScore: number;
+ trend: "up" | "down" | "stable";
+ }>;
+ attendanceRate?: number;
+ errorBookSummary?: {
+ totalErrors: number;
+ topWeakSubjects: string[];
+ masteryTrend: "improving" | "declining" | "stable";
+ };
+ homeworkCompletionRate?: number;
+};
+
+export type ChildSummaryResult = {
+ overallAssessment: string;
+ strengths: string[];
+ areasForImprovement: string[];
+ familyTutoringSuggestions: string[];
+ nextSteps: string[];
+};
+
+// ---------------------------------------------------------------------------
+// 学习路径推荐类型
+// ---------------------------------------------------------------------------
+
+export type StudyPathInput = {
+ studentId: string;
+ subject?: string;
+ currentMastery?: Array<{
+ knowledgePoint: string;
+ masteryLevel: number;
+ errorCount: number;
+ }>;
+ learningGoal?: string;
+ /** 教材 ID(传入后 action 层自动获取知识图谱注入) */
+ textbookId?: string;
+ /** 知识图谱(可直接传入,优先于 textbookId 自动获取) */
+ knowledgeGraph?: {
+ nodes: Array<{
+ id: string;
+ name: string;
+ level: number;
+ masteryLevel?: number;
+ }>;
+ edges: Array<{
+ from: string;
+ to: string;
+ type: "prerequisite";
+ }>;
+ };
+};
+
+export type StudyPathResult = {
+ currentLevel: string;
+ learningPath: Array<{
+ step: number;
+ knowledgePoint: string;
+ status: "mastered" | "in_progress" | "needs_work";
+ recommendedAction: string;
+ estimatedTime: string;
+ }>;
+ summary: string;
+ motivation: string;
+};
+
+// ---------------------------------------------------------------------------
+// 错题 AI 解释类型
+// ---------------------------------------------------------------------------
+
+export type ExplainErrorInput = {
+ questionText: string;
+ questionType: string;
+ studentAnswer: string;
+ correctAnswer?: string;
+ subject?: string;
+ knowledgePointIds?: string[];
+};
+
+export type ExplainErrorResult = {
+ errorAnalysis: string;
+ correctApproach: string;
+ keyConcepts: string[];
+ preventionTips: string[];
+ practiceSuggestion: string;
+};
+
+export type AiUsageStats = {
+ totalCalls: number;
+ callsToday: number;
+ callsThisWeek: number;
+ activeUsers: number;
+ errorRate: number;
+ avgDurationMs: number;
+ byCapability: Array<{ capability: string; count: number }>;
+ byRole: Array<{ role: string; count: number }>;
+ topUsers: Array<{ userId: string; count: number }>;
+ recentActivity: Array<{
+ userId: string;
+ capability: string;
+ success: boolean;
+ durationMs: number;
+ timestamp: string;
+ }>;
+};
+
+export type AiCapability =
+ | "chat"
+ | "exam-generate"
+ | "grading-assist"
+ | "lesson-content"
+ | "question-variant"
+ | "similar-question"
+ | "weakness-analysis"
+ | "usage-stats";
+
+// ---------------------------------------------------------------------------
+// 服务端 AI 服务接口(DefaultAiService 实现于此)
+// ---------------------------------------------------------------------------
+
+/**
+ * 服务端 AI 服务接口
+ *
+ * portal-shell 为客户端微前端,真实实现位于 BFF/服务端。
+ * 此接口仅用于类型契约;客户端组件通过 AiClientService(fetch BFF)调用。
+ */
+export interface AiService {
+ chat: (
+ messages: AiChatMessage[],
+ options?: AiChatOptions,
+ ) => Promise;
+ suggestSimilarQuestions: (
+ input: SimilarQuestionInput,
+ ) => Promise;
+ suggestGrading: (input: GradingInput) => Promise;
+ generateLessonContent: (
+ input: LessonContentInput,
+ ) => Promise;
+ generateQuestionVariant: (
+ input: QuestionVariantInput,
+ ) => Promise;
+ analyzeWeakness: (
+ input: WeaknessAnalysisInput,
+ ) => Promise;
+ summarizeChild?: (input: ChildSummaryInput) => Promise;
+ recommendStudyPath: (input: StudyPathInput) => Promise;
+ explainError: (input: ExplainErrorInput) => Promise;
+}
+
+// ---------------------------------------------------------------------------
+// 客户端 AI 服务接口
+// ---------------------------------------------------------------------------
+
+/**
+ * AI 客户端服务接口
+ *
+ * portal-shell 为客户端微前端,服务方法通过 fetch 调用 BFF/API 路由,
+ * 返回 ActionState(与 CICD Server Action 模式结构一致)。
+ * 通过 React Context 注入,组件经 useAiClient() 消费。
+ */
+export interface AiClientService {
+ chat: (input: {
+ messages: AiChatMessage[];
+ providerId?: string;
+ }) => Promise>;
+ suggestSimilarQuestions: (
+ input: SimilarQuestionInput,
+ ) => Promise>;
+ suggestGrading: (
+ input: GradingInput,
+ ) => Promise>;
+ generateLessonContent: (
+ input: LessonContentInput,
+ ) => Promise>;
+ generateQuestionVariant: (
+ input: QuestionVariantInput,
+ ) => Promise>;
+ analyzeWeakness: (
+ input: WeaknessAnalysisInput,
+ ) => Promise>;
+ explainError?: (
+ input: ExplainErrorInput,
+ ) => Promise>;
+ getAiUsageStats?: () => Promise>;
+ trackEvent?: (event: string, payload?: Record) => void;
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-grade-correlation-card.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-grade-correlation-card.tsx
new file mode 100644
index 0000000..1dab329
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-grade-correlation-card.tsx
@@ -0,0 +1,373 @@
+"use client";
+
+import { useCallback, useMemo } from "react";
+import { useTranslations } from "next-intl";
+import {
+ CartesianGrid,
+ Scatter,
+ ScatterChart,
+ XAxis,
+ YAxis,
+ ZAxis,
+ Cell,
+} from "recharts";
+import { TrendingDown, AlertTriangle, CheckCircle2, Inbox } from "lucide-react";
+
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/shared/components/charts/chart";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { Badge } from "@/shared/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { cn } from "@/shared/lib/utils";
+
+import type {
+ AttendanceGradeCorrelationSummary,
+ AttendanceGradeRiskLevel,
+} from "../types";
+
+const SCATTER_MARGIN = { top: 16, right: 16, bottom: 32, left: 16 };
+const SCATTER_GRID_PROPS = { strokeDasharray: "4 4", strokeOpacity: 0.4 };
+const TOOLTIP_CURSOR = { strokeDasharray: "3 3" };
+const Z_AXIS_RANGE: [number, number] = [60, 60];
+const AXIS_LABEL_STYLE = { fontSize: 12, fill: "hsl(var(--muted-foreground))" };
+const SCATTER_DOMAIN: [number, number] = [0, 100];
+
+const RISK_COLORS: Record = {
+ high: "hsl(var(--chart-1))",
+ medium: "hsl(var(--chart-4))",
+ low: "hsl(var(--chart-2))",
+};
+
+const RISK_BADGE_VARIANTS: Record<
+ AttendanceGradeRiskLevel,
+ "destructive" | "default" | "secondary"
+> = {
+ high: "destructive",
+ medium: "default",
+ low: "secondary",
+};
+
+const RISK_ICONS: Record = {
+ high: TrendingDown,
+ medium: AlertTriangle,
+ low: CheckCircle2,
+};
+
+const RISK_ICON_CLASSNAMES: Record = {
+ high: "text-red-500",
+ medium: "text-amber-500",
+ low: "text-emerald-500",
+};
+
+const chartConfig: ChartConfig = {
+ students: {
+ label: "Students",
+ },
+};
+interface ScatterDataItem {
+ studentId: string;
+ studentName: string;
+ attendanceRate: number;
+ averageScore: number;
+ riskLevel: AttendanceGradeRiskLevel;
+ attendanceRecordCount: number;
+ gradeRecordCount: number;
+ absentCount: number;
+}
+
+export function AttendanceGradeCorrelationCard({
+ summary,
+}: {
+ summary: AttendanceGradeCorrelationSummary | null;
+}): React.ReactElement {
+ const t = useTranslations("attendance");
+
+ const scatterData = useMemo(() => {
+ if (!summary) return [];
+ return summary.items.map((it) => ({
+ studentId: it.studentId,
+ studentName: it.studentName,
+ attendanceRate: it.attendanceRate,
+ averageScore: it.averageScore,
+ riskLevel: it.riskLevel,
+ attendanceRecordCount: it.attendanceRecordCount,
+ gradeRecordCount: it.gradeRecordCount,
+ absentCount: it.absentCount,
+ }));
+ }, [summary]);
+
+ const renderTooltip = useCallback(
+ (payload: unknown): React.ReactNode => {
+ const item = payload as { payload?: unknown } | undefined;
+ const data = item?.payload as ScatterDataItem | undefined;
+ if (!data) return null;
+ return (
+
+ {data.studentName}
+
+ {t("correlation.attendanceRate")}: {data.attendanceRate}%
+
+
+ {t("correlation.averageScore")}: {data.averageScore}
+
+
+ {t("correlation.absentCount")}: {data.absentCount}
+
+
+ );
+ },
+ [t],
+ );
+
+ if (!summary) {
+ return (
+
+
+ {t("correlation.title")}
+
+
+
+
+
+ );
+ }
+
+ const interpretationLabel = t(
+ `correlation.interpretation.${summary.correlationInterpretation}`,
+ );
+ return (
+
+
+ {t("correlation.title")}
+
+ {t("correlation.description", {
+ className: summary.className,
+ start: summary.startDate,
+ end: summary.endDate,
+ })}
+
+
+
+
+
+
+
+
+
+
+ {/* Scatter chart */}
+ {scatterData.length > 0 ? (
+
+
+ {t("correlation.scatterTitle")}
+
+
+
+
+
+
+
+
+ }
+ />
+
+ {scatterData.map((entry) => (
+ |
+ ))}
+
+
+
+
+ {(["high", "medium", "low"] as const).map((level) => (
+
+
+
+ {t("correlation.riskLevel.")}
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+ {summary.items.length > 0 && (
+
+
+ {t("correlation.studentDetails")}
+
+
+
+
+
+ {t("list.columns.student")}
+
+ {t("correlation.attendanceRate")}
+
+
+ {t("correlation.absentCount")}
+
+
+ {t("correlation.averageScore")}
+
+
+ {t("correlation.gradeRecordCount")}
+
+ {t("correlation.riskLevelLabel")}
+
+
+
+ {summary.items.map((item) => {
+ const Icon = RISK_ICONS[item.riskLevel];
+ return (
+
+
+ {item.studentName}
+
+
+ {item.attendanceRate}%
+
+
+ {item.absentCount}
+
+
+ {item.averageScore}
+
+
+ {item.gradeRecordCount}
+
+
+
+
+ {t("correlation.riskLevel.")}
+
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+
+ );
+}
+
+function SummaryStat({
+ label,
+ value,
+ sublabel,
+ icon: Icon,
+ iconClassName,
+}: {
+ label: string;
+ value: string;
+ sublabel?: string;
+ icon?: typeof AlertTriangle;
+ iconClassName?: string;
+}): React.ReactElement {
+ return (
+
+
+ {label}
+ {Icon && (
+
+ )}
+
+ {value}
+ {sublabel && (
+ {sublabel}
+ )}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-page-layout.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-page-layout.tsx
new file mode 100644
index 0000000..c35c592
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-page-layout.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from "react";
+
+import { cn } from "@/shared/lib/utils";
+
+/**
+ * 考勤模块页面布局(消除页面重复结构)。
+ *
+ * 复用模式:标题区 + 统计卡片(可选)+ 筛选区 + 内容区。
+ */
+interface AttendancePageLayoutProps {
+ /** 页面头部 */
+ header: ReactNode;
+ /** 统计卡片区 */
+ stats?: ReactNode;
+ /** 筛选区 */
+ filters?: ReactNode;
+ /** 主体内容 */
+ children: ReactNode;
+ /** 额外类名 */
+ className?: string;
+}
+
+export function AttendancePageLayout({
+ header,
+ stats,
+ filters,
+ children,
+ className,
+}: AttendancePageLayoutProps): React.ReactElement {
+ return (
+
+ {header}
+ {stats}
+ {filters}
+ {children}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-record-list.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-record-list.tsx
new file mode 100644
index 0000000..51a7a32
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-record-list.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+/**
+ * 考勤记录列表组件(ARCHITECTURE.md §9.1 教师域考勤模块)
+ *
+ * 从 CICD attendance-record-list.tsx 迁移,适配 portal-shell:
+ * - 数据:AttendanceRecord 类型从 @/lib/api 引入
+ * - 操作:onDelete 回调替代 Server Action deleteAttendanceAction
+ * - 状态:4 态(present/absent/late/leave),对齐 portal-shell ATTENDANCE_STATUS
+ * - 样式:attendanceStatusToBadgeClass 替代 ATTENDANCE_STATUS_BADGE_VARIANTS
+ *
+ * 关联:ARCHITECTURE.md §5.3 / §9.1 / project_rules §3.1
+ */
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Trash2, Inbox } from "lucide-react";
+
+import type { AttendanceRecord } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { usePermission } from "@/shared/hooks/use-permission";
+import {
+ attendanceStatusToBadgeClass,
+ formatAttendanceDate,
+ formatAttendanceStatus,
+} from "../transformations";
+
+interface AttendanceRecordListProps {
+ records: AttendanceRecord[];
+ onDelete?: (id: string) => Promise | void;
+ canDelete?: boolean;
+}
+
+export function AttendanceRecordList({
+ records,
+ onDelete,
+ canDelete = false,
+}: AttendanceRecordListProps): React.ReactElement {
+ const t = useTranslations("attendance");
+ const { hasPermission } = usePermission();
+ const canManage = canDelete && hasPermission("ATTENDANCE_MANAGE");
+ const [deleteId, setDeleteId] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const handleDelete = async (): Promise => {
+ if (!deleteId || !onDelete) return;
+ setIsDeleting(true);
+ try {
+ await onDelete(deleteId);
+ setDeleteId(null);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ if (records.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {t("list.colStudentName")}
+ {t("list.colClassName")}
+ {t("list.colDate")}
+ {t("list.colStatus")}
+ {t("list.colRemark")}
+ {t("list.colRecordedBy")}
+ {t("list.colUpdatedAt")}
+ {canManage ? : null}
+
+
+
+ {records.map((r) => (
+
+ {r.studentName}
+ {r.className}
+
+ {formatAttendanceDate(r.date)}
+
+
+
+ {formatAttendanceStatus(r.status)}
+
+
+
+ {r.remark ?? "-"}
+
+
+ {r.recordedBy}
+
+
+ {formatAttendanceDate(r.updatedAt)}
+
+ {canManage ? (
+
+
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-report-print.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-report-print.tsx
new file mode 100644
index 0000000..4f4372c
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-report-print.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { useRouter, usePathname, useSearchParams } from "next/navigation";
+import { Printer, FileText } from "lucide-react";
+
+import type { AttendanceReport } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { Select } from "@/shared/components/ui/select";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ attendanceRateToColorClass,
+ formatAttendanceRate,
+} from "../transformations";
+
+interface AttendanceReportPrintProps {
+ report: AttendanceReport | null;
+ classes: Array<{ id: string; name: string }>;
+ currentClassId: string;
+ startDate: string;
+ endDate: string;
+ reportType: "weekly" | "monthly";
+}
+
+export function AttendanceReportPrint({
+ report,
+ classes,
+ currentClassId,
+ startDate,
+ endDate,
+ reportType,
+}: AttendanceReportPrintProps): React.ReactElement {
+ const t = useTranslations("attendance");
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [isPrinting, setIsPrinting] = useState(false);
+
+ const handleParamChange = (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ router.push(`${pathname}?${params.toString()}`);
+ };
+
+ const handlePrint = (): void => {
+ setIsPrinting(true);
+ setTimeout(() => {
+ window.print();
+ setIsPrinting(false);
+ }, 100);
+ };
+
+ const hasData = report !== null && report.items.length > 0;
+
+ return (
+ <>
+ {/* 控制面板(打印时隐藏) */}
+
+
+ {t("report.controls")}
+
+
+
+
+
+ handleParamChange("classId", v)}
+ options={classes.map((c) => ({ value: c.id, label: c.name }))}
+ />
+
+
+
+ handleParamChange("reportType", v)}
+ options={[
+ { value: "weekly", label: t("report.types.weekly") },
+ { value: "monthly", label: t("report.types.monthly") },
+ ]}
+ />
+
+
+
+ handleParamChange("startDate", e.target.value)}
+ />
+
+
+
+ handleParamChange("endDate", e.target.value)}
+ />
+
+
+
+
+
+
+
+
+ {/* 打印区域 */}
+ {!hasData ? (
+
+
+
+
+
+ ) : (
+
+ {/* 报告头部 */}
+
+
+ {report.className} - {t(`report.types.${reportType}`)}
+
+
+ {t("report.period")}: {startDate || report.range} ~{" "}
+ {endDate || report.range}
+
+
+ {t("report.generatedAt")}: {new Date().toISOString().slice(0, 10)}
+
+
+
+ {/* 统计汇总 */}
+
+
+ {t("report.summary")}
+
+
+
+
+ {t("report.fieldTotal")}
+
+ {report.summary.total}
+
+
+
+ {t("report.fieldPresent")}
+
+
+ {report.summary.present}
+
+
+
+
+ {t("report.fieldAbsent")}
+
+
+ {report.summary.absent}
+
+
+
+
+ {t("report.fieldLate")}
+
+
+ {report.summary.late}
+
+
+
+
+ {t("report.fieldLeave")}
+
+
+ {report.summary.leave}
+
+
+
+
+ {t("report.fieldAttendanceRate")}
+
+
+ {formatAttendanceRate(report.summary.attendanceRate)}
+
+
+
+
+
+ {/* 学生明细 */}
+
+
+ {t("report.studentDetails")}
+
+
+
+
+
+ #
+ {t("report.colStudentName")}
+
+ {t("report.colPresent")}
+
+
+ {t("report.colAbsent")}
+
+
+ {t("report.colLate")}
+
+
+ {t("report.colLeave")}
+
+
+ {t("report.colAttendanceRate")}
+
+
+
+
+ {report.items.map((r, idx) => (
+
+ {idx + 1}
+
+ {r.studentName}
+
+
+ {r.present}
+
+
+ {r.absent}
+
+
+ {r.late}
+
+
+ {r.leave}
+
+
+ {formatAttendanceRate(r.attendanceRate)}
+
+
+ ))}
+
+
+
+
+
+ {/* 家长签字单 */}
+
+
+ {t("report.parentSignature")}
+
+
+
+ {t("report.parentSignatureNotice")}
+
+
+
+
+ {t("report.parentName")}
+
+
+
+
+
+ {t("report.signature")}
+
+
+
+
+
+ {t("report.relationship")}
+
+
+
+
+
+ {t("report.signDate")}
+
+
+
+
+
+
+ {t("report.parentComment")}
+
+
+
+
+
+
+ {/* 报告页脚 */}
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-rules-form.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-rules-form.tsx
new file mode 100644
index 0000000..145316d
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-rules-form.tsx
@@ -0,0 +1,205 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { notify } from "@/shared/lib/notify";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { Switch } from "@/shared/components/ui/switch";
+import { Select } from "@/shared/components/ui/select";
+
+import type { AttendanceRule } from "../types";
+
+export interface AttendanceRulesFormData {
+ classId: string;
+ lateThresholdMinutes: number;
+ earlyLeaveThresholdMinutes: number;
+ enableAutoMark: boolean;
+ attendanceRateThreshold: number;
+ consecutiveAbsenceThreshold: number;
+}
+
+interface AttendanceRulesFormProps {
+ classes: Array<{ id: string; name: string }>;
+ existingRules: AttendanceRule[];
+ submitting?: boolean;
+ onSubmit: (data: AttendanceRulesFormData) => Promise;
+}
+
+export function AttendanceRulesForm({
+ classes,
+ existingRules,
+ submitting = false,
+ onSubmit,
+}: AttendanceRulesFormProps): React.ReactElement {
+ const router = useRouter();
+ const t = useTranslations("attendance");
+ const [classId, setClassId] = useState(classes[0]?.id ?? "");
+ const [lateThreshold, setLateThreshold] = useState("15");
+ const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15");
+ const [enableAutoMark, setEnableAutoMark] = useState(false);
+ const [attendanceRateThreshold, setAttendanceRateThreshold] = useState("90");
+ const [consecutiveAbsenceThreshold, setConsecutiveAbsenceThreshold] =
+ useState("3");
+
+ const handleClassChange = (id: string): void => {
+ setClassId(id);
+ const rule = existingRules.find((r) => r.classId === id);
+ if (rule) {
+ setLateThreshold(String(rule.lateThresholdMinutes ?? 15));
+ setEarlyLeaveThreshold(String(rule.earlyLeaveThresholdMinutes ?? 15));
+ setEnableAutoMark(rule.enableAutoMark ?? false);
+ setAttendanceRateThreshold(String(rule.attendanceRateThreshold ?? 90));
+ setConsecutiveAbsenceThreshold(
+ String(rule.consecutiveAbsenceThreshold ?? 3),
+ );
+ } else {
+ setLateThreshold("15");
+ setEarlyLeaveThreshold("15");
+ setEnableAutoMark(false);
+ setAttendanceRateThreshold("90");
+ setConsecutiveAbsenceThreshold("3");
+ }
+ };
+
+ const handleSubmit = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+ if (!classId) {
+ notify.error(t("sheet.selectClass"));
+ return;
+ }
+ try {
+ await onSubmit({
+ classId,
+ lateThresholdMinutes: Number(lateThreshold) || 0,
+ earlyLeaveThresholdMinutes: Number(earlyLeaveThreshold) || 0,
+ enableAutoMark,
+ attendanceRateThreshold: Number(attendanceRateThreshold) || 0,
+ consecutiveAbsenceThreshold: Number(consecutiveAbsenceThreshold) || 0,
+ });
+ notify.success(t("rules.saved"));
+ router.refresh();
+ } catch {
+ notify.error(t("errors.unexpected"));
+ }
+ };
+
+ return (
+
+
+ {t("rules.title")}
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-sheet.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-sheet.tsx
new file mode 100644
index 0000000..6fa7b1e
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-sheet.tsx
@@ -0,0 +1,577 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback, useMemo } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import {
+ CalendarDays,
+ Search,
+ CheckCircle2,
+ XCircle,
+ Clock,
+ LogOut,
+} from "lucide-react";
+
+import { notify } from "@/shared/lib/notify";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { Select } from "@/shared/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/components/ui/alert-dialog";
+import { cn } from "@/shared/lib/utils";
+
+import type { AttendancePeriod, AttendanceStatus } from "../types";
+import { isAttendancePeriod } from "../lib/type-guards";
+import { formatAttendanceStatus } from "../transformations";
+
+type Option = { id: string; name: string };
+type Student = { id: string; name: string; email: string };
+
+const ATTENDANCE_STATUS_OPTIONS: AttendanceStatus[] = [
+ "present",
+ "absent",
+ "late",
+ "leave",
+];
+
+const ATTENDANCE_STATUS_SHORTCUTS: Record = {
+ p: "present",
+ a: "absent",
+ l: "late",
+ e: "leave",
+};
+
+const ATTENDANCE_PERIOD_OPTIONS: AttendancePeriod[] = [
+ "full_day",
+ "morning_reading",
+ "morning",
+ "afternoon",
+ "evening",
+];
+
+const ATTENDANCE_PERIOD_LABEL_KEYS: Record = {
+ full_day: "period.full_day",
+ morning_reading: "period.morning_reading",
+ morning: "period.morning",
+ afternoon: "period.afternoon",
+ evening: "period.evening",
+};
+
+const STATUS_STYLES: Record<
+ AttendanceStatus,
+ { active: string; icon: typeof CheckCircle2 }
+> = {
+ present: {
+ active: "bg-emerald-500 text-white border-emerald-500 hover:bg-emerald-600",
+ icon: CheckCircle2,
+ },
+ absent: {
+ active:
+ "bg-destructive text-white border-destructive hover:bg-destructive/90",
+ icon: XCircle,
+ },
+ late: {
+ active: "bg-amber-500 text-white border-amber-500 hover:bg-amber-600",
+ icon: Clock,
+ },
+ leave: {
+ active: "bg-blue-500 text-white border-blue-500 hover:bg-blue-600",
+ icon: LogOut,
+ },
+};
+
+const STATUSES_REQUIRING_REASON: ReadonlySet = new Set([
+ "absent",
+ "late",
+ "leave",
+]);
+
+export interface AttendanceSheetEntry {
+ studentId: string;
+ classId: string;
+ date: string;
+ status: AttendanceStatus;
+ period: AttendancePeriod;
+ reason?: string;
+}
+
+interface AttendanceSheetProps {
+ classes: Option[];
+ students: Student[];
+ defaultClassId?: string;
+ defaultDate?: string;
+ submitting?: boolean;
+ onSubmit: (records: AttendanceSheetEntry[]) => Promise;
+}
+
+export function AttendanceSheet({
+ classes,
+ students,
+ defaultClassId,
+ defaultDate,
+ submitting = false,
+ onSubmit,
+}: AttendanceSheetProps): React.ReactElement {
+ const router = useRouter();
+ const t = useTranslations("attendance");
+ const today = new Date().toISOString().slice(0, 10);
+ const [classId, setClassId] = useState(
+ defaultClassId ?? classes[0]?.id ?? "",
+ );
+ const [date, setDate] = useState(defaultDate ?? today);
+ const [period, setPeriod] = useState("full_day");
+ const [statuses, setStatuses] = useState>(
+ {},
+ );
+ const [reasons, setReasons] = useState>({});
+ const [searchQuery, setSearchQuery] = useState("");
+ const [focusedStudentIndex, setFocusedStudentIndex] = useState(0);
+ const [showSwitchConfirm, setShowSwitchConfirm] = useState(false);
+ const [pendingClassId, setPendingClassId] = useState(null);
+ const studentRefs = useRef<(HTMLTableRowElement | null)[]>([]);
+ const containerRef = useRef(null);
+
+ const handleStatusChange = useCallback(
+ (studentId: string, status: AttendanceStatus) => {
+ setStatuses((prev) => ({ ...prev, [studentId]: status }));
+ if (status === "present") {
+ setReasons((prev) => {
+ if (!prev[studentId]) return prev;
+ const next = { ...prev };
+ delete next[studentId];
+ return next;
+ });
+ }
+ },
+ [],
+ );
+
+ const handleReasonChange = useCallback(
+ (studentId: string, reason: string) => {
+ setReasons((prev) => ({ ...prev, [studentId]: reason }));
+ },
+ [],
+ );
+
+ const markAllPresent = useCallback(() => {
+ const all: Record = {};
+ for (const s of students) all[s.id] = "present";
+ setStatuses(all);
+ setReasons({});
+ notify.success(t("actions.markAllPresent"));
+ }, [students, t]);
+
+ const handleClassChange = (newClassId: string): void => {
+ const hasUnsaved = Object.keys(statuses).length > 0;
+ if (hasUnsaved && newClassId !== classId) {
+ setPendingClassId(newClassId);
+ setShowSwitchConfirm(true);
+ return;
+ }
+ confirmClassSwitch(newClassId);
+ };
+
+ const confirmClassSwitch = (newClassId: string): void => {
+ setClassId(newClassId);
+ setStatuses({});
+ setReasons({});
+ const newUrl = newClassId
+ ? `/shell/teacher/attendance/sheet?classId=${encodeURIComponent(newClassId)}`
+ : "/shell/teacher/attendance/sheet";
+ router.push(newUrl);
+ };
+
+ const filteredStudents = students.filter(
+ (s) =>
+ !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase()),
+ );
+
+ const statusCounts = useMemo(() => {
+ const counts: Record = {
+ present: 0,
+ absent: 0,
+ late: 0,
+ leave: 0,
+ };
+ for (const s of students) {
+ const st = statuses[s.id] ?? "present";
+ counts[st] += 1;
+ }
+ return counts;
+ }, [students, statuses]);
+
+ const effectiveFocusedIndex =
+ filteredStudents.length === 0
+ ? 0
+ : Math.min(focusedStudentIndex, filteredStudents.length - 1);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const target = e.target;
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement ||
+ (target instanceof HTMLElement && target.isContentEditable)
+ ) {
+ return;
+ }
+ const key = e.key.toLowerCase();
+ if (
+ ATTENDANCE_STATUS_SHORTCUTS[key] &&
+ filteredStudents[effectiveFocusedIndex]
+ ) {
+ e.preventDefault();
+ handleStatusChange(
+ filteredStudents[effectiveFocusedIndex].id,
+ ATTENDANCE_STATUS_SHORTCUTS[key],
+ );
+ if (effectiveFocusedIndex < filteredStudents.length - 1) {
+ setFocusedStudentIndex((prev) => prev + 1);
+ }
+ }
+ if (
+ e.key === "ArrowDown" &&
+ effectiveFocusedIndex < filteredStudents.length - 1
+ ) {
+ e.preventDefault();
+ setFocusedStudentIndex((prev) => prev + 1);
+ }
+ if (e.key === "ArrowUp" && effectiveFocusedIndex > 0) {
+ e.preventDefault();
+ setFocusedStudentIndex((prev) => prev - 1);
+ }
+ };
+ container.addEventListener("keydown", handleKeyDown);
+ return () => container.removeEventListener("keydown", handleKeyDown);
+ }, [filteredStudents, effectiveFocusedIndex, handleStatusChange]);
+
+ useEffect(() => {
+ studentRefs.current[effectiveFocusedIndex]?.scrollIntoView({
+ block: "nearest",
+ });
+ }, [effectiveFocusedIndex]);
+
+ const handleSubmit = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+ if (!classId || !date) {
+ notify.error(t("errors.invalidForm"));
+ return;
+ }
+ const records: AttendanceSheetEntry[] = students.map((s) => {
+ const status = statuses[s.id] ?? "present";
+ return {
+ studentId: s.id,
+ classId,
+ date,
+ status,
+ period,
+ reason: (() => {
+ if (!STATUSES_REQUIRING_REASON.has(status)) return undefined;
+ const reason = reasons[s.id];
+ return reason && reason.length > 0 ? reason.slice(0, 255) : undefined;
+ })(),
+ };
+ });
+ if (records.length === 0) {
+ notify.error(t("sheet.noStudents"));
+ return;
+ }
+ try {
+ await onSubmit(records);
+ notify.success(t("sheet.saved"));
+ router.push("/shell/teacher/attendance");
+ router.refresh();
+ } catch {
+ notify.error(t("errors.unexpected"));
+ }
+ };
+
+ return (
+
+ {submitting && (
+
+
+
+ {t("sheet.saving")}
+
+
+ )}
+
+ {t("sheet.title")}
+
+ {t("description.teacherRecords")}
+
+
+
+
+
+
+
+
+
+ {t("sheet.confirmClassSwitch")}
+
+ {t("sheet.confirmClassSwitch")}
+
+
+
+ {t("actions.cancel")}
+ {
+ if (pendingClassId) {
+ confirmClassSwitch(pendingClassId);
+ setPendingClassId(null);
+ }
+ setShowSwitchConfirm(false);
+ }}
+ >
+ {t("sheet.confirmClassSwitchAction")}
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-stats-class-selector.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-stats-class-selector.tsx
new file mode 100644
index 0000000..74fc20d
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-stats-class-selector.tsx
@@ -0,0 +1,29 @@
+import type { JSX } from "react";
+
+import { ChipNav } from "@/shared/components/ui/chip-nav";
+
+interface AttendanceStatsClassSelectorProps {
+ classes: Array<{ id: string; name: string }>;
+ currentClassId: string;
+ startDate: string;
+ endDate: string;
+}
+
+export function AttendanceStatsClassSelector({
+ classes,
+ currentClassId,
+ startDate,
+ endDate,
+}: AttendanceStatsClassSelectorProps): JSX.Element {
+ const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`;
+
+ return (
+
+ `/shell/teacher/attendance/stats?classId=${id}${dateParams}`
+ }
+ />
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-trend-chart.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-trend-chart.tsx
new file mode 100644
index 0000000..3c824ee
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-trend-chart.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { useRouter, usePathname, useSearchParams } from "next/navigation";
+import { TrendingUp } from "lucide-react";
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { Button } from "@/shared/components/ui/button";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ TrendLineChart,
+ type TrendLineSeries,
+} from "@/shared/components/charts/trend-line-chart";
+import { cn } from "@/shared/lib/utils";
+
+import { formatBucketLabel, type TrendGranularity } from "../lib/trend-compute";
+import type { AttendanceTrendSummary } from "../types";
+
+interface AttendanceTrendChartProps {
+ summary: AttendanceTrendSummary | null;
+ granularity: TrendGranularity;
+}
+
+const GRANULARITIES: TrendGranularity[] = ["daily", "weekly", "monthly"];
+
+export function AttendanceTrendChart({
+ summary,
+ granularity,
+}: AttendanceTrendChartProps): React.ReactElement {
+ const t = useTranslations("attendance.trend");
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const chartData = useMemo(() => {
+ if (!summary) return [];
+ return summary.points.map((p) => ({
+ title: formatBucketLabel(p.date, summary.granularity),
+ fullTitle: formatBucketLabel(p.date, summary.granularity),
+ presentRate: p.presentRate,
+ lateRate: p.lateRate,
+ absentRate: p.absentRate,
+ total: p.total,
+ }));
+ }, [summary]);
+
+ const series: TrendLineSeries[] = [
+ {
+ dataKey: "presentRate",
+ name: t("series.presentRate"),
+ color: "hsl(var(--chart-1))",
+ },
+ {
+ dataKey: "lateRate",
+ name: t("series.lateRate"),
+ color: "hsl(var(--chart-2))",
+ },
+ {
+ dataKey: "absentRate",
+ name: t("series.absentRate"),
+ color: "hsl(var(--chart-3))",
+ },
+ ];
+
+ const handleGranularityChange = (g: TrendGranularity): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("granularity", g);
+ router.push(`${pathname}?${params.toString()}`);
+ };
+ return (
+
+
+
+
+
+ {t("title")}
+
+
+ {GRANULARITIES.map((g) => (
+
+ ))}
+
+
+
+ {t("description", {
+ start: summary
+ ? formatBucketLabel(summary.startDate, summary.granularity)
+ : "-",
+ end: summary
+ ? formatBucketLabel(summary.endDate, summary.granularity)
+ : "-",
+ })}
+
+
+
+ {!summary || summary.points.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/attendance-warnings-card.tsx b/apps/portal-shell/src/features/teacher/attendance/components/attendance-warnings-card.tsx
new file mode 100644
index 0000000..6c7b850
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/attendance-warnings-card.tsx
@@ -0,0 +1,136 @@
+import { useTranslations } from "next-intl";
+import { AlertTriangle, TrendingDown, CalendarX } from "lucide-react";
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { Badge } from "@/shared/components/ui/badge";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { cn } from "@/shared/lib/utils";
+
+import type {
+ AttendanceWarning,
+ AttendanceWarningSeverity,
+ AttendanceWarningSummary,
+} from "../types";
+
+const SEVERITY_STYLES: Record<
+ AttendanceWarningSeverity,
+ { badge: string; row: string }
+> = {
+ high: {
+ badge: "bg-red-500/10 text-red-700 border-red-500/30",
+ row: "border-l-4 border-l-red-500 bg-red-50/50",
+ },
+ medium: {
+ badge: "bg-amber-500/10 text-amber-700 border-amber-500/30",
+ row: "border-l-4 border-l-amber-500 bg-amber-50/50",
+ },
+ low: {
+ badge: "bg-yellow-500/10 text-yellow-700 border-yellow-500/30",
+ row: "border-l-4 border-l-yellow-500 bg-yellow-50/50",
+ },
+};
+
+const WARNING_ICONS = {
+ low_attendance_rate: TrendingDown,
+ consecutive_absence: CalendarX,
+} as const;
+
+function WarningRow({
+ warning,
+}: {
+ warning: AttendanceWarning;
+}): React.ReactElement {
+ const t = useTranslations("attendance.warnings");
+ const Icon = WARNING_ICONS[warning.type];
+ const style = SEVERITY_STYLES[warning.severity];
+
+ return (
+
+
+
+
+ {warning.studentName}
+
+ {t(`types.${warning.type}`, {
+ current: warning.currentValue,
+ threshold: warning.threshold,
+ })}
+
+
+
+
+ {warning.relatedDates.length > 0 && (
+
+ {t("dates")}: {warning.relatedDates.join(", ")}
+
+ )}
+
+ {t(`severity.${warning.severity}`)}
+
+
+
+ );
+}
+export function AttendanceWarningsCard({
+ summary,
+}: {
+ summary: AttendanceWarningSummary | null;
+}): React.ReactElement {
+ const t = useTranslations("attendance.warnings");
+
+ if (!summary || summary.warnings.length === 0) {
+ return (
+
+
+
+
+ {t("title")}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {t("title")}
+
+ {summary.warnings.length}
+
+
+ {t("thresholdSummary", {
+ rate: summary.attendanceRateThreshold,
+ absence: summary.consecutiveAbsenceThreshold,
+ })}
+
+
+
+ {summary.warnings.map((w, idx) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/class-comparison-card.tsx b/apps/portal-shell/src/features/teacher/attendance/components/class-comparison-card.tsx
new file mode 100644
index 0000000..570fa4f
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/class-comparison-card.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { useRouter, usePathname, useSearchParams } from "next/navigation";
+import { GitCompare } from "lucide-react";
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { Button } from "@/shared/components/ui/button";
+import { Badge } from "@/shared/components/ui/badge";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import {
+ SimpleBarChart,
+ type BarSeries,
+} from "@/shared/components/charts/simple-bar-chart";
+import { cn } from "@/shared/lib/utils";
+
+import type { ClassComparisonSummary } from "../types";
+
+interface ClassComparisonCardProps {
+ summary: ClassComparisonSummary | null;
+ grades?: Array<{ id: string; name: string }>;
+ currentGradeId?: string;
+}
+
+export function ClassComparisonCard({
+ summary,
+ grades,
+ currentGradeId,
+}: ClassComparisonCardProps): React.ReactElement {
+ const t = useTranslations("attendance.comparison");
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const chartData = useMemo(() => {
+ if (!summary) return [];
+ return summary.items.map((i) => ({
+ name: i.className,
+ presentRate: i.presentRate,
+ lateRate: i.lateRate,
+ absentRate: i.absentRate,
+ }));
+ }, [summary]);
+
+ const bars: BarSeries[] = [
+ {
+ dataKey: "presentRate",
+ name: t("series.presentRate"),
+ color: "hsl(var(--chart-1))",
+ },
+ ];
+
+ const handleGradeChange = (gradeId: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("gradeId", gradeId);
+ router.push(`${pathname}?${params.toString()}`);
+ };
+ const getRateBadge = (rate: number, average: number): React.ReactElement => {
+ if (rate > average + 1) {
+ return (
+
+ {t("aboveAverage")}
+
+ );
+ }
+ if (rate < average - 1) {
+ return (
+
+ {t("belowAverage")}
+
+ );
+ }
+ return (
+
+ {t("average")}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ {t("title")}
+
+ {grades && grades.length > 0 && (
+
+ {grades.map((g) => (
+
+ ))}
+
+ )}
+
+ {summary && (
+
+ {t("description", {
+ grade: summary.gradeName,
+ avg: summary.averagePresentRate,
+ start: summary.startDate,
+ end: summary.endDate,
+ })}
+
+ )}
+ {" "}
+
+ {!summary || summary.items.length === 0 ? (
+
+ ) : (
+ <>
+ `${v}%`}
+ heightClassName="h-[280px]"
+ />
+
+
+
+
+ #
+ {t("columns.class")}
+
+ {t("columns.total")}
+
+
+ {t("columns.presentRate")}
+
+
+ {t("columns.lateRate")}
+
+
+ {t("columns.absentRate")}
+
+
+ {t("columns.badge")}
+
+
+
+
+ {summary.items.map((item, idx) => (
+
+
+ {idx + 1}
+
+ {item.className}
+
+ {item.total}
+
+
+ {item.presentRate.toFixed(1)}%
+
+
+ {item.lateRate.toFixed(1)}%
+
+
+ {item.absentRate.toFixed(1)}%
+
+
+ {getRateBadge(
+ item.presentRate,
+ summary.averagePresentRate,
+ )}
+
+
+ ))}
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/index.ts b/apps/portal-shell/src/features/teacher/attendance/components/index.ts
new file mode 100644
index 0000000..42956b6
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/index.ts
@@ -0,0 +1,23 @@
+/**
+ * Attendance 域可视化组件 barrel 导出。
+ *
+ * 适配说明(CICD -> portal-shell):
+ * - 11 个组件从 CICD 迁移并适配 portal-shell 的设计令牌、数据层与 Select 组件 API
+ * - 3 个核心算法位于 ../lib(trend-compute / warning-compute / correlation-compute)
+ * - 类型定义位于 ../types
+ *
+ * 关联:ARCHITECTURE.md §9.1 教师域考勤模块
+ */
+export { AttendancePageLayout } from "./attendance-page-layout";
+export { AttendanceStatsClassSelector } from "./attendance-stats-class-selector";
+export { AttendanceTrendChart } from "./attendance-trend-chart";
+export { AttendanceWarningsCard } from "./attendance-warnings-card";
+export { ClassComparisonCard } from "./class-comparison-card";
+export { StudentAttendanceView } from "./student-attendance-view";
+export { AttendanceGradeCorrelationCard } from "./attendance-grade-correlation-card";
+export { AttendanceRecordList } from "./attendance-record-list";
+export { AttendanceReportPrint } from "./attendance-report-print";
+export { AttendanceRulesForm } from "./attendance-rules-form";
+export type { AttendanceRulesFormData } from "./attendance-rules-form";
+export { AttendanceSheet } from "./attendance-sheet";
+export type { AttendanceSheetEntry } from "./attendance-sheet";
diff --git a/apps/portal-shell/src/features/teacher/attendance/components/student-attendance-view.tsx b/apps/portal-shell/src/features/teacher/attendance/components/student-attendance-view.tsx
new file mode 100644
index 0000000..a3ef1e1
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/components/student-attendance-view.tsx
@@ -0,0 +1,174 @@
+import { useTranslations } from "next-intl";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { StatCard } from "@/shared/components/ui/stat-card";
+import { StatsGrid } from "@/shared/components/ui/stats-grid";
+import {
+ BarChart3,
+ CalendarCheck,
+ CheckCircle2,
+ Clock,
+ FileText,
+ LogOut,
+ TrendingUp,
+ Users,
+ XCircle,
+} from "lucide-react";
+
+import {
+ attendanceStatusToBadgeClass,
+ formatAttendanceStatus,
+} from "../transformations";
+import type { StudentAttendanceSummary } from "../types";
+
+export function StudentAttendanceView({
+ summary,
+}: {
+ summary: StudentAttendanceSummary | null;
+}): React.ReactElement {
+ const t = useTranslations("attendance");
+
+ if (!summary) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {t("list.colStudentName")}
+
+
+
+ {summary.studentName}
+
+
+
+
+
+ {t("stats.fieldTotalRecords")}
+
+
+
+ {summary.stats.total}
+
+
+
+ {summary.stats.total === 0 ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
+
+ {summary.recentRecords.length === 0 ? (
+
+ ) : (
+
+
+ {t("stats.sectionTrend")}
+
+
+
+
+
+
+ {t("list.colDate")}
+ {t("list.colClassName")}
+ {t("list.colStatus")}
+ {t("list.colRemark")}
+
+
+
+ {summary.recentRecords.map((r) => (
+
+ {r.date}
+ {r.className}
+
+
+ {formatAttendanceStatus(r.status)}
+
+
+
+ {r.remark ?? "-"}
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/lib/correlation-compute.ts b/apps/portal-shell/src/features/teacher/attendance/lib/correlation-compute.ts
new file mode 100644
index 0000000..d14cd2d
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/lib/correlation-compute.ts
@@ -0,0 +1,152 @@
+import type {
+ AttendanceGradeCorrelationItem,
+ AttendanceGradeCorrelationSummary,
+ AttendanceGradeRiskLevel,
+} from "../types";
+import type { CorrelationInterpretation } from "../types";
+
+export const RISK_ATTENDANCE_HIGH_THRESHOLD = 80;
+export const RISK_ATTENDANCE_MEDIUM_THRESHOLD = 90;
+export const RISK_SCORE_HIGH_THRESHOLD = 60;
+export const RISK_SCORE_MEDIUM_THRESHOLD = 75;
+
+const CORRELATION_STRONG_THRESHOLD = 0.7;
+const CORRELATION_WEAK_THRESHOLD = 0.3;
+
+export function computePearsonCorrelation(
+ x: readonly number[],
+ y: readonly number[],
+): number | null {
+ if (x.length !== y.length) return null;
+ if (x.length < 2) return null;
+
+ const n = x.length;
+ let sumX = 0;
+ let sumY = 0;
+ let sumXY = 0;
+ let sumX2 = 0;
+ let sumY2 = 0;
+
+ for (let i = 0; i < n; i++) {
+ const xi = x[i];
+ const yi = y[i];
+ if (xi === undefined || yi === undefined) return null;
+ if (!Number.isFinite(xi) || !Number.isFinite(yi)) return null;
+ sumX += xi;
+ sumY += yi;
+ sumXY += xi * yi;
+ sumX2 += xi * xi;
+ sumY2 += yi * yi;
+ }
+
+ const numerator = n * sumXY - sumX * sumY;
+ const denominator = Math.sqrt(
+ (n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY),
+ );
+
+ if (denominator === 0) return null;
+ return Math.max(-1, Math.min(1, numerator / denominator));
+}
+
+export function classifyRiskLevel(
+ attendanceRate: number,
+ averageScore: number,
+): AttendanceGradeRiskLevel {
+ const isLowAttendance = attendanceRate < RISK_ATTENDANCE_HIGH_THRESHOLD;
+ const isMediumAttendance =
+ attendanceRate < RISK_ATTENDANCE_MEDIUM_THRESHOLD && !isLowAttendance;
+ const isLowScore = averageScore < RISK_SCORE_HIGH_THRESHOLD;
+ const isMediumScore =
+ averageScore < RISK_SCORE_MEDIUM_THRESHOLD && !isLowScore;
+
+ if (isLowAttendance && isLowScore) return "high";
+ if ((isLowAttendance || isMediumAttendance) && (isLowScore || isMediumScore))
+ return "medium";
+ return "low";
+}
+
+export function interpretCorrelation(
+ r: number | null,
+): CorrelationInterpretation {
+ if (r === null) return "insufficient_data";
+ const abs = Math.abs(r);
+ if (abs >= CORRELATION_STRONG_THRESHOLD) {
+ return r > 0 ? "strong_positive" : "strong_negative";
+ }
+ if (abs >= CORRELATION_WEAK_THRESHOLD) {
+ return r > 0 ? "weak_positive" : "weak_negative";
+ }
+ return "negligible";
+}
+const RISK_ORDER: Record = {
+ high: 0,
+ medium: 1,
+ low: 2,
+};
+
+function round2(n: number): number {
+ return Math.round(n * 100) / 100;
+}
+
+function round4(n: number): number {
+ return Math.round(n * 10000) / 10000;
+}
+
+export function computeCorrelationSummary(
+ classId: string,
+ className: string,
+ startDate: string,
+ endDate: string,
+ rawItems: ReadonlyArray<{
+ studentId: string;
+ studentName: string;
+ attendanceRate: number;
+ attendanceRecordCount: number;
+ absentCount: number;
+ averageScore: number;
+ gradeRecordCount: number;
+ }>,
+): AttendanceGradeCorrelationSummary {
+ const validItems = rawItems.filter(
+ (it) => it.attendanceRecordCount > 0 && it.gradeRecordCount > 0,
+ );
+
+ const items: AttendanceGradeCorrelationItem[] = validItems.map((it) => ({
+ studentId: it.studentId,
+ studentName: it.studentName,
+ attendanceRate: round2(it.attendanceRate),
+ attendanceRecordCount: it.attendanceRecordCount,
+ absentCount: it.absentCount,
+ averageScore: round2(it.averageScore),
+ gradeRecordCount: it.gradeRecordCount,
+ riskLevel: classifyRiskLevel(it.attendanceRate, it.averageScore),
+ }));
+
+ items.sort((a, b) => {
+ const riskDiff = RISK_ORDER[a.riskLevel] - RISK_ORDER[b.riskLevel];
+ if (riskDiff !== 0) return riskDiff;
+ return a.attendanceRate - b.attendanceRate;
+ });
+
+ const correlation = computePearsonCorrelation(
+ items.map((it) => it.attendanceRate),
+ items.map((it) => it.averageScore),
+ );
+
+ const riskCounts = {
+ high: items.filter((it) => it.riskLevel === "high").length,
+ medium: items.filter((it) => it.riskLevel === "medium").length,
+ low: items.filter((it) => it.riskLevel === "low").length,
+ };
+
+ return {
+ classId,
+ className,
+ startDate,
+ endDate,
+ items,
+ correlation: correlation !== null ? round4(correlation) : null,
+ correlationInterpretation: interpretCorrelation(correlation),
+ riskCounts,
+ };
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/lib/trend-compute.ts b/apps/portal-shell/src/features/teacher/attendance/lib/trend-compute.ts
new file mode 100644
index 0000000..ad83fc2
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/lib/trend-compute.ts
@@ -0,0 +1,97 @@
+import type { AttendanceTrendPoint } from "../types";
+
+/**
+ * 趋势粒度类型。
+ */
+export type TrendGranularity = "daily" | "weekly" | "monthly";
+
+/**
+ * 将日期字符串(YYYY-MM-DD)按粒度分组的 bucket key。
+ * - daily:返回原日期
+ * - weekly:返回该日期所在周的周一日期(ISO 周一为一周开始)
+ * - monthly:返回该日期所在月份的第一天(YYYY-MM-01)
+ */
+export const bucketizeDate = (
+ dateStr: string,
+ granularity: TrendGranularity,
+): string => {
+ if (granularity === "daily") return dateStr;
+
+ const d = new Date(dateStr);
+ if (Number.isNaN(d.getTime())) return dateStr;
+
+ if (granularity === "weekly") {
+ const day = d.getDay();
+ const diff = day === 0 ? -6 : 1 - day;
+ const monday = new Date(d);
+ monday.setDate(d.getDate() + diff);
+ return monday.toISOString().slice(0, 10);
+ }
+
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
+};
+
+/**
+ * 格式化 bucket key 为显示标签。
+ * - daily:MM-DD
+ * - weekly:MM-DD(周一日期)
+ * - monthly:YYYY-MM
+ */
+export const formatBucketLabel = (
+ bucketKey: string,
+ granularity: TrendGranularity,
+): string => {
+ if (granularity === "monthly") {
+ return bucketKey.slice(0, 7);
+ }
+ return bucketKey.slice(5);
+};
+
+/**
+ * 从按日期的原始记录列表计算趋势数据点(纯函数,便于测试)。
+ *
+ * @param records 记录列表,每项含 date(YYYY-MM-DD)和 status
+ * @param granularity 聚合粒度
+ * @returns 按 bucket key 升序的趋势数据点
+ */
+export const computeTrendPoints = (
+ records: ReadonlyArray<{ date: string; status: string }>,
+ granularity: TrendGranularity,
+): AttendanceTrendPoint[] => {
+ if (records.length === 0) return [];
+
+ const buckets = new Map<
+ string,
+ { total: number; present: number; late: number; absent: number }
+ >();
+
+ for (const r of records) {
+ const bucket = bucketizeDate(r.date, granularity);
+ const stat = buckets.get(bucket) ?? {
+ total: 0,
+ present: 0,
+ late: 0,
+ absent: 0,
+ };
+ stat.total += 1;
+ if (r.status === "present") stat.present += 1;
+ else if (r.status === "late") stat.late += 1;
+ else if (r.status === "absent") stat.absent += 1;
+ buckets.set(bucket, stat);
+ }
+
+ const sortedBuckets = Array.from(buckets.entries()).sort(([a], [b]) =>
+ a.localeCompare(b),
+ );
+
+ return sortedBuckets.map(([bucket, stat]) => {
+ const { total, present, late, absent } = stat;
+ return {
+ date: bucket,
+ presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
+ lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
+ absentRate: total > 0 ? Math.round((absent / total) * 10000) / 100 : 0,
+ total,
+ };
+ });
+};
diff --git a/apps/portal-shell/src/features/teacher/attendance/lib/type-guards.ts b/apps/portal-shell/src/features/teacher/attendance/lib/type-guards.ts
new file mode 100644
index 0000000..5e359ef
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/lib/type-guards.ts
@@ -0,0 +1,17 @@
+import type { AttendancePeriod } from "../types";
+
+const ATTENDANCE_PERIOD_VALUES = [
+ "morning_reading",
+ "morning",
+ "afternoon",
+ "evening",
+ "full_day",
+] as const;
+
+/** 类型守卫:判断值是否为合法的 AttendancePeriod */
+export function isAttendancePeriod(value: unknown): value is AttendancePeriod {
+ return (
+ typeof value === "string" &&
+ (ATTENDANCE_PERIOD_VALUES as readonly string[]).includes(value)
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/attendance/lib/warning-compute.ts b/apps/portal-shell/src/features/teacher/attendance/lib/warning-compute.ts
new file mode 100644
index 0000000..e56101a
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/lib/warning-compute.ts
@@ -0,0 +1,158 @@
+import type {
+ AttendanceWarning,
+ AttendanceWarningSeverity,
+ AttendanceWarningSummary,
+} from "../types";
+
+/**
+ * 预警阈值默认值(当班级未配置规则时使用)。
+ */
+export const DEFAULT_ATTENDANCE_RATE_THRESHOLD = 90;
+export const DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD = 3;
+
+/**
+ * 出勤率预警的严重等级划分:
+ * - high:低于阈值 10 个百分点以上
+ * - medium:低于阈值 5-10 个百分点
+ * - low:低于阈值 5 个百分点以内
+ */
+const rateSeverity = (
+ current: number,
+ threshold: number,
+): AttendanceWarningSeverity => {
+ const diff = threshold - current;
+ if (diff >= 10) return "high";
+ if (diff >= 5) return "medium";
+ return "low";
+};
+
+/**
+ * 连续缺勤预警的严重等级划分:
+ * - high:连续缺勤 >= 阈值 + 2
+ * - medium:连续缺勤 >= 阈值 + 1
+ * - low:连续缺勤 = 阈值
+ */
+const absenceSeverity = (
+ current: number,
+ threshold: number,
+): AttendanceWarningSeverity => {
+ if (current >= threshold + 2) return "high";
+ if (current >= threshold + 1) return "medium";
+ return "low";
+};
+
+/**
+ * 从按日期升序的状态列表中计算最长连续缺勤段。
+ * 缺勤定义为 status === "absent"(不含 late/leave 等其他状态)。
+ * 返回 { maxStreak, lastStreakDates }:最长连续段及其日期列表。
+ */
+export const computeConsecutiveAbsence = (
+ records: ReadonlyArray<{ date: string; status: string }>,
+): { maxStreak: number; lastStreakDates: string[] } => {
+ if (records.length === 0) return { maxStreak: 0, lastStreakDates: [] };
+
+ const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
+ let maxStreak = 0;
+ let currentStreak = 0;
+ let currentStreakStart = 0;
+ let lastMaxStart = 0;
+
+ for (let i = 0; i < sorted.length; i++) {
+ const record = sorted[i];
+ if (record === undefined) continue;
+ if (record.status === "absent") {
+ if (currentStreak === 0) currentStreakStart = i;
+ currentStreak += 1;
+ if (currentStreak > maxStreak) {
+ maxStreak = currentStreak;
+ lastMaxStart = currentStreakStart;
+ }
+ } else {
+ currentStreak = 0;
+ }
+ }
+
+ const lastStreakDates =
+ maxStreak > 0
+ ? sorted.slice(lastMaxStart, lastMaxStart + maxStreak).map((r) => r.date)
+ : [];
+
+ return { maxStreak, lastStreakDates };
+};
+
+/**
+ * 根据学生出勤率统计和阈值生成预警列表(纯函数,便于测试)。
+ *
+ * @param studentStats 学生列表
+ * @param attendanceRateThreshold 出勤率阈值(百分比)
+ * @param consecutiveAbsenceThreshold 连续缺勤阈值(次)
+ */
+export const computeAttendanceWarnings = (
+ studentStats: ReadonlyArray<{
+ studentId: string;
+ studentName: string;
+ total: number;
+ present: number;
+ records: ReadonlyArray<{ date: string; status: string }>;
+ }>,
+ attendanceRateThreshold: number,
+ consecutiveAbsenceThreshold: number,
+): AttendanceWarning[] => {
+ const warnings: AttendanceWarning[] = [];
+
+ for (const s of studentStats) {
+ if (s.total === 0) continue;
+
+ const presentRate = Math.round((s.present / s.total) * 10000) / 100;
+
+ if (presentRate < attendanceRateThreshold) {
+ warnings.push({
+ studentId: s.studentId,
+ studentName: s.studentName,
+ type: "low_attendance_rate",
+ severity: rateSeverity(presentRate, attendanceRateThreshold),
+ currentValue: presentRate,
+ threshold: attendanceRateThreshold,
+ relatedDates: [],
+ });
+ }
+
+ const { maxStreak, lastStreakDates } = computeConsecutiveAbsence(s.records);
+ if (maxStreak >= consecutiveAbsenceThreshold) {
+ warnings.push({
+ studentId: s.studentId,
+ studentName: s.studentName,
+ type: "consecutive_absence",
+ severity: absenceSeverity(maxStreak, consecutiveAbsenceThreshold),
+ currentValue: maxStreak,
+ threshold: consecutiveAbsenceThreshold,
+ relatedDates: lastStreakDates,
+ });
+ }
+ }
+
+ const severityOrder: Record = {
+ high: 0,
+ medium: 1,
+ low: 2,
+ };
+ warnings.sort(
+ (a, b) => severityOrder[a.severity] - severityOrder[b.severity],
+ );
+
+ return warnings;
+};
+
+/**
+ * 构造空的预警汇总(当班级无数据或无规则时使用)。
+ */
+export const createEmptyWarningSummary = (
+ classId: string,
+ className: string,
+): AttendanceWarningSummary => ({
+ classId,
+ className,
+ attendanceRateThreshold: DEFAULT_ATTENDANCE_RATE_THRESHOLD,
+ consecutiveAbsenceThreshold: DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD,
+ warnings: [],
+});
diff --git a/apps/portal-shell/src/features/teacher/attendance/types.ts b/apps/portal-shell/src/features/teacher/attendance/types.ts
new file mode 100644
index 0000000..56bfbf4
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/attendance/types.ts
@@ -0,0 +1,160 @@
+/**
+ * Attendance 域可视化组件类型定义。
+ *
+ * 适配说明(CICD -> portal-shell):
+ * - 状态枚举对齐 portal-shell 的 4 态(present/absent/late/leave)
+ * - 数据形状保留 CICD 的聚合结构(summary/item),由 lib/api 层与组件之间传递
+ * - 关联:ARCHITECTURE.md S9.1 教师域考勤模块
+ */
+
+/** 考勤状态(对齐 portal-shell ATTENDANCE_STATUS 枚举) */
+export type AttendanceStatus = "present" | "absent" | "late" | "leave";
+
+/** 节次类型 */
+export type AttendancePeriod =
+ "full_day" | "morning_reading" | "morning" | "afternoon" | "evening";
+
+/** 趋势粒度 */
+export type TrendGranularity = "daily" | "weekly" | "monthly";
+
+/** 趋势数据点 */
+export interface AttendanceTrendPoint {
+ date: string;
+ presentRate: number;
+ lateRate: number;
+ absentRate: number;
+ total: number;
+}
+
+/** 趋势汇总 */
+export interface AttendanceTrendSummary {
+ classId: string;
+ className: string;
+ granularity: TrendGranularity;
+ startDate: string;
+ endDate: string;
+ points: AttendanceTrendPoint[];
+}
+
+/** 预警严重等级 */
+export type AttendanceWarningSeverity = "high" | "medium" | "low";
+
+/** 单个学生的考勤预警 */
+export interface AttendanceWarning {
+ studentId: string;
+ studentName: string;
+ type: "low_attendance_rate" | "consecutive_absence";
+ severity: AttendanceWarningSeverity;
+ currentValue: number;
+ threshold: number;
+ relatedDates: string[];
+}
+
+/** 班级预警汇总 */
+export interface AttendanceWarningSummary {
+ classId: string;
+ className: string;
+ attendanceRateThreshold: number;
+ consecutiveAbsenceThreshold: number;
+ warnings: AttendanceWarning[];
+}
+
+/** 考勤-成绩关联风险等级 */
+export type AttendanceGradeRiskLevel = "high" | "medium" | "low";
+
+/** 相关系数解释类型 */
+export type CorrelationInterpretation =
+ | "strong_negative"
+ | "weak_negative"
+ | "negligible"
+ | "weak_positive"
+ | "strong_positive"
+ | "insufficient_data";
+
+/** 单个学生的考勤-成绩关联数据点 */
+export interface AttendanceGradeCorrelationItem {
+ studentId: string;
+ studentName: string;
+ attendanceRate: number;
+ attendanceRecordCount: number;
+ absentCount: number;
+ averageScore: number;
+ gradeRecordCount: number;
+ riskLevel: AttendanceGradeRiskLevel;
+}
+
+/** 班级考勤-成绩关联分析汇总 */
+export interface AttendanceGradeCorrelationSummary {
+ classId: string;
+ className: string;
+ startDate: string;
+ endDate: string;
+ items: AttendanceGradeCorrelationItem[];
+ correlation: number | null;
+ correlationInterpretation: CorrelationInterpretation;
+ riskCounts: {
+ high: number;
+ medium: number;
+ low: number;
+ };
+}
+
+/** 班级对比数据项 */
+export interface ClassComparisonItem {
+ classId: string;
+ className: string;
+ total: number;
+ presentRate: number;
+ lateRate: number;
+ absentRate: number;
+}
+
+/** 班级对比汇总 */
+export interface ClassComparisonSummary {
+ gradeId: string;
+ gradeName: string;
+ startDate: string;
+ endDate: string;
+ items: ClassComparisonItem[];
+ averagePresentRate: number;
+}
+
+/** 考勤规则配置 */
+export interface AttendanceRule {
+ id: string;
+ classId: string | null;
+ lateThresholdMinutes: number | null;
+ earlyLeaveThresholdMinutes: number | null;
+ enableAutoMark: boolean | null;
+ attendanceRateThreshold: number | null;
+ consecutiveAbsenceThreshold: number | null;
+ createdAt: string;
+ updatedAt: string;
+}
+/** 学生考勤汇总统计 */
+export interface StudentAttendanceStats {
+ total: number;
+ present: number;
+ absent: number;
+ late: number;
+ earlyLeave: number;
+ excused: number;
+ presentRate: number;
+}
+
+/** 学生考勤明细记录 */
+export interface StudentAttendanceRecord {
+ id: string;
+ date: string;
+ className: string;
+ status: AttendanceStatus;
+ remark?: string | null;
+}
+
+/** 学生考勤汇总视图(用于 student-attendance-view 组件) */
+export interface StudentAttendanceSummary {
+ studentId: string;
+ studentName: string;
+ stats: StudentAttendanceStats;
+ recentRecords: StudentAttendanceRecord[];
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-delete-dialog.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-delete-dialog.tsx
new file mode 100644
index 0000000..9a2a0ad
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-delete-dialog.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+/**
+ * 班级删除确认对话框(迁移自 CICD src/modules/classes/components/class-delete-dialog.ts)
+ *
+ * 适配点:使用 portal-shell 的 ConfirmDeleteDialog 组件,类型改为 ClassListItem。
+ */
+import { useTranslations } from "next-intl";
+
+import type { ClassListItem } from "@/lib/api";
+import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
+
+export interface ClassDeleteDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ item: ClassListItem | null;
+ onConfirm: () => Promise;
+ isWorking: boolean;
+}
+
+export function ClassDeleteDialog({
+ open,
+ onOpenChange,
+ item,
+ onConfirm,
+ isWorking,
+}: ClassDeleteDialogProps): React.ReactNode {
+ const t = useTranslations("classes");
+ return (
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-assignments-widget.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-assignments-widget.tsx
new file mode 100644
index 0000000..47fc77b
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-assignments-widget.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+/**
+ * 班级作业 widget(迁移自 CICD src/modules/classes/components/class-detail/class-assignments-widget.tsx)
+ *
+ * 适配点:
+ * - AssignmentSummary 接口保留为本地定义(@contract-pending schema 尚未对齐)
+ * - 跳转链接前缀改为 /shell/teacher/...
+ * - formatDate 内联实现(portal-shell utils 未导出 formatDate)
+ * - i18n 命名空间:classes.detail.assignments.* / classes.detail.widgets.* / classes.detail.empty.*
+ */
+import Link from "next/link";
+import { ChevronRight, FileText } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+
+export interface AssignmentSummary {
+ id: string;
+ title: string;
+ status: string;
+ isActive: boolean;
+ isOverdue: boolean;
+ dueAt: Date | null;
+ submittedCount: number;
+ targetCount: number;
+ avgScore: number | null;
+ medianScore: number | null;
+}
+
+export interface ClassAssignmentsWidgetProps {
+ classId: string;
+ assignments: AssignmentSummary[];
+}
+
+function formatDueDate(date: Date): string {
+ return date.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+}
+
+export function ClassAssignmentsWidget({
+ classId,
+ assignments,
+}: ClassAssignmentsWidgetProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const activeAssignments = assignments.filter((a) => a.isActive);
+
+ return (
+
+
+
+
+ {t("detail.widgets.recentHomework")}
+
+
+ {t("detail.assignments.activeCount", {
+ count: activeAssignments.length,
+ })}
+
+
+
+
+
+ {assignments.length === 0 ? (
+
+
+
+
+
+
+ {t("detail.empty.noAssignments")}
+
+
+ {t("detail.empty.noAssignmentsDescription")}
+
+
+
+
+ ) : (
+
+ {assignments.slice(0, 5).map((assignment) => (
+
+
+
+ {assignment.title}
+
+
+
+ {assignment.dueAt
+ ? t("detail.assignments.due", {
+ date: formatDueDate(assignment.dueAt),
+ })
+ : t("detail.assignments.noDueDate")}
+
+ ·
+
+ {t("detail.assignments.submittedCount", {
+ submitted: assignment.submittedCount,
+ total: assignment.targetCount,
+ })}
+
+
+
+
+
+ {assignment.status}
+
+ {typeof assignment.avgScore === "number" ? (
+
+ {t("detail.assignments.avgLabel")}:{" "}
+ {assignment.avgScore.toFixed(0)}%
+
+ ) : null}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-header.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-header.tsx
new file mode 100644
index 0000000..45f4fdd
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-header.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+/**
+ * 班级头部信息(迁移自 CICD src/modules/classes/components/class-detail/class-header.tsx)
+ *
+ * 适配点:
+ * - 引用本目录的 EditClassDialog(已迁移)
+ * - initialData 字段对齐 portal-shell 的 ClassInfo(gradeId / headTeacherId / description)
+ * - i18n 命名空间:classes.detail.header.*
+ */
+import { useState } from "react";
+import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/shared/components/ui/dropdown-menu";
+
+import { EditClassDialog } from "../edit-class-dialog";
+
+export interface ClassHeaderProps {
+ classId: string;
+ name: string;
+ grade: string;
+ homeroom?: string | null;
+ room?: string | null;
+ schoolName?: string | null;
+ studentCount: number;
+}
+
+export function ClassHeader({
+ classId,
+ name,
+ grade,
+ homeroom,
+ room,
+ schoolName,
+ studentCount,
+}: ClassHeaderProps): React.ReactNode {
+ const [showEdit, setShowEdit] = useState(false);
+ const t = useTranslations("classes");
+
+ return (
+ <>
+
+
+
+
+ {name}
+
+
+ {schoolName ? (
+ <>
+ {schoolName}
+ ·
+ >
+ ) : null}
+
+ {grade}
+
+ {homeroom ? (
+ <>
+ ·
+ {t("detail.header.homeroom", { name: homeroom })}
+ >
+ ) : null}
+ {room ? (
+ <>
+ ·
+ {t("detail.header.room", { room })}
+ >
+ ) : null}
+ ·
+
+ {t("detail.header.students", { count: studentCount })}
+
+
+
+
+
+
+
+
+
+
+
+ setShowEdit(true)}>
+
+ {t("detail.header.editDetails")}
+
+
+
+ {t("detail.header.inviteStudents")}
+
+
+
+
+ {t("detail.header.classSettings")}
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-overview-stats.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-overview-stats.tsx
new file mode 100644
index 0000000..7c279df
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-overview-stats.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+/**
+ * 班级概览统计卡片(迁移自 CICD src/modules/classes/components/class-detail/class-overview-stats.tsx)
+ *
+ * 适配点:
+ * - 数据通过 props 传入,由父组件从 useClassInfo / useClassStudents 等组合而来
+ * - 使用 portal-shell 的 StatCard(@/shared/components/ui/stat-card)
+ * - i18n 命名空间:classes.detail.overview.*
+ */
+import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { StatCard } from "@/shared/components/ui/stat-card";
+
+export interface ClassOverviewStatsProps {
+ averageScore: number | null;
+ submissionRate: number;
+ papersToGrade: number;
+ overdueCount: number;
+}
+
+export function ClassOverviewStats({
+ averageScore,
+ submissionRate,
+ papersToGrade,
+ overdueCount,
+}: ClassOverviewStatsProps): React.ReactNode {
+ const t = useTranslations("classes");
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-quick-actions.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-quick-actions.tsx
new file mode 100644
index 0000000..69bafc5
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-quick-actions.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+/**
+ * 班级快速操作面板(迁移自 CICD src/modules/classes/components/class-detail/class-quick-actions.tsx)
+ *
+ * 适配点:
+ * - 跳转链接前缀改为 /shell/teacher/...
+ * - i18n 命名空间:classes.detail.quickActions.* / classes.detail.header.* / classes.detail.assignments.*
+ */
+import Link from "next/link";
+import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+
+export interface ClassQuickActionsProps {
+ classId: string;
+}
+
+export function ClassQuickActions({
+ classId,
+}: ClassQuickActionsProps): React.ReactNode {
+ const t = useTranslations("classes");
+ return (
+
+
+
+ {t("detail.widgets.quickActions")}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-schedule-widget.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-schedule-widget.tsx
new file mode 100644
index 0000000..a11fd2a
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-schedule-widget.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+/**
+ * 班级课表 widget(迁移自 CICD src/modules/classes/components/class-detail/class-schedule-widget.tsx)
+ *
+ * 适配点:
+ * - 类型改为 portal-shell 的 ClassScheduleItem(@/lib/api)
+ * - subjectName(CICD 为 course)
+ * - classroom(CICD 为 location)
+ * - 移除 HoverCard 依赖(portal-shell 未提供),用 title 属性替代
+ * - i18n 命名空间:classes.detail.widgets.* / classes.detail.schedule.* / classes.schedule.weekday.*
+ */
+import Link from "next/link";
+import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import type { ClassScheduleItem } from "@/lib/api";
+
+export interface ClassScheduleWidgetProps {
+ classId: string;
+ schedule: ClassScheduleItem[];
+}
+
+const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] as const;
+
+export function ClassScheduleGrid({
+ schedule,
+ compact = false,
+}: {
+ schedule: ClassScheduleItem[];
+ compact?: boolean;
+}): React.ReactNode {
+ const t = useTranslations("classes");
+ const weekdayLabels = [
+ t("schedule.weekday.1"),
+ t("schedule.weekday.2"),
+ t("schedule.weekday.3"),
+ t("schedule.weekday.4"),
+ t("schedule.weekday.5"),
+ ];
+
+ const groupedSchedule = schedule.reduce>(
+ (acc, item) => {
+ const day = item.weekday;
+ if (!acc[day]) acc[day] = [];
+ acc[day].push(item);
+ return acc;
+ },
+ {},
+ );
+
+ Object.keys(groupedSchedule).forEach((key) => {
+ const items = groupedSchedule[Number(key)];
+ if (items) {
+ items.sort((a, b) => a.startTime.localeCompare(b.startTime));
+ }
+ });
+
+ if (schedule.length === 0) {
+ return (
+
+
+
+
+
+ {t("detail.empty.noScheduleDescription")}
+
+
+ );
+ }
+
+ return (
+
+ {weekdayLabels.map((day, idx) => (
+
+ {day}
+
+ ))}
+
+ {WEEKDAY_INDICES.slice(0, 5).map((dayNum) => {
+ const items = groupedSchedule[dayNum] ?? [];
+ return (
+
+ {items.length === 0 ? (
+
+ ) : (
+ items.map((item) => (
+
+
+ {item.subjectName}
+
+
+ {item.startTime}-{item.endTime}
+
+
+ ))
+ )}
+
+ );
+ })}
+
+ );
+}
+
+export function ClassScheduleWidget({
+ classId,
+ schedule,
+}: ClassScheduleWidgetProps): React.ReactNode {
+ const t = useTranslations("classes");
+ return (
+
+
+
+ {t("detail.widgets.weeklySchedule")}
+
+
+
+
+
+
+ * {t("detail.schedule.showingWeekdays")}
+
+
+
+ );
+}
+
+// Re-export icon constants for consumers that need richer hover info (kept for API parity).
+export { Clock, MapPin };
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-students-widget.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-students-widget.tsx
new file mode 100644
index 0000000..195ef4d
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-students-widget.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+/**
+ * 班级学生 widget(迁移自 CICD src/modules/classes/components/class-detail/class-students-widget.tsx)
+ *
+ * 适配点:
+ * - StudentSummary 接口保留为本地定义(portal-shell ClassStudent 仅有 studentNo/name 等基础字段,
+ * 由父组件做映射后传入)
+ * - 跳转链接前缀改为 /shell/teacher/...
+ * - i18n 命名空间:classes.detail.widgets.* / classes.detail.students.* / classes.students.*
+ */
+import Link from "next/link";
+import { ChevronRight, Users } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@/shared/components/ui/avatar";
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+
+export interface StudentSummary {
+ id: string;
+ name: string;
+ email: string;
+ image?: string | null;
+ status: string;
+ subjectScores?: Record;
+}
+
+export interface ClassStudentsWidgetProps {
+ classId: string;
+ students: StudentSummary[];
+}
+
+export function ClassStudentsWidget({
+ classId,
+ students,
+}: ClassStudentsWidgetProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const activeCount = students.filter((s) => s.status === "active").length;
+
+ return (
+
+
+
+
+ {t("detail.widgets.studentList")}
+
+
+ {t("detail.students.activeCount", { count: activeCount })}
+
+
+
+
+
+ {students.length === 0 ? (
+
+
+
+
+
+ {t("students.empty.description")}
+
+
+ ) : (
+
+ {students.slice(0, 6).map((student) => (
+
+
+
+
+
+
+ {student.name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2)}
+
+
+
+
+ {student.name}
+
+
+ {student.email}
+
+
+
+
+ {student.status}
+
+
+
+ {student.subjectScores &&
+ Object.keys(student.subjectScores).length > 0 ? (
+
+ {Object.entries(student.subjectScores).map(
+ ([subject, score]) => (
+
+
+ {subject}
+
+ {score !== null ? (
+ = 60
+ ? "font-semibold text-primary"
+ : "font-semibold text-destructive"
+ }
+ >
+ {score}
+
+ ) : (
+ -
+ )}
+
+ ),
+ )}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-trends-widget.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-trends-widget.tsx
new file mode 100644
index 0000000..77a47cd
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-detail/class-trends-widget.tsx
@@ -0,0 +1,541 @@
+"use client";
+
+/**
+ * 班级趋势 widget(迁移自 CICD src/modules/classes/components/class-detail/class-trends-widget.tsx)
+ *
+ * 适配点:
+ * - ChartContainer / ChartConfig / ChartTooltip / ChartTooltipContent 从 @/shared/components/charts/chart 引入
+ * (portal-shell 将 chart 组件放在 charts/ 而非 ui/)
+ * - recharts 已安装 (^3.6.0)
+ * - transformAssignmentsToChartData / ClassSubmissionTrendChart 同时导出供外部使用
+ * - i18n 命名空间:classes.detail.trends.* / classes.detail.widgets.*
+ */
+import { useState } from "react";
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Line,
+ LineChart,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { ChevronDown } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/shared/components/charts/chart";
+import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs";
+import { Button } from "@/shared/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/shared/components/ui/dropdown-menu";
+
+const SPARK_MARGIN = { top: 5, right: 0, bottom: 0, left: 0 };
+const FULL_LINE_MARGIN = { top: 20, right: 20, bottom: 0, left: 0 };
+const GRID_PROPS = { vertical: false, strokeDasharray: "3 3" };
+const GRID_PROPS_NO_HORIZONTAL = {
+ vertical: false,
+ strokeDasharray: "3 3",
+ horizontal: false,
+};
+const ACTIVE_DOT_R6 = { r: 6 };
+const ACTIVE_DOT_R4 = { r: 4 };
+const SPARK_X_AXIS_PROPS = { dataKey: "title", hide: true };
+const SPARK_Y_AXIS_PROPS = { hide: true };
+const FULL_X_AXIS_PROPS = {
+ dataKey: "title",
+ tickLine: false,
+ tickMargin: 10,
+ axisLine: false,
+ fontSize: 12,
+};
+const SCORE_Y_DOMAIN: [number, number] = [0, 100];
+
+function formatValueTick(value: number): string {
+ return `${value}`;
+}
+
+function formatPercentTick(value: number): string {
+ return `${value}%`;
+}
+
+export interface AssignmentSummary {
+ id: string;
+ title: string;
+ status: string;
+ subject?: string | null;
+ isActive: boolean;
+ isOverdue: boolean;
+ dueAt: Date | null;
+ submittedCount: number;
+ targetCount: number;
+ avgScore: number | null;
+ medianScore: number | null;
+}
+
+export interface ClassTrendsWidgetProps {
+ assignments: AssignmentSummary[];
+ compact?: boolean;
+ className?: string;
+}
+
+export interface ChartDatum {
+ title: string;
+ fullTitle: string;
+ submitted: number;
+ target: number;
+ avg: number | null;
+ median: number | null;
+}
+
+export function transformAssignmentsToChartData(
+ assignments: AssignmentSummary[],
+ limit?: number,
+): ChartDatum[] {
+ const data: ChartDatum[] = [...assignments].reverse().map((a) => ({
+ title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
+ fullTitle: a.title,
+ submitted: a.submittedCount,
+ target: a.targetCount,
+ avg: a.avgScore ? Math.round(a.avgScore) : null,
+ median: a.medianScore ? Math.round(a.medianScore) : null,
+ }));
+
+ if (limit) {
+ return data.slice(-limit);
+ }
+
+ return data;
+}
+
+export function ClassSubmissionTrendChart({
+ data,
+ className,
+}: {
+ data: ChartDatum[];
+ className?: string;
+}): React.ReactNode {
+ const t = useTranslations("classes");
+ const chartConfig = {
+ submitted: {
+ label: t("detail.trends.submitted"),
+ color: "hsl(var(--primary))",
+ },
+ target: {
+ label: t("detail.trends.totalStudents"),
+ color: "hsl(var(--muted-foreground))",
+ },
+ } satisfies ChartConfig;
+ return (
+
+
+
+
+
+ } />
+
+
+
+
+ );
+}
+
+export function ClassTrendsWidget({
+ assignments,
+ compact,
+ className,
+}: ClassTrendsWidgetProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const chartConfig = {
+ submitted: {
+ label: t("detail.trends.submitted"),
+ color: "hsl(var(--primary))",
+ },
+ target: {
+ label: t("detail.trends.totalStudents"),
+ color: "hsl(var(--muted-foreground))",
+ },
+ avg: {
+ label: t("detail.trends.averageScore"),
+ color: "hsl(var(--chart-2))",
+ },
+ median: {
+ label: t("detail.trends.medianScore"),
+ color: "hsl(var(--chart-4))",
+ },
+ } satisfies ChartConfig;
+ const [chartTab, setChartTab] = useState<"submission" | "score">(
+ "submission",
+ );
+ const [selectedSubject, setSelectedSubject] = useState("all");
+
+ const subjects = Array.from(
+ new Set(
+ assignments
+ .map((a) => a.subject)
+ .filter((s): s is string => typeof s === "string"),
+ ),
+ );
+
+ const activeAssignments = assignments.filter((a) => {
+ if (selectedSubject !== "all" && a.subject !== selectedSubject)
+ return false;
+ return a.isActive || a.status === "published";
+ });
+
+ const chartData = transformAssignmentsToChartData(activeAssignments, 7);
+
+ if (chartData.length === 0 && selectedSubject === "all") return null;
+
+ if (compact) {
+ const lastAssignment = chartData[chartData.length - 1];
+
+ let metricValue = "0%";
+ const metricLabel = t("detail.trends.latest");
+
+ if (lastAssignment) {
+ if (chartTab === "submission") {
+ metricValue =
+ lastAssignment.target > 0
+ ? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
+ : "0%";
+ } else {
+ metricValue = lastAssignment.avg ? `${lastAssignment.avg}` : "-";
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ setChartTab("submission")}
+ className="text-xs"
+ >
+ {t("detail.widgets.submissionTrends")}
+
+ setChartTab("score")}
+ className="text-xs"
+ >
+ {t("detail.trends.scoreTrends")}
+
+
+
+
+ {subjects.length > 0 ? (
+
+
+
+
+
+ setSelectedSubject("all")}
+ className="text-xs"
+ >
+ {t("detail.trends.allSubjects")}
+
+ {subjects.map((s) => (
+ setSelectedSubject(s)}
+ className="text-xs"
+ >
+ {s}
+
+ ))}
+
+
+ ) : null}
+
+
+ {metricLabel}:{" "}
+ {metricValue}
+
+
+
+
+
+ {chartTab === "submission" ? (
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ ) : (
+
+
+
+
+ }
+ />
+
+
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {chartTab === "submission"
+ ? t("detail.widgets.submissionTrends")
+ : t("detail.trends.scoreTrends")}
+
+
+ {chartTab === "submission"
+ ? t("detail.trends.recentTurnInRates")
+ : t("detail.trends.avgVsMedian")}
+
+
+ setChartTab(v as "submission" | "score")}
+ className="w-auto"
+ >
+
+
+ {t("detail.trends.submission")}
+
+
+ {t("detail.trends.score")}
+
+
+
+
+
+ {subjects.length > 0 ? (
+
+
+
+ {t("detail.trends.allSubjects")}
+
+ {subjects.map((s) => (
+
+ {s}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ {chartData.length > 0 ? (
+
+ {chartTab === "submission" ? (
+
+
+
+
+ } />
+
+
+
+ ) : (
+
+
+
+
+ } />
+
+
+
+ )}
+
+ ) : (
+
+ {t("detail.trends.noDataForSubject")}
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-invitation-manager.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-invitation-manager.tsx
new file mode 100644
index 0000000..82879a9
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-invitation-manager.tsx
@@ -0,0 +1,327 @@
+"use client";
+
+/**
+ * 班级邀请码管理(迁移自 CICD src/modules/classes/components/class-invitation-manager.tsx)
+ *
+ * 适配点:
+ * - 数据由 server action 改为 portal-shell hooks:
+ * - useClassInvitationCodes(classId) 列表查询
+ * - useGenerateClassInvitationCode() 生成
+ * - useRevokeClassInvitationCode() 撤销
+ * - 移除 react-query 直接调用(由 hooks 内部封装)
+ * - 复用 portal-shell 的 Table / Dialog / Badge / Button 组件
+ * - i18n 命名空间:classes.invitation.*
+ */
+import * as React from "react";
+import { Ban, Clock, Copy, Hash, Plus } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { notify } from "@/shared/lib/notify";
+import {
+ useGenerateClassInvitationCode,
+ useClassInvitationCodes,
+ useRevokeClassInvitationCode,
+ type ClassInvitationCode,
+} from "@/lib/api";
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/shared/components/ui/dialog";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+
+export interface ClassInvitationManagerProps {
+ classId: string;
+}
+
+function StatusBadge({ status }: { status: string }): React.ReactNode {
+ const t = useTranslations("classes.invitation");
+ const variant = status === "active" ? "default" : "secondary";
+ return {t(status)};
+}
+
+function formatExpiresAt(expiresAt: string | null): string {
+ if (!expiresAt) return "";
+ try {
+ return new Date(expiresAt).toLocaleString();
+ } catch {
+ return expiresAt;
+ }
+}
+
+export function ClassInvitationManager({
+ classId,
+}: ClassInvitationManagerProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const [isGenerateOpen, setIsGenerateOpen] = React.useState(false);
+ const [revokeTarget, setRevokeTarget] =
+ React.useState(null);
+
+ const { data: codes = [], refetch } = useClassInvitationCodes(classId);
+ const { run: generateCode, loading: isGenerating } =
+ useGenerateClassInvitationCode();
+ const { run: revokeCode, loading: isRevoking } =
+ useRevokeClassInvitationCode();
+
+ const handleCopy = async (code: string): Promise => {
+ try {
+ await navigator.clipboard.writeText(code);
+ notify.success(t("invitation.copied"));
+ } catch {
+ notify.error(t("invitation.copyFailed"));
+ }
+ };
+
+ const handleRevoke = async (): Promise => {
+ if (!revokeTarget) return;
+ try {
+ await revokeCode(revokeTarget.id);
+ notify.success(t("invitation.revokeSuccess"));
+ setRevokeTarget(null);
+ void refetch();
+ } catch (err) {
+ notify.error(t("invitation.revokeFailed"));
+ console.error("Revoke invitation code failed:", err);
+ }
+ };
+
+ const handleCreated = (): void => {
+ void refetch();
+ };
+
+ return (
+
+
+ {t("invitation.title")}
+
+
+
+ {codes.length === 0 ? (
+
+ {t("invitation.empty")}
+
+ ) : (
+
+
+
+ {t("invitation.code")}
+ {t("invitation.status")}
+ {t("invitation.usedCount")}
+ {t("invitation.expiresAt")}
+ {t("invitation.note")}
+
+ {t("invitation.copy")}
+
+
+
+
+ {codes.map((record) => (
+
+
+ {record.code}
+
+
+
+
+
+
+ {record.usedCount}
+ {record.maxUses !== null ? ` / ${record.maxUses}` : ""}
+
+
+
+ {record.expiresAt
+ ? formatExpiresAt(record.expiresAt)
+ : t("invitation.neverExpires")}
+
+
+ {record.note ?? "—"}
+
+
+
+
+ {record.status === "active" ? (
+
+ ) : null}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
+
+interface GenerateCodeDialogProps {
+ classId: string;
+ isWorking: boolean;
+ onGenerate: (input: {
+ classId: string;
+ expiresInHours: number | null;
+ maxUses: number | null;
+ note: string | null;
+ }) => Promise;
+}
+
+function GenerateCodeDialog({
+ classId,
+ isWorking,
+ onGenerate,
+}: GenerateCodeDialogProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const [expiresInHours, setExpiresInHours] = React.useState("");
+ const [maxUses, setMaxUses] = React.useState("");
+ const [note, setNote] = React.useState("");
+
+ const handleSubmit = async (e: React.FormEvent): Promise => {
+ e.preventDefault();
+ await onGenerate({
+ classId,
+ expiresInHours: expiresInHours ? Number(expiresInHours) : null,
+ maxUses: maxUses ? Number(maxUses) : null,
+ note: note || null,
+ });
+ };
+
+ return (
+
+
+ {t("invitation.generateWithCustom")}
+
+ {t("invitation.defaultDuration")} · {t("invitation.defaultMaxUses")}
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-list-table.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-list-table.tsx
new file mode 100644
index 0000000..2f2fbf0
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-list-table.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+/**
+ * 班级列表表格(迁移自 CICD src/modules/classes/components/class-list-table.tsx)
+ *
+ * 适配点:
+ * - 数据类型改为 portal-shell 的 ClassListItem(@/lib/api)
+ * - ClassListItem 无 schoolName / homeroom / room / subjectTeachers 字段,这些列已移除
+ * - 保留 name / gradeId / headTeacherName / studentCount / updatedAt 等列
+ * - formatDate 内联实现(portal-shell utils 未导出 formatDate)
+ * - i18n 命名空间:classes.list.*
+ */
+import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import type { ClassListItem } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/shared/components/ui/dropdown-menu";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+
+export interface ClassListTableProps {
+ classes: ClassListItem[];
+ onEdit: (item: ClassListItem) => void;
+ onDelete: (item: ClassListItem) => void;
+ isWorking: boolean;
+ emptyDescription?: string;
+}
+
+function formatDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+export function ClassListTable({
+ classes,
+ onEdit,
+ onDelete,
+ isWorking,
+ emptyDescription,
+}: ClassListTableProps): React.ReactNode {
+ const t = useTranslations("classes");
+ return (
+
+
+ {t("list.title")}
+
+
+ {classes.length === 0 ? (
+
+ ) : (
+
+
+
+ {t("list.colName")}
+ {t("list.colGrade")}
+ {t("list.colHeadTeacher")}
+ {t("list.colSubjectCount")}
+
+ {t("list.colStudentCount")}
+
+ {t("list.colUpdatedAt")}
+
+
+
+
+ {classes.map((c) => (
+
+ {c.name}
+
+ {c.gradeId}
+
+
+ {c.headTeacherName ?? "-"}
+
+
+ {c.subjectCount}
+
+
+ {c.studentCount}
+
+
+ {formatDate(c.updatedAt)}
+
+
+
+
+
+
+
+ onEdit(c)}>
+
+ {t("list.actions.edit")}
+
+
+ onDelete(c)}
+ >
+
+ {t("list.actions.delete")}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/class-list-toolbar.tsx b/apps/portal-shell/src/features/teacher/classes/components/class-list-toolbar.tsx
new file mode 100644
index 0000000..c321564
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/class-list-toolbar.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+
+export interface ClassListToolbarProps {
+ count: number;
+ onNew: () => void;
+ isWorking: boolean;
+ disabled?: boolean;
+}
+
+export function ClassListToolbar({
+ count,
+ onNew,
+ isWorking,
+ disabled = false,
+}: ClassListToolbarProps): React.ReactNode {
+ const t = useTranslations("classes");
+ return (
+
+
+ {count}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/edit-class-dialog.tsx b/apps/portal-shell/src/features/teacher/classes/components/edit-class-dialog.tsx
new file mode 100644
index 0000000..39ff8b6
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/edit-class-dialog.tsx
@@ -0,0 +1,161 @@
+"use client";
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import type { ClassInfo } from "@/lib/api";
+import { useUpdateClass } from "@/lib/api";
+import { notify } from "@/shared/lib/notify";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+
+export interface EditClassDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ classId: string;
+ initialData: Pick<
+ ClassInfo,
+ "name" | "gradeId" | "headTeacherId" | "description"
+ >;
+}
+
+interface EditFormState {
+ name: string;
+ gradeId: string;
+ headTeacherId: string;
+ description: string;
+}
+
+export function EditClassDialog({
+ open,
+ onOpenChange,
+ classId,
+ initialData,
+}: EditClassDialogProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const { run: updateClass, loading: isWorking } = useUpdateClass();
+ const [form, setForm] = useState({
+ name: initialData.name,
+ gradeId: initialData.gradeId,
+ headTeacherId: initialData.headTeacherId ?? "",
+ description: initialData.description ?? "",
+ });
+ useEffect(() => {
+ if (open) {
+ setForm({
+ name: initialData.name,
+ gradeId: initialData.gradeId,
+ headTeacherId: initialData.headTeacherId ?? "",
+ description: initialData.description ?? "",
+ });
+ }
+ }, [open, initialData]);
+ const handleSubmit = async (e: React.FormEvent): Promise => {
+ e.preventDefault();
+ try {
+ await updateClass(classId, {
+ name: form.name,
+ gradeId: form.gradeId,
+ headTeacherId: form.headTeacherId || null,
+ description: form.description || null,
+ });
+ notify.success(t("detail.edit.success"));
+ onOpenChange(false);
+ } catch (err) {
+ notify.error(t("detail.edit.failed"));
+ console.error("Update class failed:", err);
+ }
+ };
+ return (
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/my-classes-grid.tsx b/apps/portal-shell/src/features/teacher/classes/components/my-classes-grid.tsx
new file mode 100644
index 0000000..67a200b
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/my-classes-grid.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+/**
+ * 我的班级网格视图(迁移自 CICD src/modules/classes/components/my-classes-grid.tsx)
+ *
+ * 适配点:
+ * - 数据类型改为 portal-shell 的 ClassListItem(@/lib/api)
+ * - ClassListItem 无 invitationCode / schedule / recentAssignments / room / schoolName 字段
+ * - 移除 ensureClassInvitationCodeAction / regenerateClassInvitationCodeAction / joinClassByInvitationCodeAction 依赖
+ * (@contract-pending,invitation 流程由独立的 ClassInvitationManager 组件承载)
+ * - 移除 CICD 复杂的"票据"视觉装饰(与 portal-shell 设计令牌规范冲突),改为简洁卡片
+ * - 复用本目录的 ClassScheduleGrid(来自 class-detail/class-schedule-widget)
+ * - i18n 命名空间:classes.myClasses.* / classes.invitation.* / classes.list.*
+ */
+import Link from "next/link";
+import { Users } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import type { ClassListItem } from "@/lib/api";
+
+export interface MyClassesGridProps {
+ classes: ClassListItem[];
+}
+
+export function MyClassesGrid({
+ classes,
+}: MyClassesGridProps): React.ReactNode {
+ const t = useTranslations("classes");
+
+ if (classes.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {classes.map((c) => (
+
+
+
+
+ {c.name}
+
+
+ {c.gradeId} · {c.id.slice(-4).toUpperCase()}
+
+
+
+ {c.studentCount} {t("myClasses.students")}
+
+
+
+
+
+ {t("list.colHeadTeacher")}
+
+ {c.headTeacherName ?? "-"}
+
+
+
+ {t("list.colSubjectCount")}
+
+ {c.subjectCount}
+
+
+ {c.description ? (
+ {c.description}
+ ) : null}
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/schedule-create-dialog.tsx b/apps/portal-shell/src/features/teacher/classes/components/schedule-create-dialog.tsx
new file mode 100644
index 0000000..1842b34
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/schedule-create-dialog.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+/**
+ * 课表新增对话框(迁移自 CICD src/modules/classes/components/schedule-create-dialog.tsx)
+ *
+ * 适配点:
+ * - 表单数据由 server action 改为 useCreateClassSchedule hook(@/lib/api)
+ * - 输入字段对齐 portal-shell 的 ClassScheduleInput:
+ * - subjectName(CICD 为 course)
+ * - classroom(CICD 为 location)
+ * - 新增 period / teacherName 必填字段(schema 要求)
+ * - 使用受控表单 + useState,而非 FormData
+ * - i18n 命名空间:classes.schedule.form.* / classes.form.*
+ */
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { notify } from "@/shared/lib/notify";
+import { useCreateClassSchedule } from "@/lib/api";
+import type { ClassListItem } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { Select } from "@/shared/components/ui/select";
+import { SCHEDULE_WEEKDAYS } from "./schedule-utils";
+
+export interface ScheduleCreateDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ classes: ClassListItem[];
+ defaultClassId: string;
+ weekday: number;
+}
+
+interface CreateFormState {
+ classId: string;
+ weekday: number;
+ period: string;
+ subjectName: string;
+ teacherName: string;
+ classroom: string;
+ startTime: string;
+ endTime: string;
+}
+
+export function ScheduleCreateDialog({
+ open,
+ onOpenChange,
+ classes,
+ defaultClassId,
+ weekday,
+}: ScheduleCreateDialogProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const { run: createSchedule, loading: isWorking } = useCreateClassSchedule();
+ const [form, setForm] = useState({
+ classId: defaultClassId,
+ weekday,
+ period: "1",
+ subjectName: "",
+ teacherName: "",
+ classroom: "",
+ startTime: "08:00",
+ endTime: "08:45",
+ });
+
+ // 打开时同步默认值
+ const [prevOpen, setPrevOpen] = useState(open);
+ if (open !== prevOpen) {
+ setPrevOpen(open);
+ if (open) {
+ setForm((s) => ({ ...s, classId: defaultClassId, weekday }));
+ }
+ }
+
+ const handleSubmit = async (e: React.FormEvent): Promise => {
+ e.preventDefault();
+ if (!form.classId) return;
+ try {
+ await createSchedule({
+ classId: form.classId,
+ weekday: form.weekday,
+ period: Number(form.period) || 1,
+ subjectName: form.subjectName,
+ teacherName: form.teacherName,
+ classroom: form.classroom || null,
+ startTime: form.startTime,
+ endTime: form.endTime,
+ });
+ notify.success(t("schedule.form.createSuccess"));
+ onOpenChange(false);
+ } catch (err) {
+ notify.error(t("schedule.form.createFailed"));
+ console.error("Create schedule failed:", err);
+ }
+ };
+
+ const weekdayLabel =
+ SCHEDULE_WEEKDAYS.find((w) => w.key === weekday)?.label ??
+ "schedule.weekday.1";
+
+ return (
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/schedule-delete-dialog.tsx b/apps/portal-shell/src/features/teacher/classes/components/schedule-delete-dialog.tsx
new file mode 100644
index 0000000..cb734c3
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/schedule-delete-dialog.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+/**
+ * 课表删除确认对话框(迁移自 CICD src/modules/classes/components/schedule-delete-dialog.tsx)
+ *
+ * 适配点:
+ * - 表单数据由 server action 改为 useDeleteClassSchedule hook(@/lib/api)
+ * - 使用 portal-shell 的 ConfirmDeleteDialog 组件
+ * - i18n 命名空间:classes.schedule.form.*
+ */
+import { useTranslations } from "next-intl";
+
+import { notify } from "@/shared/lib/notify";
+import { useDeleteClassSchedule } from "@/lib/api";
+import type { ClassScheduleItem } from "@/lib/api";
+import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
+
+export interface ScheduleDeleteDialogProps {
+ deleteItem: ClassScheduleItem | null;
+ onClose: () => void;
+}
+
+export function ScheduleDeleteDialog({
+ deleteItem,
+ onClose,
+}: ScheduleDeleteDialogProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const { run: deleteSchedule, loading: isWorking } = useDeleteClassSchedule();
+
+ const handleConfirm = async (): Promise => {
+ if (!deleteItem) return;
+ try {
+ await deleteSchedule(deleteItem.id);
+ notify.success(t("schedule.form.deleteSuccess"));
+ onClose();
+ } catch (err) {
+ notify.error(t("schedule.form.deleteFailed"));
+ console.error("Delete schedule failed:", err);
+ }
+ };
+
+ return (
+ {
+ if (isWorking) return;
+ if (!open) onClose();
+ }}
+ title={t("schedule.form.deleteTitle")}
+ description={
+ deleteItem
+ ? t("schedule.form.deleteDescription", {
+ subject: deleteItem.subjectName,
+ time: `${deleteItem.startTime}-${deleteItem.endTime}`,
+ })
+ : t("schedule.form.deleteDescription", {
+ subject: "",
+ time: "",
+ })
+ }
+ confirmText={t("list.actions.delete")}
+ cancelText={t("form.cancel")}
+ onConfirm={handleConfirm}
+ isWorking={isWorking}
+ />
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/schedule-edit-dialog.tsx b/apps/portal-shell/src/features/teacher/classes/components/schedule-edit-dialog.tsx
new file mode 100644
index 0000000..1b42c27
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/schedule-edit-dialog.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+/**
+ * 课表编辑对话框(迁移自 CICD src/modules/classes/components/schedule-edit-dialog.tsx)
+ *
+ * 适配点:
+ * - 表单数据由 server action 改为 useUpdateClassSchedule hook(@/lib/api)
+ * - 输入字段对齐 portal-shell 的 ClassScheduleInput:
+ * - subjectName(CICD 为 course)
+ * - classroom(CICD 为 location)
+ * - 新增 period / teacherName 字段
+ * - 使用受控表单,editItem 变化时同步本地状态
+ * - i18n 命名空间:classes.schedule.form.* / classes.form.*
+ */
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { notify } from "@/shared/lib/notify";
+import { useUpdateClassSchedule } from "@/lib/api";
+import type { ClassListItem, ClassScheduleItem } from "@/lib/api";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { Select } from "@/shared/components/ui/select";
+import { SCHEDULE_WEEKDAYS } from "./schedule-utils";
+
+export interface ScheduleEditDialogProps {
+ editItem: ClassScheduleItem | null;
+ classes: ClassListItem[];
+ onClose: () => void;
+}
+
+interface EditFormState {
+ classId: string;
+ weekday: string;
+ period: string;
+ subjectName: string;
+ teacherName: string;
+ classroom: string;
+ startTime: string;
+ endTime: string;
+}
+
+export function ScheduleEditDialog({
+ editItem,
+ classes,
+ onClose,
+}: ScheduleEditDialogProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const { run: updateSchedule, loading: isWorking } = useUpdateClassSchedule();
+ const [form, setForm] = useState({
+ classId: "",
+ weekday: "1",
+ period: "1",
+ subjectName: "",
+ teacherName: "",
+ classroom: "",
+ startTime: "08:00",
+ endTime: "08:45",
+ });
+
+ // editItem 变更时同步本地表单状态
+ const [prevItem, setPrevItem] = useState(editItem);
+ if (editItem !== prevItem) {
+ setPrevItem(editItem);
+ if (editItem) {
+ setForm({
+ classId: classes[0]?.id ?? "",
+ weekday: String(editItem.weekday),
+ period: String(editItem.period),
+ subjectName: editItem.subjectName,
+ teacherName: editItem.teacherName,
+ classroom: editItem.classroom ?? "",
+ startTime: editItem.startTime,
+ endTime: editItem.endTime,
+ });
+ }
+ }
+
+ const handleSubmit = async (e: React.FormEvent): Promise => {
+ e.preventDefault();
+ if (!editItem) return;
+ try {
+ await updateSchedule(editItem.id, {
+ classId: form.classId,
+ weekday: Number(form.weekday),
+ period: Number(form.period) || 1,
+ subjectName: form.subjectName,
+ teacherName: form.teacherName,
+ classroom: form.classroom || null,
+ startTime: form.startTime,
+ endTime: form.endTime,
+ });
+ notify.success(t("schedule.form.editSuccess"));
+ onClose();
+ } catch (err) {
+ notify.error(t("schedule.form.editFailed"));
+ console.error("Update schedule failed:", err);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/schedule-utils.ts b/apps/portal-shell/src/features/teacher/classes/components/schedule-utils.ts
new file mode 100644
index 0000000..80b45bd
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/schedule-utils.ts
@@ -0,0 +1,122 @@
+/**
+ * 课表纯函数工具集(迁移自 CICD src/modules/classes/components/schedule-utils.ts)
+ *
+ * 适配点:
+ * - 保留中英文科目颜色映射
+ * - 时间定位函数(getPositionStyle / timeToMinutes)
+ * - SCHEDULE_WEEKDAYS 常量(label 为 i18n 翻译键)
+ */
+
+/** 科目到颜色类的映射表(中英文关键词均匹配)。 */
+const SUBJECT_COLOR_MAP: ReadonlyArray<{
+ keywords: readonly string[];
+ classes: string;
+}> = [
+ {
+ keywords: ["math", "数学"],
+ classes:
+ "bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20",
+ },
+ {
+ keywords: ["physics", "物理", "science", "科学"],
+ classes:
+ "bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20",
+ },
+ {
+ keywords: ["english", "英语", "lit"],
+ classes:
+ "bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20",
+ },
+ {
+ keywords: ["history", "历史", "geo", "地理", "社会"],
+ classes:
+ "bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20",
+ },
+ {
+ keywords: ["art", "美术", "music", "音乐"],
+ classes:
+ "bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20",
+ },
+ {
+ keywords: ["sport", "pe", "体育"],
+ classes:
+ "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20",
+ },
+ {
+ keywords: ["chinese", "语文", "language"],
+ classes:
+ "bg-rose-500/10 text-rose-700 border-rose-500/20 hover:bg-rose-500/20",
+ },
+] as const;
+
+const DEFAULT_SUBJECT_COLOR =
+ "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20";
+
+/**
+ * 根据科目名称返回对应的颜色类名(支持中英文)。
+ * 匹配规则:科目名转为小写后,检查是否包含映射表中的任一关键词。
+ */
+export function getSubjectColor(subject: string): string {
+ const s = subject.toLowerCase();
+ for (const entry of SUBJECT_COLOR_MAP) {
+ if (entry.keywords.some((kw) => s.includes(kw.toLowerCase()))) {
+ return entry.classes;
+ }
+ }
+ return DEFAULT_SUBJECT_COLOR;
+}
+
+/** 课表时间范围:8:00 - 18:00。 */
+const MIN_TIME_MINUTES = 8 * 60;
+const MAX_TIME_MINUTES = 18 * 60;
+const TOTAL_DURATION_MINUTES = MAX_TIME_MINUTES - MIN_TIME_MINUTES;
+
+/**
+ * 将 "HH:MM" 时间字符串转换为当天从 0 点起的分钟数。
+ */
+export function timeToMinutes(time: string): number {
+ const parts = time.split(":").map(Number);
+ const hours = parts[0] ?? 0;
+ const minutes = parts[1] ?? 0;
+ return hours * 60 + minutes;
+}
+
+/**
+ * 根据课程的开始/结束时间计算课表块在时间轴上的定位样式(top% / height%)。
+ * 时间轴范围:8:00 - 18:00。
+ */
+export function getPositionStyle(
+ startTime: string,
+ endTime: string,
+): { top: string; height: string } {
+ const startMinutes = timeToMinutes(startTime);
+ const endMinutes = timeToMinutes(endTime);
+
+ const top = Math.max(
+ 0,
+ ((startMinutes - MIN_TIME_MINUTES) / TOTAL_DURATION_MINUTES) * 100,
+ );
+ const height = Math.min(
+ 100 - top,
+ ((endMinutes - startMinutes) / TOTAL_DURATION_MINUTES) * 100,
+ );
+
+ return {
+ top: `${top}%`,
+ height: `${height}%`,
+ };
+}
+
+/** 课表周常量(周一~周日),label 为 i18n 翻译键。 */
+export const SCHEDULE_WEEKDAYS: ReadonlyArray<{
+ key: 1 | 2 | 3 | 4 | 5 | 6 | 7;
+ label: string;
+}> = [
+ { key: 1, label: "schedule.weekday.1" },
+ { key: 2, label: "schedule.weekday.2" },
+ { key: 3, label: "schedule.weekday.3" },
+ { key: 4, label: "schedule.weekday.4" },
+ { key: 5, label: "schedule.weekday.5" },
+ { key: 6, label: "schedule.weekday.6" },
+ { key: 7, label: "schedule.weekday.7" },
+];
diff --git a/apps/portal-shell/src/features/teacher/classes/components/schedule-view.tsx b/apps/portal-shell/src/features/teacher/classes/components/schedule-view.tsx
new file mode 100644
index 0000000..f858ac5
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/schedule-view.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+/**
+ * 课表周视图(迁移自 CICD src/modules/classes/components/schedule-view.tsx)
+ *
+ * 适配点:
+ * - 数据类型改为 portal-shell 的 ClassScheduleItem / ClassListItem(@/lib/api)
+ * - portal-shell ClassScheduleItem 字段:subjectName / classroom / classId 等
+ * - 替换 CICD 的 course / location 字段引用为 subjectName / classroom
+ * - 复用本目录的 schedule-utils.ts(getPositionStyle / getSubjectColor / SCHEDULE_WEEKDAYS)
+ * - 复用本目录的 ScheduleCreateDialog / ScheduleEditDialog / ScheduleDeleteDialog(hooks 化版本)
+ * - i18n 命名空间:classes.schedule.* / classes.list.actions.*
+ */
+import { useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
+
+import { cn } from "@/shared/lib/utils";
+import { Button } from "@/shared/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/shared/components/ui/dropdown-menu";
+import type { ClassListItem, ClassScheduleItem } from "@/lib/api";
+
+import {
+ getPositionStyle,
+ getSubjectColor,
+ SCHEDULE_WEEKDAYS,
+} from "./schedule-utils";
+import { ScheduleCreateDialog } from "./schedule-create-dialog";
+import { ScheduleEditDialog } from "./schedule-edit-dialog";
+import { ScheduleDeleteDialog } from "./schedule-delete-dialog";
+
+export interface ScheduleViewProps {
+ schedule: ClassScheduleItem[];
+ classes: ClassListItem[];
+}
+
+const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i); // 8, 9, ..., 18
+
+export function ScheduleView({
+ schedule,
+ classes,
+}: ScheduleViewProps): React.ReactNode {
+ const t = useTranslations("classes");
+ const [editItem, setEditItem] = useState(null);
+ const [deleteItem, setDeleteItem] = useState(null);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [createWeekday, setCreateWeekday] =
+ useState(1);
+
+ const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes]);
+
+ const byDay = new Map();
+ for (const d of SCHEDULE_WEEKDAYS) byDay.set(d.key, []);
+ for (const item of schedule) byDay.get(item.weekday)?.push(item);
+
+ return (
+
+
+ {/* Time Axis */}
+
+
+
+ {HOURS.map((h, i) => (
+
+ {h}:00
+
+ ))}
+
+
+
+ {/* Days Columns */}
+
+ {SCHEDULE_WEEKDAYS.slice(0, 5).map((d) => (
+
+
+
+ {t(d.label)}
+
+
+
+
+
+
+ {(byDay.get(d.key) ?? []).map((item) => (
+
+
+
+
+
+ {item.subjectName}
+
+
+ {item.startTime} - {item.endTime}
+
+
+ {item.teacherName}
+
+
+
+
+
+
+
+
+
+ setEditItem(item)}
+ className="text-xs"
+ >
+
+ {t("list.actions.edit")}
+
+
+ setDeleteItem(item)}
+ >
+
+ {t("list.actions.delete")}
+
+
+
+
+
+
+
+ ))}
+
+ {/* Add Button Overlay - Only visible on hover of the column */}
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ setEditItem(null)}
+ />
+
+ setDeleteItem(null)}
+ />
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/components/students-table.tsx b/apps/portal-shell/src/features/teacher/classes/components/students-table.tsx
new file mode 100644
index 0000000..3a7b9bf
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/components/students-table.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+/**
+ * 学生表格(迁移自 CICD src/modules/classes/components/students-table.tsx)
+ *
+ * 适配点:
+ * - 数据类型改为 portal-shell 的 ClassStudent(@/lib/api)
+ * - ClassStudent 仅有 id / studentNo / name / gender / classId / className / gradeId / enrolledAt
+ * - 移除 email / image / status / subjectScores 相关 UI(schema 未对齐)
+ * - 移除 setStudentEnrollmentStatusAction 依赖(@contract-pending,schema 暂无对应 mutation)
+ * - getInitials / formatDate 从 portal-shell utils 引入或内联
+ * - i18n 命名空间:classes.students.* / classes.detail.*
+ */
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+} from "@/shared/components/ui/card";
+import { getInitials } from "@/shared/lib/utils";
+import type { ClassStudent } from "@/lib/api";
+
+export interface StudentsTableProps {
+ students: ClassStudent[];
+}
+
+function formatDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+export function StudentsTable({
+ students,
+}: StudentsTableProps): React.ReactNode {
+ const t = useTranslations("classes");
+ return (
+
+ {students.map((s) => (
+
+
+
+ {getInitials(s.name)}
+
+
+
+
+
+ {s.name}
+
+
+ {s.studentNo}
+
+
+
+
+ {s.className}
+
+ {formatDate(s.enrolledAt)}
+
+
+
+
+
+
+
+ {t("students.noScores")}
+
+
+
+
+
+ {t("students.viewDetail")}
+
+
+ {s.gender}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/hooks/use-class-data.ts b/apps/portal-shell/src/features/teacher/classes/hooks/use-class-data.ts
new file mode 100644
index 0000000..a795e99
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/hooks/use-class-data.ts
@@ -0,0 +1,56 @@
+"use client";
+
+/**
+ * 班级列表/弹窗共享状态钩子(迁移自 CICD src/modules/classes/hooks/use-class-data.ts)
+ *
+ * 适配说明(portal-shell):
+ * - 数据类型改为 portal-shell 的 ClassListItem(@/lib/api)
+ * - 移除 subjectTeachers / schoolId / gradeId 表单字段(portal-shell 简化模型)
+ * - 保留对话框开关、编辑项、删除项、提交中标记等核心状态
+ * - 使用 derived state 模式避免 useEffect 重置表单
+ *
+ * 关联:ARCHITECTURE.md §3.4 TypeScript 规则 / §9.1 教师域班级模块
+ */
+import { useState } from "react";
+
+import type { ClassListItem } from "@/lib/api";
+
+export interface UseClassDataReturn {
+ createOpen: boolean;
+ setCreateOpen: (open: boolean) => void;
+ editItem: ClassListItem | null;
+ setEditItem: (item: ClassListItem | null) => void;
+ deleteItem: ClassListItem | null;
+ setDeleteItem: (item: ClassListItem | null) => void;
+ isWorking: boolean;
+ setIsWorking: (v: boolean) => void;
+}
+
+/**
+ * 班级列表/弹窗共享状态:对话框开关、提交中标记。
+ *
+ * - createOpen / editItem / deleteItem 控制三种对话框
+ * - isWorking 标记异步提交中,禁用按钮防止重复提交
+ *
+ * 使用示例:
+ * ```tsx
+ * const { createOpen, setCreateOpen, editItem, setEditItem, isWorking } = useClassData();
+ * ```
+ */
+export function useClassData(): UseClassDataReturn {
+ const [isWorking, setIsWorking] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editItem, setEditItem] = useState(null);
+ const [deleteItem, setDeleteItem] = useState(null);
+
+ return {
+ createOpen,
+ setCreateOpen,
+ editItem,
+ setEditItem,
+ deleteItem,
+ setDeleteItem,
+ isWorking,
+ setIsWorking,
+ };
+}
diff --git a/apps/portal-shell/src/features/teacher/classes/hooks/use-class-filters.ts b/apps/portal-shell/src/features/teacher/classes/hooks/use-class-filters.ts
new file mode 100644
index 0000000..9916d9c
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/classes/hooks/use-class-filters.ts
@@ -0,0 +1,42 @@
+"use client";
+
+/**
+ * 班级列表筛选钩子(迁移自 CICD src/modules/classes/hooks/use-class-filters.ts)
+ *
+ * 适配说明(portal-shell):
+ * - 移除按学校筛选年级的双模式(admin 模式),portal-shell 班级模型无 schoolId 字段
+ * - 改为按 gradeId 筛选班级列表(portal-shell ClassListItem 有 gradeId 字段)
+ * - 保留 useMemo 优化筛选性能
+ *
+ * 关联:ARCHITECTURE.md §3.4 TypeScript 规则 / §9.1 教师域班级模块
+ */
+import { useMemo } from "react";
+
+import type { ClassListItem } from "@/lib/api";
+
+export interface UseClassFiltersReturn {
+ filteredClasses: ClassListItem[];
+}
+
+/**
+ * 按年级筛选班级列表。
+ *
+ * - gradeId 为空字符串或 undefined 时返回全部班级
+ * - gradeId 非空时返回匹配 gradeId 的班级
+ *
+ * 使用示例:
+ * ```tsx
+ * const { filteredClasses } = useClassFilters(classes, gradeId);
+ * ```
+ */
+export function useClassFilters(
+ classes: ClassListItem[],
+ gradeId: string | undefined,
+): UseClassFiltersReturn {
+ const filteredClasses = useMemo(() => {
+ if (!gradeId) return classes;
+ return classes.filter((c) => c.gradeId === gradeId);
+ }, [classes, gradeId]);
+
+ return { filteredClasses };
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-calendar.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-calendar.tsx
new file mode 100644
index 0000000..b2cf949
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-calendar.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+/**
+ * 课程计划月历视图组件(P2 迁移)
+ *
+ * 通过纯函数 `planToCalendarEvents` 将周计划映射到日期范围,
+ * 渲染 6×7 月历网格,支持上一月/下一月/今天导航。
+ *
+ * 适配要点(vs CICD):
+ * - 移除 @dnd-kit 依赖(portal-shell 未引入)
+ * - 路径对齐 portal-shell:使用相对 import "./types" 与 "../lib/calendar-utils"
+ * - React.ReactElement 显式返回类型(portal-shell 严格模式)
+ *
+ * 关联:ARCHITECTURE.md §10 P2 / §11.3 DoD / §11.4
+ */
+import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import { cn } from "@/shared/lib/utils";
+
+import {
+ addMonths,
+ buildMonthGrid,
+ endOfMonth,
+ eventsOnDay,
+ isSameDay,
+ planToCalendarEvents,
+ startOfMonth,
+ type CalendarEvent,
+} from "../lib/calendar-utils";
+import type { CoursePlanWithItems } from "./types";
+
+interface CoursePlanCalendarProps {
+ plan: CoursePlanWithItems;
+}
+
+/**
+ * 课程计划月历视图。
+ *
+ * - 无 `plan.startDate` 时显示空状态提示
+ * - 单元格内显示当日事件,按完成状态着色
+ * - 当月事件列表附在网格下方
+ */
+export function CoursePlanCalendar({
+ plan,
+}: CoursePlanCalendarProps): React.ReactElement {
+ const t = useTranslations("coursePlans");
+ const [cursor, setCursor] = useState(() => new Date());
+
+ const events: CalendarEvent[] = useMemo(
+ () => planToCalendarEvents(plan),
+ [plan],
+ );
+
+ const grid: Date[] = useMemo(() => buildMonthGrid(cursor), [cursor]);
+ const monthStart = useMemo(() => startOfMonth(cursor), [cursor]);
+ const monthEnd = useMemo(() => endOfMonth(cursor), [cursor]);
+ const visibleEvents = useMemo(
+ () =>
+ events.filter((e) => {
+ const start = new Date(e.startDate);
+ const end = new Date(e.endDate);
+ return start <= monthEnd && end >= monthStart;
+ }),
+ [events, monthStart, monthEnd],
+ );
+
+ const today = new Date();
+ const weekLabels = t.raw("calendar.weekShort") as unknown as string[];
+
+ const handlePrev = (): void => setCursor((prev) => addMonths(prev, -1));
+ const handleNext = (): void => setCursor((prev) => addMonths(prev, 1));
+ const handleToday = (): void => setCursor(new Date());
+
+ if (!plan.startDate) {
+ return (
+
+
+
+ {t("calendar.noStartDate")}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("calendar.monthTitle", {
+ year: cursor.getFullYear(),
+ month: cursor.getMonth() + 1,
+ })}
+
+
+
+
+
+
+
+
+
+ {weekLabels.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+ {grid.map((day, index) => {
+ const dayEvents = eventsOnDay(visibleEvents, day);
+ const inMonth = day.getMonth() === cursor.getMonth();
+ const isToday = isSameDay(day, today);
+ return (
+
+
+ {day.getDate()}
+
+
+ {dayEvents.slice(0, 2).map((event) => (
+
+ {t("calendar.week", { week: event.week })}
+
+ ))}
+ {dayEvents.length > 2 ? (
+
+ +{dayEvents.length - 2}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ {visibleEvents.length > 0 ? (
+
+ {t("calendar.title")}
+
+ {visibleEvents.map((event) => (
+ -
+
+ {event.isCompleted
+ ? t("calendar.completed")
+ : t("calendar.pending")}
+
+
+ {t("calendar.week", { week: event.week })}
+
+ {event.title}
+
+ {event.startDate} ~ {event.endDate}
+
+
+ {t("calendar.hours", { hours: event.hours })}
+
+
+ ))}
+
+
+ ) : (
+
+ {t("calendar.noPlans")}
+
+ )}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-detail.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-detail.tsx
new file mode 100644
index 0000000..06bcf44
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-detail.tsx
@@ -0,0 +1,503 @@
+"use client";
+
+/**
+ * 课程计划详情组件(P2 迁移)
+ *
+ * 展示课程计划完整信息:基本信息、教学大纲/目标、周计划表格、月历视图。
+ *
+ * 适配要点(vs CICD):
+ * - 移除 @dnd-kit 依赖(portal-shell 未引入);周计划以静态表格渲染,不支持拖拽排序
+ * - 移除 usePermission/Permissions.COURSE_PLAN_MANAGE,canManage 由父组件传入
+ * - 移除 Server Actions(deleteCoursePlanAction/bulkToggleItemsAction/reorderCoursePlanItemsAction),
+ * 改为可选回调 props(onDelete/onBulkToggleComplete);未提供时按钮禁用或 no-op + notify.warning
+ * - 移除 CoursePlanItemEditor(依赖 Server Actions,待后续单独迁移)
+ * - 移除 SortableWeekRow,改为内联 WeekRow 渲染
+ * - formatDate 改用 next-intl useFormatter(portal-shell 无 formatDate 工具)
+ * - 保留 SectionErrorBoundary 排除(portal-shell 暂未提供,直接渲染)
+ * - 路径对齐 portal-shell:使用相对 import "./course-plan-calendar" 等
+ *
+ * 关联:ARCHITECTURE.md §10 P2 / §11.3 DoD / §11.4
+ */
+import { ArrowLeft, Download, Pencil, Plus, Trash2 } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useFormatter, useTranslations } from "next-intl";
+import { useState } from "react";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import { notify } from "@/shared/lib/notify";
+
+import { CoursePlanCalendar } from "./course-plan-calendar";
+import { CoursePlanProgress } from "./course-plan-progress";
+import { exportCoursePlanReport } from "../lib/export-utils";
+import { trackCoursePlanEvent } from "../lib/track-event";
+import type { CoursePlanItem, CoursePlanWithItems } from "./types";
+
+interface CoursePlanDetailProps {
+ plan: CoursePlanWithItems;
+ /** 是否具备管理权限(编辑/删除/添加周计划/批量完成) */
+ canManage?: boolean;
+ editHref?: string;
+ backHref?: string;
+ /** 删除成功后的跳转路径 */
+ successHref?: string;
+ /** 教材列表页地址(提供时章节文本渲染为链接) */
+ textbooksHref?: string;
+ /** 作业列表页地址(提供时显示作业跳转按钮) */
+ homeworkHref?: string;
+ /** 删除回调;未提供时按钮点击为 no-op + notify.warning */
+ onDelete?: (planId: string) => Promise | void;
+ /** 批量标记完成回调;未提供时按钮禁用 */
+ onBulkToggleComplete?: (
+ planId: string,
+ itemIds: string[],
+ completed: boolean,
+ ) => Promise | void;
+}
+
+/**
+ * 课程计划详情组件。
+ *
+ * - 顶部:返回 + 标题 + 操作栏(导出 CSV / 编辑 / 删除)
+ * - 基本信息 Card:状态徽章、班级/科目/学期、教师、创建/起止日期
+ * - 教学大纲 / 教学目标(若存在)
+ * - 周计划表格:勾选(canManage)+ 周次 + 主题 + 课时 + 章节 + 状态
+ * - 月历视图 Card
+ * - 删除确认对话框
+ */
+export function CoursePlanDetail({
+ plan,
+ canManage = false,
+ editHref,
+ backHref,
+ successHref,
+ textbooksHref,
+ homeworkHref,
+ onDelete,
+ onBulkToggleComplete,
+}: CoursePlanDetailProps): React.ReactElement {
+ const t = useTranslations("coursePlans");
+ const format = useFormatter();
+ const router = useRouter();
+ const [isWorking, setIsWorking] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+
+ const completedItems = plan.items.filter((i) => i.isCompleted).length;
+
+ const formatDate = (iso: string | null): string => {
+ if (!iso) return "--";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "--";
+ return format.dateTime(d, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ const handleDelete = async (): Promise => {
+ if (!onDelete) {
+ notify.warning(t("toast.deleteFailed"));
+ return;
+ }
+ setIsWorking(true);
+ try {
+ await onDelete(plan.id);
+ notify.success(t("toast.deleted"));
+ trackCoursePlanEvent("plan_deleted", { planId: plan.id });
+ setDeleteOpen(false);
+ if (successHref) {
+ router.push(successHref);
+ }
+ } catch {
+ notify.error(t("toast.deleteFailed"));
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ const toggleSelect = (id: string): void => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const handleBulkComplete = async (): Promise => {
+ if (selectedIds.size === 0) return;
+ if (!onBulkToggleComplete) {
+ notify.warning(t("toast.bulkFailed"));
+ return;
+ }
+ setIsWorking(true);
+ try {
+ await onBulkToggleComplete(plan.id, Array.from(selectedIds), true);
+ notify.success(t("toast.bulkMarked", { count: selectedIds.size }));
+ setSelectedIds(new Set());
+ } catch {
+ notify.error(t("toast.bulkFailed"));
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ const handleExport = (): void => {
+ try {
+ const filename = t("export.filename", {
+ subject: plan.subjectName ?? "unknown",
+ className: plan.className ?? "no-class",
+ });
+ exportCoursePlanReport(
+ plan,
+ {
+ week: t("detail.week"),
+ topic: t("detail.topic"),
+ content: t("export.content"),
+ hours: t("detail.hours"),
+ textbookChapter: t("detail.chapter"),
+ status: t("detail.statusCol"),
+ notes: t("export.notes"),
+ completed: t("detail.completed"),
+ pending: t("detail.pending"),
+ },
+ filename,
+ );
+ notify.success(t("export.exported"));
+ trackCoursePlanEvent("plan_exported", {
+ planId: plan.id,
+ format: "csv",
+ });
+ } catch {
+ notify.error(t("export.exportFailed"));
+ }
+ };
+
+ return (
+
+
+ {backHref ? (
+
+ ) : null}
+
+
+ {t("detail.heading")}
+
+
+
+ {canManage ? (
+ <>
+ {editHref ? (
+
+ ) : null}
+
+ >
+ ) : null}
+
+
+
+
+
+
+
+
+ {plan.className ?? t("detail.noClass")}
+
+
+ {plan.subjectName ?? t("detail.unknownSubject")}
+
+ {t(`status.${plan.status}`)}
+
+ {t("detail.semester", { semester: plan.semester })}
+
+
+
+ {plan.subjectName ?? t("detail.unknownSubjectHeading")} —{" "}
+ {plan.className ?? t("detail.noClass")}
+
+
+
+ {plan.teacherName
+ ? t("detail.teacher", { name: plan.teacherName })
+ : t("detail.unassigned")}
+
+
+ · {t("detail.created", { date: formatDate(plan.createdAt) })}
+
+ {plan.startDate ? (
+
+ · {t("detail.startDate", { date: formatDate(plan.startDate) })}
+
+ ) : null}
+ {plan.endDate ? (
+
+ · {t("detail.endDate", { date: formatDate(plan.endDate) })}
+
+ ) : null}
+
+
+
+
+ {plan.syllabus ? (
+
+ {t("detail.syllabus")}
+
+ {plan.syllabus}
+
+
+ ) : null}
+ {plan.objectives ? (
+
+
+ {t("detail.objectives")}
+
+
+ {plan.objectives}
+
+
+ ) : null}
+
+
+
+
+
+ {t("detail.weekPlans")}
+
+ {canManage && selectedIds.size > 0 ? (
+
+ ) : null}
+ {canManage ? (
+
+ ) : null}
+
+
+
+ {plan.items.length === 0 ? (
+
+ {t("detail.emptyWeekPlans")}
+ {canManage ? t("detail.emptyWeekPlansCta") : ""}
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {t("calendar.title")}
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 周计划表格(无 dnd-kit 排序,纯展示 + 选择)。
+ */
+function WeekTable({
+ items,
+ canManage,
+ selectedIds,
+ onToggleSelect,
+ textbooksHref,
+ homeworkHref,
+}: {
+ items: CoursePlanItem[];
+ canManage: boolean;
+ selectedIds: Set;
+ onToggleSelect: (id: string) => void;
+ textbooksHref?: string;
+ homeworkHref?: string;
+}): React.ReactElement {
+ const t = useTranslations("coursePlans");
+ return (
+
+
+
+ {canManage ? : null}
+ {t("detail.week")}
+ {t("detail.topic")}
+ {t("detail.hours")}
+ {t("detail.chapter")}
+ {t("detail.statusCol")}
+ {homeworkHref ? : null}
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+/**
+ * 周计划单行(静态,无拖拽)。
+ */
+function WeekRow({
+ item,
+ canManage,
+ isSelected,
+ onToggleSelect,
+ textbooksHref,
+ homeworkHref,
+}: {
+ item: CoursePlanItem;
+ canManage: boolean;
+ isSelected: boolean;
+ onToggleSelect: (id: string) => void;
+ textbooksHref?: string;
+ homeworkHref?: string;
+}): React.ReactElement {
+ const t = useTranslations("coursePlans");
+ return (
+
+ {canManage ? (
+
+ onToggleSelect(item.id)}
+ className="h-4 w-4"
+ aria-label={t("detail.selectWeekAria", { week: item.week })}
+ />
+
+ ) : null}
+ {item.week}
+
+
+ {item.topic}
+ {item.content ? (
+
+ {item.content}
+
+ ) : null}
+ {item.notes ? (
+
+ {t("detail.notes", { notes: item.notes })}
+
+ ) : null}
+
+
+ {item.hours}
+
+ {item.textbookChapter ? (
+ textbooksHref ? (
+
+ {item.textbookChapter}
+
+ ) : (
+ item.textbookChapter
+ )
+ ) : (
+ "—"
+ )}
+
+
+
+ {item.isCompleted ? t("detail.completed") : t("detail.pending")}
+
+
+ {homeworkHref ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-form.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-form.tsx
new file mode 100644
index 0000000..f9e8fc3
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-form.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+/**
+ * 课程计划表单组件(P2 迁移)
+ *
+ * 创建/编辑课程计划表单,含班级/科目/教师/学年/学期/状态/课时/日期/大纲/目标字段。
+ *
+ * 适配要点(vs CICD):
+ * - 移除 Server Actions(createCoursePlanAction/updateCoursePlanAction),
+ * 改为 onSubmit 回调 prop(接收表单值对象,由父组件决定如何持久化)
+ * - 移除 TemplatePickerDialog(依赖 Server Actions,待后续单独迁移)
+ * - 改用受控状态 + 提交时构造 payload 对象(替代 FormData)
+ * - React.ReactElement 显式返回类型(portal-shell 严格模式)
+ *
+ * 关联:ARCHITECTURE.md §10 P2 / §11.3 DoD / §11.4
+ */
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { Select } from "@/shared/components/ui/select";
+import type { SelectOption } from "@/shared/components/ui/select";
+import { Textarea } from "@/shared/components/ui/textarea";
+import { notify } from "@/shared/lib/notify";
+
+import { isCoursePlanSemester, isCoursePlanStatus } from "./types";
+import type {
+ CoursePlanListItem,
+ CoursePlanSemester,
+ CoursePlanStatus,
+} from "./types";
+
+type Mode = "create" | "edit";
+
+interface Option {
+ id: string;
+ name: string;
+}
+
+const STATUS_VALUES: CoursePlanStatus[] = [
+ "planning",
+ "active",
+ "completed",
+ "paused",
+];
+
+/** 表单提交载荷(由父组件负责持久化) */
+export interface CoursePlanFormPayload {
+ classId: string;
+ subjectId: string;
+ teacherId: string;
+ academicYearId: string;
+ semester: CoursePlanSemester;
+ status: CoursePlanStatus;
+ totalHours: number;
+ weeklyHours: number;
+ startDate: string;
+ endDate: string;
+ syllabus: string;
+ objectives: string;
+}
+
+interface CoursePlanFormProps {
+ mode: Mode;
+ plan?: CoursePlanListItem;
+ classes?: Option[];
+ subjects?: Option[];
+ teachers?: Option[];
+ academicYears?: Option[];
+ backHref?: string;
+ /** 成功后的跳转路径 */
+ successHref?: string;
+ /** 提交回调;未提供时按钮点击为 no-op + notify.warning */
+ onSubmit?: (payload: CoursePlanFormPayload) => Promise | void;
+}
+
+/**
+ * 课程计划表单组件。
+ *
+ * - 受控状态:classId/subjectId/teacherId/semester/status/academicYearId
+ * - 非受控字段(totalHours/weeklyHours/startDate/endDate/syllabus/objectives)使用 defaultValue + 读取 DOM
+ * - 提交时构造 payload 对象并调用 onSubmit 回调
+ */
+export function CoursePlanForm({
+ mode,
+ plan,
+ classes = [],
+ subjects = [],
+ teachers = [],
+ academicYears = [],
+ backHref,
+ successHref,
+ onSubmit,
+}: CoursePlanFormProps): React.ReactElement {
+ const t = useTranslations("coursePlans");
+ const router = useRouter();
+ const [isWorking, setIsWorking] = useState(false);
+
+ const [classId, setClassId] = useState(plan?.classId ?? "");
+ const [subjectId, setSubjectId] = useState(plan?.subjectId ?? "");
+ const [teacherId, setTeacherId] = useState(plan?.teacherId ?? "");
+ const [semester, setSemester] = useState(
+ plan?.semester ?? "1",
+ );
+ const [status, setStatus] = useState(
+ plan?.status ?? "planning",
+ );
+ const [academicYearId, setAcademicYearId] = useState(
+ plan?.academicYearId ?? "",
+ );
+
+ const classOptions: readonly SelectOption[] = useMemo(
+ () => classes.map((c) => ({ value: c.id, label: c.name })),
+ [classes],
+ );
+ const subjectOptions: readonly SelectOption[] = useMemo(
+ () => subjects.map((s) => ({ value: s.id, label: s.name })),
+ [subjects],
+ );
+ const teacherOptions: readonly SelectOption[] = useMemo(
+ () => teachers.map((t) => ({ value: t.id, label: t.name })),
+ [teachers],
+ );
+ const academicYearOptions: readonly SelectOption[] = useMemo(
+ () => academicYears.map((y) => ({ value: y.id, label: y.name })),
+ [academicYears],
+ );
+ const semesterOptions: readonly SelectOption[] = useMemo(
+ () =>
+ [
+ { value: "1", label: t("form.semester1") },
+ { value: "2", label: t("form.semester2") },
+ ] as const satisfies ReadonlyArray,
+ [t],
+ );
+ const statusOptions: readonly SelectOption[] = useMemo(
+ () =>
+ STATUS_VALUES.map((s) => ({
+ value: s,
+ label: t(`status.${s}`),
+ })),
+ [t],
+ );
+
+ const handleSemesterChange = (v: string): void => {
+ if (isCoursePlanSemester(v)) setSemester(v);
+ };
+
+ const handleStatusChange = (v: string): void => {
+ if (isCoursePlanStatus(v)) setStatus(v);
+ };
+
+ const handleSubmit = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+ if (!onSubmit) {
+ notify.warning(t("form.saveFailed"));
+ return;
+ }
+ const formData = new FormData(e.currentTarget);
+ setIsWorking(true);
+ try {
+ const payload: CoursePlanFormPayload = {
+ classId,
+ subjectId,
+ teacherId,
+ academicYearId,
+ semester,
+ status,
+ totalHours: Number(formData.get("totalHours") ?? 0) || 0,
+ weeklyHours: Number(formData.get("weeklyHours") ?? 0) || 0,
+ startDate: String(formData.get("startDate") ?? ""),
+ endDate: String(formData.get("endDate") ?? ""),
+ syllabus: String(formData.get("syllabus") ?? ""),
+ objectives: String(formData.get("objectives") ?? ""),
+ };
+ await onSubmit(payload);
+ notify.success(mode === "create" ? t("form.create") : t("form.save"));
+ if (successHref) {
+ router.push(successHref);
+ }
+ } catch {
+ notify.error(t("form.saveFailed"));
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ return (
+
+
+
+ {mode === "create" ? t("form.new") : t("form.edit")}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-item-editor.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-item-editor.tsx
new file mode 100644
index 0000000..87f7657
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-item-editor.tsx
@@ -0,0 +1,292 @@
+"use client";
+
+/**
+ * 课程计划周计划项编辑器(P2 迁移)
+ *
+ * 创建/编辑/删除/切换完成状态单条周计划项。
+ *
+ * 适配要点(vs CICD):
+ * - 移除 Server Actions(createCoursePlanItemAction / updateCoursePlanItemAction /
+ * deleteCoursePlanItemAction / toggleCoursePlanItemCompletedAction),
+ * 改为可选回调 props(onSubmit / onDelete / onToggleComplete);
+ * 未提供时按钮禁用或 no-op + notify.warning
+ * - 表单值由父组件统一管理:受控 state + 提交时构造 payload 对象(替代 FormData)
+ * - React.ReactElement 显式返回类型(portal-shell 严格模式)
+ *
+ * 关联:ARCHITECTURE.md §10 P2 / §11.3 DoD / §11.4
+ */
+import { Check, Trash2, X } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { Textarea } from "@/shared/components/ui/textarea";
+import { notify } from "@/shared/lib/notify";
+
+import type { CoursePlanItem } from "./types";
+
+/** 周计划项表单提交载荷(由父组件负责持久化) */
+export interface CoursePlanItemPayload {
+ week: number;
+ hours: number;
+ topic: string;
+ content: string;
+ textbookChapter: string;
+ completedAt: string;
+ notes: string;
+}
+
+type Mode = "create" | "edit";
+
+interface CoursePlanItemEditorProps {
+ /** 课程计划 ID(用于 create 模式) */
+ planId: string;
+ /** 编辑模式下的现有项;create 模式可省略 */
+ item?: CoursePlanItem;
+ mode: Mode;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** 创建/编辑提交回调;未提供时按钮禁用 */
+ onSubmit?: (
+ planId: string,
+ itemId: string | undefined,
+ payload: CoursePlanItemPayload,
+ ) => Promise | void;
+ /** 删除回调;未提供时按钮禁用 */
+ onDelete?: (itemId: string) => Promise | void;
+ /** 切换完成状态回调;未提供时按钮禁用 */
+ onToggleComplete?: (
+ itemId: string,
+ completed: boolean,
+ ) => Promise | void;
+}
+
+/**
+ * 周计划项编辑器对话框。
+ *
+ * - create 模式:仅显示保存按钮
+ * - edit 模式:显示保存 + 标记完成/取消完成 + 删除按钮
+ * - 所有操作通过回调 props 上抛,由父组件决定如何持久化
+ */
+export function CoursePlanItemEditor({
+ planId,
+ item,
+ mode,
+ open,
+ onOpenChange,
+ onSubmit,
+ onDelete,
+ onToggleComplete,
+}: CoursePlanItemEditorProps): React.ReactElement {
+ const t = useTranslations("coursePlans.item");
+ const [isWorking, setIsWorking] = useState(false);
+
+ const handleSubmit = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+ if (!onSubmit) {
+ notify.warning(t("saveFailed"));
+ return;
+ }
+ const formData = new FormData(e.currentTarget);
+ setIsWorking(true);
+ try {
+ const payload: CoursePlanItemPayload = {
+ week: Number(formData.get("week") ?? 1) || 1,
+ hours: Number(formData.get("hours") ?? 2) || 2,
+ topic: String(formData.get("topic") ?? ""),
+ content: String(formData.get("content") ?? ""),
+ textbookChapter: String(formData.get("textbookChapter") ?? ""),
+ completedAt: String(formData.get("completedAt") ?? ""),
+ notes: String(formData.get("notes") ?? ""),
+ };
+ await onSubmit(planId, item?.id, payload);
+ notify.success(
+ mode === "create" ? t("createSuccess") : t("updateSuccess"),
+ );
+ onOpenChange(false);
+ } catch {
+ notify.error(t("saveFailed"));
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ const handleDelete = async (): Promise => {
+ if (!item || !onDelete) {
+ notify.warning(t("deleteFailed"));
+ return;
+ }
+ setIsWorking(true);
+ try {
+ await onDelete(item.id);
+ notify.success(t("deleteSuccess"));
+ onOpenChange(false);
+ } catch {
+ notify.error(t("deleteFailed"));
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ const handleToggleComplete = async (): Promise => {
+ if (!item || !onToggleComplete) {
+ notify.warning(t("updateFailed"));
+ return;
+ }
+ setIsWorking(true);
+ try {
+ await onToggleComplete(item.id, !item.isCompleted);
+ notify.success(t("toggleSuccess"));
+ } catch {
+ notify.error(t("updateFailed"));
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-list.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-list.tsx
new file mode 100644
index 0000000..4b03c88
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-list.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+/**
+ * 课程计划列表组件(P2 迁移)
+ *
+ * 卡片网格展示课程计划列表,含状态过滤、新建入口、进度展示。
+ *
+ * 适配要点(vs CICD):
+ * - 移除 usePermission/Permissions.COURSE_PLAN_MANAGE 依赖,canManage 由父组件传入
+ * - formatDate 改用 next-intl useFormatter(portal-shell 无 formatDate 工具)
+ * - 路由对齐 portal-shell:/shell/teacher/course-plans
+ *
+ * 关联:ARCHITECTURE.md §10 P2 / §11.4
+ */
+import { CalendarRange, Plus } from "lucide-react";
+import Link from "next/link";
+import { useFormatter, useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { Select } from "@/shared/components/ui/select";
+import type { SelectOption } from "@/shared/components/ui/select";
+
+import { CoursePlanProgress } from "./course-plan-progress";
+import { isCoursePlanStatus } from "./types";
+import type { CoursePlanListItem, CoursePlanStatus } from "./types";
+
+const STATUS_VARIANT: Record<
+ CoursePlanStatus,
+ "default" | "secondary" | "outline"
+> = {
+ planning: "secondary",
+ active: "default",
+ completed: "outline",
+ paused: "outline",
+};
+
+const STATUS_VALUES = ["planning", "active", "completed", "paused"] as const;
+
+type Filter = "all" | CoursePlanStatus;
+
+interface CoursePlanListProps {
+ plans: CoursePlanListItem[];
+ canManage?: boolean;
+ createHref?: string;
+ detailBaseHref?: string;
+ initialStatus?: Filter;
+}
+
+/**
+ * 课程计划列表组件。
+ *
+ * - 顶部状态过滤 + 新建按钮(canManage=true 且 createHref 提供时)
+ * - 卡片网格展示计划:标题、状态徽章、班级/学期/教师、进度条
+ * - 空态显示 EmptyState
+ */
+export function CoursePlanList({
+ plans,
+ canManage = false,
+ createHref,
+ detailBaseHref,
+ initialStatus,
+}: CoursePlanListProps): React.ReactElement {
+ const t = useTranslations("coursePlans");
+ const format = useFormatter();
+ const router = useRouter();
+ const [filter, setFilter] = useState(initialStatus ?? "all");
+
+ const filterOptions: readonly SelectOption[] = useMemo(
+ () => [
+ { value: "all", label: t("filter.all") },
+ ...STATUS_VALUES.map((s) => ({
+ value: s,
+ label: t(`status.${s}`),
+ })),
+ ],
+ [t],
+ );
+
+ const filtered = useMemo(() => {
+ if (filter === "all") return plans;
+ return plans.filter((p) => p.status === filter);
+ }, [plans, filter]);
+
+ const formatDate = (iso: string): string => {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "--";
+ return format.dateTime(d, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ const handleFilterChange = (value: string): void => {
+ const next: Filter =
+ value === "all" || isCoursePlanStatus(value) ? value : "all";
+ setFilter(next);
+ const params = new URLSearchParams();
+ if (next !== "all") params.set("status", next);
+ const qs = params.toString();
+ router.replace(qs ? `?${qs}` : "?");
+ };
+
+ return (
+
+
+
+ {canManage && createHref ? (
+
+ ) : null}
+
+
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((plan) => {
+ const href = detailBaseHref
+ ? `${detailBaseHref}/${plan.id}`
+ : undefined;
+ const card = (
+
+
+
+ {plan.subjectName ?? t("list.unknownSubject")}
+
+
+ {t(`status.${plan.status}`)}
+
+
+
+
+
+ {plan.className ?? t("list.noClass")}
+
+
+ {t("list.semester", { semester: plan.semester })}
+
+ {plan.teacherName ? (
+ · {plan.teacherName}
+ ) : null}
+
+
+
+ {t("list.created", { date: formatDate(plan.createdAt) })}
+
+
+
+ );
+
+ return href ? (
+
+ {card}
+
+ ) : (
+ {card}
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-progress.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-progress.tsx
new file mode 100644
index 0000000..df18a66
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/course-plan-progress.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+/**
+ * 课程计划进度组件(P2 迁移)
+ *
+ * 展示已完成课时 / 总课时(百分比进度条),可选展示周计划完成数。
+ *
+ * 关联:ARCHITECTURE.md §10 P2
+ */
+import { useTranslations } from "next-intl";
+
+import { Progress } from "@/shared/components/ui/progress";
+
+interface CoursePlanProgressProps {
+ completedHours: number;
+ totalHours: number;
+ completedItems?: number;
+ totalItems?: number;
+ showDetails?: boolean;
+}
+
+/**
+ * 课程计划进度组件。
+ *
+ * - 进度条根据 (completedHours / totalHours) * 100 计算
+ * - showDetails=true 且提供 completedItems/totalItems 时展示周计划完成数
+ */
+export function CoursePlanProgress({
+ completedHours,
+ totalHours,
+ completedItems,
+ totalItems,
+ showDetails = true,
+}: CoursePlanProgressProps): React.ReactElement {
+ const t = useTranslations("coursePlans.progress");
+ const hoursPercent =
+ totalHours > 0 ? Math.round((completedHours / totalHours) * 100) : 0;
+
+ return (
+
+
+ {t("label")}
+
+ {t("hours", {
+ completed: completedHours,
+ total: totalHours,
+ percent: hoursPercent,
+ })}
+
+
+
+ {showDetails &&
+ typeof completedItems === "number" &&
+ typeof totalItems === "number" ? (
+
+ {t("weekPlansCompleted", {
+ completed: completedItems,
+ total: totalItems,
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/sortable-week-row.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/sortable-week-row.tsx
new file mode 100644
index 0000000..894a6e1
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/sortable-week-row.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+/**
+ * 课程计划周计划表格行(P2 迁移)
+ *
+ * 可拖拽排序的周计划表格行(CICD 原版依赖 @dnd-kit/sortable)。
+ *
+ * 适配要点(vs CICD):
+ * - portal-shell 未引入 @dnd-kit,移除拖拽能力,降级为静态行
+ * (后续若引入 @dnd-kit 可在此处恢复 useSortable 调用)
+ * - 行点击(canManage=true 时)仍可触发 onEdit
+ * - 教材章节在提供 textbooksHref 时渲染为可跳转链接
+ * - 行尾在提供 homeworkHref 时显示作业跳转按钮
+ * - React.ReactElement 显式返回类型(portal-shell 严格模式)
+ *
+ * 关联:ARCHITECTURE.md §10 P2 / §11.3 DoD / §11.4
+ */
+import { BookOpen, ExternalLink, GripVertical } from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import { TableCell, TableRow } from "@/shared/components/ui/table";
+import { cn } from "@/shared/lib/utils";
+
+import type { CoursePlanItem } from "./types";
+
+interface SortableWeekRowProps {
+ item: CoursePlanItem;
+ canManage: boolean;
+ isSelected: boolean;
+ onToggleSelect: (id: string) => void;
+ onEdit: (item: CoursePlanItem) => void;
+ /** 教材列表页地址(按角色不同);提供时章节文本渲染为可跳转链接 */
+ textbooksHref?: string;
+ /** 作业列表页地址(按角色不同);提供时显示作业跳转按钮 */
+ homeworkHref?: string;
+}
+
+/**
+ * 周计划表格行(静态,无拖拽)。
+ *
+ * - canManage=true 时显示拖拽手柄占位(视觉提示)+ 复选框 + 行点击编辑
+ * - canManage=false 时仅展示
+ *
+ * 可访问性:
+ * - 手柄/复选框带 aria-label
+ * - 跳转链接带描述性 aria-label
+ */
+export function SortableWeekRow({
+ item,
+ canManage,
+ isSelected,
+ onToggleSelect,
+ onEdit,
+ textbooksHref,
+ homeworkHref,
+}: SortableWeekRowProps): React.ReactElement {
+ const t = useTranslations("coursePlans.detail");
+
+ const handleRowClick = canManage ? () => onEdit(item) : undefined;
+
+ return (
+
+ {canManage ? (
+ e.stopPropagation()} className="w-8">
+ {/* 拖拽手柄占位:portal-shell 未引入 @dnd-kit,仅作视觉提示 */}
+
+
+
+
+ ) : null}
+ {canManage ? (
+ e.stopPropagation()} className="w-8">
+ onToggleSelect(item.id)}
+ className="h-4 w-4"
+ aria-label={t("selectWeekAria", { week: item.week })}
+ />
+
+ ) : null}
+ {item.week}
+
+
+ {item.topic}
+ {item.content ? (
+
+ {item.content}
+
+ ) : null}
+ {item.notes ? (
+
+ {t("notes", { notes: item.notes })}
+
+ ) : null}
+
+
+ {item.hours}
+
+ {item.textbookChapter ? (
+ textbooksHref ? (
+ e.stopPropagation()}
+ aria-label={t("viewTextbookAria", {
+ chapter: item.textbookChapter,
+ })}
+ >
+
+ {item.textbookChapter}
+
+ ) : (
+ item.textbookChapter
+ )
+ ) : (
+ "—"
+ )}
+
+
+
+
+ {item.isCompleted ? t("completed") : t("pending")}
+
+ {homeworkHref ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/template-picker-dialog.tsx b/apps/portal-shell/src/features/teacher/course-plans/components/template-picker-dialog.tsx
new file mode 100644
index 0000000..809a854
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/template-picker-dialog.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+/**
+ * 课程计划模板选择器对话框(P2 迁移)
+ *
+ * 复用现有计划作为模板:列出当前用户可见的计划,支持搜索过滤;
+ * 选择后克隆到目标班级,并跳转到新计划的编辑页。
+ *
+ * 适配要点(vs CICD):
+ * - 移除 Server Actions(getTemplateCandidatesAction / copyCoursePlanAction),
+ * 改为可选回调 props(onLoadCandidates / onClone);
+ * 未提供时按钮禁用或显示空态
+ * - 候选列表与克隆逻辑由父组件负责持久化(portal-shell 通过 GraphQL hooks / MSW)
+ * - 保留客户端搜索过滤(数据量可控时性能足够)
+ * - 保留 trackCoursePlanEvent 埋点(已迁移至 lib/track-event.ts,no-op 实现)
+ * - React.ReactElement 显式返回类型(portal-shell 严格模式)
+ *
+ * 关联:ARCHITECTURE.md §10 P2 / §11.3 DoD / §11.4
+ */
+import { FileText, Loader2, Search } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { Input } from "@/shared/components/ui/input";
+import { ScrollArea } from "@/shared/components/ui/scroll-area";
+import { cn } from "@/shared/lib/utils";
+import { notify } from "@/shared/lib/notify";
+
+import { trackCoursePlanEvent } from "../lib/track-event";
+import type { CoursePlanListItem } from "./types";
+
+/** 模板候选加载结果(由父组件提供) */
+interface TemplatePickerDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** 当前选中的目标班级 ID(复制目标) */
+ targetClassId?: string;
+ /** 可选学科过滤 */
+ subjectId?: string;
+ /** 复制成功后的跳转基础路径(如 /shell/teacher/course-plans) */
+ successHref: string;
+ /** 加载候选计划列表回调;未提供时显示空态 */
+ onLoadCandidates?: (subjectId?: string) => Promise;
+ /** 克隆计划回调,返回新计划 ID;未提供时按钮禁用 */
+ onClone?: (sourcePlanId: string, targetClassIds: string[]) => Promise;
+}
+
+/**
+ * 模板选择器对话框。
+ *
+ * - 打开时通过 onLoadCandidates 加载候选列表
+ * - 客户端按 query 过滤(subjectName / className / teacherName)
+ * - 确认后调用 onClone,成功后触发埋点 + 跳转到新计划编辑页
+ *
+ * 可访问性:
+ * - 列表项带 role="option" + aria-selected
+ * - 加载状态显示 aria-live="polite"
+ */
+export function TemplatePickerDialog({
+ open,
+ onOpenChange,
+ targetClassId,
+ subjectId,
+ successHref,
+ onLoadCandidates,
+ onClone,
+}: TemplatePickerDialogProps): React.ReactElement {
+ const t = useTranslations("coursePlans");
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [cloning, setCloning] = useState(false);
+ const [candidates, setCandidates] = useState([]);
+ const [query, setQuery] = useState("");
+ const [selectedId, setSelectedId] = useState(undefined);
+
+ useEffect(() => {
+ if (!open || !onLoadCandidates) return;
+ let cancelled = false;
+ setLoading(true);
+ onLoadCandidates(subjectId)
+ .then((res) => {
+ if (cancelled) return;
+ setCandidates(res);
+ })
+ .catch(() => {
+ if (!cancelled) setCandidates([]);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, subjectId, onLoadCandidates]);
+
+ const filtered = useMemo(() => {
+ if (!query.trim()) return candidates;
+ const q = query.toLowerCase();
+ return candidates.filter(
+ (c) =>
+ c.subjectName?.toLowerCase().includes(q) ||
+ c.className?.toLowerCase().includes(q) ||
+ c.teacherName?.toLowerCase().includes(q),
+ );
+ }, [candidates, query]);
+
+ const handleConfirm = async (): Promise => {
+ if (!selectedId || !targetClassId || !onClone) return;
+ setCloning(true);
+ try {
+ const newPlanId = await onClone(selectedId, [targetClassId]);
+ notify.success(t("templates.cloneSuccess"));
+ trackCoursePlanEvent("plan_created_from_template", {
+ sourcePlanId: selectedId,
+ newPlanId,
+ });
+ onOpenChange(false);
+ router.push(`${successHref}/${newPlanId}/edit`);
+ router.refresh();
+ } catch {
+ notify.error(t("templates.cloneFailed"));
+ } finally {
+ setCloning(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/components/types.ts b/apps/portal-shell/src/features/teacher/course-plans/components/types.ts
new file mode 100644
index 0000000..b864b09
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/components/types.ts
@@ -0,0 +1,75 @@
+/**
+ * 课程计划模块本地类型(P2 迁移)
+ *
+ * 这些类型对齐 CICD `@/modules/course-plans/types`,供 portal-shell
+ * 课程计划模块的卡片组件共用。后端补齐 schema 后可切换为 codegen 生成类型。
+ *
+ * 注意:portal-shell 的 lib/api/course-plans.ts 使用 snake_case + 大写状态
+ * (DRAFT/IN_PROGRESS/COMPLETED/ARCHIVED),与 CICD 的 camelCase + 小写状态
+ * (planning/active/completed/paused)不同。本组件层次保持 CICD 类型签名
+ * 以减少迁移改动,由调用方做字段映射。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+
+export type CoursePlanStatus = "planning" | "active" | "completed" | "paused";
+
+export type CoursePlanSemester = "1" | "2";
+
+export interface CoursePlan {
+ id: string;
+ classId: string;
+ subjectId: string;
+ teacherId: string;
+ academicYearId: string | null;
+ semester: CoursePlanSemester;
+ totalHours: number;
+ completedHours: number;
+ weeklyHours: number;
+ startDate: string | null;
+ endDate: string | null;
+ syllabus: string | null;
+ objectives: string | null;
+ status: CoursePlanStatus;
+ createdBy: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CoursePlanItem {
+ id: string;
+ planId: string;
+ week: number;
+ topic: string;
+ content: string | null;
+ hours: number;
+ textbookChapter: string | null;
+ notes: string | null;
+ isCompleted: boolean;
+ completedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CoursePlanListItem extends CoursePlan {
+ className: string | null;
+ subjectName: string | null;
+ teacherName: string | null;
+}
+
+export interface CoursePlanWithItems extends CoursePlanListItem {
+ items: CoursePlanItem[];
+}
+
+export interface ReorderCoursePlanItemInput {
+ id: string;
+ week: number;
+}
+
+// ── 类型守卫 ──────────────────────────────────────────────
+
+export const isCoursePlanStatus = (v: unknown): v is CoursePlanStatus =>
+ v === "planning" || v === "active" || v === "completed" || v === "paused";
+
+export const isCoursePlanSemester = (v: unknown): v is CoursePlanSemester =>
+ v === "1" || v === "2";
diff --git a/apps/portal-shell/src/features/teacher/course-plans/lib/calendar-utils.ts b/apps/portal-shell/src/features/teacher/course-plans/lib/calendar-utils.ts
new file mode 100644
index 0000000..8efdf2b
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/lib/calendar-utils.ts
@@ -0,0 +1,177 @@
+/**
+ * course-plans 日历视图工具(P2 迁移,纯函数,便于单测)。
+ *
+ * 将周计划项映射到日历日期范围:
+ * - 若 plan.startDate 存在:第 N 周对应 [startDate + (N-1)*7, startDate + N*7 - 1]
+ * - 若 plan.startDate 缺失:返回 null(无法映射到日期)
+ *
+ * 关联:ARCHITECTURE.md §10 P2
+ */
+import type { CoursePlanWithItems } from "../components/types";
+
+/** 日历事件(一个周计划项对应一个事件) */
+export interface CalendarEvent {
+ /** 事件唯一 ID(使用 item.id) */
+ id: string;
+ /** 事件标题(使用 item.topic) */
+ title: string;
+ /** 起始日期(ISO 字符串,YYYY-MM-DD) */
+ startDate: string;
+ /** 结束日期(ISO 字符串,YYYY-MM-DD,含) */
+ endDate: string;
+ /** 周次 */
+ week: number;
+ /** 课时 */
+ hours: number;
+ /** 是否已完成 */
+ isCompleted: boolean;
+ /** 教材章节 */
+ textbookChapter: string | null;
+}
+
+/** 将日期对象格式化为 YYYY-MM-DD(不依赖 date-fns) */
+export function formatDateISO(date: Date): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+/** 解析 YYYY-MM-DD 字符串为本地日期(避免 UTC 偏移) */
+export function parseISODate(iso: string): Date {
+ const parts = iso.split("-").map(Number);
+ const y = parts[0] ?? 1970;
+ const m = parts[1] ?? 1;
+ const d = parts[2] ?? 1;
+ return new Date(y, m - 1, d);
+}
+
+/** 计算某日所在周的周日(作为周起始;遵循 ISO 8601 周一为周首) */
+export function startOfWeek(date: Date): Date {
+ const d = new Date(date);
+ const day = d.getDay(); // 0=Sunday, 1=Monday, ...
+ const diff = day === 0 ? -6 : 1 - day; // 周一为周首
+ d.setDate(d.getDate() + diff);
+ d.setHours(0, 0, 0, 0);
+ return d;
+}
+
+/** 计算某日所在月的首日 */
+export function startOfMonth(date: Date): Date {
+ return new Date(date.getFullYear(), date.getMonth(), 1);
+}
+
+/** 计算某日所在月的末日 */
+export function endOfMonth(date: Date): Date {
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0);
+}
+
+/** 在日期上加天数 */
+export function addDays(date: Date, days: number): Date {
+ const d = new Date(date);
+ d.setDate(d.getDate() + days);
+ return d;
+}
+
+/** 在日期上加月数 */
+export function addMonths(date: Date, months: number): Date {
+ const d = new Date(date);
+ d.setMonth(d.getMonth() + months);
+ return d;
+}
+
+/** 判断两日期是否同一天 */
+export function isSameDay(a: Date, b: Date): boolean {
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+}
+
+/** 判断日期 a 是否在 [start, end] 区间内(含端点) */
+export function isWithinRange(date: Date, start: Date, end: Date): boolean {
+ const t = date.getTime();
+ return t >= start.getTime() && t <= end.getTime();
+}
+
+/**
+ * 将课程计划及其周计划项转换为日历事件列表(纯函数)。
+ *
+ * - 需要 `plan.startDate` 才能计算每周的日期范围
+ * - 第 N 周对应日期范围 [startDate + (N-1)*7, startDate + N*7 - 1]
+ *
+ * @returns 事件列表;若无 startDate 则返回空数组
+ */
+export function planToCalendarEvents(
+ plan: CoursePlanWithItems,
+): CalendarEvent[] {
+ if (!plan.startDate) return [];
+
+ const start = parseISODate(plan.startDate);
+ return plan.items.map((item) => {
+ const weekStart = addDays(start, (item.week - 1) * 7);
+ const weekEnd = addDays(weekStart, 6); // 周一至周日
+ return {
+ id: item.id,
+ title: item.topic,
+ startDate: formatDateISO(weekStart),
+ endDate: formatDateISO(weekEnd),
+ week: item.week,
+ hours: item.hours,
+ isCompleted: item.isCompleted,
+ textbookChapter: item.textbookChapter,
+ };
+ });
+}
+
+/**
+ * 生成日历网格:返回覆盖给定月份所需的 6 周 × 7 天 = 42 天的日期数组。
+ *
+ * 网格从月份首日所在周的周一开始,确保整月可见。
+ *
+ * @param monthDate 月份内任意一天
+ * @returns 42 个 Date 对象(6 行 × 7 列)
+ */
+export function buildMonthGrid(monthDate: Date): Date[] {
+ const monthStart = startOfMonth(monthDate);
+ const gridStart = startOfWeek(monthStart);
+ return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
+}
+
+/**
+ * 过滤在给定日期范围内有重叠的事件。
+ *
+ * @param events 事件列表
+ * @param rangeStart 范围开始
+ * @param rangeEnd 范围结束
+ */
+export function filterEventsInRange(
+ events: readonly CalendarEvent[],
+ rangeStart: Date,
+ rangeEnd: Date,
+): CalendarEvent[] {
+ return events.filter((event) => {
+ const eventStart = parseISODate(event.startDate);
+ const eventEnd = parseISODate(event.endDate);
+ // 区间相交判断:eventStart <= rangeEnd && eventEnd >= rangeStart
+ return (
+ eventStart.getTime() <= rangeEnd.getTime() &&
+ eventEnd.getTime() >= rangeStart.getTime()
+ );
+ });
+}
+
+/**
+ * 返回某一天的事件列表(事件日期范围包含该天)。
+ */
+export function eventsOnDay(
+ events: readonly CalendarEvent[],
+ day: Date,
+): CalendarEvent[] {
+ return events.filter((event) => {
+ const eventStart = parseISODate(event.startDate);
+ const eventEnd = parseISODate(event.endDate);
+ return isWithinRange(day, eventStart, eventEnd);
+ });
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/lib/export-utils.ts b/apps/portal-shell/src/features/teacher/course-plans/lib/export-utils.ts
new file mode 100644
index 0000000..08ac97d
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/lib/export-utils.ts
@@ -0,0 +1,143 @@
+/**
+ * course-plans 模块导出工具(P2 迁移,纯函数 + 客户端下载)。
+ *
+ * 设计原则:
+ * - `planToExportRows` 为纯函数,便于单测;不直接依赖 i18n,状态文本由调用方通过 columnLabels 传入
+ * - `exportCoursePlanReport` 执行客户端下载,触发埋点由调用方处理
+ *
+ * portal-shell 无 @/shared/lib/export-utils,CSV 生成在本文件内内联实现。
+ *
+ * 关联:ARCHITECTURE.md §10 P2
+ */
+import type { CoursePlanWithItems } from "../components/types";
+
+/** 导出列标识(与 ExportRow 的 key 对应) */
+export type CoursePlanExportColumnKey =
+ | "week"
+ | "topic"
+ | "content"
+ | "hours"
+ | "textbookChapter"
+ | "status"
+ | "notes";
+
+/** 导出列配置 */
+export interface ExportColumn {
+ key: CoursePlanExportColumnKey;
+ label: string;
+}
+
+/** 导出行(key 为列标识,value 为字符串或数字) */
+export type ExportRow = Record;
+
+/** 列标签映射(由调用方传入已本地化的字符串) */
+export interface CoursePlanColumnLabels {
+ week: string;
+ topic: string;
+ content: string;
+ hours: string;
+ textbookChapter: string;
+ status: string;
+ notes: string;
+ /** 已完成状态文本 */
+ completed: string;
+ /** 待完成状态文本 */
+ pending: string;
+}
+
+/**
+ * 构建导出列配置(按固定顺序)。
+ */
+export function buildExportColumns(
+ labels: CoursePlanColumnLabels,
+): readonly ExportColumn[] {
+ return [
+ { key: "week", label: labels.week },
+ { key: "topic", label: labels.topic },
+ { key: "content", label: labels.content },
+ { key: "hours", label: labels.hours },
+ { key: "textbookChapter", label: labels.textbookChapter },
+ { key: "status", label: labels.status },
+ { key: "notes", label: labels.notes },
+ ];
+}
+
+/**
+ * 将课程计划转换为导出行(纯函数)。
+ *
+ * @param plan 课程计划
+ * @param labels 列标签 + 状态文本
+ */
+export function planToExportRows(
+ plan: CoursePlanWithItems,
+ labels: CoursePlanColumnLabels,
+): ExportRow[] {
+ return plan.items.map((item) => ({
+ week: item.week,
+ topic: item.topic,
+ content: item.content ?? "",
+ hours: item.hours,
+ textbookChapter: item.textbookChapter ?? "",
+ status: item.isCompleted ? labels.completed : labels.pending,
+ notes: item.notes ?? "",
+ }));
+}
+
+/**
+ * 转义 CSV 单元格:含逗号、引号或换行符时用双引号包裹,内部双引号转义为两个双引号。
+ */
+function escapeCSVCell(value: string | number): string {
+ const str = String(value);
+ if (/[",\n\r]/.test(str)) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+}
+
+/**
+ * 将行数据转换为 CSV 字符串(含表头)。
+ *
+ * @param rows 行数据
+ * @param columns 列配置
+ * @returns CSV 字符串(以 BOM 开头确保 Excel 正确识别 UTF-8)
+ */
+export function rowsToCSV(
+ rows: readonly ExportRow[],
+ columns: readonly ExportColumn[],
+): string {
+ const header = columns.map((c) => escapeCSVCell(c.label)).join(",");
+ const body = rows
+ .map((row) => columns.map((c) => escapeCSVCell(row[c.key])).join(","))
+ .join("\n");
+ // BOM + header + body
+ return `\uFEFF${header}\n${body}`;
+}
+
+/**
+ * 客户端导出课程计划教学进度报告为 CSV。
+ *
+ * @param plan 课程计划
+ * @param labels 列标签 + 状态文本
+ * @param filename 文件名(不含扩展名)
+ */
+export function exportCoursePlanReport(
+ plan: CoursePlanWithItems,
+ labels: CoursePlanColumnLabels,
+ filename: string,
+): void {
+ const columns = buildExportColumns(labels);
+ const rows = planToExportRows(plan, labels);
+ const csv = rowsToCSV(rows, columns);
+
+ // 客户端下载(浏览器 API)
+ if (typeof window === "undefined") return;
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${filename}.csv`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
diff --git a/apps/portal-shell/src/features/teacher/course-plans/lib/track-event.ts b/apps/portal-shell/src/features/teacher/course-plans/lib/track-event.ts
new file mode 100644
index 0000000..991ac9c
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/course-plans/lib/track-event.ts
@@ -0,0 +1,20 @@
+/**
+ * 课程计划监控埋点接口(P2 迁移)
+ *
+ * 供客户端组件直接调用以记录关键操作。
+ * 当前为空实现,后续接入监控 SDK 时只需修改此函数。
+ *
+ * 客户端不能导入服务端 pino logger,因此当前为纯 no-op。
+ * 后续接入客户端监控 SDK(如 PostHog/Amplitude)时替换实现即可。
+ *
+ * 关联:ARCHITECTURE.md §12 可观测性 / §10 P2
+ */
+
+export function trackCoursePlanEvent(
+ event: string,
+ properties?: Record,
+): void {
+ // no-op: 预留接口,接入客户端监控 SDK 后实现
+ void event;
+ void properties;
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/dashboard-utils.ts b/apps/portal-shell/src/features/teacher/dashboard/components/dashboard-utils.ts
new file mode 100644
index 0000000..b626a70
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/dashboard-utils.ts
@@ -0,0 +1,65 @@
+/**
+ * 仪表盘纯逻辑工具函数(P2 迁移,与 UI 分离,便于单测)
+ *
+ * 所有函数均为纯函数:相同输入 → 相同输出,无副作用。
+ * 对齐 CICD `@/modules/dashboard/lib/dashboard-utils` 中教师仪表盘所需的子集。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2
+ */
+
+/** 周一=1 ... 周日=7 */
+export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
+
+/**
+ * 将 Date 转换为 1-7 周几表示(周一=1,周日=7)。
+ * getDay() 返回 0(周日)-6(周六),需映射为 1-7。
+ */
+export function toWeekday(d: Date): Weekday {
+ const day = d.getDay();
+ if (day < 0 || day > 6) {
+ throw new Error(`Invalid day from getDay(): ${day}`);
+ }
+ const WEEKDAY_MAP: readonly Weekday[] = [7, 1, 2, 3, 4, 5, 6];
+ return WEEKDAY_MAP[day] ?? 1;
+}
+
+/**
+ * 根据当前小时返回问候语时段 key(morning / afternoon / evening)。
+ */
+export function getGreetingKey(now: Date): "morning" | "afternoon" | "evening" {
+ const hour = now.getHours();
+ if (hour < 12) return "morning";
+ if (hour < 18) return "afternoon";
+ return "evening";
+}
+
+// ─── 课表状态计算 ──────────────────────────────────────────
+
+/**
+ * 将 "HH:MM" 格式的时间字符串转换为当天的分钟数。
+ * 无效输入返回 0。
+ */
+export function timeToMinutes(t: string): number {
+ const [h, m] = t.split(":").map(Number);
+ return (h ?? 0) * 60 + (m ?? 0);
+}
+
+/** 课表项的实时状态 */
+export type ScheduleStatus = "live" | "upcoming" | "past";
+
+/**
+ * 根据当前时间判断课程状态:进行中 / 即将开始 / 已结束。
+ */
+export function getScheduleStatus(
+ start: string,
+ end: string,
+ now: Date,
+): ScheduleStatus {
+ const currentTime = now.getHours() * 60 + now.getMinutes();
+ const startTime = timeToMinutes(start);
+ const endTime = timeToMinutes(end);
+
+ if (currentTime >= startTime && currentTime <= endTime) return "live";
+ if (currentTime < startTime) return "upcoming";
+ return "past";
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/recent-submissions.tsx b/apps/portal-shell/src/features/teacher/dashboard/components/recent-submissions.tsx
new file mode 100644
index 0000000..5388d24
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/recent-submissions.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+/**
+ * 教师最近提交卡片(P2 迁移)
+ *
+ * 表格形式展示学生最近提交的作业,含学生、作业、提交时间、是否迟到、操作。
+ * 数据由父组件传入。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+import { ArrowRight, Inbox } from "lucide-react";
+import Link from "next/link";
+import { useFormatter, useTranslations } from "next-intl";
+
+import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar";
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+
+import type { TeacherRecentSubmissionItem } from "./types";
+
+interface RecentSubmissionsProps {
+ submissions: TeacherRecentSubmissionItem[];
+ title?: string;
+ emptyTitle?: string;
+ emptyDescription?: string;
+}
+
+/**
+ * 教师最近提交卡片。
+ *
+ * - 空态显示 EmptyState(含查看全部入口)
+ * - 表格展示学生头像、作业标题、提交时间、是否迟到、批改操作
+ */
+export function RecentSubmissions({
+ submissions,
+ title,
+ emptyTitle,
+ emptyDescription,
+}: RecentSubmissionsProps): React.ReactElement {
+ const t = useTranslations("dashboard.teacherCards.recentSubmissions");
+ const format = useFormatter();
+ const hasSubmissions = submissions.length > 0;
+
+ const formatSubmitted = (iso: string | null): string => {
+ if (!iso) return "-";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "-";
+ return format.dateTime(d, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ return (
+
+
+
+
+ {title ?? t("title")}
+
+
+
+
+ {!hasSubmissions ? (
+
+ ) : (
+
+
+
+
+ {t("colStudent")}
+ {t("colAssignment")}
+
+ {t("colSubmitted")}
+
+
+ {t("colAction")}
+
+
+
+
+ {submissions.map((item) => (
+
+
+
+
+
+ {item.studentName.charAt(0)}
+
+
+
+ {item.studentName}
+
+
+
+
+
+ {item.assignmentTitle}
+
+
+
+
+
+ {formatSubmitted(item.submittedAt)}
+
+ {item.isLate && (
+
+ {t("late")}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/teacher-classes-card.tsx b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-classes-card.tsx
new file mode 100644
index 0000000..913a071
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-classes-card.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+/**
+ * 教师我的班级卡片(P2 迁移)
+ *
+ * 展示教师所带班级列表(最多 6 个),含名称、年级、班主任、学生数。
+ * 数据由父组件传入。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+import { Users } from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+
+import type { TeacherClassCardItem } from "./types";
+
+interface TeacherClassesCardProps {
+ classes: TeacherClassCardItem[];
+}
+
+/**
+ * 教师我的班级卡片。
+ *
+ * - 空态显示 EmptyState(含创建班级入口)
+ * - 列表展示前 6 个班级,点击跳转班级详情
+ */
+export function TeacherClassesCard({
+ classes,
+}: TeacherClassesCardProps): React.ReactElement {
+ const t = useTranslations("dashboard.teacherCards.classes");
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+
+
+ {classes.length === 0 ? (
+
+ ) : (
+
+ {classes.slice(0, 6).map((c) => (
+
+
+
+ {c.name}
+
+
+
+ {c.grade}
+
+ {c.homeroom && (
+ <>
+ ·
+
+ {t("homeroom")}: {c.homeroom}
+
+ >
+ )}
+ {c.room && (
+ <>
+ ·
+
+ {t("room")} {c.room}
+
+ >
+ )}
+
+
+
+
+ {c.studentCount}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/teacher-grade-trends.tsx b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-grade-trends.tsx
new file mode 100644
index 0000000..44df272
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-grade-trends.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+/**
+ * 教师成绩趋势卡片(P2 迁移)
+ *
+ * 折线图展示班级近期作业平均分(百分比),含近 3 次作业的汇总卡片。
+ * 数据由父组件传入。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+import { TrendingUp } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { ChartCardShell } from "@/shared/components/charts/chart-card-shell";
+import { TrendLineChart } from "@/shared/components/charts/trend-line-chart";
+
+import type { TeacherGradeTrendItem } from "./types";
+
+/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */
+const CHART_MARGIN = {
+ left: 12,
+ right: 12,
+ top: 12,
+ bottom: 12,
+} as const;
+
+interface TeacherGradeTrendsProps {
+ trends: TeacherGradeTrendItem[];
+}
+
+/**
+ * 教师成绩趋势卡片。
+ *
+ * - 折线图展示每次作业的平均分百分比
+ * - 下方网格展示近 3 次作业的得分率与提交情况
+ */
+export function TeacherGradeTrends({
+ trends,
+}: TeacherGradeTrendsProps): React.ReactElement {
+ const t = useTranslations("dashboard.teacherCards.gradeTrends");
+ const hasTrends = trends.length > 0;
+
+ const chartData = trends.map((item) => {
+ const percentage =
+ item.maxScore > 0 ? (item.averageScore / item.maxScore) * 100 : 0;
+ return {
+ title: item.title,
+ score: Math.round(percentage),
+ fullTitle: item.title,
+ submissionCount: item.submissionCount,
+ totalStudents: item.totalStudents,
+ };
+ });
+
+ return (
+
+
+
+
+
+ {chartData
+ .slice()
+ .reverse()
+ .slice(0, 3)
+ .map((item, i) => (
+
+
+ {item.fullTitle}
+
+
+
+ {item.score}%
+
+
+
+ {t("submittedCount", {
+ submitted: item.submissionCount,
+ total: item.totalStudents,
+ })}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/teacher-homework-card.tsx b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-homework-card.tsx
new file mode 100644
index 0000000..e2bf482
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-homework-card.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+/**
+ * 教师作业卡片(P2 迁移)
+ *
+ * 展示教师最近作业列表(最多 6 个),含标题、来源试卷、截止日期、状态。
+ * 数据由父组件传入。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+import { Calendar, PenTool, Plus } from "lucide-react";
+import Link from "next/link";
+import { useFormatter, useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { cn } from "@/shared/lib/utils";
+
+import type { TeacherHomeworkCardItem } from "./types";
+
+interface TeacherHomeworkCardProps {
+ assignments: TeacherHomeworkCardItem[];
+}
+
+/**
+ * 教师作业卡片。
+ *
+ * - 空态显示 EmptyState(含创建作业入口)
+ * - 列表展示前 6 个作业,点击跳转作业详情
+ * - 状态徽章:published(绿)/ draft(黄)/ 其他(灰)
+ */
+export function TeacherHomeworkCard({
+ assignments,
+}: TeacherHomeworkCardProps): React.ReactElement {
+ const t = useTranslations("dashboard.teacherCards.homework");
+ const format = useFormatter();
+
+ const formatDue = (iso: string | null): string => {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ return format.dateTime(d, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+
+
+ {assignments.length === 0 ? (
+
+ ) : (
+
+ {assignments.slice(0, 6).map((a) => {
+ const isPublished = a.status === "published";
+ const isDraft = a.status === "draft";
+ const dueText = formatDue(a.dueAt);
+
+ return (
+
+
+
+
+ {a.sourceExamTitle}
+
+
+
+
+ {dueText ? (
+
+
+ {dueText}
+
+ ) : (
+
+ {t("noDueDate")}
+
+ )}
+
+ {a.status}
+
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/teacher-quick-actions.tsx b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-quick-actions.tsx
new file mode 100644
index 0000000..e886786
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-quick-actions.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+/**
+ * 教师仪表盘快捷操作(P2 迁移)
+ *
+ * 顶部操作按钮组:创建作业、批改作业、我的班级。
+ * 不依赖任何数据,纯导航组件。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2
+ */
+import { CheckSquare, PlusCircle, Users } from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/shared/components/ui/button";
+
+/**
+ * 教师仪表盘快捷操作按钮组。
+ *
+ * 路由对齐 portal-shell:/shell/teacher/...
+ */
+export function TeacherQuickActions(): React.ReactElement {
+ const t = useTranslations("dashboard.teacherCards.quickActions");
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/teacher-schedule.tsx b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-schedule.tsx
new file mode 100644
index 0000000..6d5dd32
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-schedule.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+/**
+ * 教师今日课表卡片(P2 迁移)
+ *
+ * 时间轴形式展示教师今日课表,含课程名、班级、地点、起止时间。
+ * 实时状态:live(进行中)/ upcoming(即将开始)/ past(已结束)。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+import { CalendarDays, CalendarX, MapPin } from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { ScrollArea } from "@/shared/components/ui/scroll-area";
+import { cn } from "@/shared/lib/utils";
+
+import { getScheduleStatus } from "./dashboard-utils";
+import type { TeacherTodayScheduleItem } from "./types";
+
+interface TeacherScheduleProps {
+ items: TeacherTodayScheduleItem[];
+}
+
+/**
+ * 教师今日课表卡片。
+ *
+ * - 空态显示 EmptyState(含查看课表入口)
+ * - 列表展示所有今日课程,按时间升序
+ * - 进行中课程高亮显示并带 LIVE 徽章
+ * - 已结束课程灰显
+ */
+export function TeacherSchedule({
+ items,
+}: TeacherScheduleProps): React.ReactElement {
+ const t = useTranslations("dashboard.teacherCards.schedule");
+ const hasSchedule = items.length > 0;
+ const now = new Date();
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+
+ {!hasSchedule ? (
+
+ ) : (
+
+
+
+
+
+ {items.map((item, index) => {
+ const status = getScheduleStatus(
+ item.startTime,
+ item.endTime,
+ now,
+ );
+ const isLive = status === "live";
+ const isPast = status === "past";
+ const isLast = index === items.length - 1;
+
+ return (
+
+
+
+
+
+
+
+
+ {item.course}
+
+ {isLive && (
+
+ {t("live")}
+
+ )}
+
+
+ {item.className}
+ {item.location && (
+ <>
+ ·
+
+
+ {item.location}
+
+ >
+ )}
+
+
+
+
+ {item.startTime}
+
+ – {item.endTime}
+
+
+
+
+
+ {!isLast && (
+
+ )}
+
+ );
+ })}
+
+ {items.length > 3 ? (
+
+ {t("scrollForMore")}
+
+ ) : (
+
+ {t("noMoreClasses")}
+
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/teacher-todo-card.tsx b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-todo-card.tsx
new file mode 100644
index 0000000..bf3365c
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/teacher-todo-card.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+/**
+ * 教师待办卡片(P2 迁移)
+ *
+ * 展示教师待办聚合(待批改、今日考勤、活跃作业),按变体优先级排序。
+ * 数据由父组件传入(来自 teacherDashboard 富字段或 MSW 兜底)。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+import {
+ AlertCircle,
+ CalendarCheck,
+ ChevronRight,
+ ClipboardCheck,
+ FileEdit,
+} from "lucide-react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/shared/components/ui/card";
+import { cn } from "@/shared/lib/utils";
+
+import type { TeacherTodoItem, TeacherTodoVariant } from "./types";
+
+const VARIANT_STYLES: Record<
+ TeacherTodoVariant,
+ {
+ icon: typeof AlertCircle;
+ iconColor: string;
+ badge: string;
+ }
+> = {
+ urgent: {
+ icon: AlertCircle,
+ iconColor: "text-destructive",
+ badge: "bg-destructive text-destructive-foreground",
+ },
+ normal: {
+ icon: ClipboardCheck,
+ iconColor: "text-amber-500",
+ badge: "bg-amber-500 text-white",
+ },
+ info: {
+ icon: CalendarCheck,
+ iconColor: "text-blue-500",
+ badge: "bg-blue-500 text-white",
+ },
+};
+
+/** 变体优先级映射(数值越小优先级越高) */
+const VARIANT_PRIORITY: Record = {
+ urgent: 0,
+ normal: 1,
+ info: 2,
+};
+
+interface TeacherTodoCardProps {
+ items: TeacherTodoItem[];
+}
+
+/**
+ * 教师待办卡片。
+ *
+ * - 仅展示 count > 0 的待办
+ * - 按变体优先级排序:urgent > normal > info
+ */
+export function TeacherTodoCard({
+ items,
+}: TeacherTodoCardProps): React.ReactElement {
+ const t = useTranslations("dashboard.teacherCards.todo");
+ const hasItems = items.some((item) => item.count > 0);
+ const totalPending = items.reduce(
+ (acc, item) => acc + (item.count > 0 ? 1 : 0),
+ 0,
+ );
+
+ return (
+
+
+
+
+ {t("title")}
+ {totalPending > 0 && (
+
+ {totalPending}
+
+ )}
+
+
+
+ {!hasItems ? (
+
+
+ {t("empty")}
+
+ ) : (
+
+ {items
+ .filter((item) => item.count > 0)
+ .sort(
+ (a, b) =>
+ VARIANT_PRIORITY[a.variant] - VARIANT_PRIORITY[b.variant],
+ )
+ .map((item, idx) => {
+ const style = VARIANT_STYLES[item.variant];
+ const Icon = style.icon;
+ return (
+
+
+
+
+ {item.label}
+
+
+
+
+ {item.count}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/components/types.ts b/apps/portal-shell/src/features/teacher/dashboard/components/types.ts
new file mode 100644
index 0000000..6749164
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/components/types.ts
@@ -0,0 +1,70 @@
+/**
+ * 教师仪表盘卡片本地类型(P2 迁移)
+ *
+ * 这些类型对齐 CICD `@/modules/dashboard/types` 与 `@/modules/homework/types`、
+ * `@/modules/classes/types` 中卡片所需的子集,供 portal-shell 教师仪表盘
+ * 卡片组件共用。后端补齐 teacherDashboard 富字段后可切换为 codegen 生成类型。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+
+/** 教师今日课表项(对齐 CICD TeacherTodayScheduleItem) */
+export interface TeacherTodayScheduleItem {
+ id: string;
+ classId: string;
+ className: string;
+ course: string;
+ startTime: string;
+ endTime: string;
+ location: string | null;
+}
+
+/** 教师班级卡片项(对齐 CICD TeacherClass) */
+export interface TeacherClassCardItem {
+ id: string;
+ name: string;
+ grade: string;
+ homeroom: string | null;
+ room: string | null;
+ studentCount: number;
+}
+
+/** 教师作业卡片项(对齐 CICD HomeworkAssignmentListItem 子集) */
+export interface TeacherHomeworkCardItem {
+ id: string;
+ title: string;
+ sourceExamTitle: string;
+ status: "draft" | "published" | "closed";
+ dueAt: string | null;
+}
+
+/** 教师最近提交项(对齐 CICD HomeworkSubmissionListItem 子集) */
+export interface TeacherRecentSubmissionItem {
+ id: string;
+ studentName: string;
+ assignmentTitle: string;
+ submittedAt: string | null;
+ isLate: boolean;
+ status: "submitted" | "graded";
+}
+
+/** 教师成绩趋势项(对齐 CICD TeacherGradeTrendItem) */
+export interface TeacherGradeTrendItem {
+ id: string;
+ title: string;
+ averageScore: number;
+ maxScore: number;
+ submissionCount: number;
+ totalStudents: number;
+}
+
+/** 待办项变体 */
+export type TeacherTodoVariant = "urgent" | "normal" | "info";
+
+/** 教师待办项(对齐 CICD TeacherTodoItem) */
+export interface TeacherTodoItem {
+ label: string;
+ count: number;
+ href: string;
+ variant: TeacherTodoVariant;
+}
diff --git a/apps/portal-shell/src/features/teacher/dashboard/dashboard-cards-client.tsx b/apps/portal-shell/src/features/teacher/dashboard/dashboard-cards-client.tsx
new file mode 100644
index 0000000..35a23e0
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/dashboard-cards-client.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+/**
+ * 教师仪表盘卡片聚合导出(P2 迁移)
+ *
+ * 该文件统一导出教师仪表盘的 7 个卡片组件 + 工具函数 + 本地类型,
+ * 供 `app/shell/teacher/page.tsx` 等页面引用。
+ *
+ * 数据契约(@contract-pending):
+ * portal-shell 的 `useTeacherDashboard()` 仅返回基础聚合(total_classes、
+ * classes 单条、recent_warnings 单条),缺富字段(assignments、submissions、
+ * gradeTrends、todayScheduleItems)。卡片为纯展示组件,由调用方按需传入
+ * 空数组占位;待 data-ana subgraph 补齐富字段后,由调用方从聚合查询中
+ * 取数并传入。
+ *
+ * 关联:ARCHITECTURE.md §5.5 / §10 P2 / §11.4
+ */
+
+// P2 迁移:教师仪表盘卡片组件
+export { RecentSubmissions } from "./components/recent-submissions";
+export { TeacherClassesCard } from "./components/teacher-classes-card";
+export { TeacherGradeTrends } from "./components/teacher-grade-trends";
+export { TeacherHomeworkCard } from "./components/teacher-homework-card";
+export { TeacherQuickActions } from "./components/teacher-quick-actions";
+export { TeacherSchedule } from "./components/teacher-schedule";
+export { TeacherTodoCard } from "./components/teacher-todo-card";
+
+// P2 迁移:仪表盘纯逻辑工具函数
+export {
+ getGreetingKey,
+ getScheduleStatus,
+ timeToMinutes,
+ toWeekday,
+ type ScheduleStatus,
+ type Weekday,
+} from "./components/dashboard-utils";
+
+// P2 迁移:仪表盘卡片本地类型
+export type {
+ TeacherClassCardItem,
+ TeacherGradeTrendItem,
+ TeacherHomeworkCardItem,
+ TeacherRecentSubmissionItem,
+ TeacherTodayScheduleItem,
+ TeacherTodoItem,
+ TeacherTodoVariant,
+} from "./components/types";
diff --git a/apps/portal-shell/src/features/teacher/dashboard/services/dashboard-service.tsx b/apps/portal-shell/src/features/teacher/dashboard/services/dashboard-service.tsx
new file mode 100644
index 0000000..911ae9c
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/dashboard/services/dashboard-service.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+/**
+ * 仪表盘数据服务 Context(迁移自 CICD services/dashboard-service.tsx)
+ *
+ * portal-shell 适配说明:
+ * - CICD 通过 Server Actions + data-access.ts 提供数据,本文件抽象为 DashboardService 接口
+ * - portal-shell 使用 GraphQL hooks(useTeacherDashboard 等),数据层在 lib/api/dashboard.ts
+ * - 本 Context 保留依赖注入骨架,具体实现可在 Provider 中通过 GraphQL hooks 包装注入
+ * - 数据形状使用 unknown,由实现方保证类型安全(避免反向依赖具体 hook 返回类型)
+ *
+ * 关联:portal-shell ARCHITECTURE.md §5.3 / §7.1 / §11.3
+ */
+import { createContext, useContext, type ReactNode } from "react";
+
+/** 通用 Action 结果(对齐 CICD ActionState 形状) */
+export interface ActionState {
+ ok: boolean;
+ data?: T;
+ message?: string;
+}
+
+/** 教师仪表盘数据(形状由实现方决定,调用方做类型守卫) */
+export type TeacherDashboardData = unknown;
+
+/** 学生仪表盘数据 */
+export type StudentDashboardData = unknown;
+
+/** 家长仪表盘数据 */
+export type ParentDashboardData = unknown;
+
+/** 管理员仪表盘流式数据源(各分区独立 Promise) */
+export interface AdminDashboardStreams {
+ [key: string]: Promise;
+}
+
+/**
+ * 仪表盘数据服务接口(抽象数据依赖)。
+ *
+ * 每个角色提供独立的实现,封装对 GraphQL hooks 的调用并加入权限校验。
+ * 组件通过 `useDashboardService()` 获取当前注入的实现,不直接 import hooks。
+ * 测试时可注入 mock 实现以隔离数据层。
+ */
+export interface DashboardService {
+ /** 获取管理员仪表盘数据(流式:返回未解析 Promise 供各分区独立消费) */
+ getAdminStreams(): Promise;
+ /** 获取教师仪表盘数据 */
+ getTeacherData(): Promise>;
+ /** 获取学生仪表盘数据 */
+ getStudentData(): Promise<
+ ActionState<{
+ student: { id: string; name: string } | null;
+ dashboardProps: unknown | null;
+ }>
+ >;
+ /** 获取家长仪表盘数据 */
+ getParentData(): Promise<
+ ActionState<{ data: ParentDashboardData | null; hasChildren: boolean }>
+ >;
+}
+/**
+ * 仪表盘监控埋点接口。
+ *
+ * 预留关键操作埋点,供后续接入实际监控 SDK(如 PostHog / Mixpanel)。
+ * 默认实现为空操作,生产环境通过 Provider 注入实际实现。
+ */
+export interface DashboardAnalytics {
+ /** Widget 被点击 */
+ trackWidgetClick(widgetId: string, role: string): void;
+ /** 空状态被触发 */
+ trackEmptyState(widgetId: string, role: string): void;
+ /** 错误重试 */
+ trackErrorRetry(widgetId: string, role: string): void;
+ /** 页面停留 */
+ trackPageView(role: string, durationMs: number): void;
+}
+
+/** 空操作实现(默认) */
+const noopAnalytics: DashboardAnalytics = {
+ trackWidgetClick: () => {},
+ trackEmptyState: () => {},
+ trackErrorRetry: () => {},
+ trackPageView: () => {},
+};
+
+const DashboardServiceContext = createContext(null);
+const DashboardAnalyticsContext =
+ createContext(noopAnalytics);
+
+export interface DashboardServiceProviderProps {
+ service: DashboardService;
+ analytics?: DashboardAnalytics;
+ children: ReactNode;
+}
+
+/** 仪表盘服务 Provider(在页面层注入角色特定的实现) */
+export function DashboardServiceProvider({
+ service,
+ analytics,
+ children,
+}: DashboardServiceProviderProps): ReactNode {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/** 获取当前注入的仪表盘数据服务 */
+export function useDashboardService(): DashboardService {
+ const service = useContext(DashboardServiceContext);
+ if (!service) {
+ throw new Error(
+ "useDashboardService must be used within DashboardServiceProvider",
+ );
+ }
+ return service;
+}
+
+/** 获取当前注入的监控埋点接口 */
+export function useDashboardAnalytics(): DashboardAnalytics {
+ return useContext(DashboardAnalyticsContext);
+}
diff --git a/apps/portal-shell/src/features/teacher/diagnostic/components/class-diagnostic-view.tsx b/apps/portal-shell/src/features/teacher/diagnostic/components/class-diagnostic-view.tsx
new file mode 100644
index 0000000..458e4e6
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/diagnostic/components/class-diagnostic-view.tsx
@@ -0,0 +1,588 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { notify } from "@/shared/lib/notify";
+import { useTranslations } from "next-intl";
+import {
+ Users,
+ AlertTriangle,
+ TrendingUp,
+ FileText,
+ Filter,
+} from "lucide-react";
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/shared/components/ui/card";
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import { Label } from "@/shared/components/ui/label";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { Select } from "@/shared/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import type { KnowledgePointStudent } from "../services/diagnostic-service";
+import { useDiagnosticService } from "../services/diagnostic-service-context";
+import type { ClassMasterySummary } from "./types";
+
+interface ClassDiagnosticViewProps {
+ summary: ClassMasterySummary | null;
+}
+
+/** 掌握度热力图颜色 */
+function masteryColor(level: number): string {
+ if (level >= 80) return "bg-green-500";
+ if (level >= 60) return "bg-yellow-500";
+ if (level >= 40) return "bg-orange-500";
+ return "bg-red-500";
+}
+
+export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
+ const t = useTranslations("diagnostic");
+ const router = useRouter();
+ const canManage = true;
+ // v2-P1-4: 通过 Context 注入服务,不直接 import actions
+ const service = useDiagnosticService();
+ const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7));
+ const [isGenerating, setIsGenerating] = useState(false);
+
+ // v3-P2-5: 知识点筛选状态
+ const [selectedKpId, setSelectedKpId] = useState("all");
+ const [filteredStudents, setFilteredStudents] = useState<
+ KnowledgePointStudent[] | null
+ >(null);
+ const [isFiltering, setIsFiltering] = useState(false);
+
+ const handleGenerate = async () => {
+ if (!summary) return;
+ setIsGenerating(true);
+ try {
+ const result = await service.generateClassReport(summary.classId, period);
+ if (result.success) {
+ notify.success(result.message ?? t("classDiagnostic.generateButton"));
+ router.refresh();
+ } else {
+ notify.error(result.message || t("error.generateClassFailed"));
+ }
+ } catch {
+ notify.error(t("error.generateClassFailed"));
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ /**
+ * v3-P2-5: 按知识点筛选学生。
+ * 选择知识点后调用服务获取该知识点上所有学生的掌握度。
+ */
+ const handleKpFilter = async (kpId: string) => {
+ setSelectedKpId(kpId);
+ if (!summary || kpId === "all") {
+ setFilteredStudents(null);
+ return;
+ }
+ setIsFiltering(true);
+ try {
+ const result = await service.getClassStudentsByKp(summary.classId, kpId);
+ if (result.success && result.data) {
+ setFilteredStudents(result.data);
+ } else {
+ notify.error(result.message || t("error.loadFailed"));
+ setFilteredStudents(null);
+ }
+ } catch {
+ notify.error(t("error.loadFailed"));
+ setFilteredStudents(null);
+ } finally {
+ setIsFiltering(false);
+ }
+ };
+
+ if (!summary) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* v2-P1-6: 概览区块独立 Error Boundary */}
+
+
+
+
+ {t("summary.class")}
+
+
+
+ {summary.className}
+
+
+
+
+
+ {t("summary.students")}
+
+
+
+ {summary.studentCount}
+
+
+
+
+
+ {t("summary.avgMastery")}
+
+
+
+
+ {summary.averageMastery.toFixed(1)}%
+
+
+
+
+
+
+ {t("summary.needAttention")}
+
+
+
+
+ {summary.studentsNeedingAttention.length}
+
+
+
+
+
+ {/* v2-P1-6: 知识点掌握度热力图区块独立 Error Boundary */}
+
+
+
+
+ {t("chart.heatmapTitle")}
+
+
+ {t("classDiagnostic.heatmapDescription")}
+
+
+
+ {summary.knowledgePointStats.length === 0 ? (
+
+ {t("classDiagnostic.noKnowledgePointData")}
+
+ ) : (
+ <>
+
+ {summary.knowledgePointStats.map((kp) => {
+ const levelLabel =
+ kp.averageMastery >= 80
+ ? t("classDiagnostic.masteryLevelExcellent")
+ : kp.averageMastery >= 60
+ ? t("classDiagnostic.masteryLevelGood")
+ : kp.averageMastery >= 40
+ ? t("classDiagnostic.masteryLevelNeedsImprovement")
+ : t("classDiagnostic.masteryLevelWeak");
+ return (
+
+
+ {kp.knowledgePointName}
+
+
+ {kp.averageMastery.toFixed(0)}%
+
+
+ );
+ })}
+
+ {/* v4-P1-8: 热力图颜色图例 */}
+
+
+ {t("classDiagnostic.legendLabel")}
+
+
+
+
+
+ {t("classDiagnostic.masteryLevelExcellent")} (≥80%)
+
+
+
+
+
+ {t("classDiagnostic.masteryLevelGood")} (60-79%)
+
+
+
+
+
+ {t("classDiagnostic.masteryLevelNeedsImprovement")}{" "}
+ (40-59%)
+
+
+
+
+
+ {t("classDiagnostic.masteryLevelWeak")} (<40%)
+
+
+
+
+ >
+ )}
+
+
+
+ {/* v2-P1-6: 按知识点筛选学生区块独立 Error Boundary */}
+
+
+
+
+ {t("classDiagnostic.filterByKpTitle")}
+
+
+ {t("classDiagnostic.filterByKpDescription")}
+
+
+
+
+
+ ({
+ value: kp.knowledgePointId,
+ label: `${kp.knowledgePointName} (${kp.averageMastery.toFixed(0)}%)`,
+ })),
+ ]}
+ placeholder={t("classDiagnostic.kpFilterPlaceholder")}
+ id="kp-filter"
+ aria-label={t("classDiagnostic.kpFilterLabel")}
+ className="w-full md:w-80"
+ />
+
+
+ {isFiltering ? (
+
+ {t("classDiagnostic.filtering")}
+
+ ) : filteredStudents && filteredStudents.length > 0 ? (
+
+ {/* v4-P1-11: 移动端表格水平滚动 */}
+
+
+
+
+ {t("summary.student")}
+
+ {t("classDiagnostic.avgMasteryColumn")}
+
+
+ {t("classDiagnostic.totalQuestionsColumn")}
+
+
+ {t("classDiagnostic.correctQuestionsColumn")}
+
+ {t("classDiagnostic.statusColumn")}
+
+
+
+
+ {filteredStudents.map((s) => (
+
+
+ {s.studentName}
+
+
+ = 80
+ ? "default"
+ : s.masteryLevel >= 60
+ ? "secondary"
+ : "destructive"
+ }
+ >
+ {s.masteryLevel.toFixed(0)}%
+
+
+
+ {s.totalQuestions}
+
+
+ {s.correctQuestions}
+
+
+ {s.needsAttention ? (
+
+ {t("classDiagnostic.needsAttention")}
+
+ ) : (
+
+ {t("classDiagnostic.mastered")}
+
+ )}
+
+
+
+
+
+ ))}
+
+
+
+
+ ) : filteredStudents && filteredStudents.length === 0 ? (
+
+ {t("classDiagnostic.noStudentsForKp")}
+
+ ) : null}
+
+
+
+ {/* v2-P1-6: 知识点排名表区块独立 Error Boundary */}
+
+
+ {t("chart.rankingTitle")}
+
+
+ {summary.knowledgePointStats.length === 0 ? (
+
+ {t("classDiagnostic.noRankingData")}
+
+ ) : (
+
+ {/* v4-P1-11: 移动端表格水平滚动 */}
+
+
+
+
+
+ {t("classDiagnostic.knowledgePointColumn")}
+
+
+ {t("classDiagnostic.avgMasteryColumn")}
+
+
+ {t("classDiagnostic.masteredColumn")}
+
+
+ {t("classDiagnostic.notMasteredColumn")}
+
+
+
+
+ {[...summary.knowledgePointStats]
+ .sort((a, b) => b.averageMastery - a.averageMastery)
+ .map((kp) => (
+
+
+ {kp.knowledgePointName}
+
+
+ = 80
+ ? "default"
+ : kp.averageMastery >= 60
+ ? "secondary"
+ : "destructive"
+ }
+ >
+ {kp.averageMastery.toFixed(1)}%
+
+
+
+ {kp.masteredCount}
+
+
+ {kp.notMasteredCount}
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ {/* v2-P1-6: 需重点关注的学生区块独立 Error Boundary */}
+
+
+
+
+ {t("classDiagnostic.studentsNeedingAttentionTitle")}
+
+
+ {t("classDiagnostic.studentsNeedingAttentionDescription")}
+
+
+
+ {summary.studentsNeedingAttention.length === 0 ? (
+
+ {t("classDiagnostic.allStudentsAboveThreshold")}
+
+ ) : (
+
+ {/* v4-P1-11: 移动端表格水平滚动 */}
+
+
+
+
+ {t("summary.student")}
+
+ {t("classDiagnostic.avgMasteryColumn")}
+
+
+ {t("classDiagnostic.weakPointsColumn")}
+
+
+
+
+
+ {summary.studentsNeedingAttention.map((s) => (
+
+
+ {s.studentName}
+
+
+
+ {s.averageMastery.toFixed(1)}%
+
+
+
+ {s.weakCount}
+
+
+
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ {/* v2-P1-6: 生成班级报告区块独立 Error Boundary */}
+ {canManage ? (
+
+
+
+
+ {t("report.generateClass")}
+
+
+ {t("classDiagnostic.generateDescription")}
+
+
+
+
+
+
+ setPeriod(e.target.value)}
+ className="w-44"
+ />
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/diagnostic/components/confidence-utils.ts b/apps/portal-shell/src/features/teacher/diagnostic/components/confidence-utils.ts
new file mode 100644
index 0000000..613a814
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/diagnostic/components/confidence-utils.ts
@@ -0,0 +1,53 @@
+/**
+ * 诊断报告数据置信度工具(P2 迁移)
+ *
+ * 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。
+ * 提取到独立文件供 report-list 和学生诊断视图共享。
+ *
+ * 置信度基于报告中涉及的知识点数量(strengths + weaknesses 数组长度之和):
+ * - 0 个 → insufficient(数据不足)
+ * - 1-3 个 → low(数据较少)
+ * - 4-8 个 → medium(数据量一般)
+ * - >8 个 → high(数据充足)
+ */
+import type { DiagnosticReportWithDetails } from "./types";
+
+export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient";
+
+/** 置信度阈值(基于知识点数量 = strengths.length + weaknesses.length) */
+const CONFIDENCE_INSUFFICIENT_MAX = 0;
+const CONFIDENCE_LOW_MAX = 3;
+const CONFIDENCE_MEDIUM_MAX = 8;
+
+/**
+ * 根据报告数据计算置信度。
+ *
+ * @param report 诊断报告(含详情)
+ * @param totalKnowledgePoints 可选:显式传入知识点总数(优先于数组长度推断)
+ */
+export function getConfidenceLevel(
+ report: DiagnosticReportWithDetails,
+ totalKnowledgePoints?: number,
+): ConfidenceLevel {
+ if (report.overallScore === null) return "insufficient";
+
+ const kpCount =
+ totalKnowledgePoints ??
+ (report.strengths?.length ?? 0) + (report.weaknesses?.length ?? 0);
+
+ if (kpCount <= CONFIDENCE_INSUFFICIENT_MAX) return "insufficient";
+ if (kpCount <= CONFIDENCE_LOW_MAX) return "low";
+ if (kpCount <= CONFIDENCE_MEDIUM_MAX) return "medium";
+ return "high";
+}
+
+/** 置信度对应的 Badge variant(对齐 portal-shell Badge 组件) */
+export const confidenceBadgeVariant: Record<
+ ConfidenceLevel,
+ "default" | "secondary" | "destructive" | "outline"
+> = {
+ high: "default",
+ medium: "secondary",
+ low: "destructive",
+ insufficient: "outline",
+};
diff --git a/apps/portal-shell/src/features/teacher/diagnostic/components/mastery-radar-chart.tsx b/apps/portal-shell/src/features/teacher/diagnostic/components/mastery-radar-chart.tsx
new file mode 100644
index 0000000..3c59ee0
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/diagnostic/components/mastery-radar-chart.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+/**
+ * 知识点掌握度雷达图(P2 迁移)
+ *
+ * 学生分数 vs 班级平均的多维度对比雷达图。
+ * 复用 portal-shell 的 ChartCardShell + ComparisonRadarChart。
+ *
+ * 关联:ARCHITECTURE.md §5.4 / §9.1 / §10 P2
+ */
+import { Target } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { ChartCardShell } from "@/shared/components/charts/chart-card-shell";
+import { ComparisonRadarChart } from "@/shared/components/charts/comparison-radar-chart";
+import type { MasteryRadarPoint } from "./types";
+
+const MAX_AXIS_LABEL_LENGTH = 8;
+
+interface MasteryRadarChartProps {
+ data: MasteryRadarPoint[];
+}
+
+/**
+ * 知识点掌握度雷达图。
+ *
+ * - 保留完整 knowledgePoint 作为 angleKey,使 Tooltip 显示完整名称
+ * - 通过 angleTickFormatter 截断轴上显示文本,避免长名称溢出
+ */
+export function MasteryRadarChart({
+ data,
+}: MasteryRadarChartProps): React.ReactElement {
+ const t = useTranslations("diagnostic.chart");
+ const isEmpty = !data || data.length === 0;
+
+ const truncateAxisLabel = (value: string): string =>
+ value.length > MAX_AXIS_LABEL_LENGTH
+ ? `${value.slice(0, MAX_AXIS_LABEL_LENGTH)}...`
+ : value;
+
+ const chartData = isEmpty ? [] : data.map((d) => ({ ...d }));
+
+ const hasClassAverage =
+ !isEmpty && data.some((d) => d.classAverage !== undefined);
+
+ const ariaLabel = isEmpty
+ ? t("radarAriaLabelEmpty")
+ : t("radarAriaLabelNonEmpty", {
+ count: data.length,
+ withClassAverage: hasClassAverage ? t("withClassAverage") : "",
+ });
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/portal-shell/src/features/teacher/diagnostic/components/report-list.tsx b/apps/portal-shell/src/features/teacher/diagnostic/components/report-list.tsx
new file mode 100644
index 0000000..bfb1fe7
--- /dev/null
+++ b/apps/portal-shell/src/features/teacher/diagnostic/components/report-list.tsx
@@ -0,0 +1,360 @@
+"use client";
+
+/**
+ * 诊断报告列表(P2 迁移)
+ *
+ * 展示诊断报告列表,支持按类型/状态过滤,含发布/删除/导出操作。
+ * portal-shell 无对应 mutation 契约(@contract-pending),操作改为
+ * notify 提示 + no-op;后端补齐 mutation 后接入 GraphQL hooks。
+ *
+ * 关联:ARCHITECTURE.md §5.4 / §9.1 / §10 P2 / §11.4
+ */
+import { Download, FileText, Send, Trash2 } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { Badge } from "@/shared/components/ui/badge";
+import { Button } from "@/shared/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+import { EmptyState } from "@/shared/components/ui/empty-state";
+import { Label } from "@/shared/components/ui/label";
+import { Select } from "@/shared/components/ui/select";
+import type { SelectOption } from "@/shared/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/shared/components/ui/table";
+import { notify } from "@/shared/lib/notify";
+import {
+ confidenceBadgeVariant,
+ getConfidenceLevel,
+ type ConfidenceLevel,
+} from "./confidence-utils";
+import type { DiagnosticReportWithDetails, ReportStatus } from "./types";
+
+const STATUS_COLORS: Record<
+ ReportStatus,
+ "default" | "secondary" | "destructive" | "outline"
+> = {
+ draft: "secondary",
+ published: "default",
+ archived: "outline",
+};
+
+interface ReportListProps {
+ reports: DiagnosticReportWithDetails[];
+}
+
+/**
+ * 诊断报告列表组件。
+ *
+ * - 支持 URL 参数过滤(reportType, status)
+ * - 发布/删除操作通过 Dialog 确认后执行(@contract-pending no-op)
+ * - 导出操作下载简化 CSV(@contract-pending no-op,仅提示)
+ */
+export function ReportList({ reports }: ReportListProps): React.ReactElement {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const t = useTranslations("diagnostic.reportList");
+
+ const [deleteId, setDeleteId] = useState(null);
+ const [publishId, setPublishId] = useState(null);
+ const [isBusy, setIsBusy] = useState(false);
+
+ const updateParam = useCallback(
+ (key: string, value: string): void => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value && value !== "all") {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ router.push(`?${params.toString()}`);
+ },
+ [router, searchParams],
+ );
+
+ const handlePublish = async (): Promise => {
+ if (!publishId) return;
+ setIsBusy(true);
+ // @contract-pending 发布 mutation 未就绪,当前 no-op
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ setIsBusy(false);
+ notify.success(t("publishSuccess"));
+ setPublishId(null);
+ router.refresh();
+ };
+
+ const handleDelete = async (): Promise => {
+ if (!deleteId) return;
+ setIsBusy(true);
+ // @contract-pending 删除 mutation 未就绪,当前 no-op
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ setIsBusy(false);
+ notify.success(t("deleteSuccess"));
+ setDeleteId(null);
+ router.refresh();
+ };
+
+ const handleExport = (reportId: string): void => {
+ // @contract-pending 导出契约未就绪,当前 no-op
+ void reportId;
+ notify.info(t("exportPending"));
+ };
+
+ const confidenceLabel = (level: ConfidenceLevel): string => {
+ if (level === "high") return t("confidenceHigh");
+ if (level === "medium") return t("confidenceMedium");
+ if (level === "low") return t("confidenceLow");
+ return t("confidenceInsufficient");
+ };
+
+ const reportType = searchParams.get("reportType") ?? "all";
+ const status = searchParams.get("status") ?? "all";
+
+ const reportTypeOptions: readonly SelectOption[] = useMemo(
+ () =>
+ [
+ { value: "all", label: t("allTypes") },
+ { value: "individual", label: t("type.individual") },
+ { value: "class", label: t("type.class") },
+ { value: "grade", label: t("type.grade") },
+ ] as const satisfies ReadonlyArray,
+ [t],
+ );
+ const statusOptions: readonly SelectOption[] = useMemo(
+ () =>
+ [
+ { value: "all", label: t("allStatuses") },
+ { value: "draft", label: t("status.draft") },
+ { value: "published", label: t("status.published") },
+ { value: "archived", label: t("status.archived") },
+ ] as const satisfies ReadonlyArray,
+ [t],
+ );
+
+ const typeLabel = (rt: string): string => {
+ if (rt === "individual") return t("type.individual");
+ if (rt === "class") return t("type.class");
+ if (rt === "grade") return t("type.grade");
+ return rt;
+ };
+
+ const statusLabel = (st: string): string => {
+ if (st === "draft") return t("status.draft");
+ if (st === "published") return t("status.published");
+ if (st === "archived") return t("status.archived");
+ return st;
+ };
+
+ const studentTargetDisplay = (r: DiagnosticReportWithDetails): string => {
+ if (r.studentName) return r.studentName;
+ if (r.reportType === "class") return t("classReportPlaceholder");
+ if (r.reportType === "grade") return t("gradeReportPlaceholder");
+ return "-";
+ };
+
+ return (
+
+ {/* 过滤器 */}
+
+
+
+ updateParam("reportType", v)}
+ options={reportTypeOptions}
+ placeholder={t("allTypes")}
+ id="filter-report-type"
+ className="h-9"
+ />
+
+
+
+ updateParam("status", v)}
+ options={statusOptions}
+ placeholder={t("allStatuses")}
+ id="filter-report-status"
+ className="h-9"
+ />
+
+
+
+ {reports.length === 0 ? (
+
+ ) : (
+
+
+ {t("caption")}
+
+
+ {t("typeColumn")}
+ {t("studentTargetColumn")}
+ {t("periodColumn")}
+ {t("scoreColumn")}
+ {t("confidenceColumn")}
+ {t("statusColumn")}
+ {t("generatedByColumn")}
+ {t("dateColumn")}
+ {t("actionsColumn")}
+
+
+
+ {reports.map((r) => {
+ const level = getConfidenceLevel(r);
+ return (
+
+
+ {typeLabel(r.reportType)}
+
+
+ {studentTargetDisplay(r)}
+
+ {r.period ?? "-"}
+
+ {r.overallScore !== null
+ ? `${r.overallScore.toFixed(1)}%`
+ : "-"}
+
+
+
+ {confidenceLabel(level)}
+
+
+
+
+ {statusLabel(r.status)}
+
+
+
+ {r.generatedByName ?? "-"}
+
+
+ {r.createdAt}
+
+
+
+ |