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:
@@ -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 (
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
/** 是否有子节点(如师生交互的对话轮次) */
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 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<string>([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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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(
|
||||
}
|
||||
// 再查 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"],
|
||||
})
|
||||
|
||||
@@ -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<Block>)的 data 字段类型不兼容,
|
||||
// 需经 unknown 中间变量收窄(合法 as 场景:从 unknown 转换)
|
||||
const merged: unknown = { ...n, ...patch };
|
||||
return merged as TextbookContentNode;
|
||||
}
|
||||
// LessonPlanNode 与 patch 类型兼容,无需 as 断言
|
||||
return { ...n, ...patch };
|
||||
}),
|
||||
},
|
||||
isDirty: true,
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 的 patch(AI 输出为 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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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 行),复用同一类型守卫
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user