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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,9 @@ out/
|
|||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
obj/
|
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
|
# Go
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
EXAM_STATUS_LABEL,
|
EXAM_STATUS_LABEL,
|
||||||
|
countByType,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatExamDate,
|
formatExamDate,
|
||||||
formatExamStatus,
|
formatExamStatus,
|
||||||
|
formatPassRate,
|
||||||
|
formatPercent,
|
||||||
|
formatScore,
|
||||||
isExamEditable,
|
isExamEditable,
|
||||||
isExamPublished,
|
isExamPublished,
|
||||||
|
levelToColorClass,
|
||||||
|
nextSortOrder,
|
||||||
parseTotalScore,
|
parseTotalScore,
|
||||||
|
rateToColorClass,
|
||||||
|
rateToLevel,
|
||||||
|
sortBySortOrder,
|
||||||
|
sumSelectedScores,
|
||||||
toExamListItem,
|
toExamListItem,
|
||||||
} from "../transformations";
|
} from "../transformations";
|
||||||
|
|
||||||
@@ -178,3 +188,166 @@ describe("formatDuration", () => {
|
|||||||
expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("--");
|
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;
|
const rest = minutes % 60;
|
||||||
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`;
|
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 { ApiError } from "./errors";
|
||||||
import {
|
import {
|
||||||
CREATE_EXAM_DOC,
|
CREATE_EXAM_DOC,
|
||||||
|
GET_EXAM_ANALYTICS_DOC,
|
||||||
|
GET_EXAM_BUILD_DOC,
|
||||||
GET_EXAM_DOC,
|
GET_EXAM_DOC,
|
||||||
GET_EXAMS_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";
|
} from "./operations/exams.graphql";
|
||||||
import type { UseQueryResult } from "./types";
|
import type { UseQueryResult } from "./types";
|
||||||
|
|
||||||
@@ -94,6 +100,154 @@ interface CreateExamResponse {
|
|||||||
createExam: { id: string } | null;
|
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 {
|
export interface ExamQueryOptions {
|
||||||
@@ -206,3 +360,185 @@ export function useCreateExam(): {
|
|||||||
|
|
||||||
return { run, loading, error };
|
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",
|
"errorTitleRequired": "Please fill in exam title",
|
||||||
"errorDateRequired": "Please select exam date",
|
"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."
|
"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": {
|
"homework": {
|
||||||
|
|||||||
@@ -210,6 +210,95 @@
|
|||||||
"errorTitleRequired": "请填写考试标题",
|
"errorTitleRequired": "请填写考试标题",
|
||||||
"errorDateRequired": "请选择考试日期",
|
"errorDateRequired": "请选择考试日期",
|
||||||
"contractPending": "创建考试契约为 @contract-pending,当前通过 MSW 兜底。后端补齐 mutation 后将切换为真实提交。"
|
"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": {
|
"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 ───────────────────────────────────────────
|
// ── GraphQL Response ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -541,6 +783,77 @@ export function graphqlResponse(
|
|||||||
return { data: { createExam: { id: newId } } };
|
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 域(预留) ──
|
// ── Grades 域(预留) ──
|
||||||
case "GetGrades":
|
case "GetGrades":
|
||||||
return { data: { grades: mockGrades } };
|
return { data: { grades: mockGrades } };
|
||||||
|
|||||||
Reference in New Issue
Block a user