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

§9.1 教师域 exams 模块补完(继 d066da5 列表/详情/表单后):
- /shell/teacher/exams/[id]/analytics:详情页(图表)- 混合契约
  · 基础统计  assignmentAnalysis(data-ana 子图,schema 已就绪)
  · 扩展字段(排名/每题正确率) MSW 兜底(@contract-pending)
  · 含 Summary/Distribution/QuestionAccuracy/Rankings 四区
- /shell/teacher/exams/[id]/build:工作台页(组卷)@contract-pending
  · 三栏:题库候选 / 已选题目 / 预览
  · 支持搜索/类型/难度筛选,添加/移除/上移/下移/改分
- /shell/teacher/exams/[id]/edit:工作台页(富文本试卷)@contract-pending
  · contentEditable + 工具栏(B/I/U/H1-H3/列表)
  · 右栏试卷属性面板

§11.3 DoD 11 项验收:
1. route-permissions:PREFIX 表 /shell/teacher/exams/ 已覆盖
2. 页面模板:analytics 用 DetailPageShell;build/edit 用 WorkbenchPageShell
3. 三态:loading/error/empty 均实现(workbench 用 errorNode 合并 empty)
4. lib/api hooks:useExamAnalytics/useExamBuild/useQuestionsLibrary/
   useSaveExamBuild/useExamRichEditor/useSaveExamRichContent 6 个
5. @contract-pending MSW 模式:graphql-data.ts 扩展 6 个 case
6. i18n:analytics(19 keys)+build(28 keys)+edit(13 keys) 中英对齐
7. lint:0 errors(4 warnings 在 __generated__)
8. lint:tokens:0 errors
9. notify:success/error/warning 走 @/shared/lib/notify(非 sonner 直引)
10. vitest:transformations 新增 10 函数 22 测试,全量 273/273 通过
11. typecheck:0 errors(noUncheckedIndexedAccess 安全 swap 写法)

剩余:proctoring 标注"二期 WS"按 §9.1 暂缓。
This commit is contained in:
SpecialX
2026-07-22 17:35:54 +08:00
parent d066da563f
commit dca25fc42f
14 changed files with 2467 additions and 0 deletions

View File

