diff --git a/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx b/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx index 323a1ff..98071c7 100644 --- a/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx +++ b/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx @@ -8,6 +8,7 @@ import { Button } from "@/shared/components/ui/button"; import { generateLessonPlanFeedbackAction } from "../actions-ai"; import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback"; import type { LessonPlanDocument } from "../types"; +import { isAiFeedbackCategory } from "../lib/type-guards"; interface Props { doc: LessonPlanDocument; @@ -134,7 +135,7 @@ function FeedbackContent({ {/* 各维度反馈 */} - {(Object.keys(grouped) as AiFeedbackItem["category"][]).map((cat) => { + {Object.keys(grouped).filter(isAiFeedbackCategory).map((cat) => { const items = grouped[cat]; if (items.length === 0) return null; return ( diff --git a/src/modules/lesson-preparation/components/attachment-picker.tsx b/src/modules/lesson-preparation/components/attachment-picker.tsx index f47a289..3ffd2e3 100644 --- a/src/modules/lesson-preparation/components/attachment-picker.tsx +++ b/src/modules/lesson-preparation/components/attachment-picker.tsx @@ -9,7 +9,7 @@ import { useFileUpload } from "@/modules/files/hooks/use-file-upload"; import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider"; import type { LessonPlanAttachmentOption } from "../providers/lesson-plan-provider"; import type { RichTextAttachment } from "../types"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; interface Props { planId: string; @@ -85,11 +85,11 @@ export function AttachmentPicker({ attachmentType: "material", }); if (res.success) { - toast.success(t("attachment.createSuccess")); + notify.success(t("attachment.createSuccess")); // 刷新附件列表 void loadAttachments(); } else { - toast.error(res.message ?? t("error.save")); + notify.error(res.message ?? t("error.save")); } }, }); @@ -105,7 +105,7 @@ export function AttachmentPicker({ } } catch (e) { console.error("[AttachmentPicker] load failed", e); - toast.error(t("attachment.empty")); + notify.error(t("attachment.empty")); } finally { setLoading(false); } @@ -130,14 +130,14 @@ export function AttachmentPicker({ try { const res = await service.deleteLessonPlanAttachment(attachmentId); if (res.success) { - toast.success(t("attachment.deleteSuccess")); + notify.success(t("attachment.deleteSuccess")); void loadAttachments(); } else { - toast.error(res.message ?? t("error.save")); + notify.error(res.message ?? t("error.save")); } } catch (e) { console.error("[AttachmentPicker] delete failed", e); - toast.error(t("error.save")); + notify.error(t("error.save")); } } diff --git a/src/modules/lesson-preparation/components/blocks/text-study-block.tsx b/src/modules/lesson-preparation/components/blocks/text-study-block.tsx index 31ac49b..322ede1 100644 --- a/src/modules/lesson-preparation/components/blocks/text-study-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/text-study-block.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; import { Button } from "@/shared/components/ui/button"; import { Plus, Trash2 } from "lucide-react"; @@ -38,7 +38,7 @@ export function TextStudyBlock({ blockId, data }: Props) { function addAnnotation() { if (!selection) { - toast.error(t("textStudy.selectFirst")); + notify.error(t("textStudy.selectFirst")); return; } const ann: TextStudyAnnotation = { diff --git a/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx b/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx index 04aad8f..f37925e 100644 --- a/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx +++ b/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx @@ -7,7 +7,8 @@ import { DetailHead } from "./detail-head"; import { DetailProps } from "./detail-props"; import { QaEditor } from "./qa-editor"; import { BlockRenderer } from "../../config/block-registry"; -import type { InteractionBlockData, LessonPlanNode } from "../../types"; +import type { LessonPlanNode } from "../../types"; +import { isInteractionBlockData } from "../../lib/type-guards"; export function DetailPanel() { const t = useTranslations("lessonPreparation"); @@ -58,8 +59,11 @@ export function DetailPanel() {
- {isInteraction ? ( - + {isInteraction && isInteractionBlockData(node.data) ? ( + + ) : isInteraction ? ( + // 类型守卫校验失败,数据异常时渲染 null + null ) : ( updateNode(node.id, { stage: v as TeachingStage })} + onChange={(v) => { + if (isTeachingStage(v)) updateNode(node.id, { stage: v }); + }} /> updateNode(node.id, { differentiation: v as DifferentiationLevel })} + onChange={(v) => { + if (isDifferentiationLevel(v)) updateNode(node.id, { differentiation: v }); + }} />
); diff --git a/src/modules/lesson-preparation/components/inline-question-editor.tsx b/src/modules/lesson-preparation/components/inline-question-editor.tsx index 65c0fc3..8cac1ba 100644 --- a/src/modules/lesson-preparation/components/inline-question-editor.tsx +++ b/src/modules/lesson-preparation/components/inline-question-editor.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { createId } from "@paralleldrive/cuid2"; import { Button } from "@/shared/components/ui/button"; import { FocusTrap } from "@/shared/components/a11y/focus-trap"; @@ -43,7 +43,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }: function handleAdd() { if (!text.trim()) { - toast.error(t("questionBank.stemRequired")); + notify.error(t("questionBank.stemRequired")); return; } const content: Record = diff --git a/src/modules/lesson-preparation/components/lesson-plan-card.tsx b/src/modules/lesson-preparation/components/lesson-plan-card.tsx index 22f8f76..9589300 100644 --- a/src/modules/lesson-preparation/components/lesson-plan-card.tsx +++ b/src/modules/lesson-preparation/components/lesson-plan-card.tsx @@ -4,7 +4,7 @@ import { useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { Button } from "@/shared/components/ui/button"; import { AlertDialog, @@ -77,14 +77,14 @@ export function LessonPlanCard({ const res = await service.deleteLessonPlan(plan.id); if (res.success) { tracker.track("lesson_plan.archive", { planId: plan.id }); - toast.success(t("status.archived")); + notify.success(t("status.archived")); router.refresh(); } else { - toast.error(res.message ?? t("error.delete")); + notify.error(res.message ?? t("error.delete")); } } catch (e) { console.error("[LessonPlanCard] archive failed", e); - toast.error(t("error.delete")); + notify.error(t("error.delete")); } }, [service, plan.id, tracker, t, router]); @@ -96,11 +96,11 @@ export function LessonPlanCard({ tracker.track("lesson_plan.duplicate", { planId: plan.id }); router.refresh(); } else { - toast.error(res.message ?? t("error.duplicate")); + notify.error(res.message ?? t("error.duplicate")); } } catch (e) { console.error("[LessonPlanCard] duplicate failed", e); - toast.error(t("error.duplicate")); + notify.error(t("error.duplicate")); } }, [service, plan.id, tracker, t, router]); @@ -110,14 +110,14 @@ export function LessonPlanCard({ const res = await service.publishLessonPlan(plan.id); if (res.success) { tracker.track("lesson_plan.publish", { planId: plan.id }); - toast.success(res.message ?? t("action.publishPlanSuccess")); + notify.success(res.message ?? t("action.publishPlanSuccess")); router.refresh(); } else { - toast.error(res.message ?? t("error.save")); + notify.error(res.message ?? t("error.save")); } } catch (e) { console.error("[LessonPlanCard] publish failed", e); - toast.error(t("error.save")); + notify.error(t("error.save")); } }, [service, plan.id, tracker, t, router]); @@ -127,14 +127,14 @@ export function LessonPlanCard({ const res = await service.unpublishLessonPlan(plan.id); if (res.success) { tracker.track("lesson_plan.unpublish", { planId: plan.id }); - toast.success(res.message ?? t("action.unpublishPlanSuccess")); + notify.success(res.message ?? t("action.unpublishPlanSuccess")); router.refresh(); } else { - toast.error(res.message ?? t("error.save")); + notify.error(res.message ?? t("error.save")); } } catch (e) { console.error("[LessonPlanCard] unpublish failed", e); - toast.error(t("error.save")); + notify.error(t("error.save")); } }, [service, plan.id, tracker, t, router]); diff --git a/src/modules/lesson-preparation/components/lesson-plan-editor.tsx b/src/modules/lesson-preparation/components/lesson-plan-editor.tsx index 65370cf..ad8114e 100644 --- a/src/modules/lesson-preparation/components/lesson-plan-editor.tsx +++ b/src/modules/lesson-preparation/components/lesson-plan-editor.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { JSX } from "react"; import { useTranslations } from "next-intl"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor"; import { useLessonPlanPersistence } from "../hooks/use-lesson-plan-persistence"; import { StructureTree } from "./structure-tree/structure-tree"; @@ -85,11 +85,11 @@ export function LessonPlanEditor({ useEffect(() => { function handleOnline() { useLessonPlanEditor.getState().setOnline(true); - toast.success(t("status.backOnline")); + notify.success(t("status.backOnline")); } function handleOffline() { useLessonPlanEditor.getState().setOnline(false); - toast.error(t("status.offline"), { description: t("status.offlineHint") }); + notify.error(t("status.offline"), { description: t("status.offlineHint") }); } window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); @@ -164,13 +164,13 @@ export function LessonPlanEditor({ if (res.success) { setPlanStatus("published"); tracker.track("lesson_plan.publish", { planId }); - toast.success(res.message ?? t("action.publishPlanSuccess")); + notify.success(res.message ?? t("action.publishPlanSuccess")); } else { - toast.error(res.message ?? t("error.save")); + notify.error(res.message ?? t("error.save")); } } catch (e) { console.error("[LessonPlanEditor] publish failed", e); - toast.error(t("error.save")); + notify.error(t("error.save")); } finally { setPublishing(false); } @@ -185,13 +185,13 @@ export function LessonPlanEditor({ if (res.success) { setPlanStatus("draft"); tracker.track("lesson_plan.unpublish", { planId }); - toast.success(res.message ?? t("action.unpublishPlanSuccess")); + notify.success(res.message ?? t("action.unpublishPlanSuccess")); } else { - toast.error(res.message ?? t("error.save")); + notify.error(res.message ?? t("error.save")); } } catch (e) { console.error("[LessonPlanEditor] unpublish failed", e); - toast.error(t("error.save")); + notify.error(t("error.save")); } finally { setPublishing(false); } diff --git a/src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx b/src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx index 0194de5..3760cd9 100644 --- a/src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx +++ b/src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx @@ -6,7 +6,6 @@ import { Book } from "lucide-react"; import type { LessonPlanDocument, LessonPlanNode, - TeachingStage, } from "../types"; import { TEACHING_STAGE_KEYS } from "../types"; import { getNodeColor, getNodeSummary } from "../lib/node-summary"; @@ -37,7 +36,8 @@ export function LessonPlanMobileView({ doc, textbookTitle, chapterTitle }: Props const unstaged: LessonPlanNode[] = []; for (const n of teachingNodes) { if (n.stage) { - const key = n.stage as TeachingStage; + // n.stage 已通过 if 真值检查窄化为 TeachingStage(排除 undefined) + const key = n.stage; if (!groups[key]) groups[key] = []; groups[key].push(n); } else { diff --git a/src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx b/src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx index f1349e5..680ca89 100644 --- a/src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx +++ b/src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx @@ -1,8 +1,9 @@ "use client"; import { useTranslations } from "next-intl"; -import type { InteractionBlockData, LessonPlanNode, QATurn } from "../../types"; +import type { LessonPlanNode, QATurn } from "../../types"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; +import { isInteractionBlockData } from "../../lib/type-guards"; interface Props { node: LessonPlanNode; @@ -17,7 +18,9 @@ interface Props { export function InlineQaDialog({ node }: Props) { const t = useTranslations("lessonPreparation"); const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand); - const data = node.data as InteractionBlockData; + // 类型守卫校验 node.data 为 InteractionBlockData,失败返回 null + if (!isInteractionBlockData(node.data)) return null; + const data = node.data; return (
diff --git a/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx index e535ad1..84440b7 100644 --- a/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx +++ b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx @@ -3,6 +3,7 @@ import { useTranslations } from "next-intl"; import type { LessonPlanNode } from "../../types"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; +import { notify } from "@/shared/lib/notify"; export interface ContextMenuState { visible: boolean; @@ -56,11 +57,10 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) { const copyNode = async (nodeId: string) => { const newId = duplicateNode(nodeId); - const { toast } = await import("sonner"); if (newId) { - toast.success(t("v4.contextMenu.copied")); + notify.success(t("v4.contextMenu.copied")); } else { - toast.error(t("v4.contextMenu.copyFailed")); + notify.error(t("v4.contextMenu.copyFailed")); } }; @@ -69,7 +69,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) { if (onAiAction) { onAiAction(action, nodeId); } else { - void import("sonner").then(({ toast }) => toast.info(t("v4.contextMenu.aiComingSoon"))); + notify.info(t("v4.contextMenu.aiComingSoon")); } }; diff --git a/src/modules/lesson-preparation/components/schedule-dialog.tsx b/src/modules/lesson-preparation/components/schedule-dialog.tsx index 0cda6dd..62605f1 100644 --- a/src/modules/lesson-preparation/components/schedule-dialog.tsx +++ b/src/modules/lesson-preparation/components/schedule-dialog.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { X, Calendar, Trash2, Plus } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { FocusTrap } from "@/shared/components/a11y/focus-trap"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { getLessonPlanSchedulesAction, createLessonPlanScheduleAction, @@ -57,7 +57,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props) } } catch (e) { console.error("[ScheduleDialog] load failed", e); - toast.error(t("error.getOne")); + notify.error(t("error.getOne")); } finally { setLoading(false); } @@ -95,7 +95,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props) durationMin, }); if (res.success) { - toast.success(t("schedule.addSuccess")); + notify.success(t("schedule.addSuccess")); void loadSchedules(); onScheduled?.(); } else { @@ -113,15 +113,15 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props) try { const res = await deleteLessonPlanScheduleAction(id); if (res.success) { - toast.success(t("schedule.deleteSuccess")); + notify.success(t("schedule.deleteSuccess")); void loadSchedules(); onScheduled?.(); } else { - toast.error(res.message ?? t("error.save")); + notify.error(res.message ?? t("error.save")); } } catch (e) { console.error("[ScheduleDialog] delete failed", e); - toast.error(t("error.save")); + notify.error(t("error.save")); } } diff --git a/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx b/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx index 39e54d4..79433e3 100644 --- a/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx +++ b/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx @@ -4,7 +4,8 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; import { TreeNodeRow } from "./tree-node-row"; -import type { Block, InteractionBlockData, LessonPlanNode, TextbookContentNode } from "../../types"; +import type { LessonPlanNode, TextbookContentNode } from "../../types"; +import { isInteractionBlockData } from "../../lib/type-guards"; export function StructureTree() { const t = useTranslations("lessonPreparation"); @@ -69,7 +70,7 @@ export function StructureTree() { {/* 正文节点(始终在最上) */} {textbookNode && ( )} @@ -82,8 +83,8 @@ export function StructureTree() { // 师生交互节点:子节点为对话轮次 let childItems: { id: string; label: string; kind: string }[] = []; let hasChildren = false; - if (node.type === "interaction") { - const data = node.data as InteractionBlockData; + if (node.type === "interaction" && isInteractionBlockData(node.data)) { + const data = node.data; hasChildren = (data.turns?.length ?? 0) > 0; childItems = (data.turns ?? []).map((turn, idx) => ({ id: turn.id, diff --git a/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx b/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx index b0804dd..e31b960 100644 --- a/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx +++ b/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx @@ -1,11 +1,11 @@ "use client"; import { useTranslations } from "next-intl"; -import type { Block } from "../../types"; +import type { AnyLessonPlanNode } from "../../types"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; interface Props { - node: Block; + node: AnyLessonPlanNode; /** 是否展开到正文 */ isExpanded: boolean; /** 是否有子节点(如师生交互的对话轮次) */ diff --git a/src/modules/lesson-preparation/components/version-history-drawer.tsx b/src/modules/lesson-preparation/components/version-history-drawer.tsx index b6b73b2..b5d2a82 100644 --- a/src/modules/lesson-preparation/components/version-history-drawer.tsx +++ b/src/modules/lesson-preparation/components/version-history-drawer.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; import { Button } from "@/shared/components/ui/button"; import { FocusTrap } from "@/shared/components/a11y/focus-trap"; @@ -85,15 +85,15 @@ export function VersionHistoryDrawer({ const res = await service.revertLessonPlanVersion({ planId, versionNo }); if (res.success) { tracker.track("lesson_plan.revert", { planId, versionNo }); - toast.success(t("version.revertSuccess", { versionNo })); + notify.success(t("version.revertSuccess", { versionNo })); onReverted(); onClose(); } else { - toast.error(res.message ?? t("error.revert")); + notify.error(res.message ?? t("error.revert")); } } catch (e) { console.error("[VersionHistoryDrawer] revert failed", e); - toast.error(t("error.revert")); + notify.error(t("error.revert")); } } diff --git a/src/modules/lesson-preparation/config/block-registry.tsx b/src/modules/lesson-preparation/config/block-registry.tsx index 35e48a8..9c9bc49 100644 --- a/src/modules/lesson-preparation/config/block-registry.tsx +++ b/src/modules/lesson-preparation/config/block-registry.tsx @@ -31,7 +31,6 @@ import { HomeworkBlock } from "../components/blocks/homework-block"; import { BlackboardBlock } from "../components/blocks/blackboard-block"; import { ReflectionBlock } from "../components/blocks/reflection-block"; import { InteractionBlock } from "../components/blocks/interaction-block"; -import type { InteractionBlockData } from "../types"; /** * Block 注册表:配置驱动渲染。 @@ -208,7 +207,7 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re rest.onUpdate(d as InteractionBlockData)} + onUpdate={(d) => rest.onUpdate(d)} /> ); } diff --git a/src/modules/lesson-preparation/data-access-ai-evaluation.ts b/src/modules/lesson-preparation/data-access-ai-evaluation.ts index 60d714a..75c842d 100644 --- a/src/modules/lesson-preparation/data-access-ai-evaluation.ts +++ b/src/modules/lesson-preparation/data-access-ai-evaluation.ts @@ -11,6 +11,7 @@ import { lessonPlanAiEvaluations } from "@/shared/db/schema"; import { eq, desc } from "drizzle-orm"; import { cacheFn } from "@/shared/lib/cache"; import type { LessonPlanDocument } from "./types"; +import { isStringArray } from "./lib/type-guards"; /** 单次评估结果 */ export interface AiEvaluation { @@ -34,6 +35,19 @@ export interface DimensionScores { assessment: number; } +/** 类型守卫:判断 unknown 是否为合法的 DimensionScores(用于 DB JSON 字段安全收窄) */ +function isDimensionScores(v: unknown): v is DimensionScores { + if (typeof v !== "object" || v === null) return false; + const d = v as Record; + return ( + typeof d.clarity === "number" && + typeof d.alignment === "number" && + typeof d.engagement === "number" && + typeof d.differentiation === "number" && + typeof d.assessment === "number" + ); +} + /** * 查询某课案的所有 AI 评估记录 */ @@ -41,7 +55,17 @@ export async function getEvaluationsByPlanIdRaw( planId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanAiEvaluations.id, + planId: lessonPlanAiEvaluations.planId, + versionNo: lessonPlanAiEvaluations.versionNo, + overallScore: lessonPlanAiEvaluations.overallScore, + dimensionScores: lessonPlanAiEvaluations.dimensionScores, + suggestions: lessonPlanAiEvaluations.suggestions, + recommendedStandardIds: lessonPlanAiEvaluations.recommendedStandardIds, + evaluatedBy: lessonPlanAiEvaluations.evaluatedBy, + createdAt: lessonPlanAiEvaluations.createdAt, + }) .from(lessonPlanAiEvaluations) .where(eq(lessonPlanAiEvaluations.planId, planId)) .orderBy(desc(lessonPlanAiEvaluations.createdAt)); @@ -60,7 +84,17 @@ export async function getLatestEvaluationRaw( planId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanAiEvaluations.id, + planId: lessonPlanAiEvaluations.planId, + versionNo: lessonPlanAiEvaluations.versionNo, + overallScore: lessonPlanAiEvaluations.overallScore, + dimensionScores: lessonPlanAiEvaluations.dimensionScores, + suggestions: lessonPlanAiEvaluations.suggestions, + recommendedStandardIds: lessonPlanAiEvaluations.recommendedStandardIds, + evaluatedBy: lessonPlanAiEvaluations.evaluatedBy, + createdAt: lessonPlanAiEvaluations.createdAt, + }) .from(lessonPlanAiEvaluations) .where(eq(lessonPlanAiEvaluations.planId, planId)) .orderBy(desc(lessonPlanAiEvaluations.createdAt)) @@ -180,15 +214,21 @@ export function evaluateDocument( function mapRowToEvaluation( row: typeof lessonPlanAiEvaluations.$inferSelect, ): AiEvaluation { + // DB JSON 字段为 unknown,用类型守卫安全收窄 + const dimensionScores = isDimensionScores(row.dimensionScores) + ? row.dimensionScores + : null; + const recommendedStandardIds = isStringArray(row.recommendedStandardIds) + ? row.recommendedStandardIds + : null; return { id: row.id, planId: row.planId, versionNo: row.versionNo, overallScore: row.overallScore, - dimensionScores: (row.dimensionScores as DimensionScores) ?? null, + dimensionScores, suggestions: row.suggestions, - recommendedStandardIds: - (row.recommendedStandardIds as string[] | null) ?? null, + recommendedStandardIds, evaluatedBy: row.evaluatedBy, createdAt: row.createdAt, }; diff --git a/src/modules/lesson-preparation/data-access-analytics.ts b/src/modules/lesson-preparation/data-access-analytics.ts index c1b94b3..d5a1cb5 100644 --- a/src/modules/lesson-preparation/data-access-analytics.ts +++ b/src/modules/lesson-preparation/data-access-analytics.ts @@ -16,7 +16,6 @@ import { lessonPlanStandards, } from "@/shared/db/schema"; import { and, eq, gte, lte, desc, sql, count } from "drizzle-orm"; -import type { LessonPlanStatus } from "./types"; /** 教师备课投入数据点 */ export interface TeacherInvestmentDataPoint { @@ -61,7 +60,16 @@ export async function getTeacherInvestmentRaw( if (teacherId) conditions.push(eq(lessonPlanAnalyticsDaily.teacherId, teacherId)); const rows = await db - .select() + .select({ + snapshotDate: lessonPlanAnalyticsDaily.snapshotDate, + teacherId: lessonPlanAnalyticsDaily.teacherId, + newPlansCount: lessonPlanAnalyticsDaily.newPlansCount, + editDurationMin: lessonPlanAnalyticsDaily.editDurationMin, + savedVersionsCount: lessonPlanAnalyticsDaily.savedVersionsCount, + submittedCount: lessonPlanAnalyticsDaily.submittedCount, + publishedCount: lessonPlanAnalyticsDaily.publishedCount, + standardsLinked: lessonPlanAnalyticsDaily.standardsLinked, + }) .from(lessonPlanAnalyticsDaily) .where(and(...conditions)) .orderBy(desc(lessonPlanAnalyticsDaily.snapshotDate)); @@ -96,7 +104,7 @@ export async function getTemplateUsageStatsRaw(): Promise ({ - templateId: r.templateId!, + templateId: r.templateId ?? "", templateName: r.templateName ?? "(unknown)", usageCount: r.usageCount, })); @@ -175,11 +183,11 @@ export async function getGlobalLessonPlanStatsRaw(): Promise<{ const publishedRows = await db .select({ count: count() }) .from(lessonPlans) - .where(eq(lessonPlans.status, "published" as LessonPlanStatus)); + .where(eq(lessonPlans.status, "published")); const submittedRows = await db .select({ count: count() }) .from(lessonPlans) - .where(eq(lessonPlans.status, "submitted" as LessonPlanStatus)); + .where(eq(lessonPlans.status, "submitted")); const standardsRows = await db .select({ count: count() }) .from(lessonPlanStandards); @@ -208,7 +216,15 @@ export async function upsertDailyAnalytics( ): Promise { // 简化实现:先尝试更新,不存在则插入 const existing = await db - .select() + .select({ + id: lessonPlanAnalyticsDaily.id, + newPlansCount: lessonPlanAnalyticsDaily.newPlansCount, + editDurationMin: lessonPlanAnalyticsDaily.editDurationMin, + savedVersionsCount: lessonPlanAnalyticsDaily.savedVersionsCount, + submittedCount: lessonPlanAnalyticsDaily.submittedCount, + publishedCount: lessonPlanAnalyticsDaily.publishedCount, + standardsLinked: lessonPlanAnalyticsDaily.standardsLinked, + }) .from(lessonPlanAnalyticsDaily) .where( and( diff --git a/src/modules/lesson-preparation/data-access-attachments.ts b/src/modules/lesson-preparation/data-access-attachments.ts index dcc908b..3480f0b 100644 --- a/src/modules/lesson-preparation/data-access-attachments.ts +++ b/src/modules/lesson-preparation/data-access-attachments.ts @@ -28,7 +28,16 @@ export async function getAttachmentsByPlanIdRaw( planId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanAttachments.id, + planId: lessonPlanAttachments.planId, + blockId: lessonPlanAttachments.blockId, + fileId: lessonPlanAttachments.fileId, + displayName: lessonPlanAttachments.displayName, + attachmentType: lessonPlanAttachments.attachmentType, + uploadedBy: lessonPlanAttachments.uploadedBy, + createdAt: lessonPlanAttachments.createdAt, + }) .from(lessonPlanAttachments) .where(eq(lessonPlanAttachments.planId, planId)) .orderBy(desc(lessonPlanAttachments.createdAt)); @@ -46,7 +55,16 @@ export async function getAttachmentsByBlockIdRaw( blockId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanAttachments.id, + planId: lessonPlanAttachments.planId, + blockId: lessonPlanAttachments.blockId, + fileId: lessonPlanAttachments.fileId, + displayName: lessonPlanAttachments.displayName, + attachmentType: lessonPlanAttachments.attachmentType, + uploadedBy: lessonPlanAttachments.uploadedBy, + createdAt: lessonPlanAttachments.createdAt, + }) .from(lessonPlanAttachments) .where( and( @@ -120,7 +138,16 @@ export async function getAttachmentByIdRaw( id: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanAttachments.id, + planId: lessonPlanAttachments.planId, + blockId: lessonPlanAttachments.blockId, + fileId: lessonPlanAttachments.fileId, + displayName: lessonPlanAttachments.displayName, + attachmentType: lessonPlanAttachments.attachmentType, + uploadedBy: lessonPlanAttachments.uploadedBy, + createdAt: lessonPlanAttachments.createdAt, + }) .from(lessonPlanAttachments) .where(eq(lessonPlanAttachments.id, id)) .limit(1); diff --git a/src/modules/lesson-preparation/data-access-calendar.ts b/src/modules/lesson-preparation/data-access-calendar.ts index 2bf808f..a620117 100644 --- a/src/modules/lesson-preparation/data-access-calendar.ts +++ b/src/modules/lesson-preparation/data-access-calendar.ts @@ -10,6 +10,11 @@ import { db } from "@/shared/db"; import { lessonPlans, lessonPlanVersions, lessonPlanReviewRecords } from "@/shared/db/schema"; import { and, eq, gte, lte, asc } from "drizzle-orm"; import type { LessonPlanStatus } from "./types"; +// G1-026 去重:toLessonPlanStatus 已提取至 lib/status-mappers +import { toLessonPlanStatus } from "./lib/status-mappers"; + +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000; /** 日历事件 */ export interface LessonPlanCalendarEvent { @@ -51,7 +56,8 @@ export async function getCalendarEventsRaw( lte(lessonPlans.updatedAt, endDate), ), ) - .orderBy(asc(lessonPlans.updatedAt)); + .orderBy(asc(lessonPlans.updatedAt)) + .limit(DEFAULT_LIMIT); for (const p of planRows) { // 创建事件 @@ -60,7 +66,7 @@ export async function getCalendarEventsRaw( id: `${p.id}-created`, planId: p.id, title: p.title, - status: p.status as LessonPlanStatus, + status: toLessonPlanStatus(p.status), eventType: "created", occurredAt: p.createdAt, creatorId: p.creatorId, @@ -72,19 +78,19 @@ export async function getCalendarEventsRaw( id: `${p.id}-updated`, planId: p.id, title: p.title, - status: p.status as LessonPlanStatus, + status: toLessonPlanStatus(p.status), eventType: "updated", occurredAt: p.updatedAt, creatorId: p.creatorId, }); } // 提交/发布事件 - if (p.status === "submitted" as LessonPlanStatus || p.status === "published" as LessonPlanStatus) { + if (p.status === "submitted" || p.status === "published") { events.push({ id: `${p.id}-${p.status}`, planId: p.id, title: p.title, - status: p.status as LessonPlanStatus, + status: toLessonPlanStatus(p.status), eventType: p.status === "submitted" ? "submitted" : "published", occurredAt: p.updatedAt, creatorId: p.creatorId, @@ -111,14 +117,15 @@ export async function getCalendarEventsRaw( lte(lessonPlanVersions.createdAt, endDate), ), ) - .orderBy(asc(lessonPlanVersions.createdAt)); + .orderBy(asc(lessonPlanVersions.createdAt)) + .limit(DEFAULT_LIMIT); for (const v of versionRows) { events.push({ id: v.id, planId: v.planId, title: v.title, - status: "draft" as LessonPlanStatus, + status: "draft", eventType: "version_saved", occurredAt: v.createdAt, creatorId: v.creatorId, @@ -144,14 +151,15 @@ export async function getCalendarEventsRaw( gte(lessonPlanReviewRecords.createdAt, startDate), lte(lessonPlanReviewRecords.createdAt, endDate), ), - ); + ) + .limit(DEFAULT_LIMIT); for (const r of reviewRows) { events.push({ id: r.id, planId: r.planId, title: r.title, - status: "approved" as LessonPlanStatus, + status: "approved", eventType: "reviewed", occurredAt: r.createdAt, creatorId: r.reviewerId, @@ -172,7 +180,7 @@ export function groupEventsByDate( ): Map { const map = new Map(); for (const e of events) { - const dateKey = e.occurredAt.toISOString().split("T")[0]!; + const dateKey = e.occurredAt.toISOString().split("T")[0] ?? ""; const list = map.get(dateKey) ?? []; list.push(e); map.set(dateKey, list); diff --git a/src/modules/lesson-preparation/data-access-comments.ts b/src/modules/lesson-preparation/data-access-comments.ts index 7554461..92e03c8 100644 --- a/src/modules/lesson-preparation/data-access-comments.ts +++ b/src/modules/lesson-preparation/data-access-comments.ts @@ -8,7 +8,7 @@ import "server-only"; import { cacheFn } from "@/shared/lib/cache"; import { db } from "@/shared/db"; import { lessonPlanComments } from "@/shared/db/schema"; -import { and, eq, asc, isNull } from "drizzle-orm"; +import { and, eq, asc, inArray, isNull } from "drizzle-orm"; /** 评论记录 */ export interface LessonPlanComment { @@ -30,7 +30,17 @@ export async function getCommentsByPlanIdRaw( planId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanComments.id, + planId: lessonPlanComments.planId, + blockId: lessonPlanComments.blockId, + content: lessonPlanComments.content, + authorId: lessonPlanComments.authorId, + resolved: lessonPlanComments.resolved, + parentCommentId: lessonPlanComments.parentCommentId, + createdAt: lessonPlanComments.createdAt, + updatedAt: lessonPlanComments.updatedAt, + }) .from(lessonPlanComments) .where(eq(lessonPlanComments.planId, planId)) .orderBy(asc(lessonPlanComments.createdAt)); @@ -48,7 +58,17 @@ export async function getCommentsByBlockIdRaw( blockId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanComments.id, + planId: lessonPlanComments.planId, + blockId: lessonPlanComments.blockId, + content: lessonPlanComments.content, + authorId: lessonPlanComments.authorId, + resolved: lessonPlanComments.resolved, + parentCommentId: lessonPlanComments.parentCommentId, + createdAt: lessonPlanComments.createdAt, + updatedAt: lessonPlanComments.updatedAt, + }) .from(lessonPlanComments) .where( and( @@ -115,7 +135,9 @@ export async function toggleCommentResolved( .where(eq(lessonPlanComments.id, commentId)) .limit(1); if (rows.length === 0) return; - const newResolved = !rows[0]!.resolved; + const row = rows[0]; + if (!row) return; + const newResolved = !row.resolved; await db .update(lessonPlanComments) .set({ resolved: newResolved, updatedAt: new Date() }) @@ -124,19 +146,33 @@ export async function toggleCommentResolved( /** * 删除评论(递归删除子回复) + * + * 使用 BFS 逐层 `inArray` 查询收集所有后代 ID,再单条 `inArray` 批量删除, + * 替代原先每节点逐条 SELECT + DELETE 的递归 N+1 模式。 */ export async function deleteComment(commentId: string): Promise { - // 先删除所有子回复 - const children = await db - .select({ id: lessonPlanComments.id }) - .from(lessonPlanComments) - .where(eq(lessonPlanComments.parentCommentId, commentId)); - for (const child of children) { - await deleteComment(child.id); + const visited = new Set([commentId]) + let currentLevel: string[] = [commentId] + + while (currentLevel.length > 0) { + const children = await db + .select({ id: lessonPlanComments.id }) + .from(lessonPlanComments) + .where(inArray(lessonPlanComments.parentCommentId, currentLevel)); + + const nextLevel: string[] = []; + for (const child of children) { + if (!visited.has(child.id)) { + visited.add(child.id); + nextLevel.push(child.id); + } + } + currentLevel = nextLevel; } + await db .delete(lessonPlanComments) - .where(eq(lessonPlanComments.id, commentId)); + .where(inArray(lessonPlanComments.id, Array.from(visited))); } /** diff --git a/src/modules/lesson-preparation/data-access-formative.ts b/src/modules/lesson-preparation/data-access-formative.ts index bd6d345..18e0c8a 100644 --- a/src/modules/lesson-preparation/data-access-formative.ts +++ b/src/modules/lesson-preparation/data-access-formative.ts @@ -16,6 +16,9 @@ import { eq, desc, asc } from "drizzle-orm"; import type { FormativeInteractionType } from "./lib/type-guards"; import { isFormativeInteractionType } from "./lib/type-guards"; +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000; + /** 互动组件项 */ export interface FormativeItem { id: string; @@ -48,7 +51,17 @@ export async function getFormativeItemsByPlanIdRaw( planId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanFormativeItems.id, + planId: lessonPlanFormativeItems.planId, + blockId: lessonPlanFormativeItems.blockId, + interactionType: lessonPlanFormativeItems.interactionType, + payload: lessonPlanFormativeItems.payload, + instantFeedback: lessonPlanFormativeItems.instantFeedback, + orderIndex: lessonPlanFormativeItems.orderIndex, + createdAt: lessonPlanFormativeItems.createdAt, + updatedAt: lessonPlanFormativeItems.updatedAt, + }) .from(lessonPlanFormativeItems) .where(eq(lessonPlanFormativeItems.planId, planId)) .orderBy(asc(lessonPlanFormativeItems.orderIndex)); @@ -65,11 +78,22 @@ export async function getFormativeItemByIdRaw( id: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanFormativeItems.id, + planId: lessonPlanFormativeItems.planId, + blockId: lessonPlanFormativeItems.blockId, + interactionType: lessonPlanFormativeItems.interactionType, + payload: lessonPlanFormativeItems.payload, + instantFeedback: lessonPlanFormativeItems.instantFeedback, + orderIndex: lessonPlanFormativeItems.orderIndex, + createdAt: lessonPlanFormativeItems.createdAt, + updatedAt: lessonPlanFormativeItems.updatedAt, + }) .from(lessonPlanFormativeItems) .where(eq(lessonPlanFormativeItems.id, id)) .limit(1); - return rows.length === 0 ? null : mapRowToItem(rows[0]!); + const firstRow = rows[0]; + return firstRow ? mapRowToItem(firstRow) : null; } export const getFormativeItemById = cacheFn(getFormativeItemByIdRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "formative", "by-id"] }); @@ -166,7 +190,16 @@ export async function getResponsesByItemIdRaw( itemId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanFormativeResponses.id, + itemId: lessonPlanFormativeResponses.itemId, + studentId: lessonPlanFormativeResponses.studentId, + classId: lessonPlanFormativeResponses.classId, + response: lessonPlanFormativeResponses.response, + isCorrect: lessonPlanFormativeResponses.isCorrect, + durationSec: lessonPlanFormativeResponses.durationSec, + createdAt: lessonPlanFormativeResponses.createdAt, + }) .from(lessonPlanFormativeResponses) .where(eq(lessonPlanFormativeResponses.itemId, itemId)) .orderBy(desc(lessonPlanFormativeResponses.createdAt)); @@ -192,15 +225,35 @@ export async function getResponsesByStudentIdRaw( if (items.length === 0) return []; const itemIds = items.map((i) => i.id); const rows = await db - .select() + .select({ + id: lessonPlanFormativeResponses.id, + itemId: lessonPlanFormativeResponses.itemId, + studentId: lessonPlanFormativeResponses.studentId, + classId: lessonPlanFormativeResponses.classId, + response: lessonPlanFormativeResponses.response, + isCorrect: lessonPlanFormativeResponses.isCorrect, + durationSec: lessonPlanFormativeResponses.durationSec, + createdAt: lessonPlanFormativeResponses.createdAt, + }) .from(lessonPlanFormativeResponses) - .where(eq(lessonPlanFormativeResponses.studentId, studentId)); + .where(eq(lessonPlanFormativeResponses.studentId, studentId)) + .limit(DEFAULT_LIMIT); return rows.filter((r) => itemIds.includes(r.itemId)).map(mapRowToResponse); } const rows = await db - .select() + .select({ + id: lessonPlanFormativeResponses.id, + itemId: lessonPlanFormativeResponses.itemId, + studentId: lessonPlanFormativeResponses.studentId, + classId: lessonPlanFormativeResponses.classId, + response: lessonPlanFormativeResponses.response, + isCorrect: lessonPlanFormativeResponses.isCorrect, + durationSec: lessonPlanFormativeResponses.durationSec, + createdAt: lessonPlanFormativeResponses.createdAt, + }) .from(lessonPlanFormativeResponses) - .where(eq(lessonPlanFormativeResponses.studentId, studentId)); + .where(eq(lessonPlanFormativeResponses.studentId, studentId)) + .limit(DEFAULT_LIMIT); return rows.map(mapRowToResponse); } @@ -213,7 +266,10 @@ export async function getFormativeItemStatsRaw( itemId: string, ): Promise<{ total: number; correct: number; incorrect: number; avgDurationSec: number }> { const rows = await db - .select() + .select({ + isCorrect: lessonPlanFormativeResponses.isCorrect, + durationSec: lessonPlanFormativeResponses.durationSec, + }) .from(lessonPlanFormativeResponses) .where(eq(lessonPlanFormativeResponses.itemId, itemId)); diff --git a/src/modules/lesson-preparation/data-access-knowledge.ts b/src/modules/lesson-preparation/data-access-knowledge.ts index fd4d6bb..8b7e1ac 100644 --- a/src/modules/lesson-preparation/data-access-knowledge.ts +++ b/src/modules/lesson-preparation/data-access-knowledge.ts @@ -7,16 +7,12 @@ import { db } from "@/shared/db"; import { lessonPlans } from "@/shared/db/schema"; import { escapeLikePattern } from "@/shared/lib/action-utils"; import { isRecord } from "@/shared/lib/type-guards"; -import { isLessonPlanStatus } from "./lib/type-guards"; +// G1-027 去重:isStringArray 已提取至 lib/type-guards +import { isLessonPlanStatus, isStringArray } from "./lib/type-guards"; import { normalizeDocument } from "./data-access"; import type { LessonPlanListItem } from "./types"; // ---- 安全字段提取辅助(替代 as 断言,从 BlockData 联合类型收窄)---- -// 类型守卫:判断 unknown 是否为 string[](替代 as string[] 断言) -function isStringArray(v: unknown): v is string[] { - return Array.isArray(v) && v.every((x) => typeof x === "string"); -} - function getStringArray(v: unknown): string[] | undefined { return isStringArray(v) ? v : undefined; } @@ -89,8 +85,25 @@ export async function getLessonPlansByKnowledgePointRaw( userId: string, ): Promise { // content 是 JSON,用 LIKE 粗筛后内存精确过滤 + // F-02: 子串匹配需 FULLTEXT 索引,短期保留(JSON 内嵌 ID 无法前缀匹配) const rows = await db - .select() + .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, + }) .from(lessonPlans) .where( and( @@ -119,8 +132,25 @@ export async function getLessonPlansByQuestionRaw( questionId: string, userId: string, ): Promise { + // F-02: 子串匹配需 FULLTEXT 索引,短期保留(JSON 内嵌 ID 无法前缀匹配) const rows = await db - .select() + .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, + }) .from(lessonPlans) .where( and( diff --git a/src/modules/lesson-preparation/data-access-review.ts b/src/modules/lesson-preparation/data-access-review.ts index 2b6179a..e16654c 100644 --- a/src/modules/lesson-preparation/data-access-review.ts +++ b/src/modules/lesson-preparation/data-access-review.ts @@ -10,6 +10,11 @@ import { lessonPlans, lessonPlanReviewRecords } from "@/shared/db/schema"; import { and, eq, desc, asc, inArray } from "drizzle-orm"; import type { LessonPlanStatus } from "./types"; import { LESSON_PLAN_STATUS_TRANSITIONS, isReviewableStatus } from "./types"; +// G1-026 去重:toLessonPlanStatus / toReviewDecision 已提取至 lib/status-mappers +import { toLessonPlanStatus, toReviewDecision } from "./lib/status-mappers"; + +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000; /** 审核记录 */ export interface LessonPlanReviewRecord { @@ -49,7 +54,9 @@ export async function submitForReview( if (plan.length === 0) { throw new Error("PLAN_NOT_FOUND"); } - const currentStatus = plan[0]!.status as LessonPlanStatus; + const row = plan[0]; + if (!row) throw new Error("PLAN_NOT_FOUND"); + const currentStatus = toLessonPlanStatus(row.status); if (!isReviewableStatus(currentStatus) && currentStatus !== "draft" && currentStatus !== "rejected") { throw new Error("INVALID_STATUS_TRANSITION"); } @@ -81,7 +88,9 @@ export async function reviewPlan( if (plan.length === 0) { throw new Error("PLAN_NOT_FOUND"); } - const previousStatus = plan[0]!.status as LessonPlanStatus; + const planRow = plan[0]; + if (!planRow) throw new Error("PLAN_NOT_FOUND"); + const previousStatus = toLessonPlanStatus(planRow.status); if (!isReviewableStatus(previousStatus)) { throw new Error("PLAN_NOT_REVIEWABLE"); } @@ -128,7 +137,16 @@ export async function getReviewRecordsByPlanIdRaw( planId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanReviewRecords.id, + planId: lessonPlanReviewRecords.planId, + reviewerId: lessonPlanReviewRecords.reviewerId, + decision: lessonPlanReviewRecords.decision, + comment: lessonPlanReviewRecords.comment, + previousStatus: lessonPlanReviewRecords.previousStatus, + newStatus: lessonPlanReviewRecords.newStatus, + createdAt: lessonPlanReviewRecords.createdAt, + }) .from(lessonPlanReviewRecords) .where(eq(lessonPlanReviewRecords.planId, planId)) .orderBy(desc(lessonPlanReviewRecords.createdAt)); @@ -137,10 +155,10 @@ export async function getReviewRecordsByPlanIdRaw( id: r.id, planId: r.planId, reviewerId: r.reviewerId, - decision: r.decision as "approved" | "rejected", + decision: toReviewDecision(r.decision), comment: r.comment ?? undefined, - previousStatus: r.previousStatus as LessonPlanStatus, - newStatus: r.newStatus as LessonPlanStatus, + previousStatus: toLessonPlanStatus(r.previousStatus), + newStatus: toLessonPlanStatus(r.newStatus), createdAt: r.createdAt, })); } @@ -178,7 +196,8 @@ export async function getPendingReviewPlansRaw( }) .from(lessonPlans) .where(and(...conditions)) - .orderBy(asc(lessonPlans.updatedAt)); + .orderBy(asc(lessonPlans.updatedAt)) + .limit(DEFAULT_LIMIT); // 按教研组长权限过滤(gradeId/subjectId) return rows @@ -194,7 +213,7 @@ export async function getPendingReviewPlansRaw( .map((r) => ({ id: r.id, title: r.title, - status: r.status as LessonPlanStatus, + status: toLessonPlanStatus(r.status), creatorId: r.creatorId, gradeId: r.gradeId, subjectId: r.subjectId, @@ -219,7 +238,9 @@ export async function withdrawSubmission( if (plan.length === 0) { throw new Error("PLAN_NOT_FOUND"); } - const currentStatus = plan[0]!.status as LessonPlanStatus; + const withdrawRow = plan[0]; + if (!withdrawRow) throw new Error("PLAN_NOT_FOUND"); + const currentStatus = toLessonPlanStatus(withdrawRow.status); if (!isValidTransition(currentStatus, "draft")) { throw new Error("INVALID_STATUS_TRANSITION"); } @@ -265,12 +286,13 @@ export async function getPlansByStatusesRaw( }) .from(lessonPlans) .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy(desc(lessonPlans.updatedAt)); + .orderBy(desc(lessonPlans.updatedAt)) + .limit(DEFAULT_LIMIT); return rows.map((r) => ({ id: r.id, title: r.title, - status: r.status as LessonPlanStatus, + status: toLessonPlanStatus(r.status), creatorId: r.creatorId, updatedAt: r.updatedAt, })); diff --git a/src/modules/lesson-preparation/data-access-schedules.ts b/src/modules/lesson-preparation/data-access-schedules.ts index bba2951..a7e7360 100644 --- a/src/modules/lesson-preparation/data-access-schedules.ts +++ b/src/modules/lesson-preparation/data-access-schedules.ts @@ -6,10 +6,14 @@ import "server-only"; import { cacheFn } from "@/shared/lib/cache"; import { db } from "@/shared/db"; -import { lessonPlanSchedules, classes } from "@/shared/db/schema"; +import { lessonPlanSchedules } from "@/shared/db/schema"; +import { getClassNamesByIds, getClassNameById } from "@/modules/classes/data-access"; import { and, eq, gte, lte, desc } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000; + /** 课时绑定记录 */ export interface LessonPlanScheduleRecord { id: string; @@ -42,7 +46,6 @@ export async function getSchedulesByPlanIdRaw( id: lessonPlanSchedules.id, planId: lessonPlanSchedules.planId, classId: lessonPlanSchedules.classId, - className: classes.name, scheduledDate: lessonPlanSchedules.scheduledDate, period: lessonPlanSchedules.period, classScheduleId: lessonPlanSchedules.classScheduleId, @@ -52,15 +55,20 @@ export async function getSchedulesByPlanIdRaw( updatedAt: lessonPlanSchedules.updatedAt, }) .from(lessonPlanSchedules) - .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id)) .where(eq(lessonPlanSchedules.planId, planId)) .orderBy(desc(lessonPlanSchedules.scheduledDate)); + if (rows.length === 0) return []; + + // 跨模块批量解析班级名称,避免直接 JOIN classes 表 + const classIds = Array.from(new Set(rows.map((r) => r.classId))); + const classNameMap = await getClassNamesByIds(classIds); + return rows.map((r) => ({ id: r.id, planId: r.planId, classId: r.classId, - className: r.className ?? "", + className: classNameMap.get(r.classId) ?? "", scheduledDate: toDateStr(r.scheduledDate), period: r.period, classScheduleId: r.classScheduleId, @@ -85,7 +93,6 @@ export async function getSchedulesByDateRangeRaw( id: lessonPlanSchedules.id, planId: lessonPlanSchedules.planId, classId: lessonPlanSchedules.classId, - className: classes.name, scheduledDate: lessonPlanSchedules.scheduledDate, period: lessonPlanSchedules.period, classScheduleId: lessonPlanSchedules.classScheduleId, @@ -95,7 +102,6 @@ export async function getSchedulesByDateRangeRaw( updatedAt: lessonPlanSchedules.updatedAt, }) .from(lessonPlanSchedules) - .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id)) .where( and( // 简化:仅按日期范围过滤(planId 过滤由调用方处理或 join) @@ -103,23 +109,29 @@ export async function getSchedulesByDateRangeRaw( lte(lessonPlanSchedules.scheduledDate, new Date(endDate)), ), ) - .orderBy(desc(lessonPlanSchedules.scheduledDate)); + .orderBy(desc(lessonPlanSchedules.scheduledDate)) + .limit(DEFAULT_LIMIT); - return rows - .filter((r) => teacherPlanIds.includes(r.planId)) - .map((r) => ({ - id: r.id, - planId: r.planId, - classId: r.classId, - className: r.className ?? "", - scheduledDate: toDateStr(r.scheduledDate), - period: r.period, - classScheduleId: r.classScheduleId, - durationMin: r.durationMin, - createdBy: r.createdBy, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - })); + const filtered = rows.filter((r) => teacherPlanIds.includes(r.planId)); + if (filtered.length === 0) return []; + + // 跨模块批量解析班级名称,避免直接 JOIN classes 表 + const classIds = Array.from(new Set(filtered.map((r) => r.classId))); + const classNameMap = await getClassNamesByIds(classIds); + + return filtered.map((r) => ({ + id: r.id, + planId: r.planId, + classId: r.classId, + className: classNameMap.get(r.classId) ?? "", + scheduledDate: toDateStr(r.scheduledDate), + period: r.period, + classScheduleId: r.classScheduleId, + durationMin: r.durationMin, + createdBy: r.createdBy, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); } export const getSchedulesByDateRange = cacheFn(getSchedulesByDateRangeRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "schedules", "by-date-range"] }); @@ -151,7 +163,6 @@ export async function createSchedule(input: { id: lessonPlanSchedules.id, planId: lessonPlanSchedules.planId, classId: lessonPlanSchedules.classId, - className: classes.name, scheduledDate: lessonPlanSchedules.scheduledDate, period: lessonPlanSchedules.period, classScheduleId: lessonPlanSchedules.classScheduleId, @@ -161,15 +172,17 @@ export async function createSchedule(input: { updatedAt: lessonPlanSchedules.updatedAt, }) .from(lessonPlanSchedules) - .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id)) .where(eq(lessonPlanSchedules.id, newId)); - const r = created[0]!; + const r = created[0]; + if (!r) throw new Error("SCHEDULE_CREATE_FAILED"); + // 跨模块解析班级名称,避免直接 JOIN classes 表 + const className = (await getClassNameById(input.classId)) ?? ""; return { id: r.id, planId: r.planId, classId: r.classId, - className: r.className ?? "", + className, scheduledDate: toDateStr(r.scheduledDate), period: r.period, classScheduleId: r.classScheduleId, diff --git a/src/modules/lesson-preparation/data-access-substitutes.ts b/src/modules/lesson-preparation/data-access-substitutes.ts index 288aa89..5d3a5e2 100644 --- a/src/modules/lesson-preparation/data-access-substitutes.ts +++ b/src/modules/lesson-preparation/data-access-substitutes.ts @@ -31,7 +31,19 @@ export async function getSubstitutesByPlanIdRaw( planId: string, ): Promise { const rows = await db - .select() + .select({ + id: lessonPlanSubstitutes.id, + planId: lessonPlanSubstitutes.planId, + originalTeacherId: lessonPlanSubstitutes.originalTeacherId, + substituteTeacherId: lessonPlanSubstitutes.substituteTeacherId, + startDate: lessonPlanSubstitutes.startDate, + endDate: lessonPlanSubstitutes.endDate, + reason: lessonPlanSubstitutes.reason, + status: lessonPlanSubstitutes.status, + createdBy: lessonPlanSubstitutes.createdBy, + createdAt: lessonPlanSubstitutes.createdAt, + updatedAt: lessonPlanSubstitutes.updatedAt, + }) .from(lessonPlanSubstitutes) .where(eq(lessonPlanSubstitutes.planId, planId)) .orderBy(desc(lessonPlanSubstitutes.createdAt)); @@ -49,7 +61,19 @@ export async function getActiveSubstitutesByTeacherIdRaw( ): Promise { const now = new Date(); const rows = await db - .select() + .select({ + id: lessonPlanSubstitutes.id, + planId: lessonPlanSubstitutes.planId, + originalTeacherId: lessonPlanSubstitutes.originalTeacherId, + substituteTeacherId: lessonPlanSubstitutes.substituteTeacherId, + startDate: lessonPlanSubstitutes.startDate, + endDate: lessonPlanSubstitutes.endDate, + reason: lessonPlanSubstitutes.reason, + status: lessonPlanSubstitutes.status, + createdBy: lessonPlanSubstitutes.createdBy, + createdAt: lessonPlanSubstitutes.createdAt, + updatedAt: lessonPlanSubstitutes.updatedAt, + }) .from(lessonPlanSubstitutes) .where( and( @@ -122,7 +146,7 @@ export async function deleteSubstitute(substituteId: string): Promise { * - 是原教师 → true * - 是当前生效的代课教师 → true */ -export async function canTeacherAccessPlan( +export async function canTeacherAccessPlanRaw( planId: string, teacherId: string, ): Promise { @@ -133,12 +157,18 @@ export async function canTeacherAccessPlan( .where(eq(lessonPlans.id, planId)) .limit(1); if (plan.length === 0) return false; - if (plan[0]!.creatorId === teacherId) return true; + const planRow = plan[0]; + if (planRow && planRow.creatorId === teacherId) return true; // 代课教师 const substitutes = await getActiveSubstitutesByTeacherId(teacherId); return substitutes.some((s) => s.planId === planId); } +export const canTeacherAccessPlan = cacheFn(canTeacherAccessPlanRaw, { + tags: ["lesson-preparation"], + ttl: 60, + keyParts: ["lp", "substitutes", "can-teacher-access-plan"], +}) function mapRowToSubstitute( row: typeof lessonPlanSubstitutes.$inferSelect, diff --git a/src/modules/lesson-preparation/data-access-templates.ts b/src/modules/lesson-preparation/data-access-templates.ts index cf18420..2b1dad9 100644 --- a/src/modules/lesson-preparation/data-access-templates.ts +++ b/src/modules/lesson-preparation/data-access-templates.ts @@ -42,6 +42,15 @@ function mapRowToTemplate(row: { }; } +/** + * 获取可用模板列表(系统模板 + 当前用户的个人模板)。 + * + * 系统模板来自内存常量 `SYSTEM_TEMPLATES`,个人模板从 DB 查询 creatorId 等于 + * 当前用户的 personal 模板。 + * + * @param userId - 当前用户 ID(用于过滤个人模板) + * @returns 系统模板在前、个人模板在后的合并列表 + */ export async function getLessonPlanTemplatesRaw( userId: string, ): Promise { @@ -58,7 +67,16 @@ export async function getLessonPlanTemplatesRaw( })); const personalRows = await db - .select() + .select({ + id: lessonPlanTemplates.id, + name: lessonPlanTemplates.name, + type: lessonPlanTemplates.type, + scope: lessonPlanTemplates.scope, + blocks: lessonPlanTemplates.blocks, + creatorId: lessonPlanTemplates.creatorId, + createdAt: lessonPlanTemplates.createdAt, + updatedAt: lessonPlanTemplates.updatedAt, + }) .from(lessonPlanTemplates) .where( and( @@ -73,6 +91,18 @@ export async function getLessonPlanTemplatesRaw( export const getLessonPlanTemplates = cacheFn(getLessonPlanTemplatesRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "templates", "list"] }); +/** + * 将已有课案另存为个人模板。 + * + * 从课案 content 中提取 block 骨架(type + title),过滤掉 textbook_content 节点, + * 创建一条 personal 类型、custom scope 的模板记录。 + * + * @param input.sourcePlanId - 源课案 ID + * @param input.name - 新模板名称 + * @param input.userId - 当前用户 ID(需为源课案创建者) + * @returns 新创建的模板 ID + * @throws {LessonPlanDataError} 当 sourcePlanId 不存在或不属于当前用户时抛出 NOT_FOUND + */ export async function saveAsTemplate(input: { sourcePlanId: string; name: string; @@ -111,6 +141,14 @@ export async function saveAsTemplate(input: { return { templateId }; } +/** + * 删除当前用户的指定个人模板。 + * + * 同时校验 type=personal 与 creatorId 匹配,防止越权删除他人模板或系统模板。 + * + * @param templateId - 待删除的模板 ID + * @param userId - 当前用户 ID + */ export async function deletePersonalTemplate( templateId: string, userId: string, diff --git a/src/modules/lesson-preparation/data-access-versions.ts b/src/modules/lesson-preparation/data-access-versions.ts index ee3f0e7..0596f41 100644 --- a/src/modules/lesson-preparation/data-access-versions.ts +++ b/src/modules/lesson-preparation/data-access-versions.ts @@ -32,6 +32,15 @@ function mapRowToVersion(row: { }; } +/** + * 获取指定课案的全部版本列表(按版本号降序)。 + * + * 仅返回当前用户创建的课案版本,校验 planId 归属。 + * + * @param planId - 课案 ID + * @param userId - 当前用户 ID(用于归属校验) + * @returns 版本列表,按 versionNo 降序;归属不匹配返回空数组 + */ export async function getLessonPlanVersionsRaw( planId: string, userId: string, @@ -47,7 +56,16 @@ export async function getLessonPlanVersionsRaw( if (plan.length === 0) return []; const rows = await db - .select() + .select({ + id: lessonPlanVersions.id, + planId: lessonPlanVersions.planId, + versionNo: lessonPlanVersions.versionNo, + label: lessonPlanVersions.label, + content: lessonPlanVersions.content, + isAuto: lessonPlanVersions.isAuto, + creatorId: lessonPlanVersions.creatorId, + createdAt: lessonPlanVersions.createdAt, + }) .from(lessonPlanVersions) .where(eq(lessonPlanVersions.planId, planId)) .orderBy(desc(lessonPlanVersions.versionNo)); @@ -56,6 +74,19 @@ export async function getLessonPlanVersionsRaw( export const getLessonPlanVersions = cacheFn(getLessonPlanVersionsRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "versions", "by-plan"] }); +/** + * 为课案创建新版本。 + * + * 在事务内查询 `max(versionNo)+1` 生成新版本号,避免并发产生重复版本号。 + * 归属校验失败时返回 null。 + * + * @param input.planId - 课案 ID + * @param input.content - 课案文档快照 + * @param input.userId - 当前用户 ID(用于归属校验) + * @param input.isAuto - 是否为自动保存触发的版本 + * @param input.label - 可选版本标签(如手动回退标签) + * @returns 新版本号;归属不匹配返回 null + */ export async function createLessonPlanVersion(input: { planId: string; content: LessonPlanDocument; @@ -94,6 +125,16 @@ export async function createLessonPlanVersion(input: { }); } +/** + * 获取指定版本的课案内容快照。 + * + * 校验 planId 归属与 versionNo 是否属于该 planId。 + * + * @param planId - 课案 ID + * @param versionNo - 目标版本号 + * @param userId - 当前用户 ID(用于归属校验) + * @returns 版本内容文档;归属或版本不存在返回 null + */ export async function getVersionContentRaw( planId: string, versionNo: number, @@ -125,6 +166,18 @@ export async function getVersionContentRaw( export const getVersionContent = cacheFn(getVersionContentRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "versions", "content"] }); +/** + * 将课案内容回退到指定版本,并生成一条新版本记录。 + * + * 在事务内:1) 调用 `getVersionContent` 读取目标版本内容;2) 覆盖 `lessonPlans.content`; + * 3) 写入一条新版本记录(label 为 `revertLabel`)。 + * + * @param planId - 课案 ID + * @param versionNo - 目标版本号 + * @param userId - 当前用户 ID(用于归属校验) + * @param revertLabel - 由 actions 层 i18n 翻译后传入的回退标签 + * @returns 新生成的版本号;目标版本不存在或归属不匹配返回 null + */ export async function revertToVersion( planId: string, versionNo: number, @@ -164,6 +217,16 @@ export async function revertToVersion( }); } +/** + * 清理指定课案的过期自动版本(isAuto=true),保留最近 `keep` 条所有版本。 + * + * 仅删除版本总数超出 keep 阈值后、且 isAuto=true 的版本,手动版本不受影响。 + * + * @param planId - 课案 ID + * @param userId - 当前用户 ID(用于归属校验) + * @param keep - 保留的最近版本数量,默认 50 + * @returns 实际删除的版本数量;归属不匹配返回 0 + */ export async function pruneAutoVersions( planId: string, userId: string, diff --git a/src/modules/lesson-preparation/data-access.ts b/src/modules/lesson-preparation/data-access.ts index dd3d783..970ddff 100644 --- a/src/modules/lesson-preparation/data-access.ts +++ b/src/modules/lesson-preparation/data-access.ts @@ -7,8 +7,6 @@ import { db } from "@/shared/db"; import { lessonPlans, lessonPlanTemplates, - textbooks, - chapters, subjects, grades, users, @@ -23,22 +21,28 @@ import { buildInitialContent, buildDefaultSkeleton, } from "./lib/document-migration"; -import { normalizeTemplateBlocks } from "./lib/type-guards"; -import { getChaptersByTextbookId, getTextbooks, findChapterById } from "@/modules/textbooks/data-access"; +// G3-036 去重:isLessonPlanStatus / isTemplateType / isTemplateScope 已集中至 lib/type-guards +import { + normalizeTemplateBlocks, + isLessonPlanStatus, + isTemplateType, + isTemplateScope, +} from "./lib/type-guards"; +import { getChaptersByTextbookId, getTextbooks, findChapterById, getTextbookTitlesByIds, getChapterTitlesByIds } from "@/modules/textbooks/data-access"; import type { LessonPlan, LessonPlanDocument, LessonPlanListItem, LessonPlanTemplate, - LessonPlanStatus, LessonPlanVersionSummary, - TemplateType, - TemplateScope, } from "./types"; // re-export 纯函数保持向后兼容 export { migrateV1ToV2, normalizeDocument, buildInitialContent, buildDefaultSkeleton }; +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000; + // ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)---- export class LessonPlanDataError extends Error { constructor(public readonly code: "NOT_FOUND" | "TEMPLATE_NOT_FOUND") { @@ -47,20 +51,6 @@ export class LessonPlanDataError extends Error { } } -// ---- 类型守卫:安全地将 DB string 收窄为联合类型 ---- -const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const; -function isLessonPlanStatus(v: string): v is LessonPlanStatus { - return (LESSON_PLAN_STATUSES as readonly string[]).includes(v); -} -const TEMPLATE_TYPES = ["system", "personal"] as const; -function isTemplateType(v: string): v is TemplateType { - return (TEMPLATE_TYPES as readonly string[]).includes(v); -} -const TEMPLATE_SCOPES = ["regular", "review", "experiment", "inquiry", "blank", "custom"] as const; -function isTemplateScope(v: string): v is TemplateScope { - return (TEMPLATE_SCOPES as readonly string[]).includes(v); -} - // ---- 类型映射:Drizzle 行 → LessonPlan(Date → ISO string)---- function mapRowToLessonPlan(row: { id: string; @@ -234,7 +224,8 @@ export const getLessonPlansRaw = async ( conditions.push(...buildScopeCondition(scope, userId)); if (params.query) { - conditions.push(like(lessonPlans.title, `%${escapeLikePattern(params.query)}%`)); + // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 + conditions.push(like(lessonPlans.title, `${escapeLikePattern(params.query)}%`)); } if (params.textbookId) conditions.push(eq(lessonPlans.textbookId, params.textbookId)); @@ -261,22 +252,47 @@ export const getLessonPlansRaw = async ( 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)); + .orderBy(desc(lessonPlans.updatedAt)) + .limit(DEFAULT_LIMIT); - const items = rows.map(mapRowToListItem); + if (rows.length === 0) return []; + + // 跨模块批量解析教材/章节标题,避免直接 JOIN textbooks/chapters 表 + const textbookIds = Array.from( + new Set( + rows + .map((r) => r.textbookId) + .filter((v): v is string => typeof v === "string" && v.length > 0) + ) + ); + const chapterIds = Array.from( + new Set( + rows + .map((r) => r.chapterId) + .filter((v): v is string => typeof v === "string" && v.length > 0) + ) + ); + const [textbookTitleMap, chapterTitleMap] = await Promise.all([ + getTextbookTitlesByIds(textbookIds), + getChapterTitlesByIds(chapterIds), + ]); + + const items = rows.map((r) => + mapRowToListItem({ + ...r, + textbookTitle: r.textbookId ? textbookTitleMap.get(r.textbookId) ?? null : null, + chapterTitle: r.chapterId ? chapterTitleMap.get(r.chapterId) ?? null : null, + }), + ); // 版本聚合:按 textbookId + chapterId + creatorId 分组(同一教师对同一章节的多个课案视为版本) // 无 textbookId/chapterId 的课案各自独立成组 @@ -333,7 +349,23 @@ export const getLessonPlanByIdRaw = async ( userId: string, ): Promise => { const rows = await db - .select() + .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, + }) .from(lessonPlans) .where(eq(lessonPlans.id, id)) .limit(1); @@ -413,7 +445,7 @@ export async function createLessonPlan(input: { // (原本地函数已删除,改为从 @/modules/textbooks/data-access 导入) // ---- 获取教材列表(供 picker 使用)---- -export async function getTextbooksForPicker(): Promise< +export async function getTextbooksForPickerRaw(): Promise< { id: string; title: string; subject: string; grade: string | null }[] > { const textbooks = await getTextbooks(); @@ -424,9 +456,14 @@ export async function getTextbooksForPicker(): Promise< grade: t.grade, })); } +export const getTextbooksForPicker = cacheFn(getTextbooksForPickerRaw, { + tags: ["lesson-preparation"], + ttl: 300, + keyParts: ["lesson-preparation", "getTextbooksForPicker"], +}) // ---- 获取章节列表(供 picker 使用)---- -export async function getChaptersForPicker( +export async function getChaptersForPickerRaw( textbookId: string, ): Promise< // P1 修复:使用 ChapterPickerOption 递归类型,替代内联的 children?: unknown[] @@ -442,6 +479,11 @@ export async function getChaptersForPicker( children: c.children, })); } +export const getChaptersForPicker = cacheFn(getChaptersForPickerRaw, { + tags: ["lesson-preparation"], + ttl: 300, + keyParts: ["lesson-preparation", "getChaptersForPicker"], +}) // ---- 更新 content(自动保存,不生成版本)---- export async function updateLessonPlanContent( @@ -532,7 +574,23 @@ export async function duplicateLessonPlan( ): Promise<{ newPlanId: string }> { return db.transaction(async (tx) => { const rows = await tx - .select() + .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, + }) .from(lessonPlans) .where(eq(lessonPlans.id, planId)) .limit(1); @@ -571,7 +629,7 @@ export interface LessonPlanStats { archived: number; } -export async function getLessonPlanStats(): Promise { +export async function getLessonPlanStatsRaw(): Promise { // 统计所有课案(含 archived),按 status 分组计数 const rows = await db .select({ status: lessonPlans.status, count: sql`count(*)` }) @@ -588,9 +646,14 @@ export async function getLessonPlanStats(): Promise { archived, }; } +export const getLessonPlanStats = cacheFn(getLessonPlanStatsRaw, { + tags: ["lesson-preparation"], + ttl: 60, + keyParts: ["lesson-preparation", "getLessonPlanStats"], +}) // ---- 模板查询(内部)---- -export async function getTemplateById( +export async function getTemplateByIdRaw( templateId: string, ): Promise { // 先查 system 固定模板 @@ -609,9 +672,23 @@ export async function getTemplateById( } // 再查 DB(personal 模板) const rows = await db - .select() + .select({ + id: lessonPlanTemplates.id, + name: lessonPlanTemplates.name, + type: lessonPlanTemplates.type, + scope: lessonPlanTemplates.scope, + blocks: lessonPlanTemplates.blocks, + creatorId: lessonPlanTemplates.creatorId, + createdAt: lessonPlanTemplates.createdAt, + updatedAt: lessonPlanTemplates.updatedAt, + }) .from(lessonPlanTemplates) .where(eq(lessonPlanTemplates.id, templateId)) .limit(1); return rows.length > 0 ? mapRowToTemplate(rows[0]) : null; } +export const getTemplateById = cacheFn(getTemplateByIdRaw, { + tags: ["lesson-preparation"], + ttl: 300, + keyParts: ["lesson-preparation", "getTemplateById"], +}) diff --git a/src/modules/lesson-preparation/hooks/editor-slice.ts b/src/modules/lesson-preparation/hooks/editor-slice.ts index 00eab57..3231958 100644 --- a/src/modules/lesson-preparation/hooks/editor-slice.ts +++ b/src/modules/lesson-preparation/hooks/editor-slice.ts @@ -5,7 +5,6 @@ import type { Block, BlockData, BlockType, - InteractionBlockData, LessonPlanDocument, LessonPlanNode, NodeAnchor, @@ -13,6 +12,7 @@ import type { TextbookContentNodeData, } from "../types"; import { defaultDataForType } from "../lib/document-migration"; +import { isInteractionBlockData } from "../lib/type-guards"; import type { EditorState } from "./use-lesson-plan-editor"; /** @@ -21,11 +21,12 @@ import type { EditorState } from "./use-lesson-plan-editor"; */ function cloneBlockData(data: BlockData, type: BlockType): BlockData { if (type === "interaction") { - const d = data as InteractionBlockData; + // 类型守卫安全收窄 InteractionBlockData,校验失败直接返回原 data + if (!isInteractionBlockData(data)) return data; return { - designIntent: d.designIntent, - knowledgePointIds: [...d.knowledgePointIds], - turns: d.turns.map((t) => ({ ...t, id: createId() })), + designIntent: data.designIntent, + knowledgePointIds: [...data.knowledgePointIds], + turns: data.turns.map((t) => ({ ...t, id: createId() })), }; } // 其他类型:JSON 深拷贝(数据全是 POJO,无函数/Date/Symbol) @@ -121,13 +122,17 @@ export const createEditorSlice: StateCreator< set((s) => ({ doc: { ...s.doc, - nodes: s.doc.nodes.map((n) => - n.id === id - ? n.type === "textbook_content" - ? ({ ...n, ...patch } as TextbookContentNode) - : ({ ...n, ...patch } as LessonPlanNode) - : n, - ), + nodes: s.doc.nodes.map((n) => { + if (n.id !== id) return n; + if (n.type === "textbook_content") { + // TextbookContentNode.data 为 TextbookContentNodeData,与 patch(Partial)的 data 字段类型不兼容, + // 需经 unknown 中间变量收窄(合法 as 场景:从 unknown 转换) + const merged: unknown = { ...n, ...patch }; + return merged as TextbookContentNode; + } + // LessonPlanNode 与 patch 类型兼容,无需 as 断言 + return { ...n, ...patch }; + }), }, isDirty: true, })); diff --git a/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts b/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts index 3dac12b..3d5bab3 100644 --- a/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts +++ b/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { useLessonPlanEditor } from "./use-lesson-plan-editor"; import type { LessonPlanDataService, @@ -52,16 +52,16 @@ export function useLessonPlanPersistence({ content: state.doc, }); if (res.success) { - if (state.saveError) toast.success(t("status.recovered")); + if (state.saveError) notify.success(t("status.recovered")); state.markSaved(); } else { state.setSaveError(true); - toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") }); + notify.error(t("status.saveFailed"), { description: t("status.saveFailedHint") }); } } catch (e) { console.error("[LessonPlanEditor] auto-save failed", e); state.setSaveError(true); - toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") }); + notify.error(t("status.saveFailed"), { description: t("status.saveFailedHint") }); } finally { state.setSaving(false); } @@ -104,14 +104,14 @@ export function useLessonPlanPersistence({ content: state.doc, }); if (res.success) { - toast.success(t("status.recovered")); + notify.success(t("status.recovered")); state.markSaved(); } else { - toast.error(t("status.saveFailed")); + notify.error(t("status.saveFailed")); } } catch (e) { console.error("[LessonPlanEditor] retry save failed", e); - toast.error(t("status.saveFailed")); + notify.error(t("status.saveFailed")); } finally { state.setSaving(false); } diff --git a/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts b/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts index 8665124..31a7c4b 100644 --- a/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts +++ b/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { useTranslations } from "next-intl"; -import { toast } from "sonner"; +import { notify } from "@/shared/lib/notify"; import { useLessonPlanEditor } from "./use-lesson-plan-editor"; import { generateNodeContentAction, @@ -10,11 +10,8 @@ import { suggestNodeDifferentiationAction, generateLayeredQuestionsAction, } from "../actions-ai"; -import type { - BlockData, - InteractionBlockData, - LessonPlanNode, -} from "../types"; +import type { LessonPlanNode } from "../types"; +import { isInteractionBlockData, mergeBlockDataPatch } from "../lib/type-guards"; export type NodeAiAction = "generate" | "optimize" | "differentiation" | "layered"; @@ -43,18 +40,18 @@ export function useNodeAiAssist() { if (!node) return; setIsRunning(true); - toast.info(t("v4.contextMenu.aiRunning")); + notify.info(t("v4.contextMenu.aiRunning")); try { if (action === "generate") { const res = await generateNodeContentAction(node); if (res.success && res.data) { - // AI patch 合并到 node.data;patch 字段由 lib/ai-node-assist.ts 的 Zod schema 保证类型安全 - const merged = { ...node.data, ...res.data.data } as BlockData; + // AI patch 合并到 node.data,mergeBlockDataPatch 内部校验类型安全 + const merged = mergeBlockDataPatch(node, res.data.data); updateNode(nodeId, { data: merged }); - toast.success(t("v4.contextMenu.aiSuccess")); + notify.success(t("v4.contextMenu.aiSuccess")); } else { - toast.error(t("v4.contextMenu.aiFailed")); + notify.error(t("v4.contextMenu.aiFailed")); } return; } @@ -62,11 +59,11 @@ export function useNodeAiAssist() { if (action === "optimize") { const res = await optimizeNodeExpressionAction(node); if (res.success && res.data) { - const merged = { ...node.data, ...res.data.data } as BlockData; + const merged = mergeBlockDataPatch(node, res.data.data); updateNode(nodeId, { data: merged }); - toast.success(t("v4.contextMenu.aiSuccess")); + notify.success(t("v4.contextMenu.aiSuccess")); } else { - toast.error(t("v4.contextMenu.aiFailed")); + notify.error(t("v4.contextMenu.aiFailed")); } return; } @@ -75,30 +72,31 @@ export function useNodeAiAssist() { const res = await suggestNodeDifferentiationAction(node); if (res.success && res.data) { updateNode(nodeId, { differentiation: res.data.level }); - toast.success(`${t("v4.contextMenu.aiSuccess")}:${res.data.reason}`); + notify.success(`${t("v4.contextMenu.aiSuccess")}:${res.data.reason}`); } else { - toast.error(t("v4.contextMenu.aiFailed")); + notify.error(t("v4.contextMenu.aiFailed")); } return; } // layered if (node.type !== "interaction") { - toast.error(t("v4.contextMenu.aiFailed")); + notify.error(t("v4.contextMenu.aiFailed")); return; } const res = await generateLayeredQuestionsAction(node); if (res.success && res.data) { - // interaction 节点 data 类型收窄(已通过 node.type 守卫) - const interactionData = node.data as InteractionBlockData; - const merged = { - ...node.data, - turns: [...(interactionData.turns ?? []), ...res.data.turns], - } as BlockData; + // 类型守卫二次校验 node.data 为 InteractionBlockData,失败提示错误 + if (!isInteractionBlockData(node.data)) { + notify.error(t("v4.contextMenu.aiFailed")); + return; + } + const newTurns = [...(node.data.turns ?? []), ...res.data.turns]; + const merged = mergeBlockDataPatch(node, { turns: newTurns }); updateNode(nodeId, { data: merged }); - toast.success(t("v4.contextMenu.aiSuccess")); + notify.success(t("v4.contextMenu.aiSuccess")); } else { - toast.error(t("v4.contextMenu.aiFailed")); + notify.error(t("v4.contextMenu.aiFailed")); } } finally { setIsRunning(false); diff --git a/src/modules/lesson-preparation/lib/ai-node-assist.ts b/src/modules/lesson-preparation/lib/ai-node-assist.ts index bc7e6cc..6e463cc 100644 --- a/src/modules/lesson-preparation/lib/ai-node-assist.ts +++ b/src/modules/lesson-preparation/lib/ai-node-assist.ts @@ -15,19 +15,18 @@ import { createAiChatCompletion } from "@/shared/lib/ai"; import { isRecord } from "@/shared/lib/type-guards"; import { z } from "zod"; import type { - BlockData, DifferentiationLevel, - InteractionBlockData, LessonPlanNode, QATurn, } from "../types"; +import { isInteractionBlockData } from "./type-guards"; import { createId } from "@paralleldrive/cuid2"; // ---- 公共返回类型 ---- export interface NodeContentUpdate { - /** 合并到 node.data 的 patch */ - data: Partial; + /** 合并到 node.data 的 patch(AI 输出为 untrusted,类型不保证,需调用方用 mergeBlockDataPatch 校验) */ + data: Record; } export interface NodeDifferentiationUpdate { @@ -120,7 +119,7 @@ export async function generateNodeContent( const parsed: unknown = JSON.parse(jsonMatch[0]); const validated = GenerateResultSchema.safeParse(parsed); if (!validated.success) return null; - return { data: validated.data.patch as Partial }; + return { data: validated.data.patch }; } catch { return null; } @@ -158,7 +157,7 @@ export async function optimizeNodeExpression( const parsed: unknown = JSON.parse(jsonMatch[0]); const validated = OptimizeResultSchema.safeParse(parsed); if (!validated.success) return null; - return { data: validated.data.patch as Partial }; + return { data: validated.data.patch }; } catch { return null; } @@ -234,8 +233,10 @@ export async function generateLayeredQuestions( const parsed: unknown = JSON.parse(jsonMatch[0]); const validated = LayeredQuestionsResultSchema.safeParse(parsed); if (!validated.success) return null; + // 类型守卫校验 node.data 为 InteractionBlockData,失败返回 null + if (!isInteractionBlockData(node.data)) return null; // 为每条 turn 分配 id + order - const existing = (node.data as InteractionBlockData).turns ?? []; + const existing = node.data.turns ?? []; let order = existing.length; return validated.data.turns.map((t) => ({ id: createId(), diff --git a/src/modules/lesson-preparation/lib/consistency-check.ts b/src/modules/lesson-preparation/lib/consistency-check.ts index c6d3b39..9bed297 100644 --- a/src/modules/lesson-preparation/lib/consistency-check.ts +++ b/src/modules/lesson-preparation/lib/consistency-check.ts @@ -10,10 +10,10 @@ * 该模块为纯函数,无副作用,便于单测。 */ import type { - ExerciseBlockData, LessonPlanDocument, LessonPlanNode, } from "../types"; +import { isExerciseBlockData } from "./type-guards"; /** 校验级别 */ export type ConsistencySeverity = "warning" | "info"; @@ -58,7 +58,9 @@ function getTeachingNodes(doc: LessonPlanDocument): LessonPlanNode[] { /** 安全读取 exercise 节点的知识点 ID 集合 */ function getExerciseKpIds(node: LessonPlanNode): Set { if (node.type !== "exercise") return new Set(); - const data = node.data as ExerciseBlockData; + // 类型守卫校验 node.data 为 ExerciseBlockData,失败返回空集合 + if (!isExerciseBlockData(node.data)) return new Set(); + const data = node.data; const ids = new Set(); for (const item of data.items ?? []) { if (item.source === "inline" && item.inlineContent?.knowledgePointIds) { diff --git a/src/modules/lesson-preparation/lib/export.ts b/src/modules/lesson-preparation/lib/export.ts index ef456d8..353bba2 100644 --- a/src/modules/lesson-preparation/lib/export.ts +++ b/src/modules/lesson-preparation/lib/export.ts @@ -17,6 +17,7 @@ import type { KeyPointBlockData, LessonPlan, LessonPlanDocument, + LessonPlanNode, NewTeachingBlockData, ObjectiveBlockData, ReflectionBlockData, @@ -25,6 +26,20 @@ import type { TextStudyBlockData, TextbookContentNode, } from "../types"; +import { + isBlackboardBlockData, + isExerciseBlockData, + isHomeworkBlockData, + isImportBlockData, + isInteractionBlockData, + isKeyPointBlockData, + isNewTeachingBlockData, + isObjectiveBlockData, + isReflectionBlockData, + isRichTextBlockData, + isSummaryBlockData, + isTextStudyBlockData, +} from "./type-guards"; /** 导出版本 */ export type ExportVariant = "detailed" | "concise"; @@ -83,20 +98,21 @@ export function flattenLessonPlanForPrint( const doc: LessonPlanDocument = plan.content; const textbookContent = extractTextbookContent(doc); const teachingNodes = doc.nodes - .filter((n) => n.type !== "textbook_content") + .filter((n): n is LessonPlanNode => n.type !== "textbook_content") .filter((n) => variant === "detailed" || CONCISE_BLOCK_TYPES.has(n.type)) .sort((a, b) => a.order - b.order); const sections = teachingNodes.map((node) => - flattenBlock(node.type, node.title, node.data as BlockData), + flattenBlock(node.type, node.title, node.data), ); // V5-4:教学时长由 import 节点求和 const totalDurationMin = doc.nodes .filter((n) => n.type === "import") .reduce((sum, n) => { - const data = n.data as ImportBlockData; - return sum + (data.durationMin ?? 0); + // 类型守卫校验 import 节点数据结构,替代 as ImportBlockData 断言 + if (!isImportBlockData(n.data)) return sum; + return sum + (n.data.durationMin ?? 0); }, 0); return { @@ -132,29 +148,29 @@ function flattenBlock( function flattenBlockData(type: string, data: BlockData): string[] { switch (type) { case "objective": - return flattenObjective(data as ObjectiveBlockData); + return isObjectiveBlockData(data) ? flattenObjective(data) : []; case "key_point": - return flattenKeyPoint(data as KeyPointBlockData); + return isKeyPointBlockData(data) ? flattenKeyPoint(data) : []; case "import": - return flattenImport(data as ImportBlockData); + return isImportBlockData(data) ? flattenImport(data) : []; case "new_teaching": - return flattenNewTeaching(data as NewTeachingBlockData); + return isNewTeachingBlockData(data) ? flattenNewTeaching(data) : []; case "summary": - return flattenSummary(data as SummaryBlockData); + return isSummaryBlockData(data) ? flattenSummary(data) : []; case "homework": - return flattenHomework(data as HomeworkBlockData); + return isHomeworkBlockData(data) ? flattenHomework(data) : []; case "blackboard": - return flattenBlackboard(data as BlackboardBlockData); + return isBlackboardBlockData(data) ? flattenBlackboard(data) : []; case "reflection": - return flattenReflection(data as ReflectionBlockData); + return isReflectionBlockData(data) ? flattenReflection(data) : []; case "exercise": - return flattenExercise(data as ExerciseBlockData); + return isExerciseBlockData(data) ? flattenExercise(data) : []; case "text_study": - return flattenTextStudy(data as TextStudyBlockData); + return isTextStudyBlockData(data) ? flattenTextStudy(data) : []; case "rich_text": - return flattenRichText(data as RichTextBlockData); + return isRichTextBlockData(data) ? flattenRichText(data) : []; case "interaction": - return flattenInteraction(data as InteractionBlockData); + return isInteractionBlockData(data) ? flattenInteraction(data) : []; default: return []; } diff --git a/src/modules/lesson-preparation/lib/type-guards.ts b/src/modules/lesson-preparation/lib/type-guards.ts index 47f7b04..c3deb11 100644 --- a/src/modules/lesson-preparation/lib/type-guards.ts +++ b/src/modules/lesson-preparation/lib/type-guards.ts @@ -3,6 +3,7 @@ import type { BlackboardBlockData, BlockData, BlockType, + DifferentiationLevel, ExerciseBlockData, ExercisePurpose, HomeworkAssignment, @@ -19,6 +20,7 @@ import type { ReflectionItem, RichTextBlockData, SummaryBlockData, + TeachingStage, TemplateBlockSkeleton, TemplateScope, TemplateType, @@ -93,11 +95,11 @@ function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null; } -export function isRichTextBlockData(data: BlockData): data is RichTextBlockData { +export function isRichTextBlockData(data: unknown): data is RichTextBlockData { return isObject(data) && typeof data.html === "string" && Array.isArray(data.knowledgePointIds); } -export function isTextStudyBlockData(data: BlockData): data is TextStudyBlockData { +export function isTextStudyBlockData(data: unknown): data is TextStudyBlockData { return ( isObject(data) && typeof data.sourceText === "string" && @@ -106,7 +108,7 @@ export function isTextStudyBlockData(data: BlockData): data is TextStudyBlockDat ); } -export function isExerciseBlockData(data: BlockData): data is ExerciseBlockData { +export function isExerciseBlockData(data: unknown): data is ExerciseBlockData { return ( isObject(data) && Array.isArray(data.items) && @@ -114,15 +116,15 @@ export function isExerciseBlockData(data: BlockData): data is ExerciseBlockData ); } -export function isObjectiveBlockData(data: BlockData): data is ObjectiveBlockData { +export function isObjectiveBlockData(data: unknown): data is ObjectiveBlockData { return isObject(data) && Array.isArray(data.objectives); } -export function isKeyPointBlockData(data: BlockData): data is KeyPointBlockData { +export function isKeyPointBlockData(data: unknown): data is KeyPointBlockData { return isObject(data) && Array.isArray(data.keyPoints); } -export function isImportBlockData(data: BlockData): data is ImportBlockData { +export function isImportBlockData(data: unknown): data is ImportBlockData { return ( isObject(data) && typeof data.prompt === "string" && @@ -131,19 +133,19 @@ export function isImportBlockData(data: BlockData): data is ImportBlockData { ); } -export function isNewTeachingBlockData(data: BlockData): data is NewTeachingBlockData { +export function isNewTeachingBlockData(data: unknown): data is NewTeachingBlockData { return isObject(data) && Array.isArray(data.teachingPoints); } -export function isSummaryBlockData(data: BlockData): data is SummaryBlockData { +export function isSummaryBlockData(data: unknown): data is SummaryBlockData { return isObject(data) && Array.isArray(data.summaryPoints) && typeof data.homeworkPreview === "string"; } -export function isHomeworkBlockData(data: BlockData): data is HomeworkBlockData { +export function isHomeworkBlockData(data: unknown): data is HomeworkBlockData { return isObject(data) && Array.isArray(data.assignments); } -export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockData { +export function isBlackboardBlockData(data: unknown): data is BlackboardBlockData { return ( isObject(data) && typeof data.layout === "string" && @@ -152,7 +154,7 @@ export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockD ); } -export function isReflectionBlockData(data: BlockData): data is ReflectionBlockData { +export function isReflectionBlockData(data: unknown): data is ReflectionBlockData { return isObject(data) && Array.isArray(data.reflection); } @@ -215,6 +217,38 @@ export function isReflectionAspect( return (REFLECTION_ASPECTS as readonly string[]).includes(v); } +// ---- 节点属性枚举守卫 ---- + +const TEACHING_STAGES = ["import", "new_teaching", "consolidation", "summary"] as const; +export function isTeachingStage(v: string): v is TeachingStage { + return (TEACHING_STAGES as readonly string[]).includes(v); +} + +const DIFFERENTIATION_LEVELS = ["basic", "intermediate", "advanced"] as const; +export function isDifferentiationLevel(v: string): v is DifferentiationLevel { + return (DIFFERENTIATION_LEVELS as readonly string[]).includes(v); +} + +// ---- AI 反馈分类守卫 ---- + +const AI_FEEDBACK_CATEGORIES = [ + "strengths", + "improvements", + "alignment", + "differentiation", +] as const; +export type AiFeedbackCategory = (typeof AI_FEEDBACK_CATEGORIES)[number]; +export function isAiFeedbackCategory(v: string): v is AiFeedbackCategory { + return (AI_FEEDBACK_CATEGORIES as readonly string[]).includes(v); +} + +// ---- 通用数组守卫 ---- + +/** 判断 unknown 是否为 string[](用于 DB JSON 字段安全收窄) */ +export function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every((item) => typeof item === "string"); +} + // ---- 节点类型守卫 ---- export function isTextbookContentNode( node: { type: string }, @@ -262,6 +296,62 @@ export function isBlockType(v: string): v is BlockType { return (VALID_BLOCK_TYPES as readonly string[]).includes(v); } +// ---- BlockData 合并校验(替代 `{ ...node.data, ...patch } as BlockData` 断言)---- + +/** + * 根据节点 type 调用对应的类型守卫,校验 unknown 是否为该 type 的合法 BlockData。 + * consolidation 节点复用 rich_text 守卫(与 block-registry 渲染逻辑一致)。 + */ +function isValidBlockDataForType(data: unknown, type: BlockType): boolean { + switch (type) { + case "rich_text": + case "consolidation": + return isRichTextBlockData(data); + case "text_study": + return isTextStudyBlockData(data); + case "exercise": + return isExerciseBlockData(data); + case "objective": + return isObjectiveBlockData(data); + case "key_point": + return isKeyPointBlockData(data); + case "import": + return isImportBlockData(data); + case "new_teaching": + return isNewTeachingBlockData(data); + case "summary": + return isSummaryBlockData(data); + case "homework": + return isHomeworkBlockData(data); + case "blackboard": + return isBlackboardBlockData(data); + case "reflection": + return isReflectionBlockData(data); + case "interaction": + return isInteractionBlockData(data); + default: + return false; + } +} + +/** + * 合并 AI patch 到节点 data,自动校验结果是否仍为合法 BlockData。 + * 校验失败时回退到原 node.data,保证类型安全。 + * 替代 `{ ...node.data, ...patch } as BlockData` 断言。 + */ +export function mergeBlockDataPatch( + node: LessonPlanNode, + patch: Record, +): BlockData { + const merged: unknown = { ...node.data, ...patch }; + if (isValidBlockDataForType(merged, node.type)) { + // 从 unknown 收窄到 BlockData(合法 as 场景:已通过对应类型守卫校验) + return merged as BlockData; + } + // 校验失败,回退原 data 保证类型安全 + return node.data; +} + // ---- TemplateBlockSkeleton 守卫与规范化(替代 as 断言从 DB unknown 转换)---- // isObject 已在上方定义(第 56 行),复用同一类型守卫 diff --git a/src/modules/lesson-preparation/publish-service.ts b/src/modules/lesson-preparation/publish-service.ts index 268a0f5..90e3f73 100644 --- a/src/modules/lesson-preparation/publish-service.ts +++ b/src/modules/lesson-preparation/publish-service.ts @@ -61,7 +61,23 @@ export async function publishLessonPlanHomework( ): Promise { // 1. 读取课案 const rows = await db - .select() + .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, + }) .from(lessonPlans) .where(eq(lessonPlans.id, input.planId)) .limit(1); diff --git a/src/modules/lesson-preparation/services/default-question-service.ts b/src/modules/lesson-preparation/services/default-question-service.ts index 153821e..7e11fb9 100644 --- a/src/modules/lesson-preparation/services/default-question-service.ts +++ b/src/modules/lesson-preparation/services/default-question-service.ts @@ -2,7 +2,6 @@ import { getQuestionsForPickerAction } from "../actions-questions"; import type { - QuestionPickerItem, QuestionPickerParams, QuestionService, } from "../providers/lesson-plan-provider"; @@ -22,7 +21,7 @@ export function createDefaultQuestionService(): QuestionService { async getQuestions(params: QuestionPickerParams) { const res = await getQuestionsForPickerAction(params); if (res.success && res.data) { - return { success: true, data: { data: res.data.data as QuestionPickerItem[] } }; + return { success: true, data: { data: res.data.data } }; } return { success: false, message: res.message }; },