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 { generateLessonPlanFeedbackAction } from "../actions-ai";
|
||||||
import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback";
|
import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback";
|
||||||
import type { LessonPlanDocument } from "../types";
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
import { isAiFeedbackCategory } from "../lib/type-guards";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
doc: LessonPlanDocument;
|
doc: LessonPlanDocument;
|
||||||
@@ -134,7 +135,7 @@ function FeedbackContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 各维度反馈 */}
|
{/* 各维度反馈 */}
|
||||||
{(Object.keys(grouped) as AiFeedbackItem["category"][]).map((cat) => {
|
{Object.keys(grouped).filter(isAiFeedbackCategory).map((cat) => {
|
||||||
const items = grouped[cat];
|
const items = grouped[cat];
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useFileUpload } from "@/modules/files/hooks/use-file-upload";
|
|||||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||||
import type { LessonPlanAttachmentOption } from "../providers/lesson-plan-provider";
|
import type { LessonPlanAttachmentOption } from "../providers/lesson-plan-provider";
|
||||||
import type { RichTextAttachment } from "../types";
|
import type { RichTextAttachment } from "../types";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -85,11 +85,11 @@ export function AttachmentPicker({
|
|||||||
attachmentType: "material",
|
attachmentType: "material",
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(t("attachment.createSuccess"));
|
notify.success(t("attachment.createSuccess"));
|
||||||
// 刷新附件列表
|
// 刷新附件列表
|
||||||
void loadAttachments();
|
void loadAttachments();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.save"));
|
notify.error(res.message ?? t("error.save"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -105,7 +105,7 @@ export function AttachmentPicker({
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AttachmentPicker] load failed", e);
|
console.error("[AttachmentPicker] load failed", e);
|
||||||
toast.error(t("attachment.empty"));
|
notify.error(t("attachment.empty"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -130,14 +130,14 @@ export function AttachmentPicker({
|
|||||||
try {
|
try {
|
||||||
const res = await service.deleteLessonPlanAttachment(attachmentId);
|
const res = await service.deleteLessonPlanAttachment(attachmentId);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(t("attachment.deleteSuccess"));
|
notify.success(t("attachment.deleteSuccess"));
|
||||||
void loadAttachments();
|
void loadAttachments();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.save"));
|
notify.error(res.message ?? t("error.save"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AttachmentPicker] delete failed", 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 { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
@@ -38,7 +38,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
|
|
||||||
function addAnnotation() {
|
function addAnnotation() {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
toast.error(t("textStudy.selectFirst"));
|
notify.error(t("textStudy.selectFirst"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ann: TextStudyAnnotation = {
|
const ann: TextStudyAnnotation = {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { DetailHead } from "./detail-head";
|
|||||||
import { DetailProps } from "./detail-props";
|
import { DetailProps } from "./detail-props";
|
||||||
import { QaEditor } from "./qa-editor";
|
import { QaEditor } from "./qa-editor";
|
||||||
import { BlockRenderer } from "../../config/block-registry";
|
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() {
|
export function DetailPanel() {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
@@ -58,8 +59,11 @@ export function DetailPanel() {
|
|||||||
<DetailProps node={node} />
|
<DetailProps node={node} />
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: 20, overflowY: "auto" }}>
|
<div style={{ flex: 1, padding: 20, overflowY: "auto" }}>
|
||||||
{isInteraction ? (
|
{isInteraction && isInteractionBlockData(node.data) ? (
|
||||||
<QaEditor nodeId={node.id} data={node.data as InteractionBlockData} />
|
<QaEditor nodeId={node.id} data={node.data} />
|
||||||
|
) : isInteraction ? (
|
||||||
|
// 类型守卫校验失败,数据异常时渲染 null
|
||||||
|
null
|
||||||
) : (
|
) : (
|
||||||
<BlockRenderer
|
<BlockRenderer
|
||||||
type={node.type}
|
type={node.type}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Block, DifferentiationLevel, TeachingStage } from "../../types";
|
import type { Block, DifferentiationLevel, TeachingStage } from "../../types";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
|
import { isDifferentiationLevel, isTeachingStage } from "../../lib/type-guards";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: Block;
|
node: Block;
|
||||||
@@ -30,13 +31,17 @@ export function DetailProps({ node }: Props) {
|
|||||||
label={t("v4.detail.stageLabel")}
|
label={t("v4.detail.stageLabel")}
|
||||||
value={node.stage ?? "—"}
|
value={node.stage ?? "—"}
|
||||||
options={STAGES}
|
options={STAGES}
|
||||||
onChange={(v) => updateNode(node.id, { stage: v as TeachingStage })}
|
onChange={(v) => {
|
||||||
|
if (isTeachingStage(v)) updateNode(node.id, { stage: v });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<PropItem
|
<PropItem
|
||||||
label={t("v4.detail.differentiationLabel")}
|
label={t("v4.detail.differentiationLabel")}
|
||||||
value={node.differentiation ?? "—"}
|
value={node.differentiation ?? "—"}
|
||||||
options={LEVELS}
|
options={LEVELS}
|
||||||
onChange={(v) => updateNode(node.id, { differentiation: v as DifferentiationLevel })}
|
onChange={(v) => {
|
||||||
|
if (isDifferentiationLevel(v)) updateNode(node.id, { differentiation: v });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
@@ -43,7 +43,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
toast.error(t("questionBank.stemRequired"));
|
notify.error(t("questionBank.stemRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content: Record<string, unknown> =
|
const content: Record<string, unknown> =
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -77,14 +77,14 @@ export function LessonPlanCard({
|
|||||||
const res = await service.deleteLessonPlan(plan.id);
|
const res = await service.deleteLessonPlan(plan.id);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||||
toast.success(t("status.archived"));
|
notify.success(t("status.archived"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.delete"));
|
notify.error(res.message ?? t("error.delete"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanCard] archive failed", e);
|
console.error("[LessonPlanCard] archive failed", e);
|
||||||
toast.error(t("error.delete"));
|
notify.error(t("error.delete"));
|
||||||
}
|
}
|
||||||
}, [service, plan.id, tracker, t, router]);
|
}, [service, plan.id, tracker, t, router]);
|
||||||
|
|
||||||
@@ -96,11 +96,11 @@ export function LessonPlanCard({
|
|||||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.duplicate"));
|
notify.error(res.message ?? t("error.duplicate"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanCard] duplicate failed", e);
|
console.error("[LessonPlanCard] duplicate failed", e);
|
||||||
toast.error(t("error.duplicate"));
|
notify.error(t("error.duplicate"));
|
||||||
}
|
}
|
||||||
}, [service, plan.id, tracker, t, router]);
|
}, [service, plan.id, tracker, t, router]);
|
||||||
|
|
||||||
@@ -110,14 +110,14 @@ export function LessonPlanCard({
|
|||||||
const res = await service.publishLessonPlan(plan.id);
|
const res = await service.publishLessonPlan(plan.id);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.publish", { planId: plan.id });
|
tracker.track("lesson_plan.publish", { planId: plan.id });
|
||||||
toast.success(res.message ?? t("action.publishPlanSuccess"));
|
notify.success(res.message ?? t("action.publishPlanSuccess"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.save"));
|
notify.error(res.message ?? t("error.save"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanCard] publish failed", e);
|
console.error("[LessonPlanCard] publish failed", e);
|
||||||
toast.error(t("error.save"));
|
notify.error(t("error.save"));
|
||||||
}
|
}
|
||||||
}, [service, plan.id, tracker, t, router]);
|
}, [service, plan.id, tracker, t, router]);
|
||||||
|
|
||||||
@@ -127,14 +127,14 @@ export function LessonPlanCard({
|
|||||||
const res = await service.unpublishLessonPlan(plan.id);
|
const res = await service.unpublishLessonPlan(plan.id);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.unpublish", { planId: plan.id });
|
tracker.track("lesson_plan.unpublish", { planId: plan.id });
|
||||||
toast.success(res.message ?? t("action.unpublishPlanSuccess"));
|
notify.success(res.message ?? t("action.unpublishPlanSuccess"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.save"));
|
notify.error(res.message ?? t("error.save"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanCard] unpublish failed", e);
|
console.error("[LessonPlanCard] unpublish failed", e);
|
||||||
toast.error(t("error.save"));
|
notify.error(t("error.save"));
|
||||||
}
|
}
|
||||||
}, [service, plan.id, tracker, t, router]);
|
}, [service, plan.id, tracker, t, router]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { useLessonPlanPersistence } from "../hooks/use-lesson-plan-persistence";
|
import { useLessonPlanPersistence } from "../hooks/use-lesson-plan-persistence";
|
||||||
import { StructureTree } from "./structure-tree/structure-tree";
|
import { StructureTree } from "./structure-tree/structure-tree";
|
||||||
@@ -85,11 +85,11 @@ export function LessonPlanEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleOnline() {
|
function handleOnline() {
|
||||||
useLessonPlanEditor.getState().setOnline(true);
|
useLessonPlanEditor.getState().setOnline(true);
|
||||||
toast.success(t("status.backOnline"));
|
notify.success(t("status.backOnline"));
|
||||||
}
|
}
|
||||||
function handleOffline() {
|
function handleOffline() {
|
||||||
useLessonPlanEditor.getState().setOnline(false);
|
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("online", handleOnline);
|
||||||
window.addEventListener("offline", handleOffline);
|
window.addEventListener("offline", handleOffline);
|
||||||
@@ -164,13 +164,13 @@ export function LessonPlanEditor({
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
setPlanStatus("published");
|
setPlanStatus("published");
|
||||||
tracker.track("lesson_plan.publish", { planId });
|
tracker.track("lesson_plan.publish", { planId });
|
||||||
toast.success(res.message ?? t("action.publishPlanSuccess"));
|
notify.success(res.message ?? t("action.publishPlanSuccess"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.save"));
|
notify.error(res.message ?? t("error.save"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanEditor] publish failed", e);
|
console.error("[LessonPlanEditor] publish failed", e);
|
||||||
toast.error(t("error.save"));
|
notify.error(t("error.save"));
|
||||||
} finally {
|
} finally {
|
||||||
setPublishing(false);
|
setPublishing(false);
|
||||||
}
|
}
|
||||||
@@ -185,13 +185,13 @@ export function LessonPlanEditor({
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
setPlanStatus("draft");
|
setPlanStatus("draft");
|
||||||
tracker.track("lesson_plan.unpublish", { planId });
|
tracker.track("lesson_plan.unpublish", { planId });
|
||||||
toast.success(res.message ?? t("action.unpublishPlanSuccess"));
|
notify.success(res.message ?? t("action.unpublishPlanSuccess"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.save"));
|
notify.error(res.message ?? t("error.save"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanEditor] unpublish failed", e);
|
console.error("[LessonPlanEditor] unpublish failed", e);
|
||||||
toast.error(t("error.save"));
|
notify.error(t("error.save"));
|
||||||
} finally {
|
} finally {
|
||||||
setPublishing(false);
|
setPublishing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Book } from "lucide-react";
|
|||||||
import type {
|
import type {
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanNode,
|
LessonPlanNode,
|
||||||
TeachingStage,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { TEACHING_STAGE_KEYS } from "../types";
|
import { TEACHING_STAGE_KEYS } from "../types";
|
||||||
import { getNodeColor, getNodeSummary } from "../lib/node-summary";
|
import { getNodeColor, getNodeSummary } from "../lib/node-summary";
|
||||||
@@ -37,7 +36,8 @@ export function LessonPlanMobileView({ doc, textbookTitle, chapterTitle }: Props
|
|||||||
const unstaged: LessonPlanNode[] = [];
|
const unstaged: LessonPlanNode[] = [];
|
||||||
for (const n of teachingNodes) {
|
for (const n of teachingNodes) {
|
||||||
if (n.stage) {
|
if (n.stage) {
|
||||||
const key = n.stage as TeachingStage;
|
// n.stage 已通过 if 真值检查窄化为 TeachingStage(排除 undefined)
|
||||||
|
const key = n.stage;
|
||||||
if (!groups[key]) groups[key] = [];
|
if (!groups[key]) groups[key] = [];
|
||||||
groups[key].push(n);
|
groups[key].push(n);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
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 { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
|
import { isInteractionBlockData } from "../../lib/type-guards";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: LessonPlanNode;
|
node: LessonPlanNode;
|
||||||
@@ -17,7 +18,9 @@ interface Props {
|
|||||||
export function InlineQaDialog({ node }: Props) {
|
export function InlineQaDialog({ node }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
|
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 (
|
return (
|
||||||
<div className="lp-inline-node">
|
<div className="lp-inline-node">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { LessonPlanNode } from "../../types";
|
import type { LessonPlanNode } from "../../types";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
export interface ContextMenuState {
|
export interface ContextMenuState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -56,11 +57,10 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
|
|||||||
|
|
||||||
const copyNode = async (nodeId: string) => {
|
const copyNode = async (nodeId: string) => {
|
||||||
const newId = duplicateNode(nodeId);
|
const newId = duplicateNode(nodeId);
|
||||||
const { toast } = await import("sonner");
|
|
||||||
if (newId) {
|
if (newId) {
|
||||||
toast.success(t("v4.contextMenu.copied"));
|
notify.success(t("v4.contextMenu.copied"));
|
||||||
} else {
|
} 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) {
|
if (onAiAction) {
|
||||||
onAiAction(action, nodeId);
|
onAiAction(action, nodeId);
|
||||||
} else {
|
} 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 { X, Calendar, Trash2, Plus } from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import {
|
import {
|
||||||
getLessonPlanSchedulesAction,
|
getLessonPlanSchedulesAction,
|
||||||
createLessonPlanScheduleAction,
|
createLessonPlanScheduleAction,
|
||||||
@@ -57,7 +57,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[ScheduleDialog] load failed", e);
|
console.error("[ScheduleDialog] load failed", e);
|
||||||
toast.error(t("error.getOne"));
|
notify.error(t("error.getOne"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
|
|||||||
durationMin,
|
durationMin,
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(t("schedule.addSuccess"));
|
notify.success(t("schedule.addSuccess"));
|
||||||
void loadSchedules();
|
void loadSchedules();
|
||||||
onScheduled?.();
|
onScheduled?.();
|
||||||
} else {
|
} else {
|
||||||
@@ -113,15 +113,15 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
|
|||||||
try {
|
try {
|
||||||
const res = await deleteLessonPlanScheduleAction(id);
|
const res = await deleteLessonPlanScheduleAction(id);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(t("schedule.deleteSuccess"));
|
notify.success(t("schedule.deleteSuccess"));
|
||||||
void loadSchedules();
|
void loadSchedules();
|
||||||
onScheduled?.();
|
onScheduled?.();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.save"));
|
notify.error(res.message ?? t("error.save"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[ScheduleDialog] delete failed", 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 { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
import { TreeNodeRow } from "./tree-node-row";
|
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() {
|
export function StructureTree() {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
@@ -69,7 +70,7 @@ export function StructureTree() {
|
|||||||
{/* 正文节点(始终在最上) */}
|
{/* 正文节点(始终在最上) */}
|
||||||
{textbookNode && (
|
{textbookNode && (
|
||||||
<TreeNodeRow
|
<TreeNodeRow
|
||||||
node={{ ...textbookNode, type: "textbook_content" } as unknown as Block}
|
node={textbookNode}
|
||||||
isExpanded={false}
|
isExpanded={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -82,8 +83,8 @@ export function StructureTree() {
|
|||||||
// 师生交互节点:子节点为对话轮次
|
// 师生交互节点:子节点为对话轮次
|
||||||
let childItems: { id: string; label: string; kind: string }[] = [];
|
let childItems: { id: string; label: string; kind: string }[] = [];
|
||||||
let hasChildren = false;
|
let hasChildren = false;
|
||||||
if (node.type === "interaction") {
|
if (node.type === "interaction" && isInteractionBlockData(node.data)) {
|
||||||
const data = node.data as InteractionBlockData;
|
const data = node.data;
|
||||||
hasChildren = (data.turns?.length ?? 0) > 0;
|
hasChildren = (data.turns?.length ?? 0) > 0;
|
||||||
childItems = (data.turns ?? []).map((turn, idx) => ({
|
childItems = (data.turns ?? []).map((turn, idx) => ({
|
||||||
id: turn.id,
|
id: turn.id,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Block } from "../../types";
|
import type { AnyLessonPlanNode } from "../../types";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: Block;
|
node: AnyLessonPlanNode;
|
||||||
/** 是否展开到正文 */
|
/** 是否展开到正文 */
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
/** 是否有子节点(如师生交互的对话轮次) */
|
/** 是否有子节点(如师生交互的对话轮次) */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
@@ -85,15 +85,15 @@ export function VersionHistoryDrawer({
|
|||||||
const res = await service.revertLessonPlanVersion({ planId, versionNo });
|
const res = await service.revertLessonPlanVersion({ planId, versionNo });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||||
toast.success(t("version.revertSuccess", { versionNo }));
|
notify.success(t("version.revertSuccess", { versionNo }));
|
||||||
onReverted();
|
onReverted();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.revert"));
|
notify.error(res.message ?? t("error.revert"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[VersionHistoryDrawer] revert failed", 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 { BlackboardBlock } from "../components/blocks/blackboard-block";
|
||||||
import { ReflectionBlock } from "../components/blocks/reflection-block";
|
import { ReflectionBlock } from "../components/blocks/reflection-block";
|
||||||
import { InteractionBlock } from "../components/blocks/interaction-block";
|
import { InteractionBlock } from "../components/blocks/interaction-block";
|
||||||
import type { InteractionBlockData } from "../types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block 注册表:配置驱动渲染。
|
* Block 注册表:配置驱动渲染。
|
||||||
@@ -208,7 +207,7 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
|
|||||||
<InteractionBlock
|
<InteractionBlock
|
||||||
blockId={rest.blockId}
|
blockId={rest.blockId}
|
||||||
data={rest.data}
|
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 { eq, desc } from "drizzle-orm";
|
||||||
import { cacheFn } from "@/shared/lib/cache";
|
import { cacheFn } from "@/shared/lib/cache";
|
||||||
import type { LessonPlanDocument } from "./types";
|
import type { LessonPlanDocument } from "./types";
|
||||||
|
import { isStringArray } from "./lib/type-guards";
|
||||||
|
|
||||||
/** 单次评估结果 */
|
/** 单次评估结果 */
|
||||||
export interface AiEvaluation {
|
export interface AiEvaluation {
|
||||||
@@ -34,6 +35,19 @@ export interface DimensionScores {
|
|||||||
assessment: number;
|
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 评估记录
|
* 查询某课案的所有 AI 评估记录
|
||||||
*/
|
*/
|
||||||
@@ -41,7 +55,17 @@ export async function getEvaluationsByPlanIdRaw(
|
|||||||
planId: string,
|
planId: string,
|
||||||
): Promise<AiEvaluation[]> {
|
): Promise<AiEvaluation[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanAiEvaluations)
|
||||||
.where(eq(lessonPlanAiEvaluations.planId, planId))
|
.where(eq(lessonPlanAiEvaluations.planId, planId))
|
||||||
.orderBy(desc(lessonPlanAiEvaluations.createdAt));
|
.orderBy(desc(lessonPlanAiEvaluations.createdAt));
|
||||||
@@ -60,7 +84,17 @@ export async function getLatestEvaluationRaw(
|
|||||||
planId: string,
|
planId: string,
|
||||||
): Promise<AiEvaluation | null> {
|
): Promise<AiEvaluation | null> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanAiEvaluations)
|
||||||
.where(eq(lessonPlanAiEvaluations.planId, planId))
|
.where(eq(lessonPlanAiEvaluations.planId, planId))
|
||||||
.orderBy(desc(lessonPlanAiEvaluations.createdAt))
|
.orderBy(desc(lessonPlanAiEvaluations.createdAt))
|
||||||
@@ -180,15 +214,21 @@ export function evaluateDocument(
|
|||||||
function mapRowToEvaluation(
|
function mapRowToEvaluation(
|
||||||
row: typeof lessonPlanAiEvaluations.$inferSelect,
|
row: typeof lessonPlanAiEvaluations.$inferSelect,
|
||||||
): AiEvaluation {
|
): AiEvaluation {
|
||||||
|
// DB JSON 字段为 unknown,用类型守卫安全收窄
|
||||||
|
const dimensionScores = isDimensionScores(row.dimensionScores)
|
||||||
|
? row.dimensionScores
|
||||||
|
: null;
|
||||||
|
const recommendedStandardIds = isStringArray(row.recommendedStandardIds)
|
||||||
|
? row.recommendedStandardIds
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
planId: row.planId,
|
planId: row.planId,
|
||||||
versionNo: row.versionNo,
|
versionNo: row.versionNo,
|
||||||
overallScore: row.overallScore,
|
overallScore: row.overallScore,
|
||||||
dimensionScores: (row.dimensionScores as DimensionScores) ?? null,
|
dimensionScores,
|
||||||
suggestions: row.suggestions,
|
suggestions: row.suggestions,
|
||||||
recommendedStandardIds:
|
recommendedStandardIds,
|
||||||
(row.recommendedStandardIds as string[] | null) ?? null,
|
|
||||||
evaluatedBy: row.evaluatedBy,
|
evaluatedBy: row.evaluatedBy,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
lessonPlanStandards,
|
lessonPlanStandards,
|
||||||
} from "@/shared/db/schema";
|
} from "@/shared/db/schema";
|
||||||
import { and, eq, gte, lte, desc, sql, count } from "drizzle-orm";
|
import { and, eq, gte, lte, desc, sql, count } from "drizzle-orm";
|
||||||
import type { LessonPlanStatus } from "./types";
|
|
||||||
|
|
||||||
/** 教师备课投入数据点 */
|
/** 教师备课投入数据点 */
|
||||||
export interface TeacherInvestmentDataPoint {
|
export interface TeacherInvestmentDataPoint {
|
||||||
@@ -61,7 +60,16 @@ export async function getTeacherInvestmentRaw(
|
|||||||
if (teacherId) conditions.push(eq(lessonPlanAnalyticsDaily.teacherId, teacherId));
|
if (teacherId) conditions.push(eq(lessonPlanAnalyticsDaily.teacherId, teacherId));
|
||||||
|
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanAnalyticsDaily)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(lessonPlanAnalyticsDaily.snapshotDate));
|
.orderBy(desc(lessonPlanAnalyticsDaily.snapshotDate));
|
||||||
@@ -96,7 +104,7 @@ export async function getTemplateUsageStatsRaw(): Promise<TemplateUsageDataPoint
|
|||||||
.orderBy(desc(count(lessonPlans.id)));
|
.orderBy(desc(count(lessonPlans.id)));
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
templateId: r.templateId!,
|
templateId: r.templateId ?? "",
|
||||||
templateName: r.templateName ?? "(unknown)",
|
templateName: r.templateName ?? "(unknown)",
|
||||||
usageCount: r.usageCount,
|
usageCount: r.usageCount,
|
||||||
}));
|
}));
|
||||||
@@ -175,11 +183,11 @@ export async function getGlobalLessonPlanStatsRaw(): Promise<{
|
|||||||
const publishedRows = await db
|
const publishedRows = await db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.where(eq(lessonPlans.status, "published" as LessonPlanStatus));
|
.where(eq(lessonPlans.status, "published"));
|
||||||
const submittedRows = await db
|
const submittedRows = await db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.where(eq(lessonPlans.status, "submitted" as LessonPlanStatus));
|
.where(eq(lessonPlans.status, "submitted"));
|
||||||
const standardsRows = await db
|
const standardsRows = await db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(lessonPlanStandards);
|
.from(lessonPlanStandards);
|
||||||
@@ -208,7 +216,15 @@ export async function upsertDailyAnalytics(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 简化实现:先尝试更新,不存在则插入
|
// 简化实现:先尝试更新,不存在则插入
|
||||||
const existing = await db
|
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)
|
.from(lessonPlanAnalyticsDaily)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|||||||
@@ -28,7 +28,16 @@ export async function getAttachmentsByPlanIdRaw(
|
|||||||
planId: string,
|
planId: string,
|
||||||
): Promise<LessonPlanAttachment[]> {
|
): Promise<LessonPlanAttachment[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanAttachments)
|
||||||
.where(eq(lessonPlanAttachments.planId, planId))
|
.where(eq(lessonPlanAttachments.planId, planId))
|
||||||
.orderBy(desc(lessonPlanAttachments.createdAt));
|
.orderBy(desc(lessonPlanAttachments.createdAt));
|
||||||
@@ -46,7 +55,16 @@ export async function getAttachmentsByBlockIdRaw(
|
|||||||
blockId: string,
|
blockId: string,
|
||||||
): Promise<LessonPlanAttachment[]> {
|
): Promise<LessonPlanAttachment[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanAttachments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -120,7 +138,16 @@ export async function getAttachmentByIdRaw(
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<LessonPlanAttachment | null> {
|
): Promise<LessonPlanAttachment | null> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanAttachments)
|
||||||
.where(eq(lessonPlanAttachments.id, id))
|
.where(eq(lessonPlanAttachments.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { db } from "@/shared/db";
|
|||||||
import { lessonPlans, lessonPlanVersions, lessonPlanReviewRecords } from "@/shared/db/schema";
|
import { lessonPlans, lessonPlanVersions, lessonPlanReviewRecords } from "@/shared/db/schema";
|
||||||
import { and, eq, gte, lte, asc } from "drizzle-orm";
|
import { and, eq, gte, lte, asc } from "drizzle-orm";
|
||||||
import type { LessonPlanStatus } from "./types";
|
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 {
|
export interface LessonPlanCalendarEvent {
|
||||||
@@ -51,7 +56,8 @@ export async function getCalendarEventsRaw(
|
|||||||
lte(lessonPlans.updatedAt, endDate),
|
lte(lessonPlans.updatedAt, endDate),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(lessonPlans.updatedAt));
|
.orderBy(asc(lessonPlans.updatedAt))
|
||||||
|
.limit(DEFAULT_LIMIT);
|
||||||
|
|
||||||
for (const p of planRows) {
|
for (const p of planRows) {
|
||||||
// 创建事件
|
// 创建事件
|
||||||
@@ -60,7 +66,7 @@ export async function getCalendarEventsRaw(
|
|||||||
id: `${p.id}-created`,
|
id: `${p.id}-created`,
|
||||||
planId: p.id,
|
planId: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
status: p.status as LessonPlanStatus,
|
status: toLessonPlanStatus(p.status),
|
||||||
eventType: "created",
|
eventType: "created",
|
||||||
occurredAt: p.createdAt,
|
occurredAt: p.createdAt,
|
||||||
creatorId: p.creatorId,
|
creatorId: p.creatorId,
|
||||||
@@ -72,19 +78,19 @@ export async function getCalendarEventsRaw(
|
|||||||
id: `${p.id}-updated`,
|
id: `${p.id}-updated`,
|
||||||
planId: p.id,
|
planId: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
status: p.status as LessonPlanStatus,
|
status: toLessonPlanStatus(p.status),
|
||||||
eventType: "updated",
|
eventType: "updated",
|
||||||
occurredAt: p.updatedAt,
|
occurredAt: p.updatedAt,
|
||||||
creatorId: p.creatorId,
|
creatorId: p.creatorId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 提交/发布事件
|
// 提交/发布事件
|
||||||
if (p.status === "submitted" as LessonPlanStatus || p.status === "published" as LessonPlanStatus) {
|
if (p.status === "submitted" || p.status === "published") {
|
||||||
events.push({
|
events.push({
|
||||||
id: `${p.id}-${p.status}`,
|
id: `${p.id}-${p.status}`,
|
||||||
planId: p.id,
|
planId: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
status: p.status as LessonPlanStatus,
|
status: toLessonPlanStatus(p.status),
|
||||||
eventType: p.status === "submitted" ? "submitted" : "published",
|
eventType: p.status === "submitted" ? "submitted" : "published",
|
||||||
occurredAt: p.updatedAt,
|
occurredAt: p.updatedAt,
|
||||||
creatorId: p.creatorId,
|
creatorId: p.creatorId,
|
||||||
@@ -111,14 +117,15 @@ export async function getCalendarEventsRaw(
|
|||||||
lte(lessonPlanVersions.createdAt, endDate),
|
lte(lessonPlanVersions.createdAt, endDate),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(lessonPlanVersions.createdAt));
|
.orderBy(asc(lessonPlanVersions.createdAt))
|
||||||
|
.limit(DEFAULT_LIMIT);
|
||||||
|
|
||||||
for (const v of versionRows) {
|
for (const v of versionRows) {
|
||||||
events.push({
|
events.push({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
planId: v.planId,
|
planId: v.planId,
|
||||||
title: v.title,
|
title: v.title,
|
||||||
status: "draft" as LessonPlanStatus,
|
status: "draft",
|
||||||
eventType: "version_saved",
|
eventType: "version_saved",
|
||||||
occurredAt: v.createdAt,
|
occurredAt: v.createdAt,
|
||||||
creatorId: v.creatorId,
|
creatorId: v.creatorId,
|
||||||
@@ -144,14 +151,15 @@ export async function getCalendarEventsRaw(
|
|||||||
gte(lessonPlanReviewRecords.createdAt, startDate),
|
gte(lessonPlanReviewRecords.createdAt, startDate),
|
||||||
lte(lessonPlanReviewRecords.createdAt, endDate),
|
lte(lessonPlanReviewRecords.createdAt, endDate),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.limit(DEFAULT_LIMIT);
|
||||||
|
|
||||||
for (const r of reviewRows) {
|
for (const r of reviewRows) {
|
||||||
events.push({
|
events.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
planId: r.planId,
|
planId: r.planId,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
status: "approved" as LessonPlanStatus,
|
status: "approved",
|
||||||
eventType: "reviewed",
|
eventType: "reviewed",
|
||||||
occurredAt: r.createdAt,
|
occurredAt: r.createdAt,
|
||||||
creatorId: r.reviewerId,
|
creatorId: r.reviewerId,
|
||||||
@@ -172,7 +180,7 @@ export function groupEventsByDate(
|
|||||||
): Map<string, LessonPlanCalendarEvent[]> {
|
): Map<string, LessonPlanCalendarEvent[]> {
|
||||||
const map = new Map<string, LessonPlanCalendarEvent[]>();
|
const map = new Map<string, LessonPlanCalendarEvent[]>();
|
||||||
for (const e of events) {
|
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) ?? [];
|
const list = map.get(dateKey) ?? [];
|
||||||
list.push(e);
|
list.push(e);
|
||||||
map.set(dateKey, list);
|
map.set(dateKey, list);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import "server-only";
|
|||||||
import { cacheFn } from "@/shared/lib/cache";
|
import { cacheFn } from "@/shared/lib/cache";
|
||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
import { lessonPlanComments } from "@/shared/db/schema";
|
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 {
|
export interface LessonPlanComment {
|
||||||
@@ -30,7 +30,17 @@ export async function getCommentsByPlanIdRaw(
|
|||||||
planId: string,
|
planId: string,
|
||||||
): Promise<LessonPlanComment[]> {
|
): Promise<LessonPlanComment[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanComments)
|
||||||
.where(eq(lessonPlanComments.planId, planId))
|
.where(eq(lessonPlanComments.planId, planId))
|
||||||
.orderBy(asc(lessonPlanComments.createdAt));
|
.orderBy(asc(lessonPlanComments.createdAt));
|
||||||
@@ -48,7 +58,17 @@ export async function getCommentsByBlockIdRaw(
|
|||||||
blockId: string,
|
blockId: string,
|
||||||
): Promise<LessonPlanComment[]> {
|
): Promise<LessonPlanComment[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanComments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -115,7 +135,9 @@ export async function toggleCommentResolved(
|
|||||||
.where(eq(lessonPlanComments.id, commentId))
|
.where(eq(lessonPlanComments.id, commentId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
const newResolved = !rows[0]!.resolved;
|
const row = rows[0];
|
||||||
|
if (!row) return;
|
||||||
|
const newResolved = !row.resolved;
|
||||||
await db
|
await db
|
||||||
.update(lessonPlanComments)
|
.update(lessonPlanComments)
|
||||||
.set({ resolved: newResolved, updatedAt: new Date() })
|
.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> {
|
export async function deleteComment(commentId: string): Promise<void> {
|
||||||
// 先删除所有子回复
|
const visited = new Set<string>([commentId])
|
||||||
const children = await db
|
let currentLevel: string[] = [commentId]
|
||||||
.select({ id: lessonPlanComments.id })
|
|
||||||
.from(lessonPlanComments)
|
while (currentLevel.length > 0) {
|
||||||
.where(eq(lessonPlanComments.parentCommentId, commentId));
|
const children = await db
|
||||||
for (const child of children) {
|
.select({ id: lessonPlanComments.id })
|
||||||
await deleteComment(child.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
|
await db
|
||||||
.delete(lessonPlanComments)
|
.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 type { FormativeInteractionType } from "./lib/type-guards";
|
||||||
import { isFormativeInteractionType } from "./lib/type-guards";
|
import { isFormativeInteractionType } from "./lib/type-guards";
|
||||||
|
|
||||||
|
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
|
||||||
|
const DEFAULT_LIMIT = 1000;
|
||||||
|
|
||||||
/** 互动组件项 */
|
/** 互动组件项 */
|
||||||
export interface FormativeItem {
|
export interface FormativeItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,7 +51,17 @@ export async function getFormativeItemsByPlanIdRaw(
|
|||||||
planId: string,
|
planId: string,
|
||||||
): Promise<FormativeItem[]> {
|
): Promise<FormativeItem[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanFormativeItems)
|
||||||
.where(eq(lessonPlanFormativeItems.planId, planId))
|
.where(eq(lessonPlanFormativeItems.planId, planId))
|
||||||
.orderBy(asc(lessonPlanFormativeItems.orderIndex));
|
.orderBy(asc(lessonPlanFormativeItems.orderIndex));
|
||||||
@@ -65,11 +78,22 @@ export async function getFormativeItemByIdRaw(
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<FormativeItem | null> {
|
): Promise<FormativeItem | null> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanFormativeItems)
|
||||||
.where(eq(lessonPlanFormativeItems.id, id))
|
.where(eq(lessonPlanFormativeItems.id, id))
|
||||||
.limit(1);
|
.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"] });
|
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,
|
itemId: string,
|
||||||
): Promise<FormativeResponse[]> {
|
): Promise<FormativeResponse[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanFormativeResponses)
|
||||||
.where(eq(lessonPlanFormativeResponses.itemId, itemId))
|
.where(eq(lessonPlanFormativeResponses.itemId, itemId))
|
||||||
.orderBy(desc(lessonPlanFormativeResponses.createdAt));
|
.orderBy(desc(lessonPlanFormativeResponses.createdAt));
|
||||||
@@ -192,15 +225,35 @@ export async function getResponsesByStudentIdRaw(
|
|||||||
if (items.length === 0) return [];
|
if (items.length === 0) return [];
|
||||||
const itemIds = items.map((i) => i.id);
|
const itemIds = items.map((i) => i.id);
|
||||||
const rows = await db
|
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)
|
.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);
|
return rows.filter((r) => itemIds.includes(r.itemId)).map(mapRowToResponse);
|
||||||
}
|
}
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanFormativeResponses)
|
||||||
.where(eq(lessonPlanFormativeResponses.studentId, studentId));
|
.where(eq(lessonPlanFormativeResponses.studentId, studentId))
|
||||||
|
.limit(DEFAULT_LIMIT);
|
||||||
return rows.map(mapRowToResponse);
|
return rows.map(mapRowToResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +266,10 @@ export async function getFormativeItemStatsRaw(
|
|||||||
itemId: string,
|
itemId: string,
|
||||||
): Promise<{ total: number; correct: number; incorrect: number; avgDurationSec: number }> {
|
): Promise<{ total: number; correct: number; incorrect: number; avgDurationSec: number }> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select({
|
||||||
|
isCorrect: lessonPlanFormativeResponses.isCorrect,
|
||||||
|
durationSec: lessonPlanFormativeResponses.durationSec,
|
||||||
|
})
|
||||||
.from(lessonPlanFormativeResponses)
|
.from(lessonPlanFormativeResponses)
|
||||||
.where(eq(lessonPlanFormativeResponses.itemId, itemId));
|
.where(eq(lessonPlanFormativeResponses.itemId, itemId));
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,12 @@ import { db } from "@/shared/db";
|
|||||||
import { lessonPlans } from "@/shared/db/schema";
|
import { lessonPlans } from "@/shared/db/schema";
|
||||||
import { escapeLikePattern } from "@/shared/lib/action-utils";
|
import { escapeLikePattern } from "@/shared/lib/action-utils";
|
||||||
import { isRecord } from "@/shared/lib/type-guards";
|
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 { normalizeDocument } from "./data-access";
|
||||||
import type { LessonPlanListItem } from "./types";
|
import type { LessonPlanListItem } from "./types";
|
||||||
|
|
||||||
// ---- 安全字段提取辅助(替代 as 断言,从 BlockData 联合类型收窄)----
|
// ---- 安全字段提取辅助(替代 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 {
|
function getStringArray(v: unknown): string[] | undefined {
|
||||||
return isStringArray(v) ? v : undefined;
|
return isStringArray(v) ? v : undefined;
|
||||||
}
|
}
|
||||||
@@ -89,8 +85,25 @@ export async function getLessonPlansByKnowledgePointRaw(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<LessonPlanListItem[]> {
|
): Promise<LessonPlanListItem[]> {
|
||||||
// content 是 JSON,用 LIKE 粗筛后内存精确过滤
|
// content 是 JSON,用 LIKE 粗筛后内存精确过滤
|
||||||
|
// F-02: 子串匹配需 FULLTEXT 索引,短期保留(JSON 内嵌 ID 无法前缀匹配)
|
||||||
const rows = await db
|
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)
|
.from(lessonPlans)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -119,8 +132,25 @@ export async function getLessonPlansByQuestionRaw(
|
|||||||
questionId: string,
|
questionId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<LessonPlanListItem[]> {
|
): Promise<LessonPlanListItem[]> {
|
||||||
|
// F-02: 子串匹配需 FULLTEXT 索引,短期保留(JSON 内嵌 ID 无法前缀匹配)
|
||||||
const rows = await db
|
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)
|
.from(lessonPlans)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { lessonPlans, lessonPlanReviewRecords } from "@/shared/db/schema";
|
|||||||
import { and, eq, desc, asc, inArray } from "drizzle-orm";
|
import { and, eq, desc, asc, inArray } from "drizzle-orm";
|
||||||
import type { LessonPlanStatus } from "./types";
|
import type { LessonPlanStatus } from "./types";
|
||||||
import { LESSON_PLAN_STATUS_TRANSITIONS, isReviewableStatus } 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 {
|
export interface LessonPlanReviewRecord {
|
||||||
@@ -49,7 +54,9 @@ export async function submitForReview(
|
|||||||
if (plan.length === 0) {
|
if (plan.length === 0) {
|
||||||
throw new Error("PLAN_NOT_FOUND");
|
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") {
|
if (!isReviewableStatus(currentStatus) && currentStatus !== "draft" && currentStatus !== "rejected") {
|
||||||
throw new Error("INVALID_STATUS_TRANSITION");
|
throw new Error("INVALID_STATUS_TRANSITION");
|
||||||
}
|
}
|
||||||
@@ -81,7 +88,9 @@ export async function reviewPlan(
|
|||||||
if (plan.length === 0) {
|
if (plan.length === 0) {
|
||||||
throw new Error("PLAN_NOT_FOUND");
|
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)) {
|
if (!isReviewableStatus(previousStatus)) {
|
||||||
throw new Error("PLAN_NOT_REVIEWABLE");
|
throw new Error("PLAN_NOT_REVIEWABLE");
|
||||||
}
|
}
|
||||||
@@ -128,7 +137,16 @@ export async function getReviewRecordsByPlanIdRaw(
|
|||||||
planId: string,
|
planId: string,
|
||||||
): Promise<LessonPlanReviewRecord[]> {
|
): Promise<LessonPlanReviewRecord[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanReviewRecords)
|
||||||
.where(eq(lessonPlanReviewRecords.planId, planId))
|
.where(eq(lessonPlanReviewRecords.planId, planId))
|
||||||
.orderBy(desc(lessonPlanReviewRecords.createdAt));
|
.orderBy(desc(lessonPlanReviewRecords.createdAt));
|
||||||
@@ -137,10 +155,10 @@ export async function getReviewRecordsByPlanIdRaw(
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
planId: r.planId,
|
planId: r.planId,
|
||||||
reviewerId: r.reviewerId,
|
reviewerId: r.reviewerId,
|
||||||
decision: r.decision as "approved" | "rejected",
|
decision: toReviewDecision(r.decision),
|
||||||
comment: r.comment ?? undefined,
|
comment: r.comment ?? undefined,
|
||||||
previousStatus: r.previousStatus as LessonPlanStatus,
|
previousStatus: toLessonPlanStatus(r.previousStatus),
|
||||||
newStatus: r.newStatus as LessonPlanStatus,
|
newStatus: toLessonPlanStatus(r.newStatus),
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -178,7 +196,8 @@ export async function getPendingReviewPlansRaw(
|
|||||||
})
|
})
|
||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(asc(lessonPlans.updatedAt));
|
.orderBy(asc(lessonPlans.updatedAt))
|
||||||
|
.limit(DEFAULT_LIMIT);
|
||||||
|
|
||||||
// 按教研组长权限过滤(gradeId/subjectId)
|
// 按教研组长权限过滤(gradeId/subjectId)
|
||||||
return rows
|
return rows
|
||||||
@@ -194,7 +213,7 @@ export async function getPendingReviewPlansRaw(
|
|||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
status: r.status as LessonPlanStatus,
|
status: toLessonPlanStatus(r.status),
|
||||||
creatorId: r.creatorId,
|
creatorId: r.creatorId,
|
||||||
gradeId: r.gradeId,
|
gradeId: r.gradeId,
|
||||||
subjectId: r.subjectId,
|
subjectId: r.subjectId,
|
||||||
@@ -219,7 +238,9 @@ export async function withdrawSubmission(
|
|||||||
if (plan.length === 0) {
|
if (plan.length === 0) {
|
||||||
throw new Error("PLAN_NOT_FOUND");
|
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")) {
|
if (!isValidTransition(currentStatus, "draft")) {
|
||||||
throw new Error("INVALID_STATUS_TRANSITION");
|
throw new Error("INVALID_STATUS_TRANSITION");
|
||||||
}
|
}
|
||||||
@@ -265,12 +286,13 @@ export async function getPlansByStatusesRaw(
|
|||||||
})
|
})
|
||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
.orderBy(desc(lessonPlans.updatedAt));
|
.orderBy(desc(lessonPlans.updatedAt))
|
||||||
|
.limit(DEFAULT_LIMIT);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
status: r.status as LessonPlanStatus,
|
status: toLessonPlanStatus(r.status),
|
||||||
creatorId: r.creatorId,
|
creatorId: r.creatorId,
|
||||||
updatedAt: r.updatedAt,
|
updatedAt: r.updatedAt,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { cacheFn } from "@/shared/lib/cache";
|
import { cacheFn } from "@/shared/lib/cache";
|
||||||
import { db } from "@/shared/db";
|
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 { and, eq, gte, lte, desc } from "drizzle-orm";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
|
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
|
||||||
|
const DEFAULT_LIMIT = 1000;
|
||||||
|
|
||||||
/** 课时绑定记录 */
|
/** 课时绑定记录 */
|
||||||
export interface LessonPlanScheduleRecord {
|
export interface LessonPlanScheduleRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,7 +46,6 @@ export async function getSchedulesByPlanIdRaw(
|
|||||||
id: lessonPlanSchedules.id,
|
id: lessonPlanSchedules.id,
|
||||||
planId: lessonPlanSchedules.planId,
|
planId: lessonPlanSchedules.planId,
|
||||||
classId: lessonPlanSchedules.classId,
|
classId: lessonPlanSchedules.classId,
|
||||||
className: classes.name,
|
|
||||||
scheduledDate: lessonPlanSchedules.scheduledDate,
|
scheduledDate: lessonPlanSchedules.scheduledDate,
|
||||||
period: lessonPlanSchedules.period,
|
period: lessonPlanSchedules.period,
|
||||||
classScheduleId: lessonPlanSchedules.classScheduleId,
|
classScheduleId: lessonPlanSchedules.classScheduleId,
|
||||||
@@ -52,15 +55,20 @@ export async function getSchedulesByPlanIdRaw(
|
|||||||
updatedAt: lessonPlanSchedules.updatedAt,
|
updatedAt: lessonPlanSchedules.updatedAt,
|
||||||
})
|
})
|
||||||
.from(lessonPlanSchedules)
|
.from(lessonPlanSchedules)
|
||||||
.leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
|
|
||||||
.where(eq(lessonPlanSchedules.planId, planId))
|
.where(eq(lessonPlanSchedules.planId, planId))
|
||||||
.orderBy(desc(lessonPlanSchedules.scheduledDate));
|
.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) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
planId: r.planId,
|
planId: r.planId,
|
||||||
classId: r.classId,
|
classId: r.classId,
|
||||||
className: r.className ?? "",
|
className: classNameMap.get(r.classId) ?? "",
|
||||||
scheduledDate: toDateStr(r.scheduledDate),
|
scheduledDate: toDateStr(r.scheduledDate),
|
||||||
period: r.period,
|
period: r.period,
|
||||||
classScheduleId: r.classScheduleId,
|
classScheduleId: r.classScheduleId,
|
||||||
@@ -85,7 +93,6 @@ export async function getSchedulesByDateRangeRaw(
|
|||||||
id: lessonPlanSchedules.id,
|
id: lessonPlanSchedules.id,
|
||||||
planId: lessonPlanSchedules.planId,
|
planId: lessonPlanSchedules.planId,
|
||||||
classId: lessonPlanSchedules.classId,
|
classId: lessonPlanSchedules.classId,
|
||||||
className: classes.name,
|
|
||||||
scheduledDate: lessonPlanSchedules.scheduledDate,
|
scheduledDate: lessonPlanSchedules.scheduledDate,
|
||||||
period: lessonPlanSchedules.period,
|
period: lessonPlanSchedules.period,
|
||||||
classScheduleId: lessonPlanSchedules.classScheduleId,
|
classScheduleId: lessonPlanSchedules.classScheduleId,
|
||||||
@@ -95,7 +102,6 @@ export async function getSchedulesByDateRangeRaw(
|
|||||||
updatedAt: lessonPlanSchedules.updatedAt,
|
updatedAt: lessonPlanSchedules.updatedAt,
|
||||||
})
|
})
|
||||||
.from(lessonPlanSchedules)
|
.from(lessonPlanSchedules)
|
||||||
.leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
// 简化:仅按日期范围过滤(planId 过滤由调用方处理或 join)
|
// 简化:仅按日期范围过滤(planId 过滤由调用方处理或 join)
|
||||||
@@ -103,23 +109,29 @@ export async function getSchedulesByDateRangeRaw(
|
|||||||
lte(lessonPlanSchedules.scheduledDate, new Date(endDate)),
|
lte(lessonPlanSchedules.scheduledDate, new Date(endDate)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(lessonPlanSchedules.scheduledDate));
|
.orderBy(desc(lessonPlanSchedules.scheduledDate))
|
||||||
|
.limit(DEFAULT_LIMIT);
|
||||||
|
|
||||||
return rows
|
const filtered = rows.filter((r) => teacherPlanIds.includes(r.planId));
|
||||||
.filter((r) => teacherPlanIds.includes(r.planId))
|
if (filtered.length === 0) return [];
|
||||||
.map((r) => ({
|
|
||||||
id: r.id,
|
// 跨模块批量解析班级名称,避免直接 JOIN classes 表
|
||||||
planId: r.planId,
|
const classIds = Array.from(new Set(filtered.map((r) => r.classId)));
|
||||||
classId: r.classId,
|
const classNameMap = await getClassNamesByIds(classIds);
|
||||||
className: r.className ?? "",
|
|
||||||
scheduledDate: toDateStr(r.scheduledDate),
|
return filtered.map((r) => ({
|
||||||
period: r.period,
|
id: r.id,
|
||||||
classScheduleId: r.classScheduleId,
|
planId: r.planId,
|
||||||
durationMin: r.durationMin,
|
classId: r.classId,
|
||||||
createdBy: r.createdBy,
|
className: classNameMap.get(r.classId) ?? "",
|
||||||
createdAt: r.createdAt,
|
scheduledDate: toDateStr(r.scheduledDate),
|
||||||
updatedAt: r.updatedAt,
|
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"] });
|
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,
|
id: lessonPlanSchedules.id,
|
||||||
planId: lessonPlanSchedules.planId,
|
planId: lessonPlanSchedules.planId,
|
||||||
classId: lessonPlanSchedules.classId,
|
classId: lessonPlanSchedules.classId,
|
||||||
className: classes.name,
|
|
||||||
scheduledDate: lessonPlanSchedules.scheduledDate,
|
scheduledDate: lessonPlanSchedules.scheduledDate,
|
||||||
period: lessonPlanSchedules.period,
|
period: lessonPlanSchedules.period,
|
||||||
classScheduleId: lessonPlanSchedules.classScheduleId,
|
classScheduleId: lessonPlanSchedules.classScheduleId,
|
||||||
@@ -161,15 +172,17 @@ export async function createSchedule(input: {
|
|||||||
updatedAt: lessonPlanSchedules.updatedAt,
|
updatedAt: lessonPlanSchedules.updatedAt,
|
||||||
})
|
})
|
||||||
.from(lessonPlanSchedules)
|
.from(lessonPlanSchedules)
|
||||||
.leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
|
|
||||||
.where(eq(lessonPlanSchedules.id, newId));
|
.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 {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
planId: r.planId,
|
planId: r.planId,
|
||||||
classId: r.classId,
|
classId: r.classId,
|
||||||
className: r.className ?? "",
|
className,
|
||||||
scheduledDate: toDateStr(r.scheduledDate),
|
scheduledDate: toDateStr(r.scheduledDate),
|
||||||
period: r.period,
|
period: r.period,
|
||||||
classScheduleId: r.classScheduleId,
|
classScheduleId: r.classScheduleId,
|
||||||
|
|||||||
@@ -31,7 +31,19 @@ export async function getSubstitutesByPlanIdRaw(
|
|||||||
planId: string,
|
planId: string,
|
||||||
): Promise<LessonPlanSubstitute[]> {
|
): Promise<LessonPlanSubstitute[]> {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanSubstitutes)
|
||||||
.where(eq(lessonPlanSubstitutes.planId, planId))
|
.where(eq(lessonPlanSubstitutes.planId, planId))
|
||||||
.orderBy(desc(lessonPlanSubstitutes.createdAt));
|
.orderBy(desc(lessonPlanSubstitutes.createdAt));
|
||||||
@@ -49,7 +61,19 @@ export async function getActiveSubstitutesByTeacherIdRaw(
|
|||||||
): Promise<LessonPlanSubstitute[]> {
|
): Promise<LessonPlanSubstitute[]> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanSubstitutes)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -122,7 +146,7 @@ export async function deleteSubstitute(substituteId: string): Promise<void> {
|
|||||||
* - 是原教师 → true
|
* - 是原教师 → true
|
||||||
* - 是当前生效的代课教师 → true
|
* - 是当前生效的代课教师 → true
|
||||||
*/
|
*/
|
||||||
export async function canTeacherAccessPlan(
|
export async function canTeacherAccessPlanRaw(
|
||||||
planId: string,
|
planId: string,
|
||||||
teacherId: string,
|
teacherId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -133,12 +157,18 @@ export async function canTeacherAccessPlan(
|
|||||||
.where(eq(lessonPlans.id, planId))
|
.where(eq(lessonPlans.id, planId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (plan.length === 0) return false;
|
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);
|
const substitutes = await getActiveSubstitutesByTeacherId(teacherId);
|
||||||
return substitutes.some((s) => s.planId === planId);
|
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(
|
function mapRowToSubstitute(
|
||||||
row: typeof lessonPlanSubstitutes.$inferSelect,
|
row: typeof lessonPlanSubstitutes.$inferSelect,
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ function mapRowToTemplate(row: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可用模板列表(系统模板 + 当前用户的个人模板)。
|
||||||
|
*
|
||||||
|
* 系统模板来自内存常量 `SYSTEM_TEMPLATES`,个人模板从 DB 查询 creatorId 等于
|
||||||
|
* 当前用户的 personal 模板。
|
||||||
|
*
|
||||||
|
* @param userId - 当前用户 ID(用于过滤个人模板)
|
||||||
|
* @returns 系统模板在前、个人模板在后的合并列表
|
||||||
|
*/
|
||||||
export async function getLessonPlanTemplatesRaw(
|
export async function getLessonPlanTemplatesRaw(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<LessonPlanTemplate[]> {
|
): Promise<LessonPlanTemplate[]> {
|
||||||
@@ -58,7 +67,16 @@ export async function getLessonPlanTemplatesRaw(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const personalRows = await db
|
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)
|
.from(lessonPlanTemplates)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -73,6 +91,18 @@ export async function getLessonPlanTemplatesRaw(
|
|||||||
|
|
||||||
export const getLessonPlanTemplates = cacheFn(getLessonPlanTemplatesRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "templates", "list"] });
|
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: {
|
export async function saveAsTemplate(input: {
|
||||||
sourcePlanId: string;
|
sourcePlanId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -111,6 +141,14 @@ export async function saveAsTemplate(input: {
|
|||||||
return { templateId };
|
return { templateId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除当前用户的指定个人模板。
|
||||||
|
*
|
||||||
|
* 同时校验 type=personal 与 creatorId 匹配,防止越权删除他人模板或系统模板。
|
||||||
|
*
|
||||||
|
* @param templateId - 待删除的模板 ID
|
||||||
|
* @param userId - 当前用户 ID
|
||||||
|
*/
|
||||||
export async function deletePersonalTemplate(
|
export async function deletePersonalTemplate(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ function mapRowToVersion(row: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定课案的全部版本列表(按版本号降序)。
|
||||||
|
*
|
||||||
|
* 仅返回当前用户创建的课案版本,校验 planId 归属。
|
||||||
|
*
|
||||||
|
* @param planId - 课案 ID
|
||||||
|
* @param userId - 当前用户 ID(用于归属校验)
|
||||||
|
* @returns 版本列表,按 versionNo 降序;归属不匹配返回空数组
|
||||||
|
*/
|
||||||
export async function getLessonPlanVersionsRaw(
|
export async function getLessonPlanVersionsRaw(
|
||||||
planId: string,
|
planId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -47,7 +56,16 @@ export async function getLessonPlanVersionsRaw(
|
|||||||
if (plan.length === 0) return [];
|
if (plan.length === 0) return [];
|
||||||
|
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanVersions)
|
||||||
.where(eq(lessonPlanVersions.planId, planId))
|
.where(eq(lessonPlanVersions.planId, planId))
|
||||||
.orderBy(desc(lessonPlanVersions.versionNo));
|
.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"] });
|
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: {
|
export async function createLessonPlanVersion(input: {
|
||||||
planId: string;
|
planId: string;
|
||||||
content: LessonPlanDocument;
|
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(
|
export async function getVersionContentRaw(
|
||||||
planId: string,
|
planId: string,
|
||||||
versionNo: number,
|
versionNo: number,
|
||||||
@@ -125,6 +166,18 @@ export async function getVersionContentRaw(
|
|||||||
|
|
||||||
export const getVersionContent = cacheFn(getVersionContentRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "versions", "content"] });
|
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(
|
export async function revertToVersion(
|
||||||
planId: string,
|
planId: string,
|
||||||
versionNo: number,
|
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(
|
export async function pruneAutoVersions(
|
||||||
planId: string,
|
planId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { db } from "@/shared/db";
|
|||||||
import {
|
import {
|
||||||
lessonPlans,
|
lessonPlans,
|
||||||
lessonPlanTemplates,
|
lessonPlanTemplates,
|
||||||
textbooks,
|
|
||||||
chapters,
|
|
||||||
subjects,
|
subjects,
|
||||||
grades,
|
grades,
|
||||||
users,
|
users,
|
||||||
@@ -23,22 +21,28 @@ import {
|
|||||||
buildInitialContent,
|
buildInitialContent,
|
||||||
buildDefaultSkeleton,
|
buildDefaultSkeleton,
|
||||||
} from "./lib/document-migration";
|
} from "./lib/document-migration";
|
||||||
import { normalizeTemplateBlocks } from "./lib/type-guards";
|
// G3-036 去重:isLessonPlanStatus / isTemplateType / isTemplateScope 已集中至 lib/type-guards
|
||||||
import { getChaptersByTextbookId, getTextbooks, findChapterById } from "@/modules/textbooks/data-access";
|
import {
|
||||||
|
normalizeTemplateBlocks,
|
||||||
|
isLessonPlanStatus,
|
||||||
|
isTemplateType,
|
||||||
|
isTemplateScope,
|
||||||
|
} from "./lib/type-guards";
|
||||||
|
import { getChaptersByTextbookId, getTextbooks, findChapterById, getTextbookTitlesByIds, getChapterTitlesByIds } from "@/modules/textbooks/data-access";
|
||||||
import type {
|
import type {
|
||||||
LessonPlan,
|
LessonPlan,
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanListItem,
|
LessonPlanListItem,
|
||||||
LessonPlanTemplate,
|
LessonPlanTemplate,
|
||||||
LessonPlanStatus,
|
|
||||||
LessonPlanVersionSummary,
|
LessonPlanVersionSummary,
|
||||||
TemplateType,
|
|
||||||
TemplateScope,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// re-export 纯函数保持向后兼容
|
// re-export 纯函数保持向后兼容
|
||||||
export { migrateV1ToV2, normalizeDocument, buildInitialContent, buildDefaultSkeleton };
|
export { migrateV1ToV2, normalizeDocument, buildInitialContent, buildDefaultSkeleton };
|
||||||
|
|
||||||
|
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
|
||||||
|
const DEFAULT_LIMIT = 1000;
|
||||||
|
|
||||||
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
|
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
|
||||||
export class LessonPlanDataError extends Error {
|
export class LessonPlanDataError extends Error {
|
||||||
constructor(public readonly code: "NOT_FOUND" | "TEMPLATE_NOT_FOUND") {
|
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)----
|
// ---- 类型映射:Drizzle 行 → LessonPlan(Date → ISO string)----
|
||||||
function mapRowToLessonPlan(row: {
|
function mapRowToLessonPlan(row: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -234,7 +224,8 @@ export const getLessonPlansRaw = async (
|
|||||||
conditions.push(...buildScopeCondition(scope, userId));
|
conditions.push(...buildScopeCondition(scope, userId));
|
||||||
|
|
||||||
if (params.query) {
|
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)
|
if (params.textbookId)
|
||||||
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
||||||
@@ -261,22 +252,47 @@ export const getLessonPlansRaw = async (
|
|||||||
lastSavedAt: lessonPlans.lastSavedAt,
|
lastSavedAt: lessonPlans.lastSavedAt,
|
||||||
createdAt: lessonPlans.createdAt,
|
createdAt: lessonPlans.createdAt,
|
||||||
updatedAt: lessonPlans.updatedAt,
|
updatedAt: lessonPlans.updatedAt,
|
||||||
textbookTitle: textbooks.title,
|
|
||||||
chapterTitle: chapters.title,
|
|
||||||
subjectName: subjects.name,
|
subjectName: subjects.name,
|
||||||
gradeName: grades.name,
|
gradeName: grades.name,
|
||||||
creatorName: users.name,
|
creatorName: users.name,
|
||||||
})
|
})
|
||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.leftJoin(textbooks, eq(lessonPlans.textbookId, textbooks.id))
|
|
||||||
.leftJoin(chapters, eq(lessonPlans.chapterId, chapters.id))
|
|
||||||
.leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id))
|
.leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id))
|
||||||
.leftJoin(grades, eq(lessonPlans.gradeId, grades.id))
|
.leftJoin(grades, eq(lessonPlans.gradeId, grades.id))
|
||||||
.leftJoin(users, eq(lessonPlans.creatorId, users.id))
|
.leftJoin(users, eq(lessonPlans.creatorId, users.id))
|
||||||
.where(and(...conditions))
|
.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 + creatorId 分组(同一教师对同一章节的多个课案视为版本)
|
||||||
// 无 textbookId/chapterId 的课案各自独立成组
|
// 无 textbookId/chapterId 的课案各自独立成组
|
||||||
@@ -333,7 +349,23 @@ export const getLessonPlanByIdRaw = async (
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<LessonPlan | null> => {
|
): Promise<LessonPlan | null> => {
|
||||||
const rows = await db
|
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)
|
.from(lessonPlans)
|
||||||
.where(eq(lessonPlans.id, id))
|
.where(eq(lessonPlans.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -413,7 +445,7 @@ export async function createLessonPlan(input: {
|
|||||||
// (原本地函数已删除,改为从 @/modules/textbooks/data-access 导入)
|
// (原本地函数已删除,改为从 @/modules/textbooks/data-access 导入)
|
||||||
|
|
||||||
// ---- 获取教材列表(供 picker 使用)----
|
// ---- 获取教材列表(供 picker 使用)----
|
||||||
export async function getTextbooksForPicker(): Promise<
|
export async function getTextbooksForPickerRaw(): Promise<
|
||||||
{ id: string; title: string; subject: string; grade: string | null }[]
|
{ id: string; title: string; subject: string; grade: string | null }[]
|
||||||
> {
|
> {
|
||||||
const textbooks = await getTextbooks();
|
const textbooks = await getTextbooks();
|
||||||
@@ -424,9 +456,14 @@ export async function getTextbooksForPicker(): Promise<
|
|||||||
grade: t.grade,
|
grade: t.grade,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export const getTextbooksForPicker = cacheFn(getTextbooksForPickerRaw, {
|
||||||
|
tags: ["lesson-preparation"],
|
||||||
|
ttl: 300,
|
||||||
|
keyParts: ["lesson-preparation", "getTextbooksForPicker"],
|
||||||
|
})
|
||||||
|
|
||||||
// ---- 获取章节列表(供 picker 使用)----
|
// ---- 获取章节列表(供 picker 使用)----
|
||||||
export async function getChaptersForPicker(
|
export async function getChaptersForPickerRaw(
|
||||||
textbookId: string,
|
textbookId: string,
|
||||||
): Promise<
|
): Promise<
|
||||||
// P1 修复:使用 ChapterPickerOption 递归类型,替代内联的 children?: unknown[]
|
// P1 修复:使用 ChapterPickerOption 递归类型,替代内联的 children?: unknown[]
|
||||||
@@ -442,6 +479,11 @@ export async function getChaptersForPicker(
|
|||||||
children: c.children,
|
children: c.children,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export const getChaptersForPicker = cacheFn(getChaptersForPickerRaw, {
|
||||||
|
tags: ["lesson-preparation"],
|
||||||
|
ttl: 300,
|
||||||
|
keyParts: ["lesson-preparation", "getChaptersForPicker"],
|
||||||
|
})
|
||||||
|
|
||||||
// ---- 更新 content(自动保存,不生成版本)----
|
// ---- 更新 content(自动保存,不生成版本)----
|
||||||
export async function updateLessonPlanContent(
|
export async function updateLessonPlanContent(
|
||||||
@@ -532,7 +574,23 @@ export async function duplicateLessonPlan(
|
|||||||
): Promise<{ newPlanId: string }> {
|
): Promise<{ newPlanId: string }> {
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const rows = await 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)
|
.from(lessonPlans)
|
||||||
.where(eq(lessonPlans.id, planId))
|
.where(eq(lessonPlans.id, planId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -571,7 +629,7 @@ export interface LessonPlanStats {
|
|||||||
archived: number;
|
archived: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLessonPlanStats(): Promise<LessonPlanStats> {
|
export async function getLessonPlanStatsRaw(): Promise<LessonPlanStats> {
|
||||||
// 统计所有课案(含 archived),按 status 分组计数
|
// 统计所有课案(含 archived),按 status 分组计数
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ status: lessonPlans.status, count: sql<number>`count(*)` })
|
.select({ status: lessonPlans.status, count: sql<number>`count(*)` })
|
||||||
@@ -588,9 +646,14 @@ export async function getLessonPlanStats(): Promise<LessonPlanStats> {
|
|||||||
archived,
|
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,
|
templateId: string,
|
||||||
): Promise<LessonPlanTemplate | null> {
|
): Promise<LessonPlanTemplate | null> {
|
||||||
// 先查 system 固定模板
|
// 先查 system 固定模板
|
||||||
@@ -609,9 +672,23 @@ export async function getTemplateById(
|
|||||||
}
|
}
|
||||||
// 再查 DB(personal 模板)
|
// 再查 DB(personal 模板)
|
||||||
const rows = await db
|
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)
|
.from(lessonPlanTemplates)
|
||||||
.where(eq(lessonPlanTemplates.id, templateId))
|
.where(eq(lessonPlanTemplates.id, templateId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return rows.length > 0 ? mapRowToTemplate(rows[0]) : null;
|
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,
|
Block,
|
||||||
BlockData,
|
BlockData,
|
||||||
BlockType,
|
BlockType,
|
||||||
InteractionBlockData,
|
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanNode,
|
LessonPlanNode,
|
||||||
NodeAnchor,
|
NodeAnchor,
|
||||||
@@ -13,6 +12,7 @@ import type {
|
|||||||
TextbookContentNodeData,
|
TextbookContentNodeData,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { defaultDataForType } from "../lib/document-migration";
|
import { defaultDataForType } from "../lib/document-migration";
|
||||||
|
import { isInteractionBlockData } from "../lib/type-guards";
|
||||||
import type { EditorState } from "./use-lesson-plan-editor";
|
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 {
|
function cloneBlockData(data: BlockData, type: BlockType): BlockData {
|
||||||
if (type === "interaction") {
|
if (type === "interaction") {
|
||||||
const d = data as InteractionBlockData;
|
// 类型守卫安全收窄 InteractionBlockData,校验失败直接返回原 data
|
||||||
|
if (!isInteractionBlockData(data)) return data;
|
||||||
return {
|
return {
|
||||||
designIntent: d.designIntent,
|
designIntent: data.designIntent,
|
||||||
knowledgePointIds: [...d.knowledgePointIds],
|
knowledgePointIds: [...data.knowledgePointIds],
|
||||||
turns: d.turns.map((t) => ({ ...t, id: createId() })),
|
turns: data.turns.map((t) => ({ ...t, id: createId() })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// 其他类型:JSON 深拷贝(数据全是 POJO,无函数/Date/Symbol)
|
// 其他类型:JSON 深拷贝(数据全是 POJO,无函数/Date/Symbol)
|
||||||
@@ -121,13 +122,17 @@ export const createEditorSlice: StateCreator<
|
|||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
nodes: s.doc.nodes.map((n) =>
|
nodes: s.doc.nodes.map((n) => {
|
||||||
n.id === id
|
if (n.id !== id) return n;
|
||||||
? n.type === "textbook_content"
|
if (n.type === "textbook_content") {
|
||||||
? ({ ...n, ...patch } as TextbookContentNode)
|
// TextbookContentNode.data 为 TextbookContentNodeData,与 patch(Partial<Block>)的 data 字段类型不兼容,
|
||||||
: ({ ...n, ...patch } as LessonPlanNode)
|
// 需经 unknown 中间变量收窄(合法 as 场景:从 unknown 转换)
|
||||||
: n,
|
const merged: unknown = { ...n, ...patch };
|
||||||
),
|
return merged as TextbookContentNode;
|
||||||
|
}
|
||||||
|
// LessonPlanNode 与 patch 类型兼容,无需 as 断言
|
||||||
|
return { ...n, ...patch };
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
|
||||||
import type {
|
import type {
|
||||||
LessonPlanDataService,
|
LessonPlanDataService,
|
||||||
@@ -52,16 +52,16 @@ export function useLessonPlanPersistence({
|
|||||||
content: state.doc,
|
content: state.doc,
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
if (state.saveError) toast.success(t("status.recovered"));
|
if (state.saveError) notify.success(t("status.recovered"));
|
||||||
state.markSaved();
|
state.markSaved();
|
||||||
} else {
|
} else {
|
||||||
state.setSaveError(true);
|
state.setSaveError(true);
|
||||||
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
notify.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanEditor] auto-save failed", e);
|
console.error("[LessonPlanEditor] auto-save failed", e);
|
||||||
state.setSaveError(true);
|
state.setSaveError(true);
|
||||||
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
notify.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
||||||
} finally {
|
} finally {
|
||||||
state.setSaving(false);
|
state.setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -104,14 +104,14 @@ export function useLessonPlanPersistence({
|
|||||||
content: state.doc,
|
content: state.doc,
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(t("status.recovered"));
|
notify.success(t("status.recovered"));
|
||||||
state.markSaved();
|
state.markSaved();
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("status.saveFailed"));
|
notify.error(t("status.saveFailed"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanEditor] retry save failed", e);
|
console.error("[LessonPlanEditor] retry save failed", e);
|
||||||
toast.error(t("status.saveFailed"));
|
notify.error(t("status.saveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
state.setSaving(false);
|
state.setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
|
||||||
import {
|
import {
|
||||||
generateNodeContentAction,
|
generateNodeContentAction,
|
||||||
@@ -10,11 +10,8 @@ import {
|
|||||||
suggestNodeDifferentiationAction,
|
suggestNodeDifferentiationAction,
|
||||||
generateLayeredQuestionsAction,
|
generateLayeredQuestionsAction,
|
||||||
} from "../actions-ai";
|
} from "../actions-ai";
|
||||||
import type {
|
import type { LessonPlanNode } from "../types";
|
||||||
BlockData,
|
import { isInteractionBlockData, mergeBlockDataPatch } from "../lib/type-guards";
|
||||||
InteractionBlockData,
|
|
||||||
LessonPlanNode,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
export type NodeAiAction = "generate" | "optimize" | "differentiation" | "layered";
|
export type NodeAiAction = "generate" | "optimize" | "differentiation" | "layered";
|
||||||
|
|
||||||
@@ -43,18 +40,18 @@ export function useNodeAiAssist() {
|
|||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
toast.info(t("v4.contextMenu.aiRunning"));
|
notify.info(t("v4.contextMenu.aiRunning"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === "generate") {
|
if (action === "generate") {
|
||||||
const res = await generateNodeContentAction(node);
|
const res = await generateNodeContentAction(node);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
// AI patch 合并到 node.data;patch 字段由 lib/ai-node-assist.ts 的 Zod schema 保证类型安全
|
// AI patch 合并到 node.data,mergeBlockDataPatch 内部校验类型安全
|
||||||
const merged = { ...node.data, ...res.data.data } as BlockData;
|
const merged = mergeBlockDataPatch(node, res.data.data);
|
||||||
updateNode(nodeId, { data: merged });
|
updateNode(nodeId, { data: merged });
|
||||||
toast.success(t("v4.contextMenu.aiSuccess"));
|
notify.success(t("v4.contextMenu.aiSuccess"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("v4.contextMenu.aiFailed"));
|
notify.error(t("v4.contextMenu.aiFailed"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -62,11 +59,11 @@ export function useNodeAiAssist() {
|
|||||||
if (action === "optimize") {
|
if (action === "optimize") {
|
||||||
const res = await optimizeNodeExpressionAction(node);
|
const res = await optimizeNodeExpressionAction(node);
|
||||||
if (res.success && res.data) {
|
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 });
|
updateNode(nodeId, { data: merged });
|
||||||
toast.success(t("v4.contextMenu.aiSuccess"));
|
notify.success(t("v4.contextMenu.aiSuccess"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("v4.contextMenu.aiFailed"));
|
notify.error(t("v4.contextMenu.aiFailed"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,30 +72,31 @@ export function useNodeAiAssist() {
|
|||||||
const res = await suggestNodeDifferentiationAction(node);
|
const res = await suggestNodeDifferentiationAction(node);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
updateNode(nodeId, { differentiation: res.data.level });
|
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 {
|
} else {
|
||||||
toast.error(t("v4.contextMenu.aiFailed"));
|
notify.error(t("v4.contextMenu.aiFailed"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// layered
|
// layered
|
||||||
if (node.type !== "interaction") {
|
if (node.type !== "interaction") {
|
||||||
toast.error(t("v4.contextMenu.aiFailed"));
|
notify.error(t("v4.contextMenu.aiFailed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await generateLayeredQuestionsAction(node);
|
const res = await generateLayeredQuestionsAction(node);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
// interaction 节点 data 类型收窄(已通过 node.type 守卫)
|
// 类型守卫二次校验 node.data 为 InteractionBlockData,失败提示错误
|
||||||
const interactionData = node.data as InteractionBlockData;
|
if (!isInteractionBlockData(node.data)) {
|
||||||
const merged = {
|
notify.error(t("v4.contextMenu.aiFailed"));
|
||||||
...node.data,
|
return;
|
||||||
turns: [...(interactionData.turns ?? []), ...res.data.turns],
|
}
|
||||||
} as BlockData;
|
const newTurns = [...(node.data.turns ?? []), ...res.data.turns];
|
||||||
|
const merged = mergeBlockDataPatch(node, { turns: newTurns });
|
||||||
updateNode(nodeId, { data: merged });
|
updateNode(nodeId, { data: merged });
|
||||||
toast.success(t("v4.contextMenu.aiSuccess"));
|
notify.success(t("v4.contextMenu.aiSuccess"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("v4.contextMenu.aiFailed"));
|
notify.error(t("v4.contextMenu.aiFailed"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
|
|||||||
@@ -15,19 +15,18 @@ import { createAiChatCompletion } from "@/shared/lib/ai";
|
|||||||
import { isRecord } from "@/shared/lib/type-guards";
|
import { isRecord } from "@/shared/lib/type-guards";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
import type {
|
||||||
BlockData,
|
|
||||||
DifferentiationLevel,
|
DifferentiationLevel,
|
||||||
InteractionBlockData,
|
|
||||||
LessonPlanNode,
|
LessonPlanNode,
|
||||||
QATurn,
|
QATurn,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { isInteractionBlockData } from "./type-guards";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
// ---- 公共返回类型 ----
|
// ---- 公共返回类型 ----
|
||||||
|
|
||||||
export interface NodeContentUpdate {
|
export interface NodeContentUpdate {
|
||||||
/** 合并到 node.data 的 patch */
|
/** 合并到 node.data 的 patch(AI 输出为 untrusted,类型不保证,需调用方用 mergeBlockDataPatch 校验) */
|
||||||
data: Partial<BlockData>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeDifferentiationUpdate {
|
export interface NodeDifferentiationUpdate {
|
||||||
@@ -120,7 +119,7 @@ export async function generateNodeContent(
|
|||||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||||
const validated = GenerateResultSchema.safeParse(parsed);
|
const validated = GenerateResultSchema.safeParse(parsed);
|
||||||
if (!validated.success) return null;
|
if (!validated.success) return null;
|
||||||
return { data: validated.data.patch as Partial<BlockData> };
|
return { data: validated.data.patch };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -158,7 +157,7 @@ export async function optimizeNodeExpression(
|
|||||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||||
const validated = OptimizeResultSchema.safeParse(parsed);
|
const validated = OptimizeResultSchema.safeParse(parsed);
|
||||||
if (!validated.success) return null;
|
if (!validated.success) return null;
|
||||||
return { data: validated.data.patch as Partial<BlockData> };
|
return { data: validated.data.patch };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -234,8 +233,10 @@ export async function generateLayeredQuestions(
|
|||||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||||
const validated = LayeredQuestionsResultSchema.safeParse(parsed);
|
const validated = LayeredQuestionsResultSchema.safeParse(parsed);
|
||||||
if (!validated.success) return null;
|
if (!validated.success) return null;
|
||||||
|
// 类型守卫校验 node.data 为 InteractionBlockData,失败返回 null
|
||||||
|
if (!isInteractionBlockData(node.data)) return null;
|
||||||
// 为每条 turn 分配 id + order
|
// 为每条 turn 分配 id + order
|
||||||
const existing = (node.data as InteractionBlockData).turns ?? [];
|
const existing = node.data.turns ?? [];
|
||||||
let order = existing.length;
|
let order = existing.length;
|
||||||
return validated.data.turns.map((t) => ({
|
return validated.data.turns.map((t) => ({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
* 该模块为纯函数,无副作用,便于单测。
|
* 该模块为纯函数,无副作用,便于单测。
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
ExerciseBlockData,
|
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanNode,
|
LessonPlanNode,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { isExerciseBlockData } from "./type-guards";
|
||||||
|
|
||||||
/** 校验级别 */
|
/** 校验级别 */
|
||||||
export type ConsistencySeverity = "warning" | "info";
|
export type ConsistencySeverity = "warning" | "info";
|
||||||
@@ -58,7 +58,9 @@ function getTeachingNodes(doc: LessonPlanDocument): LessonPlanNode[] {
|
|||||||
/** 安全读取 exercise 节点的知识点 ID 集合 */
|
/** 安全读取 exercise 节点的知识点 ID 集合 */
|
||||||
function getExerciseKpIds(node: LessonPlanNode): Set<string> {
|
function getExerciseKpIds(node: LessonPlanNode): Set<string> {
|
||||||
if (node.type !== "exercise") return new Set();
|
if (node.type !== "exercise") return new Set();
|
||||||
const data = node.data as ExerciseBlockData;
|
// 类型守卫校验 node.data 为 ExerciseBlockData,失败返回空集合
|
||||||
|
if (!isExerciseBlockData(node.data)) return new Set();
|
||||||
|
const data = node.data;
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const item of data.items ?? []) {
|
for (const item of data.items ?? []) {
|
||||||
if (item.source === "inline" && item.inlineContent?.knowledgePointIds) {
|
if (item.source === "inline" && item.inlineContent?.knowledgePointIds) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
KeyPointBlockData,
|
KeyPointBlockData,
|
||||||
LessonPlan,
|
LessonPlan,
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
|
LessonPlanNode,
|
||||||
NewTeachingBlockData,
|
NewTeachingBlockData,
|
||||||
ObjectiveBlockData,
|
ObjectiveBlockData,
|
||||||
ReflectionBlockData,
|
ReflectionBlockData,
|
||||||
@@ -25,6 +26,20 @@ import type {
|
|||||||
TextStudyBlockData,
|
TextStudyBlockData,
|
||||||
TextbookContentNode,
|
TextbookContentNode,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import {
|
||||||
|
isBlackboardBlockData,
|
||||||
|
isExerciseBlockData,
|
||||||
|
isHomeworkBlockData,
|
||||||
|
isImportBlockData,
|
||||||
|
isInteractionBlockData,
|
||||||
|
isKeyPointBlockData,
|
||||||
|
isNewTeachingBlockData,
|
||||||
|
isObjectiveBlockData,
|
||||||
|
isReflectionBlockData,
|
||||||
|
isRichTextBlockData,
|
||||||
|
isSummaryBlockData,
|
||||||
|
isTextStudyBlockData,
|
||||||
|
} from "./type-guards";
|
||||||
|
|
||||||
/** 导出版本 */
|
/** 导出版本 */
|
||||||
export type ExportVariant = "detailed" | "concise";
|
export type ExportVariant = "detailed" | "concise";
|
||||||
@@ -83,20 +98,21 @@ export function flattenLessonPlanForPrint(
|
|||||||
const doc: LessonPlanDocument = plan.content;
|
const doc: LessonPlanDocument = plan.content;
|
||||||
const textbookContent = extractTextbookContent(doc);
|
const textbookContent = extractTextbookContent(doc);
|
||||||
const teachingNodes = doc.nodes
|
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))
|
.filter((n) => variant === "detailed" || CONCISE_BLOCK_TYPES.has(n.type))
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
const sections = teachingNodes.map((node) =>
|
const sections = teachingNodes.map((node) =>
|
||||||
flattenBlock(node.type, node.title, node.data as BlockData),
|
flattenBlock(node.type, node.title, node.data),
|
||||||
);
|
);
|
||||||
|
|
||||||
// V5-4:教学时长由 import 节点求和
|
// V5-4:教学时长由 import 节点求和
|
||||||
const totalDurationMin = doc.nodes
|
const totalDurationMin = doc.nodes
|
||||||
.filter((n) => n.type === "import")
|
.filter((n) => n.type === "import")
|
||||||
.reduce((sum, n) => {
|
.reduce((sum, n) => {
|
||||||
const data = n.data as ImportBlockData;
|
// 类型守卫校验 import 节点数据结构,替代 as ImportBlockData 断言
|
||||||
return sum + (data.durationMin ?? 0);
|
if (!isImportBlockData(n.data)) return sum;
|
||||||
|
return sum + (n.data.durationMin ?? 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -132,29 +148,29 @@ function flattenBlock(
|
|||||||
function flattenBlockData(type: string, data: BlockData): string[] {
|
function flattenBlockData(type: string, data: BlockData): string[] {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "objective":
|
case "objective":
|
||||||
return flattenObjective(data as ObjectiveBlockData);
|
return isObjectiveBlockData(data) ? flattenObjective(data) : [];
|
||||||
case "key_point":
|
case "key_point":
|
||||||
return flattenKeyPoint(data as KeyPointBlockData);
|
return isKeyPointBlockData(data) ? flattenKeyPoint(data) : [];
|
||||||
case "import":
|
case "import":
|
||||||
return flattenImport(data as ImportBlockData);
|
return isImportBlockData(data) ? flattenImport(data) : [];
|
||||||
case "new_teaching":
|
case "new_teaching":
|
||||||
return flattenNewTeaching(data as NewTeachingBlockData);
|
return isNewTeachingBlockData(data) ? flattenNewTeaching(data) : [];
|
||||||
case "summary":
|
case "summary":
|
||||||
return flattenSummary(data as SummaryBlockData);
|
return isSummaryBlockData(data) ? flattenSummary(data) : [];
|
||||||
case "homework":
|
case "homework":
|
||||||
return flattenHomework(data as HomeworkBlockData);
|
return isHomeworkBlockData(data) ? flattenHomework(data) : [];
|
||||||
case "blackboard":
|
case "blackboard":
|
||||||
return flattenBlackboard(data as BlackboardBlockData);
|
return isBlackboardBlockData(data) ? flattenBlackboard(data) : [];
|
||||||
case "reflection":
|
case "reflection":
|
||||||
return flattenReflection(data as ReflectionBlockData);
|
return isReflectionBlockData(data) ? flattenReflection(data) : [];
|
||||||
case "exercise":
|
case "exercise":
|
||||||
return flattenExercise(data as ExerciseBlockData);
|
return isExerciseBlockData(data) ? flattenExercise(data) : [];
|
||||||
case "text_study":
|
case "text_study":
|
||||||
return flattenTextStudy(data as TextStudyBlockData);
|
return isTextStudyBlockData(data) ? flattenTextStudy(data) : [];
|
||||||
case "rich_text":
|
case "rich_text":
|
||||||
return flattenRichText(data as RichTextBlockData);
|
return isRichTextBlockData(data) ? flattenRichText(data) : [];
|
||||||
case "interaction":
|
case "interaction":
|
||||||
return flattenInteraction(data as InteractionBlockData);
|
return isInteractionBlockData(data) ? flattenInteraction(data) : [];
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
BlackboardBlockData,
|
BlackboardBlockData,
|
||||||
BlockData,
|
BlockData,
|
||||||
BlockType,
|
BlockType,
|
||||||
|
DifferentiationLevel,
|
||||||
ExerciseBlockData,
|
ExerciseBlockData,
|
||||||
ExercisePurpose,
|
ExercisePurpose,
|
||||||
HomeworkAssignment,
|
HomeworkAssignment,
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
ReflectionItem,
|
ReflectionItem,
|
||||||
RichTextBlockData,
|
RichTextBlockData,
|
||||||
SummaryBlockData,
|
SummaryBlockData,
|
||||||
|
TeachingStage,
|
||||||
TemplateBlockSkeleton,
|
TemplateBlockSkeleton,
|
||||||
TemplateScope,
|
TemplateScope,
|
||||||
TemplateType,
|
TemplateType,
|
||||||
@@ -93,11 +95,11 @@ function isObject(v: unknown): v is Record<string, unknown> {
|
|||||||
return typeof v === "object" && v !== null;
|
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);
|
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 (
|
return (
|
||||||
isObject(data) &&
|
isObject(data) &&
|
||||||
typeof data.sourceText === "string" &&
|
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 (
|
return (
|
||||||
isObject(data) &&
|
isObject(data) &&
|
||||||
Array.isArray(data.items) &&
|
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);
|
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);
|
return isObject(data) && Array.isArray(data.keyPoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isImportBlockData(data: BlockData): data is ImportBlockData {
|
export function isImportBlockData(data: unknown): data is ImportBlockData {
|
||||||
return (
|
return (
|
||||||
isObject(data) &&
|
isObject(data) &&
|
||||||
typeof data.prompt === "string" &&
|
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);
|
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";
|
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);
|
return isObject(data) && Array.isArray(data.assignments);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockData {
|
export function isBlackboardBlockData(data: unknown): data is BlackboardBlockData {
|
||||||
return (
|
return (
|
||||||
isObject(data) &&
|
isObject(data) &&
|
||||||
typeof data.layout === "string" &&
|
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);
|
return isObject(data) && Array.isArray(data.reflection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +217,38 @@ export function isReflectionAspect(
|
|||||||
return (REFLECTION_ASPECTS as readonly string[]).includes(v);
|
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(
|
export function isTextbookContentNode(
|
||||||
node: { type: string },
|
node: { type: string },
|
||||||
@@ -262,6 +296,62 @@ export function isBlockType(v: string): v is BlockType {
|
|||||||
return (VALID_BLOCK_TYPES as readonly string[]).includes(v);
|
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 转换)----
|
// ---- TemplateBlockSkeleton 守卫与规范化(替代 as 断言从 DB unknown 转换)----
|
||||||
// isObject 已在上方定义(第 56 行),复用同一类型守卫
|
// isObject 已在上方定义(第 56 行),复用同一类型守卫
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,23 @@ export async function publishLessonPlanHomework(
|
|||||||
): Promise<PublishResult> {
|
): Promise<PublishResult> {
|
||||||
// 1. 读取课案
|
// 1. 读取课案
|
||||||
const rows = await db
|
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)
|
.from(lessonPlans)
|
||||||
.where(eq(lessonPlans.id, input.planId))
|
.where(eq(lessonPlans.id, input.planId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { getQuestionsForPickerAction } from "../actions-questions";
|
import { getQuestionsForPickerAction } from "../actions-questions";
|
||||||
import type {
|
import type {
|
||||||
QuestionPickerItem,
|
|
||||||
QuestionPickerParams,
|
QuestionPickerParams,
|
||||||
QuestionService,
|
QuestionService,
|
||||||
} from "../providers/lesson-plan-provider";
|
} from "../providers/lesson-plan-provider";
|
||||||
@@ -22,7 +21,7 @@ export function createDefaultQuestionService(): QuestionService {
|
|||||||
async getQuestions(params: QuestionPickerParams) {
|
async getQuestions(params: QuestionPickerParams) {
|
||||||
const res = await getQuestionsForPickerAction(params);
|
const res = await getQuestionsForPickerAction(params);
|
||||||
if (res.success && res.data) {
|
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 };
|
return { success: false, message: res.message };
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user