@@ -9,12 +9,22 @@ import type { Exam } from "@/lib/api";
import {
EXAM_STATUS_LABEL,
countByType,
formatDuration,
formatExamDate,
formatExamStatus,
formatPassRate,
formatPercent,
formatScore,
isExamEditable,
isExamPublished,
levelToColorClass,
nextSortOrder,
parseTotalScore,
rateToColorClass,
rateToLevel,
sortBySortOrder,
sumSelectedScores,
toExamListItem,
} from "../transformations";
@@ -178,3 +188,166 @@ describe("formatDuration", () => {
expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("--");
});
});
// ── Analytics 纯函数单测 ──────────────────────────────────────
describe("formatScore", () => {
it("formats finite numbers with 1 decimal place", () => {
expect(formatScore(82.5)).toBe("82.5");
expect(formatScore(98)).toBe("98.0");
expect(formatScore(0)).toBe("0.0");
});
it("returns placeholder for non-finite input", () => {
expect(formatScore(Number.NaN)).toBe("--");
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--");
});
});
describe("formatPassRate / formatPercent", () => {
it("formats rate in [0,1] as percentage", () => {
expect(formatPassRate(0.86)).toBe("86%");
expect(formatPassRate(0)).toBe("0%");
expect(formatPassRate(1)).toBe("100%");
expect(formatPercent(0.92)).toBe("92%");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatPassRate(-0.1)).toBe("--");
expect(formatPassRate(1.1)).toBe("--");
expect(formatPassRate(Number.NaN)).toBe("--");
expect(formatPercent(Number.NaN)).toBe("--");
});
});
describe("rateToLevel", () => {
it("maps rate to A/B/C/D levels", () => {
expect(rateToLevel(0.9)).toBe("A");
expect(rateToLevel(0.85)).toBe("A");
expect(rateToLevel(0.75)).toBe("B");
expect(rateToLevel(0.7)).toBe("B");
expect(rateToLevel(0.65)).toBe("C");
expect(rateToLevel(0.6)).toBe("C");
expect(rateToLevel(0.5)).toBe("D");
expect(rateToLevel(0)).toBe("D");
});
it("returns placeholder for non-finite input", () => {
expect(rateToLevel(Number.NaN)).toBe("--");
});
});
describe("rateToColorClass", () => {
it("returns emerald for high rates", () => {
expect(rateToColorClass(0.8)).toBe("text-emerald-600");
expect(rateToColorClass(0.95)).toBe("text-emerald-600");
});
it("returns amber for medium rates", () => {
expect(rateToColorClass(0.6)).toBe("text-amber-600");
expect(rateToColorClass(0.79)).toBe("text-amber-600");
});
it("returns destructive for low rates", () => {
expect(rateToColorClass(0.59)).toBe("text-destructive");
expect(rateToColorClass(0)).toBe("text-destructive");
});
it("returns muted for non-finite input", () => {
expect(rateToColorClass(Number.NaN)).toBe("text-muted-foreground");
});
});
describe("levelToColorClass", () => {
it("maps each level to correct color class", () => {
expect(levelToColorClass("A")).toBe("text-emerald-600");
expect(levelToColorClass("B")).toBe("text-blue-600");
expect(levelToColorClass("C")).toBe("text-amber-600");
expect(levelToColorClass("D")).toBe("text-destructive");
});
it("returns muted for unknown level", () => {
expect(levelToColorClass("X")).toBe("text-muted-foreground");
expect(levelToColorClass("")).toBe("text-muted-foreground");
});
});
// ── Build 纯函数单测 ──────────────────────────────────────────
describe("sumSelectedScores", () => {
it("sums all scores", () => {
expect(
sumSelectedScores([{ score: 10 }, { score: 15 }, { score: 20 }]),
).toBe(45);
});
it("returns 0 for empty array", () => {
expect(sumSelectedScores([])).toBe(0);
});
it("handles single item", () => {
expect(sumSelectedScores([{ score: 5 }])).toBe(5);
});
});
describe("countByType", () => {
it("counts items per type", () => {
const items = [
{ type: "single_choice" },
{ type: "single_choice" },
{ type: "multiple_choice" },
{ type: "fill_blank" },
];
const result = countByType(items);
expect(result.single_choice).toBe(2);
expect(result.multiple_choice).toBe(1);
expect(result.fill_blank).toBe(1);
});
it("returns empty object for empty array", () => {
expect(countByType([])).toEqual({});
});
});
describe("sortBySortOrder", () => {
it("sorts ascending by sortOrder", () => {
const items = [
{ sortOrder: 3, id: "c" },
{ sortOrder: 1, id: "a" },
{ sortOrder: 2, id: "b" },
];
const result = sortBySortOrder(items);
expect(result.map((i) => i.id)).toEqual(["a", "b", "c"]);
});
it("does not mutate the original array", () => {
const items = [
{ sortOrder: 2, id: "b" },
{ sortOrder: 1, id: "a" },
];
const original = [...items];
sortBySortOrder(items);
expect(items).toEqual(original);
});
it("returns empty array for empty input", () => {
expect(sortBySortOrder([])).toEqual([]);
});
});
describe("nextSortOrder", () => {
it("returns 1 for empty array", () => {
expect(nextSortOrder([])).toBe(1);
});
it("returns max + 1 for non-empty array", () => {
expect(
nextSortOrder([{ sortOrder: 1 }, { sortOrder: 3 }, { sortOrder: 2 }]),
).toBe(4);
});
it("handles single item", () => {
expect(nextSortOrder([{ sortOrder: 5 }])).toBe(6);
});
});

View File

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

View File

