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

主要变更:

- 新增 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:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View 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 }),
}));