feat(portal-shell): exams 三子页面迁移(analytics/build/edit)

§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 暂缓。
This commit is contained in:
SpecialX
2026-07-22 17:35:54 +08:00
parent d066da563f
commit dca25fc42f
14 changed files with 2467 additions and 0 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -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 边界包裹。
* 业务逻辑在 ExamAnalyticsClientclient component中。
*
* 数据契约:混合契约
* - 基础统计 ✅ assignmentAnalysisdata-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 (
<Suspense fallback={<DetailPageSkeleton />}>
<ExamAnalyticsClient />
</Suspense>
);
}

View File

@@ -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 边界包裹。
* 业务逻辑在 ExamBuildClientclient 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 (
<Suspense fallback={<WorkbenchPageSkeleton />}>
<ExamBuildClient />
</Suspense>
);
}

View File

@@ -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 边界包裹。
* 业务逻辑在 ExamEditClientclient 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 (
<Suspense fallback={<WorkbenchPageSkeleton />}>
<ExamEditClient />
</Suspense>
);
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,342 @@
"use client";
/**
* 考试分析页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(混合契约):
* - 基础统计 ✅ assignmentAnalysisdata-ana 子图schema 已就绪)
* - 扩展字段(排名/每题正确率/分布)❌ → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 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 包裹在 <Suspense> 中。
*/
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 ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
return (
<DetailPageShell
title={data?.examTitle ?? t("analytics.title")}
description={
data ? t("analytics.subtitle", { examId: data.examId }) : undefined
}
icon={<BarChart3 className="size-6" />}
backHref={`/shell/teacher/exams/${examId}`}
actions={
<Button variant="outline" type="button">
<FileText className="mr-1 size-4" />
{t("analytics.exportCsv")}
</Button>
}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("analytics.notFound")}
</div>
) : undefined
}
>
{data ? <ExamAnalyticsBody data={data} /> : null}
</DetailPageShell>
);
}
/**
* 分析内容区(汇总卡片 + 分布图 + 每题正确率 + 学生排名)。
*/
function ExamAnalyticsBody({
data,
}: {
data: NonNullable<ReturnType<typeof useExamAnalytics>["data"]>;
}): React.ReactElement {
const t = useTranslations("exams");
return (
<>
<DetailSection title={t("analytics.sectionSummary")}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<SummaryCard
label={t("analytics.summaryExpected")}
value={String(data.summary.expectedCount)}
/>
<SummaryCard
label={t("analytics.summaryAttended")}
value={String(data.summary.attendedCount)}
/>
<SummaryCard
label={t("analytics.summaryAvg")}
value={formatScore(data.summary.avgScore)}
/>
<SummaryCard
label={t("analytics.summaryMax")}
value={formatScore(data.summary.maxScore)}
/>
<SummaryCard
label={t("analytics.summaryMin")}
value={formatScore(data.summary.minScore)}
/>
<SummaryCard
label={t("analytics.summaryPassRate")}
value={formatPassRate(data.summary.passRate)}
/>
</div>
</DetailSection>
<DetailSection title={t("analytics.sectionDistribution")}>
<DistributionChart items={data.distribution} />
</DetailSection>
<DetailSection title={t("analytics.sectionQuestionAccuracy")}>
<QuestionAccuracyTable items={data.questionAccuracy} />
</DetailSection>
<DetailSection title={t("analytics.sectionRankings")}>
<RankingsTable items={data.rankings} />
</DetailSection>
</>
);
}
/**
* 汇总卡片(单字段)。
*/
function SummaryCard({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold">{value}</p>
</div>
);
}
/**
* 分数段分布柱状图(纯 SVG无外部图表库依赖
*/
function DistributionChart({
items,
}: {
items: Array<{ label: string; count: number }>;
}): React.ReactElement {
const t = useTranslations("exams");
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyDistribution")}
</p>
);
}
const maxCount = Math.max(...items.map((i) => i.count), 1);
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-3">
<span className="w-16 shrink-0 text-sm text-muted-foreground">
{item.label}
</span>
<div className="h-6 flex-1 overflow-hidden rounded bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-sm">{item.count}</span>
</div>
))}
</div>
);
}
/**
* 每题正确率表格。
*/
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 (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyQuestionAccuracy")}
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">{t("analytics.colOrder")}</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colQuestionTitle")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colCorrectRate")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colAvgScore")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colMaxScore")}
</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.questionId} className="border-b last:border-0">
<td className="py-2 pr-4">{item.order}</td>
<td className="py-2 pr-4">{item.questionTitle}</td>
<td className="py-2 pr-4">
<span className="inline-flex items-center gap-2">
<span
className={
item.correctRate >= 0.8
? "text-emerald-600"
: item.correctRate >= 0.6
? "text-amber-600"
: "text-destructive"
}
>
{formatPercent(item.correctRate)}
</span>
<span className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<span
className="block h-full bg-current"
style={{ width: `${item.correctRate * 100}%` }}
/>
</span>
</span>
</td>
<td className="py-2 pr-4">{formatScore(item.avgScore)}</td>
<td className="py-2 pr-4">{formatScore(item.maxScore)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 学生排名表格。
*/
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 (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyRankings")}
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">{t("analytics.colRank")}</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colStudentNo")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colStudentName")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colTotalScore")}
</th>
<th className="py-2 pr-4 font-medium">{t("analytics.colLevel")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.studentId} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{item.rank}</td>
<td className="py-2 pr-4 font-mono text-xs">{item.studentNo}</td>
<td className="py-2 pr-4">{item.studentName}</td>
<td className="py-2 pr-4">{formatScore(item.totalScore)}</td>
<td className="py-2 pr-4">
<span
className={
item.level === "A"
? "text-emerald-600"
: item.level === "B"
? "text-blue-600"
: item.level === "C"
? "text-amber-600"
: "text-destructive"
}
>
{item.level}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -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
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 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 包裹在 <Suspense> 中。
*/
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<ExamBuildNode[] | null>(null);
// 首次拿到数据时初始化本地状态useEffect 处理副作用,不用 useMemo
useEffect(() => {
if (data && selected === null) {
setSelected(sortBySortOrder(data.selected));
}
}, [data, selected]);
const saveMutation = useSaveExamBuild();
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
// (工作台页"无数据"等价于"无法工作",合并表达更直接)
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("build.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
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 (
<WorkbenchPageShell
title={data?.title ?? t("build.title")}
description={
data
? t("build.subtitle", {
total: sumSelectedScores(selected ?? data.selected),
pass: data.passScore,
})
: undefined
}
icon={<PencilLine className="size-6" />}
actions={
<Button
type="button"
disabled={saveMutation.loading || !selected}
onClick={handleSave}
>
{saveMutation.loading ? tCommon("status.loading") : t("build.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
left={
data && selected ? (
<QuestionsLibraryPanel
onAdd={(item) => {
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 ? (
<SelectedQuestionsPanel selected={selected} onChange={setSelected} />
) : null
}
right={
data && selected ? (
<PreviewPanel
selected={selected}
totalScoreBaseline={data.totalScore}
passScore={data.passScore}
/>
) : 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<QuestionsLibraryFilter>({
q: "",
type: "",
difficulty: "",
page: 1,
pageSize: 10,
});
const { data, loading, error } = useQuestionsLibrary(filter);
const items = data?.items ?? [];
return (
<WorkbenchPanel title={t("build.libraryTitle")}>
<div className="space-y-3">
<Input
type="search"
placeholder={t("build.searchPlaceholder")}
value={filter.q ?? ""}
onChange={(e) =>
setFilter((prev) => ({ ...prev, q: e.target.value, page: 1 }))
}
/>
<div className="flex gap-2">
<select
className="h-9 rounded-md border bg-background px-3 text-sm"
value={filter.type ?? ""}
onChange={(e) =>
setFilter((prev) => ({
...prev,
type: e.target.value || null,
page: 1,
}))
}
aria-label={t("build.filterType")}
>
<option value="">{t("build.allTypes")}</option>
<option value="single_choice">{t("build.typeSingle")}</option>
<option value="multiple_choice">{t("build.typeMultiple")}</option>
<option value="fill_blank">{t("build.typeFill")}</option>
<option value="essay">{t("build.typeEssay")}</option>
</select>
<select
className="h-9 rounded-md border bg-background px-3 text-sm"
value={filter.difficulty ?? ""}
onChange={(e) =>
setFilter((prev) => ({
...prev,
difficulty: e.target.value || null,
page: 1,
}))
}
aria-label={t("build.filterDifficulty")}
>
<option value="">{t("build.allDifficulties")}</option>
<option value="easy">{t("build.diffEasy")}</option>
<option value="medium">{t("build.diffMedium")}</option>
<option value="hard">{t("build.diffHard")}</option>
</select>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">
{t("build.loadingLibrary")}
</p>
) : error ? (
<p className="text-sm text-destructive">
{t("build.loadLibraryFailed")}
</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("build.libraryEmpty")}
</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li
key={item.questionId}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<p className="text-sm">{item.content}</p>
<p className="text-xs text-muted-foreground">
<span className="mr-2">{item.type}</span>
<span className="mr-2">{item.difficulty}</span>
<span>
{item.score} {t("build.unitScore")}
</span>
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAdd(item)}
aria-label={t("build.addToExam")}
>
<Plus className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
{data ? (
<p className="text-xs text-muted-foreground">
{t("build.libraryTotal", { total: data.total })}
</p>
) : null}
</div>
</WorkbenchPanel>
);
}
/**
* 中栏:已选题目列表(可上移/下移/编辑分值/移除)。
*/
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 (
<WorkbenchPanel
title={t("build.selectedTitle")}
actions={
<span className="text-xs text-muted-foreground">
{t("build.selectedCount", { count: selected.length })}
</span>
}
>
{selected.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("build.selectedEmpty")}
</p>
) : (
<ol className="space-y-2">
{selected.map((node, idx) => (
<li
key={node.questionId}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-sm font-semibold text-muted-foreground">
{idx + 1}.
</span>
<div className="flex-1 space-y-2">
<p className="text-sm">{node.content}</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{node.type} · {node.difficulty}
</span>
<label className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">
{t("build.scoreLabel")}
</span>
<Input
type="number"
min={0}
value={node.score}
onChange={(e) =>
updateScore(
node.questionId,
Number(e.target.value) || 0,
)
}
className="h-7 w-16"
/>
</label>
</div>
</div>
<div className="flex flex-col gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveUp(idx)}
disabled={idx === 0}
aria-label={t("build.moveUp")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveDown(idx)}
disabled={idx === selected.length - 1}
aria-label={t("build.moveDown")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(node.questionId)}
aria-label={t("build.remove")}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</li>
))}
</ol>
)}
</WorkbenchPanel>
);
}
/**
* 右栏:预览(总分、题型分布、及格分提示)。
*/
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 (
<WorkbenchPanel title={t("build.previewTitle")}>
<div className="space-y-4">
<div className="rounded-md border bg-background p-4">
<p className="text-xs text-muted-foreground">
{t("build.previewTotal")}
</p>
<p className="mt-1 text-3xl font-semibold">{total}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("build.previewBaseline", { baseline: totalScoreBaseline })}
{diff !== 0 ? (
<span
className={diff > 0 ? "text-amber-600" : "text-destructive"}
>
{" "}
({diff > 0 ? "+" : ""}
{diff})
</span>
) : null}
</p>
</div>
<div className="rounded-md border bg-background p-4">
<p className="text-xs text-muted-foreground">
{t("build.previewPassScore")}
</p>
<p className="mt-1 text-xl font-semibold">{passScore}</p>
</div>
<div className="rounded-md border bg-background p-4">
<p className="mb-2 text-xs text-muted-foreground">
{t("build.previewTypeBreakdown")}
</p>
{Object.keys(typeCounts).length === 0 ? (
<p className="text-sm text-muted-foreground">--</p>
) : (
<ul className="space-y-1 text-sm">
{Object.entries(typeCounts).map(([type, count]) => (
<li key={type} className="flex justify-between">
<span className="text-muted-foreground">{type}</span>
<span>{count}</span>
</li>
))}
</ul>
)}
</div>
</div>
</WorkbenchPanel>
);
}

View File

@@ -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
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 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 包裹在 <Suspense> 中。
*/
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<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
// 首次拿到数据时初始化 HTML 内容
useEffect(() => {
if (data && html === null) {
setHtml(contentToHtml(data.content));
}
}, [data, html]);
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("edit.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
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 (
<WorkbenchPageShell
title={data?.title ?? t("edit.title")}
description={
data
? t("edit.subtitle", {
total: data.totalScore,
count: data.questionCount,
})
: undefined
}
icon={<FileText className="size-6" />}
actions={
<Button
type="button"
disabled={saveMutation.loading || !data}
onClick={handleSave}
>
{saveMutation.loading ? tCommon("status.loading") : t("edit.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
center={
data && html !== null ? (
<RichEditor
ref={editorRef}
html={html}
onChange={setHtml}
onExec={exec}
/>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("edit.propertiesTitle")}>
<div className="space-y-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propExamId")}
</p>
<p className="mt-1 font-mono text-xs">{data.examId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propTotalScore")}
</p>
<p className="mt-1 font-semibold">{data.totalScore}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propQuestionCount")}
</p>
<p className="mt-1 font-semibold">{data.questionCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propUpdatedAt")}
</p>
<p className="mt-1 text-xs">{data.updatedAt}</p>
</div>
<p className="text-xs text-muted-foreground">
{t("edit.contractPending")}
</p>
</div>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 富文本编辑器contentEditable + 工具栏)。
*
* 简化实现:使用 document.execCommanddeprecated 但仍可用)。
* 后续可升级到 tiptap/lexical@contract-pending 富文本库契约)。
*/
interface RichEditorProps {
html: string;
onChange: (html: string) => void;
onExec: (command: string, value?: string) => void;
}
const RichEditor = forwardRef<HTMLDivElement, RichEditorProps>(
function RichEditor({ html, onChange, onExec }, ref) {
const t = useTranslations("exams");
return (
<WorkbenchPanel title={t("edit.editorTitle")}>
<div className="flex h-full flex-col gap-3">
{/* 工具栏 */}
<div className="flex flex-wrap gap-1 border-b pb-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("bold")}
aria-label={t("edit.toolbarBold")}
>
<strong>B</strong>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("italic")}
aria-label={t("edit.toolbarItalic")}
>
<em>I</em>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("underline")}
aria-label={t("edit.toolbarUnderline")}
>
<u>U</u>
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h1>")}
>
H1
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h2>")}
>
H2
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h3>")}
>
H3
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertUnorderedList")}
aria-label={t("edit.toolbarBulletList")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertOrderedList")}
aria-label={t("edit.toolbarOrderedList")}
>
1.
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<p>")}
>
P
</Button>
</div>
{/* 可编辑区域 */}
<div
ref={ref}
contentEditable
suppressContentEditableWarning
className="flex-1 overflow-y-auto rounded-md border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
dangerouslySetInnerHTML={{ __html: html }}
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
style={{ minHeight: "400px" }}
/>
</div>
</WorkbenchPanel>
);
},
);
/**
* 将 mock data.content结构化数组转换为 HTML 字符串。
* 输入是 unknown来自 MSW输出是可编辑的 HTML。
*/
function contentToHtml(content: unknown): string {
if (!Array.isArray(content)) return "";
return content
.map((node: Record<string, unknown>) => {
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}</${tag}>`;
}
case "paragraph":
return `<p>${text}</p>`;
case "question": {
const order = node.order as number;
const options = (node.options as string[]) ?? [];
const optsHtml = options.map((opt) => `<div>${opt}</div>`).join("");
return `<div><strong>${order}. ${text}</strong>${optsHtml}</div>`;
}
default:
return `<div>${text}</div>`;
}
})
.join("");
}

View File

@@ -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<string, number> {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.type] = (acc[item.type] ?? 0) + 1;
return acc;
}, {});
}
/**
* 按 sortOrder 升序排序已选题目(返回新数组,不修改原数组)。
*/
export function sortBySortOrder<T extends { sortOrder: number }>(
items: ReadonlyArray<T>,
): 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;
}

View File

@@ -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<ExamAnalytics | null> {
const result = useWidgetQuery<ExamAnalyticsResponse, { examId: string }>(
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-pendingMSW 兜底)。
*
* schema 无 examBuild(examId) 根字段,由 MSW 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 build 工作台页 / §11.4 契约工单
*/
export function useExamBuild(
examId: string,
options?: ExamQueryOptions,
): UseQueryResult<ExamBuild | null> {
const result = useWidgetQuery<ExamBuildResponse, { examId: string }>(
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-pendingMSW 兜底)。
*
* 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-pendingMSW 兜底)。
*
* 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<SaveExamBuildResponse, { input: SaveExamBuildInput }>(
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-pendingMSW 兜底)。
*
* schema 无 examRichEditor(examId) 根字段,由 MSW 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 edit 工作台页 / §11.4 契约工单
*/
export function useExamRichEditor(
examId: string,
options?: ExamQueryOptions,
): UseQueryResult<ExamRichEditor | null> {
const result = useWidgetQuery<ExamRichEditorResponse, { examId: string }>(
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-pendingMSW 兜底)。
*
* 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 };
}

View File

@@ -70,3 +70,124 @@ export const CREATE_EXAM_DOC = gql`
}
}
`;
// ── 考试分析(混合契约)─────────────────────────────────────────
// 基础统计 ✅ 真实字段 assignmentAnalysisschema 已就绪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
}
}
`;

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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",
// 富文本内容(简化 mockHTML 字符串数组)
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const examId = (input.examId as string) ?? "exam-001";
return { data: { saveExamRichContent: { examId } } };
}
// ── Grades 域(预留) ──
case "GetGrades":
return { data: { grades: mockGrades } };