@@ -0,0 +1,499 @@
"use client";
/**
* 组卷工作台页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - examBuild(examId) ❌ → MSW 兜底
* - questionsLibrary(filter) ❌ → MSW 兜底
* - saveExamBuild(input) mutation ❌ → MSW 兜底
*
* 三栏布局WorkbenchPageShell
* - left题库候选列表带 q/type/difficulty 筛选)
* - center已选题目列表可上移/下移/编辑分值/移除)
* - right预览总分、题型分布、及格分提示
*
* 三态规范§11.3 DoD
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { PencilLine, Plus, Trash2 } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import {
useExamBuild,
useQuestionsLibrary,
useSaveExamBuild,
type ExamBuildNode,
type QuestionsLibraryFilter,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import {
WorkbenchPageShell,
WorkbenchPageSkeleton,
WorkbenchPanel,
} from "@/shared/components/page-templates";
import {
countByType,
nextSortOrder,
sortBySortOrder,
sumSelectedScores,
} from "@/features/teacher/exams/transformations";
/**
* 组卷工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ExamBuildClient(): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
// 已选题目(本地状态,从 useExamBuild 初始化)
const { data, loading, error } = useExamBuild(examId);
const [selected, setSelected] = useState<ExamBuildNode[] | null>(null);
// 首次拿到数据时初始化本地状态useEffect 处理副作用,不用 useMemo
useEffect(() => {
if (data && selected === null) {
setSelected(sortBySortOrder(data.selected));
}
}, [data, selected]);
const saveMutation = useSaveExamBuild();
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
// (工作台页"无数据"等价于"无法工作",合并表达更直接)
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("build.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
if (!selected) return;
try {
await saveMutation.run({
examId,
questions: selected.map((q) => ({
questionId: q.questionId,
score: q.score,
sortOrder: q.sortOrder,
})),
});
notify.success(t("build.saveSuccess"));
} catch (err) {
notify.error(t("build.saveFailed", { message: String(err) }));
}
};
return (
<WorkbenchPageShell
title={data?.title ?? t("build.title")}
description={
data
? t("build.subtitle", {
total: sumSelectedScores(selected ?? data.selected),
pass: data.passScore,
})
: undefined
}
icon={<PencilLine className="size-6" />}
actions={
<Button
type="button"
disabled={saveMutation.loading || !selected}
onClick={handleSave}
>
{saveMutation.loading ? tCommon("status.loading") : t("build.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
left={
data && selected ? (
<QuestionsLibraryPanel
onAdd={(item) => {
if (selected.some((s) => s.questionId === item.questionId)) {
notify.warning(t("build.alreadyAdded"));
return;
}
const newNode: ExamBuildNode = {
questionId: item.questionId,
score: item.score,
sortOrder: nextSortOrder(selected),
content: item.content,
type: item.type,
difficulty: item.difficulty,
};
setSelected([...selected, newNode]);
}}
/>
) : null
}
center={
data && selected ? (
<SelectedQuestionsPanel selected={selected} onChange={setSelected} />
) : null
}
right={
data && selected ? (
<PreviewPanel
selected={selected}
totalScoreBaseline={data.totalScore}
passScore={data.passScore}
/>
) : null
}
/>
);
}
/**
* 左栏:题库候选列表(带筛选)。
*/
function QuestionsLibraryPanel({
onAdd,
}: {
onAdd: (item: {
questionId: string;
content: string;
type: string;
difficulty: string;
score: number;
textbookName: string | null;
}) => void;
}): React.ReactElement {
const t = useTranslations("exams");
const [filter, setFilter] = useState<QuestionsLibraryFilter>({
q: "",
type: "",
difficulty: "",
page: 1,
pageSize: 10,
});
const { data, loading, error } = useQuestionsLibrary(filter);
const items = data?.items ?? [];
return (
<WorkbenchPanel title={t("build.libraryTitle")}>
<div className="space-y-3">
<Input
type="search"
placeholder={t("build.searchPlaceholder")}
value={filter.q ?? ""}
onChange={(e) =>
setFilter((prev) => ({ ...prev, q: e.target.value, page: 1 }))
}
/>
<div className="flex gap-2">
<select
className="h-9 rounded-md border bg-background px-3 text-sm"
value={filter.type ?? ""}
onChange={(e) =>
setFilter((prev) => ({
...prev,
type: e.target.value || null,
page: 1,
}))
}
aria-label={t("build.filterType")}
>
<option value="">{t("build.allTypes")}</option>
<option value="single_choice">{t("build.typeSingle")}</option>
<option value="multiple_choice">{t("build.typeMultiple")}</option>
<option value="fill_blank">{t("build.typeFill")}</option>
<option value="essay">{t("build.typeEssay")}</option>
</select>
<select
className="h-9 rounded-md border bg-background px-3 text-sm"
value={filter.difficulty ?? ""}
onChange={(e) =>
setFilter((prev) => ({
...prev,
difficulty: e.target.value || null,
page: 1,
}))
}
aria-label={t("build.filterDifficulty")}
>
<option value="">{t("build.allDifficulties")}</option>
<option value="easy">{t("build.diffEasy")}</option>
<option value="medium">{t("build.diffMedium")}</option>
<option value="hard">{t("build.diffHard")}</option>
</select>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">
{t("build.loadingLibrary")}
</p>
) : error ? (
<p className="text-sm text-destructive">
{t("build.loadLibraryFailed")}
</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("build.libraryEmpty")}
</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li
key={item.questionId}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<p className="text-sm">{item.content}</p>
<p className="text-xs text-muted-foreground">
<span className="mr-2">{item.type}</span>
<span className="mr-2">{item.difficulty}</span>
<span>
{item.score} {t("build.unitScore")}
</span>
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAdd(item)}
aria-label={t("build.addToExam")}
>
<Plus className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
{data ? (
<p className="text-xs text-muted-foreground">
{t("build.libraryTotal", { total: data.total })}
</p>
) : null}
</div>
</WorkbenchPanel>
);
}
/**
* 中栏:已选题目列表(可上移/下移/编辑分值/移除)。
*/
function SelectedQuestionsPanel({
selected,
onChange,
}: {
selected: ExamBuildNode[];
onChange: (next: ExamBuildNode[]) => void;
}): React.ReactElement {
const t = useTranslations("exams");
const moveUp = (index: number): void => {
if (index === 0) return;
const next = [...selected];
const a = next[index - 1];
const b = next[index];
if (!a || !b) return;
next[index - 1] = b;
next[index] = a;
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
};
const moveDown = (index: number): void => {
if (index === selected.length - 1) return;
const next = [...selected];
const a = next[index];
const b = next[index + 1];
if (!a || !b) return;
next[index] = b;
next[index + 1] = a;
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
};
const remove = (questionId: string): void => {
onChange(
selected
.filter((s) => s.questionId !== questionId)
.map((node, i) => ({ ...node, sortOrder: i + 1 })),
);
};
const updateScore = (questionId: string, score: number): void => {
onChange(
selected.map((s) => (s.questionId === questionId ? { ...s, score } : s)),
);
};
return (
<WorkbenchPanel
title={t("build.selectedTitle")}
actions={
<span className="text-xs text-muted-foreground">
{t("build.selectedCount", { count: selected.length })}
</span>
}
>
{selected.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("build.selectedEmpty")}
</p>
) : (
<ol className="space-y-2">
{selected.map((node, idx) => (
<li
key={node.questionId}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-sm font-semibold text-muted-foreground">
{idx + 1}.
</span>
<div className="flex-1 space-y-2">
<p className="text-sm">{node.content}</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{node.type} · {node.difficulty}
</span>
<label className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">
{t("build.scoreLabel")}
</span>
<Input
type="number"
min={0}
value={node.score}
onChange={(e) =>
updateScore(
node.questionId,
Number(e.target.value) || 0,
)
}
className="h-7 w-16"
/>
</label>
</div>
</div>
<div className="flex flex-col gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveUp(idx)}
disabled={idx === 0}
aria-label={t("build.moveUp")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveDown(idx)}
disabled={idx === selected.length - 1}
aria-label={t("build.moveDown")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(node.questionId)}
aria-label={t("build.remove")}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</li>
))}
</ol>
)}
</WorkbenchPanel>
);
}
/**
* 右栏:预览(总分、题型分布、及格分提示)。
*/
function PreviewPanel({
selected,
totalScoreBaseline,
passScore,
}: {
selected: ExamBuildNode[];
totalScoreBaseline: number;
passScore: number;
}): React.ReactElement {
const t = useTranslations("exams");
const total = sumSelectedScores(selected);
const typeCounts = countByType(selected);
const diff = total - totalScoreBaseline;
return (
<WorkbenchPanel title={t("build.previewTitle")}>
<div className="space-y-4">
<div className="rounded-md border bg-background p-4">
<p className="text-xs text-muted-foreground">
{t("build.previewTotal")}
</p>
<p className="mt-1 text-3xl font-semibold">{total}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("build.previewBaseline", { baseline: totalScoreBaseline })}
{diff !== 0 ? (
<span
className={diff > 0 ? "text-amber-600" : "text-destructive"}
>
{" "}
({diff > 0 ? "+" : ""}
{diff})
</span>
) : null}
</p>
</div>
<div className="rounded-md border bg-background p-4">
<p className="text-xs text-muted-foreground">
{t("build.previewPassScore")}
</p>
<p className="mt-1 text-xl font-semibold">{passScore}</p>
</div>
<div className="rounded-md border bg-background p-4">
<p className="mb-2 text-xs text-muted-foreground">
{t("build.previewTypeBreakdown")}
</p>
{Object.keys(typeCounts).length === 0 ? (
<p className="text-sm text-muted-foreground">--</p>
) : (
<ul className="space-y-1 text-sm">
{Object.entries(typeCounts).map(([type, count]) => (
<li key={type} className="flex justify-between">
<span className="text-muted-foreground">{type}</span>
<span>{count}</span>
</li>
))}
</ul>
)}
</div>
</div>
</WorkbenchPanel>
);
}

