feat(lesson-preparation): major update with data-access splits and new components

- Update ai-feedback-dialog, attachment-picker, blocks/text-study-block

- Update detail-panel (detail-panel, detail-props)

- Update inline-question-editor, lesson-plan-card, lesson-plan-editor,

  lesson-plan-mobile-view, schedule-dialog, version-history-drawer

- Update paper-editor (inline-qa-dialog, paper-context-menu)

- Update structure-tree (structure-tree, tree-node-row)

- Update config/block-registry, hooks (editor-slice, use-lesson-plan-persistence,

  use-node-ai-assist), lib (ai-node-assist, consistency-check, export, type-guards)

- Update publish-service, services/default-question-service

- Update data-access files (ai-evaluation, analytics, attachments, calendar,

  comments, formative, knowledge, review, schedules, templates, versions, main)
This commit is contained in:
SpecialX
2026-07-07 16:20:34 +08:00
parent 2adf61faa8
commit 524ecade19
38 changed files with 874 additions and 278 deletions

View File

@@ -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({
</div>
{/* 各维度反馈 */}
{(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 (

View File

@@ -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"));
}
}

View File

@@ -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 = {

View File

@@ -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() {
<DetailProps node={node} />
<div style={{ flex: 1, padding: 20, overflowY: "auto" }}>
{isInteraction ? (
<QaEditor nodeId={node.id} data={node.data as InteractionBlockData} />
{isInteraction && isInteractionBlockData(node.data) ? (
<QaEditor nodeId={node.id} data={node.data} />
) : isInteraction ? (
// 类型守卫校验失败,数据异常时渲染 null
null
) : (
<BlockRenderer
type={node.type}

View File

@@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import type { Block, DifferentiationLevel, TeachingStage } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { isDifferentiationLevel, isTeachingStage } from "../../lib/type-guards";
interface Props {
node: Block;
@@ -30,13 +31,17 @@ export function DetailProps({ node }: Props) {
label={t("v4.detail.stageLabel")}
value={node.stage ?? "—"}
options={STAGES}
onChange={(v) => updateNode(node.id, { stage: v as TeachingStage })}
onChange={(v) => {
if (isTeachingStage(v)) updateNode(node.id, { stage: v });
}}
/>
<PropItem
label={t("v4.detail.differentiationLabel")}
value={node.differentiation ?? "—"}
options={LEVELS}
onChange={(v) => updateNode(node.id, { differentiation: v as DifferentiationLevel })}
onChange={(v) => {
if (isDifferentiationLevel(v)) updateNode(node.id, { differentiation: v });
}}
/>
</div>
);

View File

@@ -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<string, unknown> =

View File

@@ -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]);

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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 (
<div className="lp-inline-node">

View File

@@ -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"));
}
};

View File

@@ -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"));
}
}

View File

@@ -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 && (
<TreeNodeRow
node={{ ...textbookNode, type: "textbook_content" } as unknown as Block}
node={textbookNode}
isExpanded={false}
/>
)}
@@ -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,

View File

@@ -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;
/** 是否有子节点(如师生交互的对话轮次) */

View File

@@ -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"));
}
}

View File

@@ -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
<InteractionBlock
blockId={rest.blockId}
data={rest.data}
onUpdate={(d) => rest.onUpdate(d as InteractionBlockData)}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
}

View File

@@ -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<string, unknown>;
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<AiEvaluation[]> {
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<AiEvaluation | null> {
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,
};

View File

@@ -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<TemplateUsageDataPoint
.orderBy(desc(count(lessonPlans.id)));
return rows.map((r) => ({
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<void> {
// 简化实现:先尝试更新,不存在则插入
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(

View File

@@ -28,7 +28,16 @@ export async function getAttachmentsByPlanIdRaw(
planId: string,
): Promise<LessonPlanAttachment[]> {
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<LessonPlanAttachment[]> {
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<LessonPlanAttachment | null> {
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);

View File

@@ -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<string, LessonPlanCalendarEvent[]> {
const map = new Map<string, LessonPlanCalendarEvent[]>();
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);

View File

@@ -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<LessonPlanComment[]> {
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<LessonPlanComment[]> {
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<void> {
// 先删除所有子回复
const visited = new Set<string>([commentId])
let currentLevel: string[] = [commentId]
while (currentLevel.length > 0) {
const children = await db
.select({ id: lessonPlanComments.id })
.from(lessonPlanComments)
.where(eq(lessonPlanComments.parentCommentId, commentId));
.where(inArray(lessonPlanComments.parentCommentId, currentLevel));
const nextLevel: string[] = [];
for (const child of children) {
await deleteComment(child.id);
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)));
}
/**

View File

@@ -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<FormativeItem[]> {
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<FormativeItem | null> {
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<FormativeResponse[]> {
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));

View File

@@ -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<LessonPlanListItem[]> {
// 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<LessonPlanListItem[]> {
// 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(

View File

@@ -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<LessonPlanReviewRecord[]> {
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,
}));

View File

@@ -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,15 +109,21 @@ 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) => ({
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: r.className ?? "",
className: classNameMap.get(r.classId) ?? "",
scheduledDate: toDateStr(r.scheduledDate),
period: r.period,
classScheduleId: r.classScheduleId,
@@ -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,

View File

@@ -31,7 +31,19 @@ export async function getSubstitutesByPlanIdRaw(
planId: string,
): Promise<LessonPlanSubstitute[]> {
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<LessonPlanSubstitute[]> {
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<void> {
* - 是原教师 → true
* - 是当前生效的代课教师 → true
*/
export async function canTeacherAccessPlan(
export async function canTeacherAccessPlanRaw(
planId: string,
teacherId: string,
): Promise<boolean> {
@@ -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,

View File

@@ -42,6 +42,15 @@ function mapRowToTemplate(row: {
};
}
/**
* 获取可用模板列表(系统模板 + 当前用户的个人模板)。
*
* 系统模板来自内存常量 `SYSTEM_TEMPLATES`,个人模板从 DB 查询 creatorId 等于
* 当前用户的 personal 模板。
*
* @param userId - 当前用户 ID用于过滤个人模板
* @returns 系统模板在前、个人模板在后的合并列表
*/
export async function getLessonPlanTemplatesRaw(
userId: string,
): Promise<LessonPlanTemplate[]> {
@@ -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,

View File

@@ -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,

View File

@@ -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 行 → LessonPlanDate → 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<LessonPlan | null> => {
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<LessonPlanStats> {
export async function getLessonPlanStatsRaw(): Promise<LessonPlanStats> {
// 统计所有课案(含 archived按 status 分组计数
const rows = await db
.select({ status: lessonPlans.status, count: sql<number>`count(*)` })
@@ -588,9 +646,14 @@ export async function getLessonPlanStats(): Promise<LessonPlanStats> {
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<LessonPlanTemplate | null> {
// 先查 system 固定模板
@@ -609,9 +672,23 @@ export async function getTemplateById(
}
// 再查 DBpersonal 模板)
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"],
})

View File

@@ -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与 patchPartial<Block>)的 data 字段类型不兼容,
// 需经 unknown 中间变量收窄(合法 as 场景:从 unknown 转换)
const merged: unknown = { ...n, ...patch };
return merged as TextbookContentNode;
}
// LessonPlanNode 与 patch 类型兼容,无需 as 断言
return { ...n, ...patch };
}),
},
isDirty: true,
}));

View File

@@ -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);
}

View File

@@ -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.datapatch 字段由 lib/ai-node-assist.ts 的 Zod schema 保证类型安全
const merged = { ...node.data, ...res.data.data } as BlockData;
// AI patch 合并到 node.datamergeBlockDataPatch 内部校验类型安全
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);

View File

@@ -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<BlockData>;
/** 合并到 node.data 的 patchAI 输出为 untrusted类型不保证需调用方用 mergeBlockDataPatch 校验) */
data: Record<string, unknown>;
}
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<BlockData> };
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<BlockData> };
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(),

View File

@@ -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<string> {
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<string>();
for (const item of data.items ?? []) {
if (item.source === "inline" && item.inlineContent?.knowledgePointIds) {

View File

@@ -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 [];
}

View File

@@ -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<string, unknown> {
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<string, unknown>,
): 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 行),复用同一类型守卫

View File

@@ -61,7 +61,23 @@ export async function publishLessonPlanHomework(
): Promise<PublishResult> {
// 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);

View File

@@ -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 };
},