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:
318
src/modules/lesson-preparation/data-access.ts
Normal file
318
src/modules/lesson-preparation/data-access.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { and, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import {
|
||||
lessonPlans,
|
||||
lessonPlanTemplates,
|
||||
textbooks,
|
||||
chapters,
|
||||
subjects,
|
||||
grades,
|
||||
users,
|
||||
} from "@/shared/db/schema";
|
||||
import type { DataScope } from "@/shared/types/permissions";
|
||||
import { SYSTEM_TEMPLATES } from "./constants";
|
||||
import type {
|
||||
LessonPlan,
|
||||
LessonPlanDocument,
|
||||
LessonPlanDocumentV1,
|
||||
LessonPlanEdge,
|
||||
LessonPlanListItem,
|
||||
LessonPlanNode,
|
||||
LessonPlanTemplate,
|
||||
TemplateBlockSkeleton,
|
||||
} from "./types";
|
||||
|
||||
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
|
||||
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
|
||||
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
|
||||
...b,
|
||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||
}));
|
||||
const edges: LessonPlanEdge[] = [];
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
edges.push({
|
||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||
source: nodes[i].id,
|
||||
target: nodes[i + 1].id,
|
||||
});
|
||||
}
|
||||
return { version: 2, nodes, edges };
|
||||
}
|
||||
|
||||
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
|
||||
export function normalizeDocument(
|
||||
content: unknown,
|
||||
): LessonPlanDocument {
|
||||
if (content && typeof content === "object") {
|
||||
const c = content as { version?: number };
|
||||
if (c.version === 2) {
|
||||
return content as LessonPlanDocument;
|
||||
}
|
||||
if (c.version === 1) {
|
||||
return migrateV1ToV2(content as LessonPlanDocumentV1);
|
||||
}
|
||||
}
|
||||
// 空文档
|
||||
return { version: 2, nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
||||
export function buildInitialContent(
|
||||
blocks: TemplateBlockSkeleton[],
|
||||
): LessonPlanDocument {
|
||||
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
|
||||
id: createId(),
|
||||
type: b.type,
|
||||
title: b.title,
|
||||
data:
|
||||
b.type === "exercise"
|
||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
||||
: b.type === "text_study"
|
||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
||||
: { html: "", knowledgePointIds: [] },
|
||||
order: i,
|
||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||
}));
|
||||
const edges: LessonPlanEdge[] = [];
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
edges.push({
|
||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||
source: nodes[i].id,
|
||||
target: nodes[i + 1].id,
|
||||
});
|
||||
}
|
||||
return { version: 2, nodes, edges };
|
||||
}
|
||||
|
||||
// ---- DataScope → 查询条件 ----
|
||||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||
switch (scope.type) {
|
||||
case "all":
|
||||
return [];
|
||||
case "owned":
|
||||
return [eq(lessonPlans.creatorId, userId)];
|
||||
case "class_taught":
|
||||
case "grade_managed":
|
||||
case "class_members":
|
||||
case "children":
|
||||
// 教师看自己创建的 + published 的
|
||||
return [
|
||||
or(
|
||||
eq(lessonPlans.creatorId, userId),
|
||||
eq(lessonPlans.status, "published"),
|
||||
)!,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 课案列表 ----
|
||||
export const getLessonPlans = cache(
|
||||
async (
|
||||
params: {
|
||||
query?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
},
|
||||
scope: DataScope,
|
||||
userId: string,
|
||||
): Promise<LessonPlanListItem[]> => {
|
||||
const conditions: SQL[] = [
|
||||
sql`${lessonPlans.status} != 'archived'`,
|
||||
];
|
||||
conditions.push(...buildScopeCondition(scope, userId));
|
||||
|
||||
if (params.query) {
|
||||
conditions.push(like(lessonPlans.title, `%${params.query}%`));
|
||||
}
|
||||
if (params.textbookId)
|
||||
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
||||
if (params.chapterId)
|
||||
conditions.push(eq(lessonPlans.chapterId, params.chapterId));
|
||||
if (params.subjectId)
|
||||
conditions.push(eq(lessonPlans.subjectId, params.subjectId));
|
||||
if (params.status) conditions.push(eq(lessonPlans.status, params.status));
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lessonPlans.id,
|
||||
title: lessonPlans.title,
|
||||
textbookId: lessonPlans.textbookId,
|
||||
chapterId: lessonPlans.chapterId,
|
||||
coursePlanItemId: lessonPlans.coursePlanItemId,
|
||||
subjectId: lessonPlans.subjectId,
|
||||
gradeId: lessonPlans.gradeId,
|
||||
templateId: lessonPlans.templateId,
|
||||
templateName: lessonPlans.templateName,
|
||||
content: lessonPlans.content,
|
||||
status: lessonPlans.status,
|
||||
creatorId: lessonPlans.creatorId,
|
||||
lastSavedAt: lessonPlans.lastSavedAt,
|
||||
createdAt: lessonPlans.createdAt,
|
||||
updatedAt: lessonPlans.updatedAt,
|
||||
textbookTitle: textbooks.title,
|
||||
chapterTitle: chapters.title,
|
||||
subjectName: subjects.name,
|
||||
gradeName: grades.name,
|
||||
creatorName: users.name,
|
||||
})
|
||||
.from(lessonPlans)
|
||||
.leftJoin(textbooks, eq(lessonPlans.textbookId, textbooks.id))
|
||||
.leftJoin(chapters, eq(lessonPlans.chapterId, chapters.id))
|
||||
.leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id))
|
||||
.leftJoin(grades, eq(lessonPlans.gradeId, grades.id))
|
||||
.leftJoin(users, eq(lessonPlans.creatorId, users.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(lessonPlans.updatedAt));
|
||||
|
||||
const items = rows as unknown as LessonPlanListItem[];
|
||||
items.forEach((it) => {
|
||||
it.content = normalizeDocument(it.content);
|
||||
});
|
||||
return items;
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 单课案 ----
|
||||
export const getLessonPlanById = cache(
|
||||
async (id: string, userId: string): Promise<LessonPlan | null> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(eq(lessonPlans.id, id))
|
||||
.limit(1);
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
// 权限:creator 可看 draft;非 creator 仅 published
|
||||
if (row.creatorId !== userId && row.status !== "published") return null;
|
||||
const plan = row as unknown as LessonPlan;
|
||||
plan.content = normalizeDocument(plan.content);
|
||||
return plan;
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 创建 ----
|
||||
export async function createLessonPlan(input: {
|
||||
title: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
gradeId?: string;
|
||||
templateId: string;
|
||||
creatorId: string;
|
||||
}): Promise<{ planId: string }> {
|
||||
const template = await getTemplateById(input.templateId);
|
||||
if (!template) throw new Error("模板不存在");
|
||||
|
||||
const planId = createId();
|
||||
const content = buildInitialContent(template.blocks);
|
||||
|
||||
await db.insert(lessonPlans).values({
|
||||
id: planId,
|
||||
title: input.title,
|
||||
textbookId: input.textbookId ?? null,
|
||||
chapterId: input.chapterId ?? null,
|
||||
subjectId: input.subjectId ?? null,
|
||||
gradeId: input.gradeId ?? null,
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
content,
|
||||
status: "draft",
|
||||
creatorId: input.creatorId,
|
||||
lastSavedAt: new Date(),
|
||||
});
|
||||
|
||||
return { planId };
|
||||
}
|
||||
|
||||
// ---- 更新 content(自动保存,不生成版本)----
|
||||
export async function updateLessonPlanContent(
|
||||
planId: string,
|
||||
userId: string,
|
||||
patch: { title?: string; content: LessonPlanDocument },
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(lessonPlans)
|
||||
.set({
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
content: patch.content,
|
||||
lastSavedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 软删除 ----
|
||||
export async function softDeleteLessonPlan(
|
||||
planId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(lessonPlans)
|
||||
.set({ status: "archived" })
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 复制 ----
|
||||
export async function duplicateLessonPlan(
|
||||
planId: string,
|
||||
userId: string,
|
||||
): Promise<{ newPlanId: string }> {
|
||||
const src = await getLessonPlanById(planId, userId);
|
||||
if (!src) throw new Error("课案不存在或无权访问");
|
||||
|
||||
const newId = createId();
|
||||
await db.insert(lessonPlans).values({
|
||||
id: newId,
|
||||
title: `${src.title} - 副本`,
|
||||
textbookId: src.textbookId,
|
||||
chapterId: src.chapterId,
|
||||
subjectId: src.subjectId,
|
||||
gradeId: src.gradeId,
|
||||
templateId: src.templateId,
|
||||
templateName: src.templateName,
|
||||
content: src.content,
|
||||
status: "draft",
|
||||
creatorId: userId,
|
||||
lastSavedAt: new Date(),
|
||||
});
|
||||
return { newPlanId: newId };
|
||||
}
|
||||
|
||||
// ---- 模板查询(内部)----
|
||||
export async function getTemplateById(
|
||||
templateId: string,
|
||||
): Promise<LessonPlanTemplate | null> {
|
||||
// 先查 system 固定模板
|
||||
const sysDef = SYSTEM_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (sysDef) {
|
||||
return {
|
||||
id: sysDef.id,
|
||||
name: sysDef.name,
|
||||
type: "system",
|
||||
scope: sysDef.scope,
|
||||
blocks: sysDef.blocks,
|
||||
creatorId: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
// 再查 DB(personal 模板)
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlanTemplates)
|
||||
.where(eq(lessonPlanTemplates.id, templateId))
|
||||
.limit(1);
|
||||
return rows.length > 0
|
||||
? (rows[0] as unknown as LessonPlanTemplate)
|
||||
: null;
|
||||
}
|
||||
Reference in New Issue
Block a user