View File

@@ -0,0 +1,314 @@
"use client";
/**
* 富文本试卷编辑页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - examRichEditor(examId) ❌ → MSW 兜底
* - saveExamRichContent(input) mutation ❌ → MSW 兜底
*
* 布局WorkbenchPageShell 单 center 栏):
* - center富文本编辑器contentEditable + 工具栏)
* - 工具栏:加粗/斜体/标题 H1/H2/H3/列表/插入题目占位
* - 内容区:可编辑 div
* - 底部:保存/预览按钮
*
* 三态规范§11.3 DoD
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { forwardRef, useEffect, useRef, useState } from "react";
import { useExamRichEditor, useSaveExamRichContent } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
WorkbenchPageShell,
WorkbenchPageSkeleton,
WorkbenchPanel,
} from "@/shared/components/page-templates";
/**
* 富文本编辑客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ExamEditClient(): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
// @contract-pending MSW 兜底
const { data, loading, error } = useExamRichEditor(examId);
const saveMutation = useSaveExamRichContent();
// 本地 HTML 内容(从 data.content 初始化)
const [html, setHtml] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
// 首次拿到数据时初始化 HTML 内容
useEffect(() => {
if (data && html === null) {
setHtml(contentToHtml(data.content));
}
}, [data, html]);
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("edit.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
if (!data) return;
const currentHtml = editorRef.current?.innerHTML ?? html ?? "";
try {
await saveMutation.run({ examId, content: currentHtml });
notify.success(t("edit.saveSuccess"));
} catch (err) {
notify.error(t("edit.saveFailed", { message: String(err) }));
}
};
const exec = (command: string, value?: string): void => {
if (!editorRef.current) return;
editorRef.current.focus();
// document.execCommand 已 deprecated 但 contentEditable 简单方案仍可用
// 后续可换 tiptap/lexical@contract-pending 富文本库)
document.execCommand(command, false, value);
setHtml(editorRef.current.innerHTML);
};
return (
<WorkbenchPageShell
title={data?.title ?? t("edit.title")}
description={
data
? t("edit.subtitle", {
total: data.totalScore,
count: data.questionCount,
})
: undefined
}
icon={<FileText className="size-6" />}
actions={
<Button
type="button"
disabled={saveMutation.loading || !data}
onClick={handleSave}
>
{saveMutation.loading ? tCommon("status.loading") : t("edit.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
center={
data && html !== null ? (
<RichEditor
ref={editorRef}
html={html}
onChange={setHtml}
onExec={exec}
/>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("edit.propertiesTitle")}>
<div className="space-y-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propExamId")}
</p>
<p className="mt-1 font-mono text-xs">{data.examId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propTotalScore")}
</p>
<p className="mt-1 font-semibold">{data.totalScore}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propQuestionCount")}
</p>
<p className="mt-1 font-semibold">{data.questionCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propUpdatedAt")}
</p>
<p className="mt-1 text-xs">{data.updatedAt}</p>
</div>
<p className="text-xs text-muted-foreground">
{t("edit.contractPending")}
</p>
</div>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 富文本编辑器contentEditable + 工具栏)。
*
* 简化实现:使用 document.execCommanddeprecated 但仍可用)。
* 后续可升级到 tiptap/lexical@contract-pending 富文本库契约)。
*/
interface RichEditorProps {
html: string;
onChange: (html: string) => void;
onExec: (command: string, value?: string) => void;
}
const RichEditor = forwardRef<HTMLDivElement, RichEditorProps>(
function RichEditor({ html, onChange, onExec }, ref) {
const t = useTranslations("exams");
return (
<WorkbenchPanel title={t("edit.editorTitle")}>
<div className="flex h-full flex-col gap-3">
{/* 工具栏 */}
<div className="flex flex-wrap gap-1 border-b pb-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("bold")}
aria-label={t("edit.toolbarBold")}
>
<strong>B</strong>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("italic")}
aria-label={t("edit.toolbarItalic")}
>
<em>I</em>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("underline")}
aria-label={t("edit.toolbarUnderline")}
>
<u>U</u>
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h1>")}
>
H1
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h2>")}
>
H2
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h3>")}
>
H3
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertUnorderedList")}
aria-label={t("edit.toolbarBulletList")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertOrderedList")}
aria-label={t("edit.toolbarOrderedList")}
>
1.
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<p>")}
>
P
</Button>
</div>
{/* 可编辑区域 */}
<div
ref={ref}
contentEditable
suppressContentEditableWarning
className="flex-1 overflow-y-auto rounded-md border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
dangerouslySetInnerHTML={{ __html: html }}
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
style={{ minHeight: "400px" }}
/>
</div>
</WorkbenchPanel>
);
},
);
/**
* 将 mock data.content结构化数组转换为 HTML 字符串。
* 输入是 unknown来自 MSW输出是可编辑的 HTML。
*/
function contentToHtml(content: unknown): string {
if (!Array.isArray(content)) return "";
return content
.map((node: Record<string, unknown>) => {
const type = node.type as string;
const text = (node.text as string) ?? "";
switch (type) {
case "heading": {
const level = node.level as number;
const tag = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
return `<${tag}>${text}</${tag}>`;
}
case "paragraph":
return `<p>${text}</p>`;
case "question": {
const order = node.order as number;
const options = (node.options as string[]) ?? [];
const optsHtml = options.map((opt) => `<div>${opt}</div>`).join("");
return `<div><strong>${order}. ${text}</strong>${optsHtml}</div>`;
}
default:
return `<div>${text}</div>`;
}
})
.join("");
}

