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 文档
231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|