From dca25fc42fe89e08b542bf8d9a58f5abbed336d0 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:35:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal-shell):=20exams=20=E4=B8=89?= =?UTF-8?q?=E5=AD=90=E9=A1=B5=E9=9D=A2=E8=BF=81=E7=A7=BB=EF=BC=88analytics?= =?UTF-8?q?/build/edit=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.1 教师域 exams 模块补完(继 d066da5 列表/详情/表单后): - /shell/teacher/exams/[id]/analytics:详情页(图表)- 混合契约 · 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪) · 扩展字段(排名/每题正确率)❌ MSW 兜底(@contract-pending) · 含 Summary/Distribution/QuestionAccuracy/Rankings 四区 - /shell/teacher/exams/[id]/build:工作台页(组卷)@contract-pending · 三栏:题库候选 / 已选题目 / 预览 · 支持搜索/类型/难度筛选,添加/移除/上移/下移/改分 - /shell/teacher/exams/[id]/edit:工作台页(富文本试卷)@contract-pending · contentEditable + 工具栏(B/I/U/H1-H3/列表) · 右栏试卷属性面板 §11.3 DoD 11 项验收: 1. route-permissions:PREFIX 表 /shell/teacher/exams/ 已覆盖 2. 页面模板:analytics 用 DetailPageShell;build/edit 用 WorkbenchPageShell 3. 三态:loading/error/empty 均实现(workbench 用 errorNode 合并 empty) 4. lib/api hooks:useExamAnalytics/useExamBuild/useQuestionsLibrary/ useSaveExamBuild/useExamRichEditor/useSaveExamRichContent 6 个 5. @contract-pending MSW 模式:graphql-data.ts 扩展 6 个 case 6. i18n:analytics(19 keys)+build(28 keys)+edit(13 keys) 中英对齐 7. lint:0 errors(4 warnings 在 __generated__) 8. lint:tokens:0 errors 9. notify:success/error/warning 走 @/shared/lib/notify(非 sonner 直引) 10. vitest:transformations 新增 10 函数 22 测试,全量 273/273 通过 11. typecheck:0 errors(noUncheckedIndexedAccess 安全 swap 写法) 剩余:proctoring 标注"二期 WS"按 §9.1 暂缓。 --- .gitignore | 3 + .../teacher/exams/[id]/analytics/page.tsx | 24 + .../shell/teacher/exams/[id]/build/page.tsx | 25 + .../shell/teacher/exams/[id]/edit/page.tsx | 24 + .../exams/__tests__/transformations.test.ts | 173 ++++++ .../teacher/exams/exam-analytics-client.tsx | 342 ++++++++++++ .../teacher/exams/exam-build-client.tsx | 499 ++++++++++++++++++ .../teacher/exams/exam-edit-client.tsx | 314 +++++++++++ .../features/teacher/exams/transformations.ts | 115 ++++ apps/portal-shell/src/lib/api/exams.ts | 336 ++++++++++++ .../src/lib/api/operations/exams.graphql.ts | 121 +++++ apps/portal-shell/src/messages/en.json | 89 ++++ apps/portal-shell/src/messages/zh-CN.json | 89 ++++ apps/portal-shell/src/mocks/graphql-data.ts | 313 +++++++++++ 14 files changed, 2467 insertions(+) create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/[id]/analytics/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/[id]/build/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/[id]/edit/page.tsx create mode 100644 apps/portal-shell/src/features/teacher/exams/exam-analytics-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/exams/exam-build-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/exams/exam-edit-client.tsx diff --git a/.gitignore b/.gitignore index a4d8f29..aa97c49 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ out/ target/ bin/ obj/ +# Allow Next.js app router route segments named "build" (e.g. exams/[id]/build) +!apps/portal-shell/src/app/**/build/ +!apps/portal-shell/src/app/**/build/** # Go *.exe diff --git a/apps/portal-shell/src/app/shell/teacher/exams/[id]/analytics/page.tsx b/apps/portal-shell/src/app/shell/teacher/exams/[id]/analytics/page.tsx new file mode 100644 index 0000000..b6fa86c --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/[id]/analytics/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; + +import { ExamAnalyticsClient } from "@/features/teacher/exams/exam-analytics-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 考试分析页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 ExamAnalyticsClient(client component)中。 + * + * 数据契约:混合契约 + * - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪) + * - 扩展字段(排名/每题正确率)❌ → MSW 兜底(@contract-pending) + * + * 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ExamAnalyticsPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/exams/[id]/build/page.tsx b/apps/portal-shell/src/app/shell/teacher/exams/[id]/build/page.tsx new file mode 100644 index 0000000..32c0854 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/[id]/build/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; + +import { ExamBuildClient } from "@/features/teacher/exams/exam-build-client"; +import { WorkbenchPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 组卷工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 ExamBuildClient(client component)中。 + * + * 数据契约:❌ @contract-pending + * - examBuild(examId) 根字段不存在 → MSW 兜底 + * - questionsLibrary(filter) 根字段不存在 → MSW 兜底 + * - saveExamBuild(input) mutation 不存在 → MSW 兜底 + * + * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ExamBuildPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/exams/[id]/edit/page.tsx b/apps/portal-shell/src/app/shell/teacher/exams/[id]/edit/page.tsx new file mode 100644 index 0000000..9e390a6 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/[id]/edit/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; + +import { ExamEditClient } from "@/features/teacher/exams/exam-edit-client"; +import { WorkbenchPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 富文本试卷编辑页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 ExamEditClient(client component)中。 + * + * 数据契约:❌ @contract-pending + * - examRichEditor(examId) 根字段不存在 → MSW 兜底 + * - saveExamRichContent(input) mutation 不存在 → MSW 兜底 + * + * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ExamEditPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts index 5da41da..7161bac 100644 --- a/apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts +++ b/apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts @@ -9,12 +9,22 @@ import type { Exam } from "@/lib/api"; import { EXAM_STATUS_LABEL, + countByType, formatDuration, formatExamDate, formatExamStatus, + formatPassRate, + formatPercent, + formatScore, isExamEditable, isExamPublished, + levelToColorClass, + nextSortOrder, parseTotalScore, + rateToColorClass, + rateToLevel, + sortBySortOrder, + sumSelectedScores, toExamListItem, } from "../transformations"; @@ -178,3 +188,166 @@ describe("formatDuration", () => { expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("--"); }); }); + +// ── Analytics 纯函数单测 ────────────────────────────────────── + +describe("formatScore", () => { + it("formats finite numbers with 1 decimal place", () => { + expect(formatScore(82.5)).toBe("82.5"); + expect(formatScore(98)).toBe("98.0"); + expect(formatScore(0)).toBe("0.0"); + }); + + it("returns placeholder for non-finite input", () => { + expect(formatScore(Number.NaN)).toBe("--"); + expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--"); + expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--"); + }); +}); + +describe("formatPassRate / formatPercent", () => { + it("formats rate in [0,1] as percentage", () => { + expect(formatPassRate(0.86)).toBe("86%"); + expect(formatPassRate(0)).toBe("0%"); + expect(formatPassRate(1)).toBe("100%"); + expect(formatPercent(0.92)).toBe("92%"); + }); + + it("returns placeholder for out-of-range or non-finite input", () => { + expect(formatPassRate(-0.1)).toBe("--"); + expect(formatPassRate(1.1)).toBe("--"); + expect(formatPassRate(Number.NaN)).toBe("--"); + expect(formatPercent(Number.NaN)).toBe("--"); + }); +}); + +describe("rateToLevel", () => { + it("maps rate to A/B/C/D levels", () => { + expect(rateToLevel(0.9)).toBe("A"); + expect(rateToLevel(0.85)).toBe("A"); + expect(rateToLevel(0.75)).toBe("B"); + expect(rateToLevel(0.7)).toBe("B"); + expect(rateToLevel(0.65)).toBe("C"); + expect(rateToLevel(0.6)).toBe("C"); + expect(rateToLevel(0.5)).toBe("D"); + expect(rateToLevel(0)).toBe("D"); + }); + + it("returns placeholder for non-finite input", () => { + expect(rateToLevel(Number.NaN)).toBe("--"); + }); +}); + +describe("rateToColorClass", () => { + it("returns emerald for high rates", () => { + expect(rateToColorClass(0.8)).toBe("text-emerald-600"); + expect(rateToColorClass(0.95)).toBe("text-emerald-600"); + }); + + it("returns amber for medium rates", () => { + expect(rateToColorClass(0.6)).toBe("text-amber-600"); + expect(rateToColorClass(0.79)).toBe("text-amber-600"); + }); + + it("returns destructive for low rates", () => { + expect(rateToColorClass(0.59)).toBe("text-destructive"); + expect(rateToColorClass(0)).toBe("text-destructive"); + }); + + it("returns muted for non-finite input", () => { + expect(rateToColorClass(Number.NaN)).toBe("text-muted-foreground"); + }); +}); + +describe("levelToColorClass", () => { + it("maps each level to correct color class", () => { + expect(levelToColorClass("A")).toBe("text-emerald-600"); + expect(levelToColorClass("B")).toBe("text-blue-600"); + expect(levelToColorClass("C")).toBe("text-amber-600"); + expect(levelToColorClass("D")).toBe("text-destructive"); + }); + + it("returns muted for unknown level", () => { + expect(levelToColorClass("X")).toBe("text-muted-foreground"); + expect(levelToColorClass("")).toBe("text-muted-foreground"); + }); +}); + +// ── Build 纯函数单测 ────────────────────────────────────────── + +describe("sumSelectedScores", () => { + it("sums all scores", () => { + expect( + sumSelectedScores([{ score: 10 }, { score: 15 }, { score: 20 }]), + ).toBe(45); + }); + + it("returns 0 for empty array", () => { + expect(sumSelectedScores([])).toBe(0); + }); + + it("handles single item", () => { + expect(sumSelectedScores([{ score: 5 }])).toBe(5); + }); +}); + +describe("countByType", () => { + it("counts items per type", () => { + const items = [ + { type: "single_choice" }, + { type: "single_choice" }, + { type: "multiple_choice" }, + { type: "fill_blank" }, + ]; + const result = countByType(items); + expect(result.single_choice).toBe(2); + expect(result.multiple_choice).toBe(1); + expect(result.fill_blank).toBe(1); + }); + + it("returns empty object for empty array", () => { + expect(countByType([])).toEqual({}); + }); +}); + +describe("sortBySortOrder", () => { + it("sorts ascending by sortOrder", () => { + const items = [ + { sortOrder: 3, id: "c" }, + { sortOrder: 1, id: "a" }, + { sortOrder: 2, id: "b" }, + ]; + const result = sortBySortOrder(items); + expect(result.map((i) => i.id)).toEqual(["a", "b", "c"]); + }); + + it("does not mutate the original array", () => { + const items = [ + { sortOrder: 2, id: "b" }, + { sortOrder: 1, id: "a" }, + ]; + const original = [...items]; + sortBySortOrder(items); + expect(items).toEqual(original); + }); + + it("returns empty array for empty input", () => { + expect(sortBySortOrder([])).toEqual([]); + }); +}); + +describe("nextSortOrder", () => { + it("returns 1 for empty array", () => { + expect(nextSortOrder([])).toBe(1); + }); + + it("returns max + 1 for non-empty array", () => { + expect( + nextSortOrder([{ sortOrder: 1 }, { sortOrder: 3 }, { sortOrder: 2 }]), + ).toBe(4); + }); + + it("handles single item", () => { + expect(nextSortOrder([{ sortOrder: 5 }])).toBe(6); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/exams/exam-analytics-client.tsx b/apps/portal-shell/src/features/teacher/exams/exam-analytics-client.tsx new file mode 100644 index 0000000..ee8cced --- /dev/null +++ b/apps/portal-shell/src/features/teacher/exams/exam-analytics-client.tsx @@ -0,0 +1,342 @@ +"use client"; + +/** + * 考试分析页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约(混合契约): + * - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪) + * - 扩展字段(排名/每题正确率/分布)❌ → MSW 兜底(@contract-pending) + * + * 三态规范(§11.3 DoD): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - empty:data 为 null 时显示空态节点 + * + * 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { BarChart3, FileText } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { useExamAnalytics } from "@/lib/api"; +import { Button } from "@/shared/components/ui/button"; +import { + DetailPageShell, + DetailPageSkeleton, + DetailSection, +} from "@/shared/components/page-templates"; +import { + formatPassRate, + formatPercent, + formatScore, +} from "@/features/teacher/exams/transformations"; + +/** + * 分析客户端主体。需由 server page 包裹在 中。 + */ +export function ExamAnalyticsClient(): React.ReactElement { + const t = useTranslations("exams"); + const tCommon = useTranslations("common"); + const params = useParams<{ id: string }>(); + const examId = params?.id ?? ""; + + // 混合契约:基础统计真实 + 扩展字段 MSW + const { data, loading, error } = useExamAnalytics(examId); + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : undefined; + + return ( + } + backHref={`/shell/teacher/exams/${examId}`} + actions={ + + } + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={ + !loading && !error && !data ? ( +
+ {t("analytics.notFound")} +
+ ) : undefined + } + > + {data ? : null} +
+ ); +} + +/** + * 分析内容区(汇总卡片 + 分布图 + 每题正确率 + 学生排名)。 + */ +function ExamAnalyticsBody({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("exams"); + return ( + <> + +
+ + + + + + +
+
+ + + + + + + + + + + + + + ); +} + +/** + * 汇总卡片(单字段)。 + */ +function SummaryCard({ + label, + value, +}: { + label: string; + value: string; +}): React.ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * 分数段分布柱状图(纯 SVG,无外部图表库依赖)。 + */ +function DistributionChart({ + items, +}: { + items: Array<{ label: string; count: number }>; +}): React.ReactElement { + const t = useTranslations("exams"); + if (items.length === 0) { + return ( +

+ {t("analytics.emptyDistribution")} +

+ ); + } + const maxCount = Math.max(...items.map((i) => i.count), 1); + return ( +
+ {items.map((item) => ( +
+ + {item.label} + +
+
+
+ {item.count} +
+ ))} +
+ ); +} + +/** + * 每题正确率表格。 + */ +function QuestionAccuracyTable({ + items, +}: { + items: Array<{ + questionId: string; + order: number; + questionTitle: string; + correctRate: number; + avgScore: number; + maxScore: number; + }>; +}): React.ReactElement { + const t = useTranslations("exams"); + if (items.length === 0) { + return ( +

+ {t("analytics.emptyQuestionAccuracy")} +

+ ); + } + return ( +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
{t("analytics.colOrder")} + {t("analytics.colQuestionTitle")} + + {t("analytics.colCorrectRate")} + + {t("analytics.colAvgScore")} + + {t("analytics.colMaxScore")} +
{item.order}{item.questionTitle} + + = 0.8 + ? "text-emerald-600" + : item.correctRate >= 0.6 + ? "text-amber-600" + : "text-destructive" + } + > + {formatPercent(item.correctRate)} + + + + + + {formatScore(item.avgScore)}{formatScore(item.maxScore)}
+
+ ); +} + +/** + * 学生排名表格。 + */ +function RankingsTable({ + items, +}: { + items: Array<{ + studentId: string; + studentNo: string; + studentName: string; + totalScore: number; + rank: number; + level: string; + }>; +}): React.ReactElement { + const t = useTranslations("exams"); + if (items.length === 0) { + return ( +

+ {t("analytics.emptyRankings")} +

+ ); + } + return ( +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
{t("analytics.colRank")} + {t("analytics.colStudentNo")} + + {t("analytics.colStudentName")} + + {t("analytics.colTotalScore")} + {t("analytics.colLevel")}
{item.rank}{item.studentNo}{item.studentName}{formatScore(item.totalScore)} + + {item.level} + +
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/exams/exam-build-client.tsx b/apps/portal-shell/src/features/teacher/exams/exam-build-client.tsx new file mode 100644 index 0000000..6812858 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/exams/exam-build-client.tsx @@ -0,0 +1,499 @@ +"use client"; + +/** + * 组卷工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * 数据契约(@contract-pending 全 MSW): + * - examBuild(examId) ❌ → MSW 兜底 + * - questionsLibrary(filter) ❌ → MSW 兜底 + * - saveExamBuild(input) mutation ❌ → MSW 兜底 + * + * 三栏布局(WorkbenchPageShell): + * - left:题库候选列表(带 q/type/difficulty 筛选) + * - center:已选题目列表(可上移/下移/编辑分值/移除) + * - right:预览(总分、题型分布、及格分提示) + * + * 三态规范(§11.3 DoD): + * - loading:WorkbenchPageSkeleton + * - error:errorNode 局部降级 + * - empty:data 为 null 时显示空态节点 + * + * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { PencilLine, Plus, Trash2 } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +import { + useExamBuild, + useQuestionsLibrary, + useSaveExamBuild, + type ExamBuildNode, + type QuestionsLibraryFilter, +} from "@/lib/api"; +import { notify } from "@/shared/lib/notify"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { + WorkbenchPageShell, + WorkbenchPageSkeleton, + WorkbenchPanel, +} from "@/shared/components/page-templates"; +import { + countByType, + nextSortOrder, + sortBySortOrder, + sumSelectedScores, +} from "@/features/teacher/exams/transformations"; + +/** + * 组卷工作台客户端主体。需由 server page 包裹在 中。 + */ +export function ExamBuildClient(): React.ReactElement { + const t = useTranslations("exams"); + const tCommon = useTranslations("common"); + const params = useParams<{ id: string }>(); + const examId = params?.id ?? ""; + + // 已选题目(本地状态,从 useExamBuild 初始化) + const { data, loading, error } = useExamBuild(examId); + const [selected, setSelected] = useState(null); + + // 首次拿到数据时初始化本地状态(useEffect 处理副作用,不用 useMemo) + useEffect(() => { + if (data && selected === null) { + setSelected(sortBySortOrder(data.selected)); + } + }, [data, selected]); + + const saveMutation = useSaveExamBuild(); + + // WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景 + // (工作台页"无数据"等价于"无法工作",合并表达更直接) + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : !loading && !data ? ( +
+ {t("build.notFound")} +
+ ) : undefined; + + const handleSave = async (): Promise => { + if (!selected) return; + try { + await saveMutation.run({ + examId, + questions: selected.map((q) => ({ + questionId: q.questionId, + score: q.score, + sortOrder: q.sortOrder, + })), + }); + notify.success(t("build.saveSuccess")); + } catch (err) { + notify.error(t("build.saveFailed", { message: String(err) })); + } + }; + + return ( + } + actions={ + + } + loading={loading} + loadingNode={} + errorNode={errorNode} + left={ + data && selected ? ( + { + if (selected.some((s) => s.questionId === item.questionId)) { + notify.warning(t("build.alreadyAdded")); + return; + } + const newNode: ExamBuildNode = { + questionId: item.questionId, + score: item.score, + sortOrder: nextSortOrder(selected), + content: item.content, + type: item.type, + difficulty: item.difficulty, + }; + setSelected([...selected, newNode]); + }} + /> + ) : null + } + center={ + data && selected ? ( + + ) : null + } + right={ + data && selected ? ( + + ) : null + } + /> + ); +} + +/** + * 左栏:题库候选列表(带筛选)。 + */ +function QuestionsLibraryPanel({ + onAdd, +}: { + onAdd: (item: { + questionId: string; + content: string; + type: string; + difficulty: string; + score: number; + textbookName: string | null; + }) => void; +}): React.ReactElement { + const t = useTranslations("exams"); + const [filter, setFilter] = useState({ + q: "", + type: "", + difficulty: "", + page: 1, + pageSize: 10, + }); + + const { data, loading, error } = useQuestionsLibrary(filter); + const items = data?.items ?? []; + + return ( + +
+ + setFilter((prev) => ({ ...prev, q: e.target.value, page: 1 })) + } + /> +
+ + +
+ + {loading ? ( +

+ {t("build.loadingLibrary")} +

+ ) : error ? ( +

+ {t("build.loadLibraryFailed")} +

+ ) : items.length === 0 ? ( +

+ {t("build.libraryEmpty")} +

+ ) : ( +
    + {items.map((item) => ( +
  • +
    +
    +

    {item.content}

    +

    + {item.type} + {item.difficulty} + + {item.score} {t("build.unitScore")} + +

    +
    + +
    +
  • + ))} +
+ )} + {data ? ( +

+ {t("build.libraryTotal", { total: data.total })} +

+ ) : null} +
+
+ ); +} + +/** + * 中栏:已选题目列表(可上移/下移/编辑分值/移除)。 + */ +function SelectedQuestionsPanel({ + selected, + onChange, +}: { + selected: ExamBuildNode[]; + onChange: (next: ExamBuildNode[]) => void; +}): React.ReactElement { + const t = useTranslations("exams"); + + const moveUp = (index: number): void => { + if (index === 0) return; + const next = [...selected]; + const a = next[index - 1]; + const b = next[index]; + if (!a || !b) return; + next[index - 1] = b; + next[index] = a; + onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 }))); + }; + + const moveDown = (index: number): void => { + if (index === selected.length - 1) return; + const next = [...selected]; + const a = next[index]; + const b = next[index + 1]; + if (!a || !b) return; + next[index] = b; + next[index + 1] = a; + onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 }))); + }; + + const remove = (questionId: string): void => { + onChange( + selected + .filter((s) => s.questionId !== questionId) + .map((node, i) => ({ ...node, sortOrder: i + 1 })), + ); + }; + + const updateScore = (questionId: string, score: number): void => { + onChange( + selected.map((s) => (s.questionId === questionId ? { ...s, score } : s)), + ); + }; + + return ( + + {t("build.selectedCount", { count: selected.length })} + + } + > + {selected.length === 0 ? ( +

+ {t("build.selectedEmpty")} +

+ ) : ( +
    + {selected.map((node, idx) => ( +
  1. +
    + + {idx + 1}. + +
    +

    {node.content}

    +
    + + {node.type} · {node.difficulty} + + +
    +
    +
    + + + +
    +
    +
  2. + ))} +
+ )} +
+ ); +} + +/** + * 右栏:预览(总分、题型分布、及格分提示)。 + */ +function PreviewPanel({ + selected, + totalScoreBaseline, + passScore, +}: { + selected: ExamBuildNode[]; + totalScoreBaseline: number; + passScore: number; +}): React.ReactElement { + const t = useTranslations("exams"); + const total = sumSelectedScores(selected); + const typeCounts = countByType(selected); + const diff = total - totalScoreBaseline; + + return ( + +
+
+

+ {t("build.previewTotal")} +

+

{total}

+

+ {t("build.previewBaseline", { baseline: totalScoreBaseline })} + {diff !== 0 ? ( + 0 ? "text-amber-600" : "text-destructive"} + > + {" "} + ({diff > 0 ? "+" : ""} + {diff}) + + ) : null} +

+
+ +
+

+ {t("build.previewPassScore")} +

+

{passScore}

+
+ +
+

+ {t("build.previewTypeBreakdown")} +

+ {Object.keys(typeCounts).length === 0 ? ( +

--

+ ) : ( +
    + {Object.entries(typeCounts).map(([type, count]) => ( +
  • + {type} + {count} +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/exams/exam-edit-client.tsx b/apps/portal-shell/src/features/teacher/exams/exam-edit-client.tsx new file mode 100644 index 0000000..1659033 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/exams/exam-edit-client.tsx @@ -0,0 +1,314 @@ +"use client"; + +/** + * 富文本试卷编辑页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * 数据契约(@contract-pending 全 MSW): + * - examRichEditor(examId) ❌ → MSW 兜底 + * - saveExamRichContent(input) mutation ❌ → MSW 兜底 + * + * 布局(WorkbenchPageShell 单 center 栏): + * - center:富文本编辑器(contentEditable + 工具栏) + * - 工具栏:加粗/斜体/标题 H1/H2/H3/列表/插入题目占位 + * - 内容区:可编辑 div + * - 底部:保存/预览按钮 + * + * 三态规范(§11.3 DoD): + * - loading:WorkbenchPageSkeleton + * - error:errorNode 局部降级 + * - empty:data 为 null 时显示空态节点 + * + * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { FileText } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { forwardRef, useEffect, useRef, useState } from "react"; + +import { useExamRichEditor, useSaveExamRichContent } from "@/lib/api"; +import { notify } from "@/shared/lib/notify"; +import { Button } from "@/shared/components/ui/button"; +import { + WorkbenchPageShell, + WorkbenchPageSkeleton, + WorkbenchPanel, +} from "@/shared/components/page-templates"; + +/** + * 富文本编辑客户端主体。需由 server page 包裹在 中。 + */ +export function ExamEditClient(): React.ReactElement { + const t = useTranslations("exams"); + const tCommon = useTranslations("common"); + const params = useParams<{ id: string }>(); + const examId = params?.id ?? ""; + + // @contract-pending MSW 兜底 + const { data, loading, error } = useExamRichEditor(examId); + const saveMutation = useSaveExamRichContent(); + + // 本地 HTML 内容(从 data.content 初始化) + const [html, setHtml] = useState(null); + const editorRef = useRef(null); + + // 首次拿到数据时初始化 HTML 内容 + useEffect(() => { + if (data && html === null) { + setHtml(contentToHtml(data.content)); + } + }, [data, html]); + + // WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景 + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : !loading && !data ? ( +
+ {t("edit.notFound")} +
+ ) : undefined; + + const handleSave = async (): Promise => { + if (!data) return; + const currentHtml = editorRef.current?.innerHTML ?? html ?? ""; + try { + await saveMutation.run({ examId, content: currentHtml }); + notify.success(t("edit.saveSuccess")); + } catch (err) { + notify.error(t("edit.saveFailed", { message: String(err) })); + } + }; + + const exec = (command: string, value?: string): void => { + if (!editorRef.current) return; + editorRef.current.focus(); + // document.execCommand 已 deprecated 但 contentEditable 简单方案仍可用 + // 后续可换 tiptap/lexical(@contract-pending 富文本库) + document.execCommand(command, false, value); + setHtml(editorRef.current.innerHTML); + }; + + return ( + } + actions={ + + } + loading={loading} + loadingNode={} + errorNode={errorNode} + center={ + data && html !== null ? ( + + ) : null + } + right={ + data ? ( + +
+
+

+ {t("edit.propExamId")} +

+

{data.examId}

+
+
+

+ {t("edit.propTotalScore")} +

+

{data.totalScore}

+
+
+

+ {t("edit.propQuestionCount")} +

+

{data.questionCount}

+
+
+

+ {t("edit.propUpdatedAt")} +

+

{data.updatedAt}

+
+

+ {t("edit.contractPending")} +

+
+
+ ) : null + } + /> + ); +} + +/** + * 富文本编辑器(contentEditable + 工具栏)。 + * + * 简化实现:使用 document.execCommand(deprecated 但仍可用)。 + * 后续可升级到 tiptap/lexical(@contract-pending 富文本库契约)。 + */ +interface RichEditorProps { + html: string; + onChange: (html: string) => void; + onExec: (command: string, value?: string) => void; +} + +const RichEditor = forwardRef( + function RichEditor({ html, onChange, onExec }, ref) { + const t = useTranslations("exams"); + return ( + +
+ {/* 工具栏 */} +
+ + + + + + + + + + + + +
+ + {/* 可编辑区域 */} +
onChange((e.target as HTMLDivElement).innerHTML)} + style={{ minHeight: "400px" }} + /> +
+ + ); + }, +); + +/** + * 将 mock data.content(结构化数组)转换为 HTML 字符串。 + * 输入是 unknown(来自 MSW),输出是可编辑的 HTML。 + */ +function contentToHtml(content: unknown): string { + if (!Array.isArray(content)) return ""; + return content + .map((node: Record) => { + const type = node.type as string; + const text = (node.text as string) ?? ""; + switch (type) { + case "heading": { + const level = node.level as number; + const tag = level === 1 ? "h1" : level === 2 ? "h2" : "h3"; + return `<${tag}>${text}`; + } + case "paragraph": + return `

${text}

`; + case "question": { + const order = node.order as number; + const options = (node.options as string[]) ?? []; + const optsHtml = options.map((opt) => `
${opt}
`).join(""); + return `
${order}. ${text}${optsHtml}
`; + } + default: + return `
${text}
`; + } + }) + .join(""); +} diff --git a/apps/portal-shell/src/features/teacher/exams/transformations.ts b/apps/portal-shell/src/features/teacher/exams/transformations.ts index 238dfe2..5260e51 100644 --- a/apps/portal-shell/src/features/teacher/exams/transformations.ts +++ b/apps/portal-shell/src/features/teacher/exams/transformations.ts @@ -101,3 +101,118 @@ export function formatDuration(minutes: number): string { const rest = minutes % 60; return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`; } + +// ── Analytics 纯函数(ARCHITECTURE.md §9.1 analytics 页 / §11.3 DoD)── + +/** + * 格式化分数(数值)为展示字符串,保留 1 位小数。 + * 输入无效返回 "--"。 + */ +export function formatScore(score: number): string { + if (!Number.isFinite(score)) return "--"; + return score.toFixed(1); +} + +/** + * 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。 + * 输入无效返回 "--"。 + */ +export function formatPassRate(rate: number): string { + if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--"; + return `${(rate * 100).toFixed(0)}%`; +} + +/** + * 将 0-1 的小数(如 0.92)格式化为百分比字符串 "92%"。 + * 与 formatPassRate 同义,语义区分:用于正确率/通过率等不同场景。 + */ +export function formatPercent(rate: number): string { + if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--"; + return `${(rate * 100).toFixed(0)}%`; +} + +/** + * 根据正确率返回等级标签(A/B/C/D)。 + * - rate >= 0.85 → A + * - rate >= 0.7 → B + * - rate >= 0.6 → C + * - 其他 → D + */ +export function rateToLevel(rate: number): string { + if (!Number.isFinite(rate)) return "--"; + if (rate >= 0.85) return "A"; + if (rate >= 0.7) return "B"; + if (rate >= 0.6) return "C"; + return "D"; +} + +/** + * 根据正确率返回 Tailwind 文本语义类名。 + * - >= 0.8 → text-emerald-600(高) + * - >= 0.6 → text-amber-600(中) + * - 其他 → text-destructive(低) + */ +export function rateToColorClass(rate: number): string { + if (!Number.isFinite(rate)) return "text-muted-foreground"; + if (rate >= 0.8) return "text-emerald-600"; + if (rate >= 0.6) return "text-amber-600"; + return "text-destructive"; +} + +/** + * 根据等级(A/B/C/D)返回 Tailwind 文本语义类名。 + */ +export function levelToColorClass(level: string): string { + switch (level) { + case "A": + return "text-emerald-600"; + case "B": + return "text-blue-600"; + case "C": + return "text-amber-600"; + case "D": + return "text-destructive"; + default: + return "text-muted-foreground"; + } +} + +// ── Build 纯函数(ARCHITECTURE.md §9.1 build 工作台页 / §11.3 DoD)── + +/** + * 计算已选题目总分。 + */ +export function sumSelectedScores(items: Array<{ score: number }>): number { + return items.reduce((sum, item) => sum + item.score, 0); +} + +/** + * 按题量统计题型分布(如 { single_choice: 5, multiple_choice: 3 })。 + */ +export function countByType( + items: Array<{ type: string }>, +): Record { + return items.reduce>((acc, item) => { + acc[item.type] = (acc[item.type] ?? 0) + 1; + return acc; + }, {}); +} + +/** + * 按 sortOrder 升序排序已选题目(返回新数组,不修改原数组)。 + */ +export function sortBySortOrder( + items: ReadonlyArray, +): T[] { + return [...items].sort((a, b) => a.sortOrder - b.sortOrder); +} + +/** + * 生成下一个 sortOrder(当前最大值 + 1)。 + */ +export function nextSortOrder( + items: ReadonlyArray<{ sortOrder: number }>, +): number { + if (items.length === 0) return 1; + return Math.max(...items.map((i) => i.sortOrder)) + 1; +} diff --git a/apps/portal-shell/src/lib/api/exams.ts b/apps/portal-shell/src/lib/api/exams.ts index db8294f..8199afd 100644 --- a/apps/portal-shell/src/lib/api/exams.ts +++ b/apps/portal-shell/src/lib/api/exams.ts @@ -20,8 +20,14 @@ import { useWidgetQuery } from "../useWidgetQuery"; import { ApiError } from "./errors"; import { CREATE_EXAM_DOC, + GET_EXAM_ANALYTICS_DOC, + GET_EXAM_BUILD_DOC, GET_EXAM_DOC, GET_EXAMS_DOC, + GET_EXAM_RICH_EDITOR_DOC, + GET_QUESTIONS_LIBRARY_DOC, + SAVE_EXAM_BUILD_DOC, + SAVE_EXAM_RICH_CONTENT_DOC, } from "./operations/exams.graphql"; import type { UseQueryResult } from "./types"; @@ -94,6 +100,154 @@ interface CreateExamResponse { createExam: { id: string } | null; } +// ===== Analytics 类型(混合契约:基础统计真实 + 扩展 MSW)===== + +/** 考试分析汇总 */ +export interface ExamAnalyticsSummary { + expectedCount: number; + attendedCount: number; + avgScore: number; + maxScore: number; + minScore: number; + passRate: number; +} + +/** 分数段分布 */ +export interface ScoreDistribution { + label: string; + count: number; +} + +/** 每题正确率 */ +export interface ExamQuestionAccuracy { + questionId: string; + order: number; + questionTitle: string; + correctRate: number; + avgScore: number; + maxScore: number; +} + +/** 学生排名 */ +export interface ExamRanking { + studentId: string; + studentNo: string; + studentName: string; + totalScore: number; + rank: number; + level: string; +} + +/** 考试分析完整数据(@contract-pending 扩展字段) */ +export interface ExamAnalytics { + examId: string; + examTitle: string; + summary: ExamAnalyticsSummary; + distribution: ScoreDistribution[]; + questionAccuracy: ExamQuestionAccuracy[]; + rankings: ExamRanking[]; +} + +/** Analytics 查询响应 */ +interface ExamAnalyticsResponse { + examAnalytics: ExamAnalytics | null; +} + +// ===== Build 类型(@contract-pending 全 MSW)===== + +/** 组卷已选题目节点 */ +export interface ExamBuildNode { + questionId: string; + score: number; + sortOrder: number; + content: string; + type: string; + difficulty: string; +} + +/** 组卷数据 */ +export interface ExamBuild { + examId: string; + title: string; + totalScore: number; + passScore: number; + duration: number; + selected: ExamBuildNode[]; +} + +/** Build 查询响应 */ +interface ExamBuildResponse { + examBuild: ExamBuild | null; +} + +/** 题库候选题目 */ +export interface QuestionItem { + questionId: string; + content: string; + type: string; + difficulty: string; + score: number; + textbookName: string | null; +} + +/** 题库筛选条件 */ +export interface QuestionsLibraryFilter { + q?: string | null; + type?: string | null; + difficulty?: string | null; + textbook?: string | null; + page?: number; + pageSize?: number; +} + +/** 题库列表响应 */ +interface QuestionsLibraryResponse { + questionsLibrary: { items: QuestionItem[]; total: number }; +} + +/** 保存组卷输入 */ +export interface SaveExamBuildInput { + examId: string; + questions: Array<{ + questionId: string; + score: number; + sortOrder: number; + }>; +} + +/** 保存组卷响应 */ +interface SaveExamBuildResponse { + saveExamBuild: { examId: string } | null; +} + +// ===== Rich Editor 类型(@contract-pending 全 MSW)===== + +/** 富文本试卷编辑器数据 */ +export interface ExamRichEditor { + examId: string; + title: string; + totalScore: number; + questionCount: number; + updatedAt: string; + content: unknown; +} + +/** Rich Editor 查询响应 */ +interface ExamRichEditorResponse { + examRichEditor: ExamRichEditor | null; +} + +/** 保存富文本内容输入 */ +export interface SaveExamRichContentInput { + examId: string; + content: unknown; +} + +/** 保存富文本内容响应 */ +interface SaveExamRichContentResponse { + saveExamRichContent: { examId: string } | null; +} + // ===== 查询选项 ===== export interface ExamQueryOptions { @@ -206,3 +360,185 @@ export function useCreateExam(): { return { run, loading, error }; } + +// ===== Analytics / Build / Rich Editor Hooks ===== + +/** + * 查询考试分析数据(混合契约:基础统计真实 + 扩展 MSW)。 + * + * schema 有 assignmentAnalysis 基础字段,但排名/每题正确率等扩展字段走 MSW。 + * MSW 开启时返回完整 mock;后端补齐扩展字段后切换真实 fetcher。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 analytics 页 / §11.4 契约工单 + */ +export function useExamAnalytics( + examId: string, + options?: ExamQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + GET_EXAM_ANALYTICS_DOC, + { examId }, + { + ...options, + enabled: options?.enabled ?? examId.length > 0, + }, + ); + return { + data: result.data?.examAnalytics ?? null, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} + +/** + * 查询组卷数据(@contract-pending,MSW 兜底)。 + * + * schema 无 examBuild(examId) 根字段,由 MSW 返回 mock 数据。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 build 工作台页 / §11.4 契约工单 + */ +export function useExamBuild( + examId: string, + options?: ExamQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + GET_EXAM_BUILD_DOC, + { examId }, + { + ...options, + enabled: options?.enabled ?? examId.length > 0, + }, + ); + return { + data: result.data?.examBuild ?? null, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} + +/** + * 查询题库候选列表(@contract-pending,MSW 兜底)。 + * + * schema 无 questionsLibrary 根字段,由 MSW 返回 mock 数据。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 build 工作台页 / §11.4 契约工单 + */ +export function useQuestionsLibrary( + filter: QuestionsLibraryFilter, + options?: ExamQueryOptions, +): UseQueryResult<{ items: QuestionItem[]; total: number }> { + const result = useWidgetQuery< + QuestionsLibraryResponse, + { filter: QuestionsLibraryFilter } + >( + GET_QUESTIONS_LIBRARY_DOC, + { filter }, + { + enabled: options?.enabled ?? true, + fetchPolicy: options?.fetchPolicy, + pollInterval: options?.pollInterval, + }, + ); + return { + data: result.data?.questionsLibrary, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} + +/** + * 保存组卷(@contract-pending,MSW 兜底)。 + * + * schema 无 Mutation 类型,由 MSW 返回 mock 数据。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 build 工作台页 / §11.4 契约工单 + */ +export function useSaveExamBuild(): { + run: (input: SaveExamBuildInput) => Promise<{ examId: string }>; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation( + SAVE_EXAM_BUILD_DOC, + ); + + const run = async ( + input: SaveExamBuildInput, + ): Promise<{ examId: string }> => { + const data = await rawRun({ input }); + if (!data?.saveExamBuild) { + throw new ApiError("Failed to save exam build", "INTERNAL_ERROR"); + } + return data.saveExamBuild; + }; + + return { run, loading, error }; +} + +/** + * 查询富文本试卷内容(@contract-pending,MSW 兜底)。 + * + * schema 无 examRichEditor(examId) 根字段,由 MSW 返回 mock 数据。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 edit 工作台页 / §11.4 契约工单 + */ +export function useExamRichEditor( + examId: string, + options?: ExamQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + GET_EXAM_RICH_EDITOR_DOC, + { examId }, + { + ...options, + enabled: options?.enabled ?? examId.length > 0, + }, + ); + return { + data: result.data?.examRichEditor ?? null, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} + +/** + * 保存富文本试卷内容(@contract-pending,MSW 兜底)。 + * + * schema 无 Mutation 类型,由 MSW 返回 mock 数据。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 edit 工作台页 / §11.4 契约工单 + */ +export function useSaveExamRichContent(): { + run: (input: SaveExamRichContentInput) => Promise<{ examId: string }>; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation< + SaveExamRichContentResponse, + { input: SaveExamRichContentInput } + >(SAVE_EXAM_RICH_CONTENT_DOC); + + const run = async ( + input: SaveExamRichContentInput, + ): Promise<{ examId: string }> => { + const data = await rawRun({ input }); + if (!data?.saveExamRichContent) { + throw new ApiError("Failed to save exam rich content", "INTERNAL_ERROR"); + } + return data.saveExamRichContent; + }; + + return { run, loading, error }; +} diff --git a/apps/portal-shell/src/lib/api/operations/exams.graphql.ts b/apps/portal-shell/src/lib/api/operations/exams.graphql.ts index 16756f6..78dbe06 100644 --- a/apps/portal-shell/src/lib/api/operations/exams.graphql.ts +++ b/apps/portal-shell/src/lib/api/operations/exams.graphql.ts @@ -70,3 +70,124 @@ export const CREATE_EXAM_DOC = gql` } } `; + +// ── 考试分析(混合契约)───────────────────────────────────────── +// 基础统计 ✅ 真实字段 assignmentAnalysis(schema 已就绪,data-ana 子图) +// 扩展字段(排名/每题正确率/知识点雷达/班级对比/历次趋势)❌ → MSW 兜底 +// 契约工单:core-edu_contract.md#exam-analytics-extension +export const GET_EXAM_ANALYTICS_DOC = gql` + query GetExamAnalytics($examId: ID!) { + examAnalytics(examId: $examId) { + examId + examTitle + summary { + expectedCount + attendedCount + avgScore + maxScore + minScore + passRate + } + distribution { + label + count + } + questionAccuracy { + questionId + order + questionTitle + correctRate + avgScore + maxScore + } + rankings { + studentId + studentNo + studentName + totalScore + rank + level + } + } + } +`; + +// ── 组卷数据(@contract-pending)─────────────────────────────── +// schema 无 examBuild(examId) 根字段 → MSW 兜底 +// 契约工单:core-edu_contract.md#exam-build +export const GET_EXAM_BUILD_DOC = gql` + query GetExamBuild($examId: ID!) { + examBuild(examId: $examId) { + examId + title + totalScore + passScore + duration + selected { + questionId + score + sortOrder + content + type + difficulty + } + } + } +`; + +// ── 题库候选列表(@contract-pending)─────────────────────────── +// schema 无 questionsLibrary 根字段 → MSW 兜底 +// 契约工单:core-edu_contract.md#questions-library +export const GET_QUESTIONS_LIBRARY_DOC = gql` + query GetQuestionsLibrary($filter: QuestionsLibraryFilterInput) { + questionsLibrary(filter: $filter) { + items { + questionId + content + type + difficulty + score + textbookName + } + total + } + } +`; + +// ── 保存组卷(@contract-pending mutation)────────────────────── +// schema 无 Mutation 类型 → MSW 兜底 +// 契约工单:core-edu_contract.md#save-exam-build +export const SAVE_EXAM_BUILD_DOC = gql` + mutation SaveExamBuild($input: SaveExamBuildInput!) { + saveExamBuild(input: $input) { + examId + } + } +`; + +// ── 富文本试卷内容(@contract-pending)───────────────────────── +// schema 无 examRichEditor(examId) 根字段 → MSW 兜底 +// 契约工单:core-edu_contract.md#exam-rich-editor +export const GET_EXAM_RICH_EDITOR_DOC = gql` + query GetExamRichEditor($examId: ID!) { + examRichEditor(examId: $examId) { + examId + title + totalScore + questionCount + updatedAt + content + } + } +`; + +// ── 保存富文本内容(@contract-pending mutation)──────────────── +// schema 无 Mutation 类型 → MSW 兜底 +// 契约工单:core-edu_contract.md#save-exam-rich-content +export const SAVE_EXAM_RICH_CONTENT_DOC = gql` + mutation SaveExamRichContent($input: SaveExamRichContentInput!) { + saveExamRichContent(input: $input) { + examId + } + } +`; diff --git a/apps/portal-shell/src/messages/en.json b/apps/portal-shell/src/messages/en.json index 8892730..c102315 100644 --- a/apps/portal-shell/src/messages/en.json +++ b/apps/portal-shell/src/messages/en.json @@ -210,6 +210,95 @@ "errorTitleRequired": "Please fill in exam title", "errorDateRequired": "Please select exam date", "contractPending": "Create exam contract is @contract-pending, currently backed by MSW. Will switch to real submission once backend mutation is ready." + }, + "analytics": { + "title": "Exam Analytics", + "subtitle": "Exam ID: {examId}", + "exportCsv": "Export CSV", + "notFound": "No analytics data found, grading may not be complete", + "sectionSummary": "Summary", + "sectionDistribution": "Score Distribution", + "sectionQuestionAccuracy": "Question Accuracy", + "sectionRankings": "Student Rankings", + "summaryExpected": "Expected", + "summaryAttended": "Attended", + "summaryAvg": "Average", + "summaryMax": "Max", + "summaryMin": "Min", + "summaryPassRate": "Pass Rate", + "emptyDistribution": "No score distribution data", + "emptyQuestionAccuracy": "No question accuracy data", + "emptyRankings": "No ranking data", + "colOrder": "#", + "colQuestionTitle": "Question", + "colCorrectRate": "Correct Rate", + "colAvgScore": "Avg Score", + "colMaxScore": "Max Score", + "colRank": "Rank", + "colStudentNo": "Student No.", + "colStudentName": "Name", + "colTotalScore": "Total Score", + "colLevel": "Level" + }, + "build": { + "title": "Build Exam", + "subtitle": "Current total: {total} pts · Pass score: {pass} pts", + "save": "Save Build", + "saveSuccess": "Build saved successfully", + "saveFailed": "Save failed: {message}", + "notFound": "No build data found", + "alreadyAdded": "This question has already been added", + "libraryTitle": "Question Library", + "searchPlaceholder": "Search question content...", + "filterType": "Filter by type", + "filterDifficulty": "Filter by difficulty", + "allTypes": "All types", + "allDifficulties": "All difficulties", + "typeSingle": "Single Choice", + "typeMultiple": "Multiple Choice", + "typeFill": "Fill in the Blank", + "typeEssay": "Essay", + "diffEasy": "Easy", + "diffMedium": "Medium", + "diffHard": "Hard", + "loadingLibrary": "Loading library...", + "loadLibraryFailed": "Library load failed", + "libraryEmpty": "No questions match the filter", + "libraryTotal": "{total} questions total", + "addToExam": "Add to exam", + "unitScore": "pts", + "selectedTitle": "Selected Questions", + "selectedCount": "{count} questions", + "selectedEmpty": "Add questions from the library on the left", + "scoreLabel": "Score", + "moveUp": "Move up", + "moveDown": "Move down", + "remove": "Remove", + "previewTitle": "Preview", + "previewTotal": "Total Score", + "previewBaseline": "Baseline: {baseline} pts", + "previewPassScore": "Pass Score", + "previewTypeBreakdown": "Type Breakdown" + }, + "edit": { + "title": "Edit Exam Paper", + "subtitle": "Total: {total} pts · {count} questions", + "save": "Save Paper", + "saveSuccess": "Paper saved successfully", + "saveFailed": "Save failed: {message}", + "notFound": "No paper content found", + "propertiesTitle": "Paper Properties", + "propExamId": "Exam ID", + "propTotalScore": "Total Score", + "propQuestionCount": "Question Count", + "propUpdatedAt": "Last Updated", + "contractPending": "Rich text editor contract is @contract-pending, currently backed by MSW. Will switch to real data once backend is ready.", + "editorTitle": "Paper Content", + "toolbarBold": "Bold", + "toolbarItalic": "Italic", + "toolbarUnderline": "Underline", + "toolbarBulletList": "Bullet list", + "toolbarOrderedList": "Ordered list" } }, "homework": { diff --git a/apps/portal-shell/src/messages/zh-CN.json b/apps/portal-shell/src/messages/zh-CN.json index 68342de..4331c90 100644 --- a/apps/portal-shell/src/messages/zh-CN.json +++ b/apps/portal-shell/src/messages/zh-CN.json @@ -210,6 +210,95 @@ "errorTitleRequired": "请填写考试标题", "errorDateRequired": "请选择考试日期", "contractPending": "创建考试契约为 @contract-pending,当前通过 MSW 兜底。后端补齐 mutation 后将切换为真实提交。" + }, + "analytics": { + "title": "考试分析", + "subtitle": "考试 ID:{examId}", + "exportCsv": "导出 CSV", + "notFound": "未找到考试分析数据,可能尚未完成批改", + "sectionSummary": "汇总统计", + "sectionDistribution": "分数段分布", + "sectionQuestionAccuracy": "每题正确率", + "sectionRankings": "学生排名", + "summaryExpected": "应考人数", + "summaryAttended": "实考人数", + "summaryAvg": "平均分", + "summaryMax": "最高分", + "summaryMin": "最低分", + "summaryPassRate": "及格率", + "emptyDistribution": "暂无分数段分布数据", + "emptyQuestionAccuracy": "暂无每题正确率数据", + "emptyRankings": "暂无学生排名数据", + "colOrder": "题号", + "colQuestionTitle": "题目", + "colCorrectRate": "正确率", + "colAvgScore": "平均得分", + "colMaxScore": "满分", + "colRank": "排名", + "colStudentNo": "学号", + "colStudentName": "姓名", + "colTotalScore": "总分", + "colLevel": "等级" + }, + "build": { + "title": "组卷", + "subtitle": "当前总分:{total} 分 · 及格分:{pass} 分", + "save": "保存组卷", + "saveSuccess": "组卷保存成功", + "saveFailed": "保存失败:{message}", + "notFound": "未找到组卷数据", + "alreadyAdded": "该题目已添加,请勿重复添加", + "libraryTitle": "题库候选", + "searchPlaceholder": "搜索题目内容...", + "filterType": "题型筛选", + "filterDifficulty": "难度筛选", + "allTypes": "全部题型", + "allDifficulties": "全部难度", + "typeSingle": "单选题", + "typeMultiple": "多选题", + "typeFill": "填空题", + "typeEssay": "解答题", + "diffEasy": "简单", + "diffMedium": "中等", + "diffHard": "困难", + "loadingLibrary": "题库加载中...", + "loadLibraryFailed": "题库加载失败", + "libraryEmpty": "暂无符合条件的题目", + "libraryTotal": "共 {total} 题", + "addToExam": "添加到试卷", + "unitScore": "分", + "selectedTitle": "已选题目", + "selectedCount": "共 {count} 题", + "selectedEmpty": "请从左侧题库添加题目", + "scoreLabel": "分值", + "moveUp": "上移", + "moveDown": "下移", + "remove": "移除", + "previewTitle": "预览", + "previewTotal": "总分", + "previewBaseline": "基准:{baseline} 分", + "previewPassScore": "及格分", + "previewTypeBreakdown": "题型分布" + }, + "edit": { + "title": "试卷编辑", + "subtitle": "满分:{total} 分 · 共 {count} 题", + "save": "保存试卷", + "saveSuccess": "试卷保存成功", + "saveFailed": "保存失败:{message}", + "notFound": "未找到试卷内容", + "propertiesTitle": "试卷属性", + "propExamId": "考试 ID", + "propTotalScore": "满分", + "propQuestionCount": "题目数量", + "propUpdatedAt": "最后更新", + "contractPending": "富文本编辑契约为 @contract-pending,当前通过 MSW 兜底。后端补齐后切换为真实数据。", + "editorTitle": "试卷内容", + "toolbarBold": "加粗", + "toolbarItalic": "斜体", + "toolbarUnderline": "下划线", + "toolbarBulletList": "无序列表", + "toolbarOrderedList": "有序列表" } }, "homework": { diff --git a/apps/portal-shell/src/mocks/graphql-data.ts b/apps/portal-shell/src/mocks/graphql-data.ts index 1da76f1..b388bd1 100644 --- a/apps/portal-shell/src/mocks/graphql-data.ts +++ b/apps/portal-shell/src/mocks/graphql-data.ts @@ -333,6 +333,248 @@ const mockGrades = [ }, ]; +// ── Exam Analytics mock(混合契约:基础统计真实 + 扩展字段 MSW)── +// 用于 /shell/teacher/exams/[id]/analytics 详情页 MSW 兜底 +// 关联:ARCHITECTURE.md §9.1 analytics 页 / §11.4 契约工单 +const mockExamAnalytics = { + examId: "exam-001", + examTitle: "2026 春季期中考试", + summary: { + expectedCount: 38, + attendedCount: 36, + avgScore: 82.5, + maxScore: 98, + minScore: 45, + passRate: 0.86, + }, + distribution: [ + { label: "0-59", count: 5 }, + { label: "60-69", count: 6 }, + { label: "70-79", count: 8 }, + { label: "80-89", count: 10 }, + { label: "90-100", count: 7 }, + ], + questionAccuracy: [ + { + questionId: "q-001", + order: 1, + questionTitle: "集合的概念", + correctRate: 0.92, + avgScore: 4.6, + maxScore: 5, + }, + { + questionId: "q-002", + order: 2, + questionTitle: "函数的定义域", + correctRate: 0.78, + avgScore: 3.9, + maxScore: 5, + }, + { + questionId: "q-003", + order: 3, + questionTitle: "二次函数图像", + correctRate: 0.55, + avgScore: 2.75, + maxScore: 5, + }, + { + questionId: "q-004", + order: 4, + questionTitle: "指数函数性质", + correctRate: 0.68, + avgScore: 3.4, + maxScore: 5, + }, + { + questionId: "q-005", + order: 5, + questionTitle: "对数运算", + correctRate: 0.45, + avgScore: 2.25, + maxScore: 5, + }, + ], + rankings: [ + { + studentId: "stu-001", + studentNo: "2026001", + studentName: "张明", + totalScore: 98, + rank: 1, + level: "A", + }, + { + studentId: "stu-002", + studentNo: "2026002", + studentName: "李华", + totalScore: 95, + rank: 2, + level: "A", + }, + { + studentId: "stu-003", + studentNo: "2026003", + studentName: "王芳", + totalScore: 91, + rank: 3, + level: "A", + }, + { + studentId: "stu-004", + studentNo: "2026004", + studentName: "赵六", + totalScore: 88, + rank: 4, + level: "B", + }, + { + studentId: "stu-005", + studentNo: "2026005", + studentName: "钱七", + totalScore: 45, + rank: 36, + level: "D", + }, + ], +}; + +// ── Exam Build mock(@contract-pending 全 MSW)── +// 用于 /shell/teacher/exams/[id]/build 工作台页 MSW 兜底 +const mockExamBuild = { + examId: "exam-001", + title: "2026 春季期中考试", + totalScore: 100, + passScore: 60, + duration: 120, + selected: [ + { + questionId: "q-001", + score: 10, + sortOrder: 1, + content: "下列哪个是质数?", + type: "single_choice", + difficulty: "easy", + }, + { + questionId: "q-002", + score: 15, + sortOrder: 2, + content: "下列哪些是偶数?", + type: "multiple_choice", + difficulty: "medium", + }, + { + questionId: "q-003", + score: 20, + sortOrder: 3, + content: "sin(30°) = ?", + type: "fill_blank", + difficulty: "hard", + }, + ], +}; + +// ── Questions Library mock(@contract-pending 全 MSW)── +// 用于 /shell/teacher/exams/[id]/build 工作台页左侧题库候选 +const mockQuestionsLibrary = [ + { + questionId: "lib-q-001", + content: "已知集合 A={1,2,3},B={2,3,4},求 A∩B", + type: "single_choice", + difficulty: "easy", + score: 5, + textbookName: "高中数学必修一", + }, + { + questionId: "lib-q-002", + content: "函数 f(x)=x²+2x+1 的最小值是?", + type: "fill_blank", + difficulty: "medium", + score: 5, + textbookName: "高中数学必修一", + }, + { + questionId: "lib-q-003", + content: "下列函数中是偶函数的是?", + type: "multiple_choice", + difficulty: "medium", + score: 8, + textbookName: "高中数学必修一", + }, + { + questionId: "lib-q-004", + content: "求 log₂8 的值", + type: "fill_blank", + difficulty: "easy", + score: 5, + textbookName: "高中数学必修一", + }, + { + questionId: "lib-q-005", + content: "已知 sinα=1/2,求 cosα", + type: "single_choice", + difficulty: "hard", + score: 10, + textbookName: "高中数学必修一", + }, + { + questionId: "lib-q-006", + content: "等差数列前 n 项和公式推导", + type: "essay", + difficulty: "hard", + score: 15, + textbookName: "高中数学必修一", + }, + { + questionId: "lib-q-007", + content: "向量点乘的几何意义", + type: "single_choice", + difficulty: "medium", + score: 8, + textbookName: "高中数学必修二", + }, + { + questionId: "lib-q-008", + content: "椭圆标准方程推导", + type: "essay", + difficulty: "hard", + score: 20, + textbookName: "高中数学必修二", + }, +]; + +// ── Exam Rich Editor mock(@contract-pending 全 MSW)── +// 用于 /shell/teacher/exams/[id]/edit 工作台页富文本编辑器 +const mockExamRichEditor = { + examId: "exam-001", + title: "2026 春季期中考试", + totalScore: 100, + questionCount: 5, + updatedAt: "2026-04-14T10:00:00Z", + // 富文本内容(简化 mock:HTML 字符串数组) + content: [ + { type: "heading", level: 1, text: "2026 春季期中考试" }, + { type: "paragraph", text: "考试时间:120 分钟 满分:100 分" }, + { type: "heading", level: 2, text: "一、选择题(每题 5 分,共 25 分)" }, + { + type: "question", + order: 1, + text: "下列哪个是质数?", + options: ["A. 4", "B. 7", "C. 9", "D. 15"], + }, + { + type: "question", + order: 2, + text: "下列哪些是偶数?", + options: ["A. 2", "B. 3", "C. 4", "D. 5"], + }, + { type: "heading", level: 2, text: "二、填空题(每题 5 分,共 15 分)" }, + { type: "question", order: 3, text: "sin(30°) = ___" }, + ], +}; + // ── GraphQL Response ─────────────────────────────────────────── /** @@ -541,6 +783,77 @@ export function graphqlResponse( return { data: { createExam: { id: newId } } }; } + // ── Exam Analytics(混合契约:基础统计真实 + 扩展字段 MSW)── + // 用于 /shell/teacher/exams/[id]/analytics 详情页 + // 关联:ARCHITECTURE.md §9.1 analytics 页 / §11.4 契约工单 + case "GetExamAnalytics": + return { data: { examAnalytics: mockExamAnalytics } }; + + // ── Exam Build(@contract-pending 全 MSW)── + // 用于 /shell/teacher/exams/[id]/build 工作台页 + case "GetExamBuild": + return { data: { examBuild: mockExamBuild } }; + + // ── Questions Library(@contract-pending 全 MSW)── + // 用于 /shell/teacher/exams/[id]/build 工作台页左侧题库候选 + // 支持 filter: { q, type, difficulty, textbook, page, pageSize } + case "GetQuestionsLibrary": { + const filter = (variables?.filter ?? {}) as Record; + let filtered = [...mockQuestionsLibrary]; + if (typeof filter.q === "string" && filter.q.length > 0) { + const q = filter.q.toLowerCase(); + filtered = filtered.filter((item) => + item.content.toLowerCase().includes(q), + ); + } + if (typeof filter.type === "string" && filter.type.length > 0) { + filtered = filtered.filter((item) => item.type === filter.type); + } + if ( + typeof filter.difficulty === "string" && + filter.difficulty.length > 0 + ) { + filtered = filtered.filter( + (item) => item.difficulty === filter.difficulty, + ); + } + if (typeof filter.textbook === "string" && filter.textbook.length > 0) { + filtered = filtered.filter( + (item) => item.textbookName === filter.textbook, + ); + } + const page = (filter.page as number) ?? 1; + const pageSize = (filter.pageSize as number) ?? 10; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + return { + data: { + questionsLibrary: { items, total: filtered.length }, + }, + }; + } + + // ── SaveExamBuild(@contract-pending mutation)── + // 用于 /shell/teacher/exams/[id]/build 工作台页保存 + case "SaveExamBuild": { + const input = (variables?.input ?? {}) as Record; + const examId = (input.examId as string) ?? "exam-001"; + return { data: { saveExamBuild: { examId } } }; + } + + // ── Exam Rich Editor(@contract-pending 全 MSW)── + // 用于 /shell/teacher/exams/[id]/edit 工作台页富文本编辑器 + case "GetExamRichEditor": + return { data: { examRichEditor: mockExamRichEditor } }; + + // ── SaveExamRichContent(@contract-pending mutation)── + // 用于 /shell/teacher/exams/[id]/edit 工作台页保存 + case "SaveExamRichContent": { + const input = (variables?.input ?? {}) as Record; + const examId = (input.examId as string) ?? "exam-001"; + return { data: { saveExamRichContent: { examId } } }; + } + // ── Grades 域(预留) ── case "GetGrades": return { data: { grades: mockGrades } };