View File

@@ -101,3 +101,118 @@ export function formatDuration(minutes: number): string {
const rest = minutes % 60;
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`;
}
// ── Analytics 纯函数ARCHITECTURE.md §9.1 analytics 页 / §11.3 DoD──
/**
* 格式化分数(数值)为展示字符串,保留 1 位小数。
* 输入无效返回 "--"。
*/
export function formatScore(score: number): string {
if (!Number.isFinite(score)) return "--";
return score.toFixed(1);
}
/**
* 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。
* 输入无效返回 "--"。
*/
export function formatPassRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 将 0-1 的小数(如 0.92)格式化为百分比字符串 "92%"。
* 与 formatPassRate 同义,语义区分:用于正确率/通过率等不同场景。
*/
export function formatPercent(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 根据正确率返回等级标签A/B/C/D
* - rate >= 0.85 → A
* - rate >= 0.7 → B
* - rate >= 0.6 → C
* - 其他 → D
*/
export function rateToLevel(rate: number): string {
if (!Number.isFinite(rate)) return "--";
if (rate >= 0.85) return "A";
if (rate >= 0.7) return "B";
if (rate >= 0.6) return "C";
return "D";
}
/**
* 根据正确率返回 Tailwind 文本语义类名。
* - >= 0.8 → text-emerald-600
* - >= 0.6 → text-amber-600
* - 其他 → text-destructive
*/
export function rateToColorClass(rate: number): string {
if (!Number.isFinite(rate)) return "text-muted-foreground";
if (rate >= 0.8) return "text-emerald-600";
if (rate >= 0.6) return "text-amber-600";
return "text-destructive";
}
/**
* 根据等级A/B/C/D返回 Tailwind 文本语义类名。
*/
export function levelToColorClass(level: string): string {
switch (level) {
case "A":
return "text-emerald-600";
case "B":
return "text-blue-600";
case "C":
return "text-amber-600";
case "D":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
// ── Build 纯函数ARCHITECTURE.md §9.1 build 工作台页 / §11.3 DoD──
/**
* 计算已选题目总分。
*/
export function sumSelectedScores(items: Array<{ score: number }>): number {
return items.reduce((sum, item) => sum + item.score, 0);
}
/**
* 按题量统计题型分布(如 { single_choice: 5, multiple_choice: 3 })。
*/
export function countByType(
items: Array<{ type: string }>,
): Record<string, number> {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.type] = (acc[item.type] ?? 0) + 1;
return acc;
}, {});
}
/**
* 按 sortOrder 升序排序已选题目(返回新数组,不修改原数组)。
*/
export function sortBySortOrder<T extends { sortOrder: number }>(
items: ReadonlyArray<T>,
): T[] {
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
}
/**
* 生成下一个 sortOrder当前最大值 + 1
*/
export function nextSortOrder(
items: ReadonlyArray<{ sortOrder: number }>,
): number {
if (items.length === 0) return 1;
return Math.max(...items.map((i) => i.sortOrder)) + 1;
}