feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
234
src/modules/lesson-preparation/components/lesson-plan-editor.tsx
Normal file
234
src/modules/lesson-preparation/components/lesson-plan-editor.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 { BLOCK_TYPE_LABELS } from "../constants";
|
||||
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 editor = useLessonPlanEditor();
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
const [panelOpen, setPanelOpen] = 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]);
|
||||
|
||||
// 选中节点时打开侧边面板
|
||||
useEffect(() => {
|
||||
if (editor.selectedNodeId) setPanelOpen(true);
|
||||
}, [editor.selectedNodeId]);
|
||||
|
||||
// 自动保存(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: "自动版本",
|
||||
});
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
// 离开未保存提示(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();
|
||||
}, []);
|
||||
|
||||
// 版本回退后刷新内容(修复 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
|
||||
? "保存中..."
|
||||
: editor.isDirty
|
||||
? "未保存"
|
||||
: "已保存"}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowVersions(true)}
|
||||
>
|
||||
<History className="w-4 h-4 mr-1" /> 版本
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
||||
<Save className="w-4 h-4 mr-1" /> 保存版本
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 主区域:画布 + 侧边面板 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 节点画布 */}
|
||||
<div className="flex-1 relative">
|
||||
<NodeEditor
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
{/* 添加节点浮动按钮 */}
|
||||
<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" /> 添加节点
|
||||
</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((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
editor.addNode(t);
|
||||
setShowAddMenu(false);
|
||||
}}
|
||||
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||
>
|
||||
{BLOCK_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边内容编辑面板 */}
|
||||
{panelOpen && 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user