Files
NextEdu/src/modules/lesson-preparation/components/lesson-plan-editor.tsx
SpecialX 97e59b95a1 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 文档
2026-06-22 18:45:35 +08:00

231 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
import { NodeEditPanel } from "./node-edit-panel";
import { VersionHistoryDrawer } from "./version-history-drawer";
import {
updateLessonPlanAction,
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";
interface Props {
planId: string;
initialTitle: string;
initialDoc: import("../types").LessonPlanDocument;
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}
const BLOCK_TYPES_TO_ADD: BlockType[] = [
"objective",
"key_point",
"import",
"new_teaching",
"consolidation",
"summary",
"homework",
"blackboard",
"exercise",
"text_study",
"rich_text",
"reflection",
];
export function LessonPlanEditor({
planId,
initialTitle,
initialDoc,
textbookId,
chapterId,
classes,
}: 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);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const addMenuRef = useRef<HTMLDivElement>(null);
// 初始化:仅在 planId 变化时 hydrate修复 P1-3
const initKey = planId;
useEffect(() => {
useLessonPlanEditor.getState().hydrate(planId, initialTitle, initialDoc);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initKey]);
// 自动保存debounce 3s- 用 getState() 获取最新值(修复 P1-4
useEffect(() => {
if (!editor.isDirty) return;
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
state.setSaving(true);
const res = await updateLessonPlanAction({
planId: state.planId,
title: state.title,
content: state.doc,
});
state.setSaving(false);
if (res.success) state.markSaved();
}, 3000);
return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
};
}, [editor.isDirty, editor.doc, planId]);
// 定时自动版本30min
useEffect(() => {
versionTimer.current = setInterval(async () => {
const state = useLessonPlanEditor.getState();
if (!state.isDirty) return;
await saveLessonPlanVersionAction({
planId: state.planId,
content: state.doc,
label: t("version.autoLabel"),
});
}, 30 * 60 * 1000);
return () => {
if (versionTimer.current) clearInterval(versionTimer.current);
};
}, [planId, t]);
// 离开未保存提示P3-1
useEffect(() => {
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (useLessonPlanEditor.getState().isDirty) {
e.preventDefault();
e.returnValue = "";
}
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
// 添加节点菜单点击外部关闭P3-2
useEffect(() => {
if (!showAddMenu) return;
function handleClickOutside(e: MouseEvent) {
if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) {
setShowAddMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddMenu]);
const handleManualSave = useCallback(async () => {
const state = useLessonPlanEditor.getState();
state.setSaving(true);
const res = await saveLessonPlanVersionAction({
planId: state.planId,
content: state.doc,
});
state.setSaving(false);
if (res.success) {
state.markSaved();
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
}
}, [tracker]);
// 版本回退后刷新内容(修复 P1-1
const handleReverted = useCallback(async () => {
const state = useLessonPlanEditor.getState();
const res = await getLessonPlanByIdAction(state.planId);
if (res.success && res.data?.plan) {
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
}
}, []);
return (
<div className="flex flex-col h-full">
{/* 顶部工具栏 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
<input
value={editor.title}
onChange={(e) => editor.setTitle(e.target.value)}
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
/>
<span className="text-on-surface-variant text-sm">
{editor.isSaving
? t("status.saving")
: editor.isDirty
? t("status.unsaved")
: t("status.saved")}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowVersions(true)}
>
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
</Button>
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
</Button>
</div>
{/* 主区域:画布 + 侧边面板 */}
<div className="flex-1 flex overflow-hidden">
{/* 节点画布 */}
<div className="flex-1 relative">
<NodeEditor />
{/* 添加节点浮动按钮 */}
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
<Button
variant="default"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<Plus className="w-4 h-4 mr-1" /> {t("action.addNode")}
</Button>
{showAddMenu && (
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
{BLOCK_TYPES_TO_ADD.map((blockType) => (
<button
key={blockType}
onClick={() => {
editor.addNode(blockType, undefined, t(`blockType.${blockType}`));
setShowAddMenu(false);
}}
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
>
{t(`blockType.${blockType}`)}
</button>
))}
</div>
)}
</div>
</div>
{/* 侧边内容编辑面板:直接用 selectedNodeId 控制显示(修复 P1-2 */}
{editor.selectedNodeId && (
<div className="w-[420px] flex-shrink-0">
<NodeEditPanel
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
/>
</div>
)}
</div>
<VersionHistoryDrawer
open={showVersions}
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
/>
</div>
);
}