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:
170
src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
Normal file
170
src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type {
|
||||
Block,
|
||||
BlockType,
|
||||
LessonPlanDocument,
|
||||
LessonPlanEdge,
|
||||
LessonPlanNode,
|
||||
} from "../types";
|
||||
import { BLOCK_TYPE_LABELS } from "../constants";
|
||||
|
||||
interface EditorState {
|
||||
planId: string;
|
||||
title: string;
|
||||
doc: LessonPlanDocument;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
lastSavedAt: number | null;
|
||||
selectedNodeId: string | null;
|
||||
|
||||
setTitle: (title: string) => void;
|
||||
setPlanId: (planId: string) => void;
|
||||
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
||||
|
||||
addNode: (type: BlockType, position?: { x: number; y: number }) => string;
|
||||
updateNode: (id: string, patch: Partial<Block>) => void;
|
||||
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
|
||||
removeNode: (id: string) => void;
|
||||
|
||||
connect: (source: string, target: string) => void;
|
||||
disconnect: (edgeId: string) => void;
|
||||
setEdges: (edges: LessonPlanEdge[]) => void;
|
||||
|
||||
selectNode: (id: string | null) => void;
|
||||
|
||||
markSaved: () => void;
|
||||
setSaving: (saving: boolean) => void;
|
||||
replaceDoc: (doc: LessonPlanDocument) => void;
|
||||
}
|
||||
|
||||
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
|
||||
return nodes.map((n, i) => ({ ...n, order: i }));
|
||||
}
|
||||
|
||||
function defaultData(type: BlockType): Block["data"] {
|
||||
return type === "exercise"
|
||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
||||
: type === "text_study"
|
||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
||||
: { html: "", knowledgePointIds: [] };
|
||||
}
|
||||
|
||||
export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
planId: "",
|
||||
title: "",
|
||||
doc: { version: 2, nodes: [], edges: [] },
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
lastSavedAt: null,
|
||||
selectedNodeId: null,
|
||||
|
||||
setTitle: (title) => set({ title, isDirty: true }),
|
||||
|
||||
setPlanId: (planId) => set({ planId }),
|
||||
|
||||
// 仅在 planId 变化时调用,避免覆盖用户编辑内容(修复 P1-3)
|
||||
hydrate: (planId, title, doc) =>
|
||||
set({
|
||||
planId,
|
||||
title,
|
||||
doc,
|
||||
isDirty: false,
|
||||
lastSavedAt: Date.now(),
|
||||
selectedNodeId: null,
|
||||
}),
|
||||
|
||||
addNode: (type, position) => {
|
||||
const id = createId();
|
||||
const nodeCount = get().doc.nodes.length;
|
||||
const node: LessonPlanNode = {
|
||||
id,
|
||||
type,
|
||||
title: BLOCK_TYPE_LABELS[type],
|
||||
data: defaultData(type),
|
||||
order: nodeCount,
|
||||
position: position ?? {
|
||||
x: 80 + (nodeCount % 4) * 280,
|
||||
y: 80 + Math.floor(nodeCount / 4) * 200,
|
||||
},
|
||||
};
|
||||
set((s) => ({
|
||||
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
|
||||
isDirty: true,
|
||||
selectedNodeId: id,
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
updateNode: (id, patch) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
nodes: s.doc.nodes.map((n) =>
|
||||
n.id === id ? { ...n, ...patch } : n,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
updateNodePosition: (id, position) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
nodes: s.doc.nodes.map((n) =>
|
||||
n.id === id ? { ...n, position } : n,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
removeNode: (id) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
nodes: reindex(s.doc.nodes.filter((n) => n.id !== id)),
|
||||
edges: s.doc.edges.filter(
|
||||
(e) => e.source !== id && e.target !== id,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
selectedNodeId:
|
||||
s.selectedNodeId === id ? null : s.selectedNodeId,
|
||||
})),
|
||||
|
||||
connect: (source, target) =>
|
||||
set((s) => {
|
||||
// 避免重复连线
|
||||
if (
|
||||
s.doc.edges.some(
|
||||
(e) => e.source === source && e.target === target,
|
||||
)
|
||||
)
|
||||
return s;
|
||||
const edge: LessonPlanEdge = {
|
||||
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
|
||||
source,
|
||||
target,
|
||||
};
|
||||
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
|
||||
}),
|
||||
|
||||
disconnect: (edgeId) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
edges: s.doc.edges.filter((e) => e.id !== edgeId),
|
||||
},
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
setEdges: (edges) => set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
|
||||
|
||||
selectNode: (id) => set({ selectedNodeId: id }),
|
||||
|
||||
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
|
||||
setSaving: (saving) => set({ isSaving: saving }),
|
||||
replaceDoc: (doc) => set({ doc, isDirty: false }),
|
||||
}));
|
||||
Reference in New Issue
Block a user