refactor(lesson-preparation): V2 审计深度修复 — Server Actions i18n + 错误码模式 + 类型断言清零 + a11y 深度修复 + Tracker 埋点接入
V2-1: 12 个 Server Action 通过 getTranslations 翻译错误消息;Service/DataAccess 层抛出错误码异常(PublishServiceError/LessonPlanDataError),Actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息 V2-2: SYSTEM_TEMPLATES name/title 改为 i18n 键,createLessonPlan 接受 translateTitle 函数在服务端翻译后存储到 DB V2-3: 8 处 as unknown as 断言替换为显式类型映射函数(mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate/mapRowToVersion)+ 类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope) V2-4: MiniMap nodeColor 复用 lib/node-summary.ts 的 getNodeColor V2-5: a11y 深度修复 — lesson-plan-filters/exercise-block/inline-question-editor 的 select 添加 label htmlFor 关联;exercise-block 题目列表改为 ul/li;node-editor 画布添加 role=application + 键盘导航配置 V2-6: Tracker 埋点接入 — 新增 useLessonPlanTrackerSafe hook,在 create/save/publish/revert/duplicate/archive 6 处调用 tracker.track 同步更新架构图 004 和 005 文档
This commit is contained in:
@@ -52,8 +52,12 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<label htmlFor={`exercise-purpose-${blockId}`} className="text-sm font-medium">
|
||||
{t("exercise.purposeLabel")}
|
||||
</label>
|
||||
<select
|
||||
id={`exercise-purpose-${blockId}`}
|
||||
value={data.purpose}
|
||||
onChange={(e) =>
|
||||
update({ purpose: e.target.value as ExercisePurpose })
|
||||
@@ -69,9 +73,9 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
{t("questionBank.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<ul className="space-y-1 list-none p-0" role="list">
|
||||
{data.items.map((item, idx) => (
|
||||
<div
|
||||
<li
|
||||
key={item.questionId}
|
||||
className="flex items-center gap-2 border rounded p-2"
|
||||
>
|
||||
@@ -87,9 +91,9 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
<button onClick={() => removeItem(idx)} aria-label={t("action.delete")}>
|
||||
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
|
||||
@@ -79,8 +79,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("questionBank.typeLabel")}</label>
|
||||
<label htmlFor="inline-question-type" className="text-sm font-medium">
|
||||
{t("questionBank.typeLabel")}
|
||||
</label>
|
||||
<select
|
||||
id="inline-question-type"
|
||||
value={type}
|
||||
onChange={(e) => {
|
||||
if (isQuestionType(e.target.value)) {
|
||||
@@ -95,8 +98,9 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
|
||||
<label htmlFor="inline-question-stem" className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
|
||||
<textarea
|
||||
id="inline-question-stem"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
|
||||
@@ -170,8 +174,9 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
|
||||
<label htmlFor="inline-question-difficulty" className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
|
||||
<select
|
||||
id="inline-question-difficulty"
|
||||
value={difficulty}
|
||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { formatDateTime } from "@/shared/lib/utils";
|
||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||
import { useLessonPlanContextSafe, useRoleConfig } from "../providers/lesson-plan-provider";
|
||||
import { useLessonPlanContextSafe, useRoleConfig, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const router = useRouter();
|
||||
const roleConfig = useRoleConfig();
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
|
||||
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
@@ -35,6 +36,7 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||
toast.success(t("status.archived"));
|
||||
router.refresh();
|
||||
} else {
|
||||
@@ -46,7 +48,10 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) router.refresh();
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
saveLessonPlanVersionAction,
|
||||
getLessonPlanByIdAction,
|
||||
} from "../actions";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import type { BlockType } from "../types";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Save, History } from "lucide-react";
|
||||
@@ -49,6 +50,7 @@ export function LessonPlanEditor({
|
||||
}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const editor = useLessonPlanEditor();
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -130,8 +132,11 @@ export function LessonPlanEditor({
|
||||
content: state.doc,
|
||||
});
|
||||
state.setSaving(false);
|
||||
if (res.success) state.markSaved();
|
||||
}, []);
|
||||
if (res.success) {
|
||||
state.markSaved();
|
||||
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||
}
|
||||
}, [tracker]);
|
||||
|
||||
// 版本回退后刷新内容(修复 P1-1)
|
||||
const handleReverted = useCallback(async () => {
|
||||
|
||||
@@ -30,14 +30,22 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
||||
}, [debouncedQuery, subjectId, status, onFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<label htmlFor="lesson-plan-search" className="sr-only">
|
||||
{t("filters.searchPlaceholder")}
|
||||
</label>
|
||||
<input
|
||||
id="lesson-plan-search"
|
||||
placeholder={t("filters.searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<label htmlFor="lesson-plan-subject" className="sr-only">
|
||||
{t("filters.allSubjects")}
|
||||
</label>
|
||||
<select
|
||||
id="lesson-plan-subject"
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
@@ -49,7 +57,11 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor="lesson-plan-status" className="sr-only">
|
||||
{t("filters.allStatus")}
|
||||
</label>
|
||||
<select
|
||||
id="lesson-plan-status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
|
||||
@@ -19,6 +19,7 @@ import "@xyflow/react/dist/style.css";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { LessonNode } from "./nodes/lesson-node";
|
||||
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||
import { getNodeColor } from "../lib/node-summary";
|
||||
import type { LessonPlanNode } from "../types";
|
||||
|
||||
const nodeTypes = { lesson: LessonNode };
|
||||
@@ -87,7 +88,7 @@ export function NodeEditor({}: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<div className="w-full h-full relative" role="application" aria-label={t("editor.canvasLabel")}>
|
||||
{doc.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-center text-on-surface-variant">
|
||||
@@ -107,6 +108,12 @@ export function NodeEditor({}: Props) {
|
||||
onPaneClick={() => selectNode(null)}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
|
||||
nodesFocusable
|
||||
nodesDraggable
|
||||
edgesFocusable
|
||||
elementsSelectable
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
|
||||
defaultEdgeOptions={{
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
@@ -126,21 +133,7 @@ export function NodeEditor({}: Props) {
|
||||
nodeColor={(n) => {
|
||||
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
||||
if (!nodeData) return "#9e9e9e";
|
||||
const colors: Record<string, string> = {
|
||||
objective: "#4caf50",
|
||||
key_point: "#f44336",
|
||||
import: "#2196f3",
|
||||
new_teaching: "#9c27b0",
|
||||
consolidation: "#ff9800",
|
||||
summary: "#607d8b",
|
||||
homework: "#795548",
|
||||
blackboard: "#009688",
|
||||
text_study: "#3f51b5",
|
||||
exercise: "#e91e63",
|
||||
rich_text: "#9e9e9e",
|
||||
reflection: "#cddc39",
|
||||
};
|
||||
return colors[nodeData.type] ?? "#9e9e9e";
|
||||
return getNodeColor(nodeData.type);
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
@@ -6,10 +6,12 @@ import { createLessonPlanAction } from "../actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { SYSTEM_TEMPLATES } from "../constants";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
|
||||
export function TemplatePicker() {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const router = useRouter();
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -20,6 +22,7 @@ export function TemplatePicker() {
|
||||
formData.set("title", title);
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
if (res.success && res.data) {
|
||||
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
} else {
|
||||
setError(res.message ?? t("error.createFailed"));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
} from "../actions";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -36,6 +37,7 @@ export function VersionHistoryDrawer({
|
||||
onReverted,
|
||||
}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -60,6 +62,7 @@ export function VersionHistoryDrawer({
|
||||
async function handleRevert(versionNo: number) {
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
onReverted();
|
||||
onClose();
|
||||
|
||||
Reference in New Issue
Block a user