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:
@@ -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 (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ExamAnalyticsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<WorkbenchPageSkeleton />}>
|
||||
<ExamBuildClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<WorkbenchPageSkeleton />}>
|
||||
<ExamEditClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 包裹在 <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>
|
||||
);
|
||||
}
|
||||
@@ -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 包裹在 <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>
|
||||
);
|
||||
}
|
||||
@@ -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 包裹在 <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.execCommand(deprecated 但仍可用)。
|
||||
* 后续可升级到 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("");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-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<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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<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 } };
|
||||
|
||||
Reference in New Issue
Block a user