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:
@@ -9,12 +9,22 @@ import type { Exam } from "@/lib/api";
|
||||
|
||||
import {
|
||||
EXAM_STATUS_LABEL,
|
||||
countByType,
|
||||
formatDuration,
|
||||
formatExamDate,
|
||||
formatExamStatus,
|
||||
formatPassRate,
|
||||
formatPercent,
|
||||
formatScore,
|
||||
isExamEditable,
|
||||
isExamPublished,
|
||||
levelToColorClass,
|
||||
nextSortOrder,
|
||||
parseTotalScore,
|
||||
rateToColorClass,
|
||||
rateToLevel,
|
||||
sortBySortOrder,
|
||||
sumSelectedScores,
|
||||
toExamListItem,
|
||||
} from "../transformations";
|
||||
|
||||
@@ -178,3 +188,166 @@ describe("formatDuration", () => {
|
||||
expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Analytics 纯函数单测 ──────────────────────────────────────
|
||||
|
||||
describe("formatScore", () => {
|
||||
it("formats finite numbers with 1 decimal place", () => {
|
||||
expect(formatScore(82.5)).toBe("82.5");
|
||||
expect(formatScore(98)).toBe("98.0");
|
||||
expect(formatScore(0)).toBe("0.0");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite input", () => {
|
||||
expect(formatScore(Number.NaN)).toBe("--");
|
||||
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPassRate / formatPercent", () => {
|
||||
it("formats rate in [0,1] as percentage", () => {
|
||||
expect(formatPassRate(0.86)).toBe("86%");
|
||||
expect(formatPassRate(0)).toBe("0%");
|
||||
expect(formatPassRate(1)).toBe("100%");
|
||||
expect(formatPercent(0.92)).toBe("92%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatPassRate(-0.1)).toBe("--");
|
||||
expect(formatPassRate(1.1)).toBe("--");
|
||||
expect(formatPassRate(Number.NaN)).toBe("--");
|
||||
expect(formatPercent(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateToLevel", () => {
|
||||
it("maps rate to A/B/C/D levels", () => {
|
||||
expect(rateToLevel(0.9)).toBe("A");
|
||||
expect(rateToLevel(0.85)).toBe("A");
|
||||
expect(rateToLevel(0.75)).toBe("B");
|
||||
expect(rateToLevel(0.7)).toBe("B");
|
||||
expect(rateToLevel(0.65)).toBe("C");
|
||||
expect(rateToLevel(0.6)).toBe("C");
|
||||
expect(rateToLevel(0.5)).toBe("D");
|
||||
expect(rateToLevel(0)).toBe("D");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite input", () => {
|
||||
expect(rateToLevel(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateToColorClass", () => {
|
||||
it("returns emerald for high rates", () => {
|
||||
expect(rateToColorClass(0.8)).toBe("text-emerald-600");
|
||||
expect(rateToColorClass(0.95)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns amber for medium rates", () => {
|
||||
expect(rateToColorClass(0.6)).toBe("text-amber-600");
|
||||
expect(rateToColorClass(0.79)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for low rates", () => {
|
||||
expect(rateToColorClass(0.59)).toBe("text-destructive");
|
||||
expect(rateToColorClass(0)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for non-finite input", () => {
|
||||
expect(rateToColorClass(Number.NaN)).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("levelToColorClass", () => {
|
||||
it("maps each level to correct color class", () => {
|
||||
expect(levelToColorClass("A")).toBe("text-emerald-600");
|
||||
expect(levelToColorClass("B")).toBe("text-blue-600");
|
||||
expect(levelToColorClass("C")).toBe("text-amber-600");
|
||||
expect(levelToColorClass("D")).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for unknown level", () => {
|
||||
expect(levelToColorClass("X")).toBe("text-muted-foreground");
|
||||
expect(levelToColorClass("")).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Build 纯函数单测 ──────────────────────────────────────────
|
||||
|
||||
describe("sumSelectedScores", () => {
|
||||
it("sums all scores", () => {
|
||||
expect(
|
||||
sumSelectedScores([{ score: 10 }, { score: 15 }, { score: 20 }]),
|
||||
).toBe(45);
|
||||
});
|
||||
|
||||
it("returns 0 for empty array", () => {
|
||||
expect(sumSelectedScores([])).toBe(0);
|
||||
});
|
||||
|
||||
it("handles single item", () => {
|
||||
expect(sumSelectedScores([{ score: 5 }])).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countByType", () => {
|
||||
it("counts items per type", () => {
|
||||
const items = [
|
||||
{ type: "single_choice" },
|
||||
{ type: "single_choice" },
|
||||
{ type: "multiple_choice" },
|
||||
{ type: "fill_blank" },
|
||||
];
|
||||
const result = countByType(items);
|
||||
expect(result.single_choice).toBe(2);
|
||||
expect(result.multiple_choice).toBe(1);
|
||||
expect(result.fill_blank).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty object for empty array", () => {
|
||||
expect(countByType([])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortBySortOrder", () => {
|
||||
it("sorts ascending by sortOrder", () => {
|
||||
const items = [
|
||||
{ sortOrder: 3, id: "c" },
|
||||
{ sortOrder: 1, id: "a" },
|
||||
{ sortOrder: 2, id: "b" },
|
||||
];
|
||||
const result = sortBySortOrder(items);
|
||||
expect(result.map((i) => i.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("does not mutate the original array", () => {
|
||||
const items = [
|
||||
{ sortOrder: 2, id: "b" },
|
||||
{ sortOrder: 1, id: "a" },
|
||||
];
|
||||
const original = [...items];
|
||||
sortBySortOrder(items);
|
||||
expect(items).toEqual(original);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(sortBySortOrder([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextSortOrder", () => {
|
||||
it("returns 1 for empty array", () => {
|
||||
expect(nextSortOrder([])).toBe(1);
|
||||
});
|
||||
|
||||
it("returns max + 1 for non-empty array", () => {
|
||||
expect(
|
||||
nextSortOrder([{ sortOrder: 1 }, { sortOrder: 3 }, { sortOrder: 2 }]),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("handles single item", () => {
|
||||
expect(nextSortOrder([{ sortOrder: 5 }])).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试分析页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(混合契约):
|
||||
* - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪)
|
||||
* - 扩展字段(排名/每题正确率/分布)❌ → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BarChart3, FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useExamAnalytics } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatPassRate,
|
||||
formatPercent,
|
||||
formatScore,
|
||||
} from "@/features/teacher/exams/transformations";
|
||||
|
||||
/**
|
||||
* 分析客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ExamAnalyticsClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// 混合契约:基础统计真实 + 扩展字段 MSW
|
||||
const { data, loading, error } = useExamAnalytics(examId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.examTitle ?? t("analytics.title")}
|
||||
description={
|
||||
data ? t("analytics.subtitle", { examId: data.examId }) : undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref={`/shell/teacher/exams/${examId}`}
|
||||
actions={
|
||||
<Button variant="outline" type="button">
|
||||
<FileText className="mr-1 size-4" />
|
||||
{t("analytics.exportCsv")}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("analytics.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <ExamAnalyticsBody data={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析内容区(汇总卡片 + 分布图 + 每题正确率 + 学生排名)。
|
||||
*/
|
||||
function ExamAnalyticsBody({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useExamAnalytics>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("analytics.sectionSummary")}>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryExpected")}
|
||||
value={String(data.summary.expectedCount)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryAttended")}
|
||||
value={String(data.summary.attendedCount)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryAvg")}
|
||||
value={formatScore(data.summary.avgScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMax")}
|
||||
value={formatScore(data.summary.maxScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMin")}
|
||||
value={formatScore(data.summary.minScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryPassRate")}
|
||||
value={formatPassRate(data.summary.passRate)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionDistribution")}>
|
||||
<DistributionChart items={data.distribution} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionQuestionAccuracy")}>
|
||||
<QuestionAccuracyTable items={data.questionAccuracy} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionRankings")}>
|
||||
<RankingsTable items={data.rankings} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总卡片(单字段)。
|
||||
*/
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分数段分布柱状图(纯 SVG,无外部图表库依赖)。
|
||||
*/
|
||||
function DistributionChart({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ label: string; count: number }>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyDistribution")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const maxCount = Math.max(...items.map((i) => i.count), 1);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="w-16 shrink-0 text-sm text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="h-6 flex-1 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 shrink-0 text-right text-sm">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每题正确率表格。
|
||||
*/
|
||||
function QuestionAccuracyTable({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{
|
||||
questionId: string;
|
||||
order: number;
|
||||
questionTitle: string;
|
||||
correctRate: number;
|
||||
avgScore: number;
|
||||
maxScore: number;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyQuestionAccuracy")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colOrder")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colQuestionTitle")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colCorrectRate")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colAvgScore")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colMaxScore")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.questionId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">{item.order}</td>
|
||||
<td className="py-2 pr-4">{item.questionTitle}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
item.correctRate >= 0.8
|
||||
? "text-emerald-600"
|
||||
: item.correctRate >= 0.6
|
||||
? "text-amber-600"
|
||||
: "text-destructive"
|
||||
}
|
||||
>
|
||||
{formatPercent(item.correctRate)}
|
||||
</span>
|
||||
<span className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className="block h-full bg-current"
|
||||
style={{ width: `${item.correctRate * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.avgScore)}</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.maxScore)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生排名表格。
|
||||
*/
|
||||
function RankingsTable({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{
|
||||
studentId: string;
|
||||
studentNo: string;
|
||||
studentName: string;
|
||||
totalScore: number;
|
||||
rank: number;
|
||||
level: string;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyRankings")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colRank")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentNo")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentName")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colTotalScore")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colLevel")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.studentId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{item.rank}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{item.studentNo}</td>
|
||||
<td className="py-2 pr-4">{item.studentName}</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.totalScore)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={
|
||||
item.level === "A"
|
||||
? "text-emerald-600"
|
||||
: item.level === "B"
|
||||
? "text-blue-600"
|
||||
: item.level === "C"
|
||||
? "text-amber-600"
|
||||
: "text-destructive"
|
||||
}
|
||||
>
|
||||
{item.level}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 组卷工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - examBuild(examId) ❌ → MSW 兜底
|
||||
* - questionsLibrary(filter) ❌ → MSW 兜底
|
||||
* - saveExamBuild(input) mutation ❌ → MSW 兜底
|
||||
*
|
||||
* 三栏布局(WorkbenchPageShell):
|
||||
* - left:题库候选列表(带 q/type/difficulty 筛选)
|
||||
* - center:已选题目列表(可上移/下移/编辑分值/移除)
|
||||
* - right:预览(总分、题型分布、及格分提示)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:WorkbenchPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { PencilLine, Plus, Trash2 } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
useExamBuild,
|
||||
useQuestionsLibrary,
|
||||
useSaveExamBuild,
|
||||
type ExamBuildNode,
|
||||
type QuestionsLibraryFilter,
|
||||
} from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPageSkeleton,
|
||||
WorkbenchPanel,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
countByType,
|
||||
nextSortOrder,
|
||||
sortBySortOrder,
|
||||
sumSelectedScores,
|
||||
} from "@/features/teacher/exams/transformations";
|
||||
|
||||
/**
|
||||
* 组卷工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ExamBuildClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// 已选题目(本地状态,从 useExamBuild 初始化)
|
||||
const { data, loading, error } = useExamBuild(examId);
|
||||
const [selected, setSelected] = useState<ExamBuildNode[] | null>(null);
|
||||
|
||||
// 首次拿到数据时初始化本地状态(useEffect 处理副作用,不用 useMemo)
|
||||
useEffect(() => {
|
||||
if (data && selected === null) {
|
||||
setSelected(sortBySortOrder(data.selected));
|
||||
}
|
||||
}, [data, selected]);
|
||||
|
||||
const saveMutation = useSaveExamBuild();
|
||||
|
||||
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
|
||||
// (工作台页"无数据"等价于"无法工作",合并表达更直接)
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : !loading && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("build.notFound")}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await saveMutation.run({
|
||||
examId,
|
||||
questions: selected.map((q) => ({
|
||||
questionId: q.questionId,
|
||||
score: q.score,
|
||||
sortOrder: q.sortOrder,
|
||||
})),
|
||||
});
|
||||
notify.success(t("build.saveSuccess"));
|
||||
} catch (err) {
|
||||
notify.error(t("build.saveFailed", { message: String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title={data?.title ?? t("build.title")}
|
||||
description={
|
||||
data
|
||||
? t("build.subtitle", {
|
||||
total: sumSelectedScores(selected ?? data.selected),
|
||||
pass: data.passScore,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<PencilLine className="size-6" />}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saveMutation.loading || !selected}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveMutation.loading ? tCommon("status.loading") : t("build.save")}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
left={
|
||||
data && selected ? (
|
||||
<QuestionsLibraryPanel
|
||||
onAdd={(item) => {
|
||||
if (selected.some((s) => s.questionId === item.questionId)) {
|
||||
notify.warning(t("build.alreadyAdded"));
|
||||
return;
|
||||
}
|
||||
const newNode: ExamBuildNode = {
|
||||
questionId: item.questionId,
|
||||
score: item.score,
|
||||
sortOrder: nextSortOrder(selected),
|
||||
content: item.content,
|
||||
type: item.type,
|
||||
difficulty: item.difficulty,
|
||||
};
|
||||
setSelected([...selected, newNode]);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
center={
|
||||
data && selected ? (
|
||||
<SelectedQuestionsPanel selected={selected} onChange={setSelected} />
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data && selected ? (
|
||||
<PreviewPanel
|
||||
selected={selected}
|
||||
totalScoreBaseline={data.totalScore}
|
||||
passScore={data.passScore}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左栏:题库候选列表(带筛选)。
|
||||
*/
|
||||
function QuestionsLibraryPanel({
|
||||
onAdd,
|
||||
}: {
|
||||
onAdd: (item: {
|
||||
questionId: string;
|
||||
content: string;
|
||||
type: string;
|
||||
difficulty: string;
|
||||
score: number;
|
||||
textbookName: string | null;
|
||||
}) => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const [filter, setFilter] = useState<QuestionsLibraryFilter>({
|
||||
q: "",
|
||||
type: "",
|
||||
difficulty: "",
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data, loading, error } = useQuestionsLibrary(filter);
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<WorkbenchPanel title={t("build.libraryTitle")}>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("build.searchPlaceholder")}
|
||||
value={filter.q ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({ ...prev, q: e.target.value, page: 1 }))
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm"
|
||||
value={filter.type ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
type: e.target.value || null,
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
aria-label={t("build.filterType")}
|
||||
>
|
||||
<option value="">{t("build.allTypes")}</option>
|
||||
<option value="single_choice">{t("build.typeSingle")}</option>
|
||||
<option value="multiple_choice">{t("build.typeMultiple")}</option>
|
||||
<option value="fill_blank">{t("build.typeFill")}</option>
|
||||
<option value="essay">{t("build.typeEssay")}</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm"
|
||||
value={filter.difficulty ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
difficulty: e.target.value || null,
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
aria-label={t("build.filterDifficulty")}
|
||||
>
|
||||
<option value="">{t("build.allDifficulties")}</option>
|
||||
<option value="easy">{t("build.diffEasy")}</option>
|
||||
<option value="medium">{t("build.diffMedium")}</option>
|
||||
<option value="hard">{t("build.diffHard")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("build.loadingLibrary")}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{t("build.loadLibraryFailed")}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("build.libraryEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.questionId}
|
||||
className="rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm">{item.content}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="mr-2">{item.type}</span>
|
||||
<span className="mr-2">{item.difficulty}</span>
|
||||
<span>
|
||||
{item.score} {t("build.unitScore")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAdd(item)}
|
||||
aria-label={t("build.addToExam")}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("build.libraryTotal", { total: data.total })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中栏:已选题目列表(可上移/下移/编辑分值/移除)。
|
||||
*/
|
||||
function SelectedQuestionsPanel({
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
selected: ExamBuildNode[];
|
||||
onChange: (next: ExamBuildNode[]) => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
|
||||
const moveUp = (index: number): void => {
|
||||
if (index === 0) return;
|
||||
const next = [...selected];
|
||||
const a = next[index - 1];
|
||||
const b = next[index];
|
||||
if (!a || !b) return;
|
||||
next[index - 1] = b;
|
||||
next[index] = a;
|
||||
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
|
||||
};
|
||||
|
||||
const moveDown = (index: number): void => {
|
||||
if (index === selected.length - 1) return;
|
||||
const next = [...selected];
|
||||
const a = next[index];
|
||||
const b = next[index + 1];
|
||||
if (!a || !b) return;
|
||||
next[index] = b;
|
||||
next[index + 1] = a;
|
||||
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
|
||||
};
|
||||
|
||||
const remove = (questionId: string): void => {
|
||||
onChange(
|
||||
selected
|
||||
.filter((s) => s.questionId !== questionId)
|
||||
.map((node, i) => ({ ...node, sortOrder: i + 1 })),
|
||||
);
|
||||
};
|
||||
|
||||
const updateScore = (questionId: string, score: number): void => {
|
||||
onChange(
|
||||
selected.map((s) => (s.questionId === questionId ? { ...s, score } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchPanel
|
||||
title={t("build.selectedTitle")}
|
||||
actions={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("build.selectedCount", { count: selected.length })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{selected.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("build.selectedEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{selected.map((node, idx) => (
|
||||
<li
|
||||
key={node.questionId}
|
||||
className="rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 text-sm font-semibold text-muted-foreground">
|
||||
{idx + 1}.
|
||||
</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm">{node.content}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{node.type} · {node.difficulty}
|
||||
</span>
|
||||
<label className="flex items-center gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("build.scoreLabel")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={node.score}
|
||||
onChange={(e) =>
|
||||
updateScore(
|
||||
node.questionId,
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="h-7 w-16"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
aria-label={t("build.moveUp")}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveDown(idx)}
|
||||
disabled={idx === selected.length - 1}
|
||||
aria-label={t("build.moveDown")}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(node.questionId)}
|
||||
aria-label={t("build.remove")}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右栏:预览(总分、题型分布、及格分提示)。
|
||||
*/
|
||||
function PreviewPanel({
|
||||
selected,
|
||||
totalScoreBaseline,
|
||||
passScore,
|
||||
}: {
|
||||
selected: ExamBuildNode[];
|
||||
totalScoreBaseline: number;
|
||||
passScore: number;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const total = sumSelectedScores(selected);
|
||||
const typeCounts = countByType(selected);
|
||||
const diff = total - totalScoreBaseline;
|
||||
|
||||
return (
|
||||
<WorkbenchPanel title={t("build.previewTitle")}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("build.previewTotal")}
|
||||
</p>
|
||||
<p className="mt-1 text-3xl font-semibold">{total}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("build.previewBaseline", { baseline: totalScoreBaseline })}
|
||||
{diff !== 0 ? (
|
||||
<span
|
||||
className={diff > 0 ? "text-amber-600" : "text-destructive"}
|
||||
>
|
||||
{" "}
|
||||
({diff > 0 ? "+" : ""}
|
||||
{diff})
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("build.previewPassScore")}
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold">{passScore}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background p-4">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t("build.previewTypeBreakdown")}
|
||||
</p>
|
||||
{Object.keys(typeCounts).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">--</p>
|
||||
) : (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(typeCounts).map(([type, count]) => (
|
||||
<li key={type} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{type}</span>
|
||||
<span>{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 富文本试卷编辑页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - examRichEditor(examId) ❌ → MSW 兜底
|
||||
* - saveExamRichContent(input) mutation ❌ → MSW 兜底
|
||||
*
|
||||
* 布局(WorkbenchPageShell 单 center 栏):
|
||||
* - center:富文本编辑器(contentEditable + 工具栏)
|
||||
* - 工具栏:加粗/斜体/标题 H1/H2/H3/列表/插入题目占位
|
||||
* - 内容区:可编辑 div
|
||||
* - 底部:保存/预览按钮
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:WorkbenchPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useExamRichEditor, useSaveExamRichContent } from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPageSkeleton,
|
||||
WorkbenchPanel,
|
||||
} from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 富文本编辑客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ExamEditClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// @contract-pending MSW 兜底
|
||||
const { data, loading, error } = useExamRichEditor(examId);
|
||||
const saveMutation = useSaveExamRichContent();
|
||||
|
||||
// 本地 HTML 内容(从 data.content 初始化)
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 首次拿到数据时初始化 HTML 内容
|
||||
useEffect(() => {
|
||||
if (data && html === null) {
|
||||
setHtml(contentToHtml(data.content));
|
||||
}
|
||||
}, [data, html]);
|
||||
|
||||
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : !loading && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("edit.notFound")}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!data) return;
|
||||
const currentHtml = editorRef.current?.innerHTML ?? html ?? "";
|
||||
try {
|
||||
await saveMutation.run({ examId, content: currentHtml });
|
||||
notify.success(t("edit.saveSuccess"));
|
||||
} catch (err) {
|
||||
notify.error(t("edit.saveFailed", { message: String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string): void => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current.focus();
|
||||
// document.execCommand 已 deprecated 但 contentEditable 简单方案仍可用
|
||||
// 后续可换 tiptap/lexical(@contract-pending 富文本库)
|
||||
document.execCommand(command, false, value);
|
||||
setHtml(editorRef.current.innerHTML);
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title={data?.title ?? t("edit.title")}
|
||||
description={
|
||||
data
|
||||
? t("edit.subtitle", {
|
||||
total: data.totalScore,
|
||||
count: data.questionCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<FileText className="size-6" />}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saveMutation.loading || !data}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveMutation.loading ? tCommon("status.loading") : t("edit.save")}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
center={
|
||||
data && html !== null ? (
|
||||
<RichEditor
|
||||
ref={editorRef}
|
||||
html={html}
|
||||
onChange={setHtml}
|
||||
onExec={exec}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("edit.propertiesTitle")}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propExamId")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs">{data.examId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propTotalScore")}
|
||||
</p>
|
||||
<p className="mt-1 font-semibold">{data.totalScore}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propQuestionCount")}
|
||||
</p>
|
||||
<p className="mt-1 font-semibold">{data.questionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propUpdatedAt")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">{data.updatedAt}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.contractPending")}
|
||||
</p>
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本编辑器(contentEditable + 工具栏)。
|
||||
*
|
||||
* 简化实现:使用 document.execCommand(deprecated 但仍可用)。
|
||||
* 后续可升级到 tiptap/lexical(@contract-pending 富文本库契约)。
|
||||
*/
|
||||
interface RichEditorProps {
|
||||
html: string;
|
||||
onChange: (html: string) => void;
|
||||
onExec: (command: string, value?: string) => void;
|
||||
}
|
||||
|
||||
const RichEditor = forwardRef<HTMLDivElement, RichEditorProps>(
|
||||
function RichEditor({ html, onChange, onExec }, ref) {
|
||||
const t = useTranslations("exams");
|
||||
return (
|
||||
<WorkbenchPanel title={t("edit.editorTitle")}>
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap gap-1 border-b pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("bold")}
|
||||
aria-label={t("edit.toolbarBold")}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("italic")}
|
||||
aria-label={t("edit.toolbarItalic")}
|
||||
>
|
||||
<em>I</em>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("underline")}
|
||||
aria-label={t("edit.toolbarUnderline")}
|
||||
>
|
||||
<u>U</u>
|
||||
</Button>
|
||||
<span className="mx-1 border-l" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<h1>")}
|
||||
>
|
||||
H1
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<h2>")}
|
||||
>
|
||||
H2
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<h3>")}
|
||||
>
|
||||
H3
|
||||
</Button>
|
||||
<span className="mx-1 border-l" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("insertUnorderedList")}
|
||||
aria-label={t("edit.toolbarBulletList")}
|
||||
>
|
||||
•
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("insertOrderedList")}
|
||||
aria-label={t("edit.toolbarOrderedList")}
|
||||
>
|
||||
1.
|
||||
</Button>
|
||||
<span className="mx-1 border-l" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<p>")}
|
||||
>
|
||||
P
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 可编辑区域 */}
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="flex-1 overflow-y-auto rounded-md border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
|
||||
style={{ minHeight: "400px" }}
|
||||
/>
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 将 mock data.content(结构化数组)转换为 HTML 字符串。
|
||||
* 输入是 unknown(来自 MSW),输出是可编辑的 HTML。
|
||||
*/
|
||||
function contentToHtml(content: unknown): string {
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((node: Record<string, unknown>) => {
|
||||
const type = node.type as string;
|
||||
const text = (node.text as string) ?? "";
|
||||
switch (type) {
|
||||
case "heading": {
|
||||
const level = node.level as number;
|
||||
const tag = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
|
||||
return `<${tag}>${text}</${tag}>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<p>${text}</p>`;
|
||||
case "question": {
|
||||
const order = node.order as number;
|
||||
const options = (node.options as string[]) ?? [];
|
||||
const optsHtml = options.map((opt) => `<div>${opt}</div>`).join("");
|
||||
return `<div><strong>${order}. ${text}</strong>${optsHtml}</div>`;
|
||||
}
|
||||
default:
|
||||
return `<div>${text}</div>`;
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -101,3 +101,118 @@ export function formatDuration(minutes: number): string {
|
||||
const rest = minutes % 60;
|
||||
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`;
|
||||
}
|
||||
|
||||
// ── Analytics 纯函数(ARCHITECTURE.md §9.1 analytics 页 / §11.3 DoD)──
|
||||
|
||||
/**
|
||||
* 格式化分数(数值)为展示字符串,保留 1 位小数。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatScore(score: number): string {
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatPassRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数(如 0.92)格式化为百分比字符串 "92%"。
|
||||
* 与 formatPassRate 同义,语义区分:用于正确率/通过率等不同场景。
|
||||
*/
|
||||
export function formatPercent(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据正确率返回等级标签(A/B/C/D)。
|
||||
* - rate >= 0.85 → A
|
||||
* - rate >= 0.7 → B
|
||||
* - rate >= 0.6 → C
|
||||
* - 其他 → D
|
||||
*/
|
||||
export function rateToLevel(rate: number): string {
|
||||
if (!Number.isFinite(rate)) return "--";
|
||||
if (rate >= 0.85) return "A";
|
||||
if (rate >= 0.7) return "B";
|
||||
if (rate >= 0.6) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据正确率返回 Tailwind 文本语义类名。
|
||||
* - >= 0.8 → text-emerald-600(高)
|
||||
* - >= 0.6 → text-amber-600(中)
|
||||
* - 其他 → text-destructive(低)
|
||||
*/
|
||||
export function rateToColorClass(rate: number): string {
|
||||
if (!Number.isFinite(rate)) return "text-muted-foreground";
|
||||
if (rate >= 0.8) return "text-emerald-600";
|
||||
if (rate >= 0.6) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据等级(A/B/C/D)返回 Tailwind 文本语义类名。
|
||||
*/
|
||||
export function levelToColorClass(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "text-emerald-600";
|
||||
case "B":
|
||||
return "text-blue-600";
|
||||
case "C":
|
||||
return "text-amber-600";
|
||||
case "D":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build 纯函数(ARCHITECTURE.md §9.1 build 工作台页 / §11.3 DoD)──
|
||||
|
||||
/**
|
||||
* 计算已选题目总分。
|
||||
*/
|
||||
export function sumSelectedScores(items: Array<{ score: number }>): number {
|
||||
return items.reduce((sum, item) => sum + item.score, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按题量统计题型分布(如 { single_choice: 5, multiple_choice: 3 })。
|
||||
*/
|
||||
export function countByType(
|
||||
items: Array<{ type: string }>,
|
||||
): Record<string, number> {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.type] = (acc[item.type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 sortOrder 升序排序已选题目(返回新数组,不修改原数组)。
|
||||
*/
|
||||
export function sortBySortOrder<T extends { sortOrder: number }>(
|
||||
items: ReadonlyArray<T>,
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一个 sortOrder(当前最大值 + 1)。
|
||||
*/
|
||||
export function nextSortOrder(
|
||||
items: ReadonlyArray<{ sortOrder: number }>,
|
||||
): number {
|
||||
if (items.length === 0) return 1;
|
||||
return Math.max(...items.map((i) => i.sortOrder)) + 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user