diff --git a/src/modules/lesson-preparation/actions-publish.ts b/src/modules/lesson-preparation/actions-publish.ts index 7ac5e72..db4294c 100644 --- a/src/modules/lesson-preparation/actions-publish.ts +++ b/src/modules/lesson-preparation/actions-publish.ts @@ -9,6 +9,7 @@ import { Permissions } from "@/shared/types/permissions"; import { handleActionError, safeParseDate } from "@/shared/lib/action-utils"; import { publishLessonPlanHomework, PublishServiceError } from "./publish-service"; import { publishLessonPlanHomeworkSchema } from "./schema"; +import { translateFieldErrors } from "./lib/i18n-errors"; import type { ActionState } from "./types"; export async function publishLessonPlanHomeworkAction(input: { @@ -22,24 +23,32 @@ export async function publishLessonPlanHomeworkAction(input: { try { const parsed = publishLessonPlanHomeworkSchema.safeParse(input); if (!parsed.success) { - return { success: false, errors: parsed.error.flatten().fieldErrors }; + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; } const ctx = await requirePermission( Permissions.LESSON_PLAN_PUBLISH, ); await requirePermission(Permissions.HOMEWORK_CREATE); + // V3 修复:作业标题/描述由 actions 层 i18n 翻译后传入,避免 service 层硬编码中文 + const homeworkTitle = t("publish.homeworkTitle", { title: parsed.data.planId }); + const homeworkDescription = t("publish.homeworkDescription"); + const availableAtLabel = t("publish.availableAtLabel"); + const dueAtLabel = t("publish.dueAtLabel"); const result = await publishLessonPlanHomework({ planId: parsed.data.planId, blockId: parsed.data.blockId, userId: ctx.userId, classIds: parsed.data.classIds, availableAt: parsed.data.availableAt - ? safeParseDate(parsed.data.availableAt, "可用时间") + ? safeParseDate(parsed.data.availableAt, availableAtLabel) : undefined, dueAt: parsed.data.dueAt - ? safeParseDate(parsed.data.dueAt, "截止时间") + ? safeParseDate(parsed.data.dueAt, dueAtLabel) : undefined, + homeworkTitle, + homeworkDescription, }); revalidatePath("/teacher/lesson-plans"); revalidatePath("/teacher/homework"); @@ -69,4 +78,5 @@ const PUBLISH_ERROR_KEY_MAP: Record = { ALREADY_PUBLISHED: "publish.alreadyPublished", NO_SUBJECT_OR_GRADE: "publish.noSubjectOrGrade", NO_STUDENTS: "publish.noStudents", + INVALID_QUESTION_TYPE: "error.invalidQuestionType", }; diff --git a/src/modules/lesson-preparation/actions.ts b/src/modules/lesson-preparation/actions.ts index bf077b7..df9b6c6 100644 --- a/src/modules/lesson-preparation/actions.ts +++ b/src/modules/lesson-preparation/actions.ts @@ -11,6 +11,8 @@ import { createLessonPlan, updateLessonPlanContent, softDeleteLessonPlan, + publishLessonPlan, + unpublishLessonPlan, duplicateLessonPlan, getTextbooksForPicker, getChaptersForPicker, @@ -34,7 +36,8 @@ import { revertVersionSchema, saveAsTemplateSchema, } from "./schema"; -import type { ActionState, LessonPlanDocument } from "./types"; +import { translateFieldErrors } from "./lib/i18n-errors"; +import type { ActionState, LessonPlan, LessonPlanDocument } from "./types"; // ---- 课案列表 ---- export async function getLessonPlansAction(params: { @@ -60,13 +63,12 @@ export async function getLessonPlansAction(params: { // ---- 单课案 ---- export async function getLessonPlanByIdAction( planId: string, -): Promise< - ActionState<{ plan: Awaited> }> -> { +): Promise> { + const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const plan = await getLessonPlanById(planId, ctx.userId); - if (!plan) return { success: false, message: "课案不存在" }; + if (!plan) return { success: false, message: t("error.notFound") }; return { success: true, data: { plan } }; } catch (e) { return handleActionError(e); @@ -90,7 +92,8 @@ export async function createLessonPlanAction( templateId: formData.get("templateId"), }); if (!parsed.success) { - return { success: false, errors: parsed.error.flatten().fieldErrors }; + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; } const { planId } = await createLessonPlan({ ...parsed.data, @@ -131,8 +134,10 @@ export async function updateLessonPlanAction(input: { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = updateLessonPlanContentSchema.safeParse(input); - if (!parsed.success) - return { success: false, errors: parsed.error.flatten().fieldErrors }; + if (!parsed.success) { + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; + } await updateLessonPlanContent(parsed.data.planId, ctx.userId, { ...(parsed.data.title ? { title: parsed.data.title } : {}), // 从 unknown 转换:Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫 @@ -154,8 +159,10 @@ export async function saveLessonPlanVersionAction(input: { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = saveVersionSchema.safeParse(input); - if (!parsed.success) - return { success: false, errors: parsed.error.flatten().fieldErrors }; + if (!parsed.success) { + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; + } const { versionNo } = await createLessonPlanVersion({ planId: parsed.data.planId, content: input.content, @@ -192,17 +199,23 @@ export async function revertLessonPlanVersionAction(input: { planId: string; versionNo: number; }): Promise> { + const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = revertVersionSchema.safeParse(input); - if (!parsed.success) - return { success: false, errors: parsed.error.flatten().fieldErrors }; + if (!parsed.success) { + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; + } + // V3 修复:传入 i18n 翻译的回退标签,避免 data-access 硬编码中文 + const revertLabel = t("version.revertLabel", { versionNo: parsed.data.versionNo }); const result = await revertToVersion( parsed.data.planId, parsed.data.versionNo, ctx.userId, + revertLabel, ); - if (!result) return { success: false, message: "版本不存在" }; + if (!result) return { success: false, message: t("error.versionNotFound") }; revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`); return { success: true, data: { newVersionNo: result.newVersionNo } }; } catch (e) { @@ -224,6 +237,44 @@ export async function deleteLessonPlanAction( } } +// ---- 发布课案(P0-1 修复)---- +// 发布后学生和家长可查看此课案 +export async function publishLessonPlanAction( + planId: string, +): Promise { + const t = await getTranslations("lessonPreparation"); + try { + const ctx = await requirePermission(Permissions.LESSON_PLAN_PUBLISH); + await publishLessonPlan(planId, ctx.userId); + revalidatePath("/teacher/lesson-plans"); + revalidatePath(`/teacher/lesson-plans/${planId}/edit`); + return { success: true, message: t("action.publishPlanSuccess") }; + } catch (e) { + if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND") + return { success: false, message: t("error.notFound") }; + return handleActionError(e); + } +} + +// ---- 撤回发布 ---- +// 撤回后学生和家长将无法查看此课案 +export async function unpublishLessonPlanAction( + planId: string, +): Promise { + const t = await getTranslations("lessonPreparation"); + try { + const ctx = await requirePermission(Permissions.LESSON_PLAN_PUBLISH); + await unpublishLessonPlan(planId, ctx.userId); + revalidatePath("/teacher/lesson-plans"); + revalidatePath(`/teacher/lesson-plans/${planId}/edit`); + return { success: true, message: t("action.unpublishPlanSuccess") }; + } catch (e) { + if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND") + return { success: false, message: t("error.notFound") }; + return handleActionError(e); + } +} + // ---- 复制 ---- export async function duplicateLessonPlanAction( planId: string, @@ -231,7 +282,12 @@ export async function duplicateLessonPlanAction( const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE); - const { newPlanId } = await duplicateLessonPlan(planId, ctx.userId); + // V3 修复:传入 i18n 翻译的副本后缀,避免 data-access 硬编码中文 + const { newPlanId } = await duplicateLessonPlan( + planId, + ctx.userId, + t("error.duplicateSuffix"), + ); revalidatePath("/teacher/lesson-plans"); return { success: true, data: { newPlanId } }; } catch (e) { @@ -265,8 +321,10 @@ export async function saveAsTemplateAction(input: { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE); const parsed = saveAsTemplateSchema.safeParse(input); - if (!parsed.success) - return { success: false, errors: parsed.error.flatten().fieldErrors }; + if (!parsed.success) { + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; + } const { templateId } = await saveAsTemplate({ ...parsed.data, userId: ctx.userId, diff --git a/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx index 6e6c310..7c683e9 100644 --- a/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { Tag } from "lucide-react"; import type { BlackboardBlockData } from "../../types"; +import { isBlackboardLayout } from "../../lib/type-guards"; import { KnowledgePointPicker } from "../knowledge-point-picker"; interface Props { @@ -30,12 +31,12 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props - update({ purpose: e.target.value as ExercisePurpose }) - } + onChange={(e) => { + const value = e.target.value; + if (isExercisePurpose(value)) { + update({ purpose: value }); + } + }} className="border rounded px-2 py-1 text-sm" > diff --git a/src/modules/lesson-preparation/components/blocks/homework-block.tsx b/src/modules/lesson-preparation/components/blocks/homework-block.tsx index 075e4d0..ad237b6 100644 --- a/src/modules/lesson-preparation/components/blocks/homework-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/homework-block.tsx @@ -3,6 +3,7 @@ import { useTranslations } from "next-intl"; import { Plus, Trash2 } from "lucide-react"; import type { HomeworkAssignment, HomeworkBlockData } from "../../types"; +import { isHomeworkType } from "../../lib/type-guards"; import { Button } from "@/shared/components/ui/button"; interface Props { @@ -45,11 +46,12 @@ export function HomeworkBlock({ data, onUpdate }: Props) {
- onUpdate({ ...data, method: e.target.value as ImportBlockData["method"] }) - } + onChange={(e) => { + const value = e.target.value; + if (isImportMethod(value)) { + onUpdate({ ...data, method: value }); + } + }} className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface" > {METHODS.map((m) => ( diff --git a/src/modules/lesson-preparation/components/blocks/key-point-block.tsx b/src/modules/lesson-preparation/components/blocks/key-point-block.tsx index 59d950b..9413ab9 100644 --- a/src/modules/lesson-preparation/components/blocks/key-point-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/key-point-block.tsx @@ -3,6 +3,7 @@ import { useTranslations } from "next-intl"; import { Plus, Trash2 } from "lucide-react"; import type { KeyPointBlockData, KeyPointItem } from "../../types"; +import { isKeyPointType } from "../../lib/type-guards"; import { Button } from "@/shared/components/ui/button"; interface Props { @@ -45,9 +46,12 @@ export function KeyPointBlock({ data, onUpdate }: Props) {
- updateItem(idx, { - dimension: e.target.value as ObjectiveItem["dimension"], - }) - } + onChange={(e) => { + const value = e.target.value; + if (isObjectiveDimension(value)) { + updateItem(idx, { dimension: value }); + } + }} className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface" > {DIMENSIONS.map((d) => ( diff --git a/src/modules/lesson-preparation/components/blocks/reflection-block.tsx b/src/modules/lesson-preparation/components/blocks/reflection-block.tsx index cac416e..c2a983c 100644 --- a/src/modules/lesson-preparation/components/blocks/reflection-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/reflection-block.tsx @@ -3,6 +3,7 @@ import { useTranslations } from "next-intl"; import { Plus, Trash2 } from "lucide-react"; import type { ReflectionBlockData, ReflectionItem } from "../../types"; +import { isReflectionAspect } from "../../lib/type-guards"; import { Button } from "@/shared/components/ui/button"; interface Props { @@ -45,11 +46,12 @@ export function ReflectionBlock({ data, onUpdate }: Props) {
{ + const selectedId = e.target.value; + if (selectedId && selectedId !== plan.id) { + router.push(getVersionHref(selectedId)); + } + }} + > + {plan.versions.map((v, idx) => ( + + ))} + +
+ )}
- {roleConfig.canDuplicate && ( + {roleConfig.canDuplicate && !isReadOnly && ( )} - {roleConfig.canArchive && ( + {/* 发布/撤回发布按钮(仅教师视图)*/} + {!isReadOnly && plan.status === "draft" && ( + + + + + + + {t("action.publishPlan")} + + {t("action.publishPlanConfirm")} + + + + {t("action.cancel")} + + {t("action.confirm")} + + + + + )} + {!isReadOnly && plan.status === "published" && ( + + + + + + + {t("action.unpublishPlan")} + + {t("action.unpublishPlanConfirm")} + + + + {t("action.cancel")} + + {t("action.confirm")} + + + + + )} + {roleConfig.canArchive && !isReadOnly && ( + {/* 发布/撤回发布按钮(P0-1 修复)*/} + {planStatus === "published" ? ( + + + + + + + {t("action.unpublishPlan")} + + {t("action.unpublishPlanConfirm")} + + + + {t("action.cancel")} + + {t("action.confirm")} + + + + + ) : ( + + + + + + + {t("action.publishPlan")} + + {t("action.publishPlanConfirm")} + + + + {t("action.cancel")} + + {t("action.confirm")} + + + + + )}
{/* 主区域:画布 + 侧边面板 */} diff --git a/src/modules/lesson-preparation/components/lesson-plan-error-boundary.tsx b/src/modules/lesson-preparation/components/lesson-plan-error-boundary.tsx index 515be36..f7fcc0b 100644 --- a/src/modules/lesson-preparation/components/lesson-plan-error-boundary.tsx +++ b/src/modules/lesson-preparation/components/lesson-plan-error-boundary.tsx @@ -1,6 +1,7 @@ "use client"; import { Component, type ReactNode, type ErrorInfo } from "react"; +import { useTranslations } from "next-intl"; import { Button } from "@/shared/components/ui/button"; interface Props { @@ -8,6 +9,10 @@ interface Props { fallback?: ReactNode; /** 错误时的回调,用于上报埋点 */ onError?: (error: Error, info: ErrorInfo) => void; + /** 错误提示文案(V3 i18n:由包装组件注入)*/ + errorText?: string; + /** 重试按钮文案(V3 i18n:由包装组件注入)*/ + retryText?: string; } interface State { @@ -16,11 +21,13 @@ interface State { } /** - * 备课模块错误边界。 + * 备课模块错误边界(内部类组件)。 * 包裹独立数据区块(版本抽屉/题库选择器/知识点选择器/发布对话框), * 单个区块异常不影响整页。 + * + * V3 修复:i18n 文案由外层 LessonPlanErrorBoundary 包装组件通过 useTranslations 注入。 */ -export class LessonPlanErrorBoundary extends Component { +class LessonPlanErrorBoundaryBase extends Component { constructor(props: Props) { super(props); this.state = { hasError: false, error: null }; @@ -46,10 +53,10 @@ export class LessonPlanErrorBoundary extends Component { return (

- {this.state.error?.message ?? "区块加载失败"} + {this.state.error?.message ?? this.props.errorText}

); @@ -57,3 +64,18 @@ export class LessonPlanErrorBoundary extends Component { return this.props.children; } } + +/** + * 备课模块错误边界(V3 i18n 包装组件)。 + * 使用 useTranslations 注入错误文案,对外保持原有 API 不变。 + */ +export function LessonPlanErrorBoundary(props: Props): ReactNode { + const t = useTranslations("lessonPreparation"); + return ( + + ); +} diff --git a/src/modules/lesson-preparation/components/lesson-plan-list.tsx b/src/modules/lesson-preparation/components/lesson-plan-list.tsx index 310b6f9..04117ff 100644 --- a/src/modules/lesson-preparation/components/lesson-plan-list.tsx +++ b/src/modules/lesson-preparation/components/lesson-plan-list.tsx @@ -4,23 +4,31 @@ import { useCallback, useState } from "react"; import { useTranslations } from "next-intl"; import { LessonPlanCard } from "./lesson-plan-card"; import { LessonPlanFilters } from "./lesson-plan-filters"; -import { getLessonPlansAction } from "../actions"; import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider"; import type { LessonPlanListItem } from "../types"; interface Props { initialItems: LessonPlanListItem[]; subjects: { id: string; name: string }[]; + /** + * 视图模式:决定卡片的跳转链接和可用操作。 + * - teacher(默认):跳转到编辑页 + * - student / parent:跳转到只读查看页 + * - admin:跳转到管理员查看页 + * - gradeHead:跳转到教研组长查看页 + */ + viewMode?: "teacher" | "student" | "parent" | "admin" | "gradeHead"; } -export function LessonPlanList({ initialItems, subjects }: Props) { +export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }: Props) { const t = useTranslations("lessonPreparation"); const [items, setItems] = useState(initialItems); const [error, setError] = useState(null); const ctx = useLessonPlanContextSafe(); const service = ctx?.service ?? null; - // 使用 useCallback 稳定 handleFilter 引用,避免 LessonPlanFilters 的 useEffect 无限循环 + // V3 修复:完全通过 service 调用,不直接 import actions + // 若未在 Provider 内使用,则不执行任何服务端调用(强制要求 Provider 包裹) const handleFilter = useCallback( async (params: { query?: string; @@ -28,17 +36,9 @@ export function LessonPlanList({ initialItems, subjects }: Props) { status?: string; }) => { setError(null); + if (!service) return; try { - if (service) { - const res = await service.getLessonPlans(params); - if (res.success && res.data) { - setItems(res.data.items); - } else { - setError(res.message ?? t("error.loadFailed")); - } - return; - } - const res = await getLessonPlansAction(params); + const res = await service.getLessonPlans(params); if (res.success && res.data) { setItems(res.data.items); } else { @@ -67,7 +67,7 @@ export function LessonPlanList({ initialItems, subjects }: Props) { ) : (
{items.map((p) => ( - + ))}
)} diff --git a/src/modules/lesson-preparation/components/lesson-plan-readonly-view.tsx b/src/modules/lesson-preparation/components/lesson-plan-readonly-view.tsx new file mode 100644 index 0000000..7a66ef2 --- /dev/null +++ b/src/modules/lesson-preparation/components/lesson-plan-readonly-view.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + ReactFlow, + Background, + BackgroundVariant, + Controls, + MiniMap, + type Node, + type Edge, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { LessonNode } from "./nodes/lesson-node"; +import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node"; +import { toRfNodes, toRfEdges } from "../lib/rf-mappers"; +import { getNodeColor } from "../lib/node-summary"; +import type { LessonPlanDocument } from "../types"; + +const nodeTypes = { + lesson: LessonNode, + textbook_content: TextbookContentNodeComponent, +}; + +interface Props { + doc: LessonPlanDocument; + textbookTitle?: string; + chapterTitle?: string; +} + +/** + * 只读课案画布视图(学生/家长/管理员/教研组长使用)。 + * + * 复用 React Flow 渲染,但禁用所有编辑交互: + * - 节点不可拖动、不可连线 + * - 可缩放、可平移(便于查看) + * - 可点击节点查看详情(通过 onSelectNode 回调) + */ +export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) { + const t = useTranslations("lessonPreparation"); + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]); + const rfEdges = useMemo( + () => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? []), + [doc.edges, doc.anchors, selectedNodeId], + ); + + // 为正文节点准备 data(锚点、选中节点、选择回调) + const nodesWithData: Node[] = useMemo(() => { + return rfNodes.map((n) => { + if (n.type === "textbook_content") { + return { + ...n, + data: { + ...n.data, + node: doc.nodes.find((nn) => nn.id === n.id), + anchors: doc.anchors ?? [], + selectedNodeId, + onSelectNode: setSelectedNodeId, + }, + }; + } + return n; + }); + }, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]); + + return ( +
+ {/* 顶部信息条 */} + {(textbookTitle || chapterTitle) && ( +
+ {textbookTitle && ( + + {t("editor.textbookLabel")} + {textbookTitle} + + )} + {chapterTitle && ( + + {t("editor.chapterLabel")} + {chapterTitle} + + )} +
+ )} + + + + + { + // V3 修复:使用 getNodeColor 替代硬编码颜色,与编辑器保持一致 + const data = n.data; + if (!data || typeof data !== "object") return getNodeColor(n.type ?? ""); + const nodeData = data.node; + if ( + nodeData && + typeof nodeData === "object" && + nodeData !== null && + "type" in nodeData && + typeof nodeData.type === "string" + ) { + return getNodeColor(nodeData.type); + } + return getNodeColor(n.type ?? ""); + }} + /> + +
+ ); +} diff --git a/src/modules/lesson-preparation/components/node-edit-panel.tsx b/src/modules/lesson-preparation/components/node-edit-panel.tsx index ccdcf52..85e49c1 100644 --- a/src/modules/lesson-preparation/components/node-edit-panel.tsx +++ b/src/modules/lesson-preparation/components/node-edit-panel.tsx @@ -10,6 +10,7 @@ import { Button } from "@/shared/components/ui/button"; import { Trash2, X } from "lucide-react"; import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator"; import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider"; +import { getNodeColor } from "../lib/node-summary"; interface Props { textbookId?: string; @@ -20,7 +21,7 @@ interface Props { export function NodeEditPanel({ textbookId, chapterId, classes }: Props) { const t = useTranslations("lessonPreparation"); const tAi = useTranslations("ai"); - const { doc, selectedNodeId, updateNode, removeNode, selectNode } = + const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } = useLessonPlanEditor(); const aiClient = useAiClientOptional(); const [showAiPanel, setShowAiPanel] = useState(false); @@ -35,8 +36,16 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) { ); } - // 正文节点不在侧边面板编辑(直接在画布上交互) + // P2-1:正文节点显示操作提示 + 锚点列表(而非误导性的"内容为空") if (node.type === "textbook_content") { + // 收集所有锚点,并关联到对应的教学节点 + const anchorsWithNode = doc.anchors + .map((a) => { + const linkedNode = doc.nodes.find((n) => n.id === a.nodeId); + return { anchor: a, nodeTitle: linkedNode?.title ?? "?", nodeType: linkedNode?.type ?? "rich_text" }; + }) + .sort((a, b) => a.anchor.start - b.anchor.start); + return (
@@ -52,15 +61,65 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
-
- {t("editor.textbookContentEmpty")} +
+ {/* 操作提示 */} +
+ {t("editor.textbookOperateHint")} +
+ {/* 锚点列表 */} +
+
+ {t("editor.anchorListTitle")}({anchorsWithNode.length}) +
+ {anchorsWithNode.length === 0 ? ( +

+ {t("editor.anchorListEmpty")} +

+ ) : ( +
    + {anchorsWithNode.map(({ anchor, nodeTitle, nodeType }) => ( +
  • + + + {anchor.type === "range" + ? t("editor.anchorRangeLabel") + : t("editor.anchorPointLabel")} + + + {nodeTitle} + + {anchor.textPreview && ( + + “{anchor.textPreview}” + + )} + +
  • + ))} +
+ )} +
); } - // 教学节点:通过类型守卫收窄为 LessonPlanNode - const lessonNode = node as import("../types").LessonPlanNode; + // 教学节点:textbook_content 分支已上方 return,此处 TypeScript 已收窄为 LessonPlanNode + const lessonNode = node; // 从节点标题提取主题用于 AI 内容生成 const aiTopic = lessonNode.title || t("editor.textbookContent"); diff --git a/src/modules/lesson-preparation/components/node-editor.tsx b/src/modules/lesson-preparation/components/node-editor.tsx index ef31542..3bf0a54 100644 --- a/src/modules/lesson-preparation/components/node-editor.tsx +++ b/src/modules/lesson-preparation/components/node-editor.tsx @@ -21,7 +21,7 @@ import { LessonNode } from "./nodes/lesson-node"; import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node"; import { toRfNodes, toRfEdges } from "../lib/rf-mappers"; import { getNodeColor } from "../lib/node-summary"; -import type { AnyLessonPlanNode } from "../types"; +import type { AnyLessonPlanNode, BlockType } from "../types"; const nodeTypes = { lesson: LessonNode, @@ -42,22 +42,27 @@ export function NodeEditor({}: Props) { selectNode, setEdges, addAnchor, - updateTextbookContent, + addNode, } = useLessonPlanEditor(); + // P1-1:构建可锚定的教学节点列表(排除正文节点) + const anchorableNodes = useMemo( + () => + doc.nodes + .filter((n): n is Extract => n.type !== "textbook_content") + .map((n) => ({ id: n.id, title: n.title, type: n.type })), + [doc.nodes], + ); + // 锚点添加回调(正文节点使用) const handleAddRangeAnchor = useCallback( (params: { nodeId: string; start: number; end: number; textPreview: string }) => { - // 如果 nodeId 是 __selected__,使用当前选中节点 - // 如果是 __new__,提示用户先创建节点 + // __selected__ 表示使用当前选中节点 const actualNodeId = params.nodeId === "__selected__" ? selectedNodeId ?? "" : params.nodeId; - if (!actualNodeId || actualNodeId === "__new__") { - // 简化:不自动创建新节点,提示用户先选中或创建 - return; - } + if (!actualNodeId) return; addAnchor({ nodeId: actualNodeId, type: "range", @@ -75,9 +80,7 @@ export function NodeEditor({}: Props) { params.nodeId === "__selected__" ? selectedNodeId ?? "" : params.nodeId; - if (!actualNodeId || actualNodeId === "__new__") { - return; - } + if (!actualNodeId) return; addAnchor({ nodeId: actualNodeId, type: "point", @@ -87,11 +90,25 @@ export function NodeEditor({}: Props) { [addAnchor, selectedNodeId], ); - const handleZoomChange = useCallback( - (zoom: number) => { - updateTextbookContent({ zoom }); + // P1-1:创建新节点并锚定 + const handleCreateNewNode = useCallback( + (params: { + anchorType: "range" | "point"; + start: number; + end?: number; + textPreview?: string; + }) => { + // 默认创建 rich_text 节点(最通用的类型),用户可后续切换 + const newNodeId = addNode("rich_text", undefined, t("blockType.rich_text")); + addAnchor({ + nodeId: newNodeId, + type: params.anchorType, + start: params.start, + ...(params.end !== undefined ? { end: params.end } : {}), + ...(params.textPreview ? { textPreview: params.textPreview } : {}), + }); }, - [updateTextbookContent], + [addNode, addAnchor, t], ); // 使用纯函数映射 nodes/edges @@ -100,12 +117,13 @@ export function NodeEditor({}: Props) { toRfNodes(doc.nodes, selectedNodeId, { anchors: doc.anchors, selectedNodeId, + anchorableNodes, onAddRangeAnchor: handleAddRangeAnchor, onAddPointAnchor: handleAddPointAnchor, + onCreateNewNode: handleCreateNewNode, onSelectNode: selectNode, - onZoomChange: handleZoomChange, }), - [doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange], + [doc.nodes, doc.anchors, selectedNodeId, anchorableNodes, handleAddRangeAnchor, handleAddPointAnchor, handleCreateNewNode, selectNode], ); const rfEdges: Edge[] = useMemo( @@ -173,7 +191,13 @@ export function NodeEditor({}: Props) { ); return ( -
+
e.preventDefault()} + > {doc.nodes.length === 0 && (
@@ -216,9 +240,21 @@ export function NodeEditor({}: Props) { { - const nodeData = (n.data as { node?: AnyLessonPlanNode }).node; - if (!nodeData) return "#9e9e9e"; - return getNodeColor(nodeData.type); + // V3 修复:从 React Flow 的 Node.data(Record)安全提取 node 字段 + // 使用类型守卫替代 as 断言 + const data = n.data; + if (!data || typeof data !== "object") return "#9e9e9e"; + const nodeData = data.node; + if ( + nodeData && + typeof nodeData === "object" && + nodeData !== null && + "type" in nodeData && + typeof nodeData.type === "string" + ) { + return getNodeColor(nodeData.type); + } + return "#9e9e9e"; }} /> diff --git a/src/modules/lesson-preparation/components/nodes/anchor-node-selector.tsx b/src/modules/lesson-preparation/components/nodes/anchor-node-selector.tsx new file mode 100644 index 0000000..d853298 --- /dev/null +++ b/src/modules/lesson-preparation/components/nodes/anchor-node-selector.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { getNodeColor } from "../../lib/node-summary"; + +interface AnchorNodeSelectorProps { + t: ReturnType; + anchorableNodes: { id: string; title: string; type: string }[]; + hasSelectedNode: boolean; + onPickNode: (nodeId: string) => void; + onCreateNew: () => void; +} + +/** + * 锚点节点选择器(P1-1 完善) + * - 渲染可锚定的教学节点列表(点击即关联到该节点) + * - 提供"关联到当前选中节点"快捷项(仅当有选中节点时) + * - 提供"创建新节点并关联"选项(触发 onCreateNew 回调) + */ +export function AnchorNodeSelector({ + t, + anchorableNodes, + hasSelectedNode, + onPickNode, + onCreateNew, +}: AnchorNodeSelectorProps) { + return ( +
+ {hasSelectedNode && ( + + )} + {anchorableNodes.length > 0 && ( + <> +
+ {t("editor.selectNodeForAnchor")} +
+
+ {anchorableNodes.map((n) => ( + + ))} +
+ + )} +
+ +
+
+ ); +} diff --git a/src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx b/src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx index cf72c73..6adedf0 100644 --- a/src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx +++ b/src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx @@ -1,13 +1,9 @@ "use client"; import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react"; +import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; import { NodeProps } from "@xyflow/react"; -import ReactMarkdown from "react-markdown"; -import remarkBreaks from "remark-breaks"; -import remarkGfm from "remark-gfm"; -import rehypeSanitize from "rehype-sanitize"; -import { ZoomIn, ZoomOut } from "lucide-react"; import type { NodeAnchor, TextbookContentNode as TextbookContentNodeModel } from "../../types"; import { @@ -15,29 +11,54 @@ import { parseAnchoredText, toCircledNumber, getNextPointIndex, + markdownToPlainText, } from "../../lib/anchor-injector"; import { getNodeColor } from "../../lib/node-summary"; -import { Button } from "@/shared/components/ui/button"; +import { AnchorNodeSelector } from "./anchor-node-selector"; +import { renderSegments } from "./textbook-segments"; interface TextbookContentNodeProps { - data: { - node: TextbookContentNodeModel; - anchors: NodeAnchor[]; - selectedNodeId: string | null; - onAddRangeAnchor?: (params: { - nodeId: string; - start: number; - end: number; - textPreview: string; - }) => void; - onAddPointAnchor?: (params: { - nodeId: string; - start: number; - }) => void; - onSelectNode?: (id: string | null) => void; - onZoomChange?: (zoom: number) => void; - }; - selected: boolean; + node: TextbookContentNodeModel; + anchors: NodeAnchor[]; + selectedNodeId: string | null; + /** 可锚定的教学节点列表(用于锚点节点选择器)*/ + anchorableNodes?: { id: string; title: string; type: string }[]; + onAddRangeAnchor?: (params: { + nodeId: string; + start: number; + end: number; + textPreview: string; + }) => void; + onAddPointAnchor?: (params: { + nodeId: string; + start: number; + }) => void; + /** 创建新节点并锚定 */ + onCreateNewNode?: (params: { + anchorType: "range" | "point"; + start: number; + end?: number; + textPreview?: string; + }) => void; + onSelectNode?: (id: string | null) => void; + onResize?: (width: number, height: number) => void; +} + +/** + * 类型守卫:安全收窄 React Flow NodeProps.data 为 TextbookContentNodeProps + * 替代 `as unknown as` 断言,通过结构检查确保数据形状正确 + */ +function isTextbookContentNodePropsData( + data: unknown, +): data is TextbookContentNodeProps { + if (typeof data !== "object" || data === null) return false; + const obj = data as Record; + return ( + typeof obj.node === "object" && + obj.node !== null && + typeof (obj.node as Record).type === "string" && + Array.isArray(obj.anchors) + ); } export const TextbookContentNode = memo(function TextbookContentNode({ @@ -45,21 +66,27 @@ export const TextbookContentNode = memo(function TextbookContentNode({ selected, }: NodeProps) { const t = useTranslations("lessonPreparation"); - const props = (data as unknown as TextbookContentNodeProps["data"]).node - ? (data as unknown as TextbookContentNodeProps["data"]) - : null; + const props = isTextbookContentNodePropsData(data) ? data : null; const contentRef = useRef(null); + const containerRef = useRef(null); + const resizeHandleRef = useRef(null); const [showAnchorMenu, setShowAnchorMenu] = useState<{ x: number; y: number; selection: { start: number; end: number; text: string } | null; point: number | null; } | null>(null); + // 光标位置指示器(左键点击时显示) + const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null); + // 拖拽缩放状态 + const [resizing, setResizing] = useState(false); + const resizeStart = useRef<{ x: number; y: number; w: number; h: number } | null>(null); const node = props?.node; const anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]); const selectedNodeId = props?.selectedNodeId ?? null; + const anchorableNodes = props?.anchorableNodes ?? []; // 注入锚点标记后的 Markdown const injectedContent = useMemo(() => { @@ -91,84 +118,114 @@ export const TextbookContentNode = memo(function TextbookContentNode({ [anchors], ); - // 处理文本选择 - const handleMouseUp = useCallback(() => { - if (!node) return; - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) { - // 点击空白处:尝试计算点击位置偏移 - return; - } + // 计算选中文本在纯文本中的偏移 + const computeSelectionOffset = useCallback( + (selectedText: string): { start: number; end: number } | null => { + if (!node) return null; + const plainText = markdownToPlainText(node.data.content); + const start = plainText.indexOf(selectedText); + if (start >= 0) { + return { start, end: start + selectedText.length }; + } + return null; + }, + [node], + ); - const text = selection.toString(); - if (!text) return; + // 计算点击位置在纯文本中的偏移,并返回 caret 的视口坐标(用于精确定位光标指示器) + const computePointOffset = useCallback( + (clientX: number, clientY: number): { offset: number; rect: DOMRect | null } => { + let offset = -1; + let rect: DOMRect | null = null; + // 优先用 caretPositionFromPoint(标准 API) + if (document.caretPositionFromPoint) { + const pos = document.caretPositionFromPoint(clientX, clientY); + if (pos) { + offset = pos.offset; + try { + const range = document.createRange(); + range.setStart(pos.offsetNode, pos.offset); + range.setEnd(pos.offsetNode, pos.offset); + // collapsed range 用 getClientRects 获取 caret 位置 + const rects = range.getClientRects(); + rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect(); + } catch { + rect = null; + } + } + } else if (document.caretRangeFromPoint) { + // 回退:WebKit 专用 API + const range = document.caretRangeFromPoint(clientX, clientY); + if (range) { + offset = range.startOffset; + const rects = range.getClientRects(); + rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect(); + } + } + return { offset, rect }; + }, + [], + ); - // 计算纯文本偏移量 - const range = selection.getRangeAt(0); - const plainText = node.data.content; - const startContainer = range.startContainer; - const endContainer = range.endContainer; + // 右键菜单:在右键位置弹出锚点菜单 + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + if (!node) return; + e.preventDefault(); + e.stopPropagation(); - // 简化:用 selection 的 anchorOffset 和 focusOffset - // 注意:这是近似值,对于复杂 DOM 结构可能不准确 - const startOffset = range.startOffset; - const endOffset = range.endOffset; + const selection = window.getSelection(); + const selectedText = selection && !selection.isCollapsed ? selection.toString() : ""; - // 如果在同一文本节点 - if (startContainer === endContainer && startContainer.nodeType === Node.TEXT_NODE) { - const containerText = startContainer.textContent ?? ""; - const containerStart = plainText.indexOf(containerText); - if (containerStart >= 0) { - const absoluteStart = containerStart + startOffset; - const absoluteEnd = containerStart + endOffset; - const selectedText = plainText.slice(absoluteStart, absoluteEnd); - - // 显示锚点菜单 - const rect = range.getBoundingClientRect(); + if (selectedText) { + // 有选中文本:提供区间锚定 + const offsets = computeSelectionOffset(selectedText); + if (!offsets) return; setShowAnchorMenu({ - x: rect.left + rect.width / 2, - y: rect.top - 10, - selection: { start: absoluteStart, end: absoluteEnd, text: selectedText }, + x: e.clientX, + y: e.clientY, + selection: { ...offsets, text: selectedText }, point: null, }); + } else { + // 无选中文本:提供点锚定 + const { offset } = computePointOffset(e.clientX, e.clientY); + if (offset < 0) return; + setShowAnchorMenu({ + x: e.clientX, + y: e.clientY, + selection: null, + point: offset, + }); } - } + }, + [node, computeSelectionOffset, computePointOffset], + ); - selection.removeAllRanges(); - }, [node]); - - // 处理点击(点锚定) + // 左键点击:显示光标位置指示器(用 caret 实际坐标精确定位) const handleClick = useCallback( (e: React.MouseEvent) => { if (!node) return; - // 如果有选中文本,不处理点击 + // 如果有选中文本,不显示光标(让浏览器处理选择) const selection = window.getSelection(); - if (selection && !selection.isCollapsed) return; - - // 计算点击位置在纯文本中的偏移 - // 简化:使用 caretRangeFromPoint(Chromium)或 caretPositionFromPoint(Firefox) - const x = e.clientX; - const y = e.clientY; - let offset = -1; - - if (document.caretPositionFromPoint) { - const pos = document.caretPositionFromPoint(x, y); - if (pos) offset = pos.offset; - } else if (document.caretRangeFromPoint) { - const range = document.caretRangeFromPoint(x, y); - if (range) offset = range.startOffset; + if (selection && !selection.isCollapsed) { + setCursorPos(null); + return; } - if (offset < 0) return; + // 计算 caret 位置,优先用 caret 的 rect 坐标,fallback 到鼠标坐标 + const { offset, rect } = computePointOffset(e.clientX, e.clientY); + if (offset < 0) { + setCursorPos(null); + return; + } - setShowAnchorMenu({ - x, - y, - selection: null, - point: offset, + setCursorPos({ + x: rect && rect.width >= 0 ? rect.left : e.clientX, + y: rect && rect.width >= 0 ? rect.top : e.clientY, }); }, - [node], + [node, computePointOffset], ); // 关闭锚点菜单 @@ -184,18 +241,65 @@ export const TextbookContentNode = memo(function TextbookContentNode({ return () => document.removeEventListener("mousedown", handleOutside); }, [showAnchorMenu]); - // 缩放控制 - const handleZoomIn = useCallback(() => { - if (!node || !props?.onZoomChange) return; - const newZoom = Math.min(2, node.data.zoom + 0.1); - props.onZoomChange(newZoom); - }, [node, props]); + // 光标指示器自动消失 + useEffect(() => { + if (!cursorPos) return; + const timer = setTimeout(() => setCursorPos(null), 2000); + return () => clearTimeout(timer); + }, [cursorPos]); - const handleZoomOut = useCallback(() => { - if (!node || !props?.onZoomChange) return; - const newZoom = Math.max(0.5, node.data.zoom - 0.1); - props.onZoomChange(newZoom); - }, [node, props]); + // 阻止 React Flow 在正文内容区和缩放手柄上拦截 pointerdown 事件(切实保障文本选择和缩放可用) + // nodrag class 只能阻止拖拽,但 React Flow 可能在更上层 preventDefault 阻止文本选择 + // 使用原生事件监听器 stopPropagation,让 React Flow 完全收不到 pointerdown + useEffect(() => { + const stopPointer = (e: PointerEvent) => { + e.stopPropagation(); + }; + const contentEl = contentRef.current; + const resizeEl = resizeHandleRef.current; + contentEl?.addEventListener("pointerdown", stopPointer); + resizeEl?.addEventListener("pointerdown", stopPointer); + return () => { + contentEl?.removeEventListener("pointerdown", stopPointer); + resizeEl?.removeEventListener("pointerdown", stopPointer); + }; + }, []); + + // 拖拽缩放 + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + resizeStart.current = { x: e.clientX, y: e.clientY, w: rect.width, h: rect.height }; + setResizing(true); + }, + [], + ); + + useEffect(() => { + if (!resizing || !resizeStart.current || !containerRef.current) return; + function handleMove(e: MouseEvent) { + if (!resizeStart.current || !containerRef.current) return; + const dx = e.clientX - resizeStart.current.x; + const dy = e.clientY - resizeStart.current.y; + const newW = Math.max(300, resizeStart.current.w + dx); + const newH = Math.max(200, resizeStart.current.h + dy); + containerRef.current.style.width = `${newW}px`; + containerRef.current.style.height = `${newH}px`; + } + function handleUp() { + setResizing(false); + resizeStart.current = null; + } + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + return () => { + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + }, [resizing]); if (!node) { return ( @@ -209,67 +313,45 @@ export const TextbookContentNode = memo(function TextbookContentNode({ return (
{/* 头部 */}
{t("editor.textbookContent")} -
- - {Math.round(node.data.zoom * 100)}% - -
+ + {t("editor.rightClickHint")} +
{/* 正文内容 */}
{node.data.content ? ( -
- { - // 将段落中的锚点标记渲染为 span - return

{renderChildrenWithAnchors(children, segments, activeAnchorIds, getAnchorNodeColor, props?.onSelectNode, anchors)}

; - }, - }} - > - {injectedContent} -
+
+ {renderSegments({ + segments, + activeAnchorIds, + getAnchorNodeColor, + onSelectNode: props?.onSelectNode, + anchors, + })}
) : (
@@ -278,15 +360,43 @@ export const TextbookContentNode = memo(function TextbookContentNode({ )}
- {/* 锚点浮动菜单 */} - {showAnchorMenu && ( + {/* 拖拽缩放手柄 */} +
+ + + +
+ + {/* 光标位置指示器(通过 portal 渲染到 body,避免 React Flow transform 容器影响 fixed 定位)*/} + {cursorPos && typeof document !== "undefined" && createPortal( +
, + document.body, + )} + + {/* 锚点浮动菜单(右键触发,通过 portal 渲染到 body 保证 fixed 定位相对视口)*/} + {showAnchorMenu && typeof document !== "undefined" && createPortal(
{showAnchorMenu.selection ? ( @@ -296,7 +406,9 @@ export const TextbookContentNode = memo(function TextbookContentNode({
{ + anchorableNodes={anchorableNodes} + hasSelectedNode={!!selectedNodeId} + onPickNode={(nodeId) => { if (props?.onAddRangeAnchor && showAnchorMenu.selection) { props.onAddRangeAnchor({ nodeId, @@ -307,6 +419,17 @@ export const TextbookContentNode = memo(function TextbookContentNode({ } setShowAnchorMenu(null); }} + onCreateNew={() => { + if (props?.onCreateNewNode && showAnchorMenu.selection) { + props.onCreateNewNode({ + anchorType: "range", + start: showAnchorMenu.selection.start, + end: showAnchorMenu.selection.end, + textPreview: showAnchorMenu.selection.text, + }); + } + setShowAnchorMenu(null); + }} />
) : showAnchorMenu.point !== null ? ( @@ -316,7 +439,9 @@ export const TextbookContentNode = memo(function TextbookContentNode({
{ + anchorableNodes={anchorableNodes} + hasSelectedNode={!!selectedNodeId} + onPickNode={(nodeId) => { if (props?.onAddPointAnchor && showAnchorMenu.point !== null) { props.onAddPointAnchor({ nodeId, @@ -325,116 +450,22 @@ export const TextbookContentNode = memo(function TextbookContentNode({ } setShowAnchorMenu(null); }} + onCreateNew={() => { + if (props?.onCreateNewNode && showAnchorMenu.point !== null) { + props.onCreateNewNode({ + anchorType: "point", + start: showAnchorMenu.point, + }); + } + setShowAnchorMenu(null); + }} />
) : null} -
+
, + document.body, )}
); }); -/** - * 锚点节点选择器(简化版:由父组件传入节点列表) - * 实际节点列表通过 context 或 props 传入,这里仅渲染触发按钮 - */ -function AnchorNodeSelector({ - t, - onSelect, -}: { - t: ReturnType; - onSelect: (nodeId: string) => void; -}) { - // 简化:直接调用 onAddRangeAnchor/onAddPointAnchor 时由父组件决定 nodeId - // 这里提供一个输入框让用户输入节点 ID 或选择 - // 实际实现中应从父组件获取可锚定节点列表 - return ( -
- - -
- ); -} - -/** - * 渲染带锚点标记的子节点。 - * 由于 ReactMarkdown 的 components 自定义渲染较为复杂, - * 这里采用简化方案:在文本节点中查找锚点标记并替换为 span。 - */ -function renderChildrenWithAnchors( - children: React.ReactNode, - segments: ReturnType, - activeAnchorIds: Set, - getAnchorNodeColor: (anchorId: string) => string, - onSelectNode?: (id: string | null) => void, - anchors?: NodeAnchor[], -): React.ReactNode { - // 简化:直接遍历 segments 渲染 - return segments.map((seg, idx) => { - if (seg.type === "text") { - return {seg.content}; - } - if (seg.type === "anchor-range") { - const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false; - const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e"; - const anchor = anchors?.find((a) => a.id === seg.anchorId); - return ( - { - e.stopPropagation(); - if (anchor && onSelectNode) { - onSelectNode(anchor.nodeId); - } - }} - > - {seg.content} - - ); - } - // point anchor - const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false; - const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e"; - const anchor = anchors?.find((a) => a.id === seg.anchorId); - const pointIndex = anchor - ? (anchors?.filter((a) => a.type === "point").indexOf(anchor) ?? -1) + 1 - : 1; - return ( - { - e.stopPropagation(); - if (anchor && onSelectNode) { - onSelectNode(anchor.nodeId); - } - }} - > - {toCircledNumber(pointIndex ?? 1)} - - ); - }); -} diff --git a/src/modules/lesson-preparation/components/nodes/textbook-segments.tsx b/src/modules/lesson-preparation/components/nodes/textbook-segments.tsx new file mode 100644 index 0000000..6686cf4 --- /dev/null +++ b/src/modules/lesson-preparation/components/nodes/textbook-segments.tsx @@ -0,0 +1,87 @@ +import type { ReactNode, CSSProperties } from "react"; + +import type { NodeAnchor } from "../../types"; +import { + parseAnchoredText, + toCircledNumber, +} from "../../lib/anchor-injector"; + +interface RenderSegmentsParams { + segments: ReturnType; + activeAnchorIds: Set; + getAnchorNodeColor: (anchorId: string) => string; + onSelectNode?: (id: string | null) => void; + anchors?: NodeAnchor[]; +} + +/** + * 渲染锚点段落数组(简化版:直接遍历 segments,不使用 ReactMarkdown) + * 解决问题 7:避免每个段落重复渲染整个文档内容 + */ +export function renderSegments({ + segments, + activeAnchorIds, + getAnchorNodeColor, + onSelectNode, + anchors, +}: RenderSegmentsParams): ReactNode { + return segments.map((seg, idx) => { + if (seg.type === "text") { + return {seg.content}; + } + if (seg.type === "anchor-range") { + const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false; + const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e"; + const anchor = anchors?.find((a) => a.id === seg.anchorId); + return ( + { + e.stopPropagation(); + if (anchor && onSelectNode) { + onSelectNode(anchor.nodeId); + } + }} + > + {seg.content} + + ); + } + // point anchor + const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false; + const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e"; + const anchor = anchors?.find((a) => a.id === seg.anchorId); + const pointIndex = anchor + ? (anchors?.filter((a) => a.type === "point").indexOf(anchor) ?? -1) + 1 + : 1; + return ( + { + e.stopPropagation(); + if (anchor && onSelectNode) { + onSelectNode(anchor.nodeId); + } + }} + > + {toCircledNumber(pointIndex ?? 1)} + + ); + }); +} diff --git a/src/modules/lesson-preparation/components/publish-homework-dialog.tsx b/src/modules/lesson-preparation/components/publish-homework-dialog.tsx index 7ccddff..c93fcf3 100644 --- a/src/modules/lesson-preparation/components/publish-homework-dialog.tsx +++ b/src/modules/lesson-preparation/components/publish-homework-dialog.tsx @@ -2,8 +2,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; -import { publishLessonPlanHomeworkAction } from "../actions-publish"; -import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; +import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; import { Button } from "@/shared/components/ui/button"; import { X } from "lucide-react"; @@ -23,6 +22,8 @@ export function PublishHomeworkDialog({ onPublished, }: Props) { const t = useTranslations("lessonPreparation"); + const ctx = useLessonPlanContextSafe(); + const service = ctx?.service ?? null; const tracker = useLessonPlanTrackerSafe(); const [selectedClasses, setSelectedClasses] = useState([]); const [availableAt, setAvailableAt] = useState(""); @@ -31,6 +32,7 @@ export function PublishHomeworkDialog({ const [loading, setLoading] = useState(false); async function handlePublish() { + if (!service) return; if (selectedClasses.length === 0) { setError(t("publish.selectClass")); return; @@ -38,7 +40,7 @@ export function PublishHomeworkDialog({ setLoading(true); setError(null); try { - const res = await publishLessonPlanHomeworkAction({ + const res = await service.publishLessonPlanHomework({ planId, blockId, classIds: selectedClasses, diff --git a/src/modules/lesson-preparation/components/question-bank-picker.tsx b/src/modules/lesson-preparation/components/question-bank-picker.tsx index 033412c..c2f0d36 100644 --- a/src/modules/lesson-preparation/components/question-bank-picker.tsx +++ b/src/modules/lesson-preparation/components/question-bank-picker.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from "react" import { useTranslations } from "next-intl" -import { getQuestionsAction } from "@/modules/questions/actions" +import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider" +import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider" import { Button } from "@/shared/components/ui/button" import { useDebounce } from "@/shared/hooks/use-debounce" import { X } from "lucide-react" @@ -10,11 +11,16 @@ import { QuestionBankFilters } from "@/shared/components/question/question-bank- import type { ExerciseItem } from "../types" import type { QuestionType } from "@/modules/questions/types" -interface QuestionRow { - id: string - type: string - difficulty: number - content: unknown +// 类型守卫:验证字符串是否为有效的 QuestionType(避免 as 断言) +function isQuestionType(v: string): v is QuestionType { + const validTypes: readonly string[] = [ + "single_choice", + "multiple_choice", + "judgment", + "text", + "composite", + ] + return validTypes.includes(v) } interface Props { @@ -25,7 +31,9 @@ interface Props { export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) { const t = useTranslations("lessonPreparation") - const [questions, setQuestions] = useState([]) + const ctx = useLessonPlanContextSafe() + const service = ctx?.service ?? null + const [questions, setQuestions] = useState([]) const [picked, setPicked] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -35,18 +43,13 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) { const [typeValue, setTypeValue] = useState("all") const [difficultyValue, setDifficultyValue] = useState("all") - const filters = useMemo<{ - q?: string - type?: QuestionType - difficulty?: number - }>(() => { - const newFilters: { - q?: string - type?: QuestionType - difficulty?: number - } = {} + const filters = useMemo(() => { + const newFilters: QuestionPickerParams = {} if (searchValue) newFilters.q = searchValue - if (typeValue !== "all") newFilters.type = typeValue as QuestionType + // 类型守卫:仅当值为有效 QuestionType 时才赋值(避免 as 断言) + if (typeValue !== "all" && isQuestionType(typeValue)) { + newFilters.type = typeValue + } if (difficultyValue !== "all") newFilters.difficulty = Number(difficultyValue) return newFilters }, [searchValue, typeValue, difficultyValue]) @@ -55,6 +58,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) { const debouncedFilters = useDebounce(filters, 300) useEffect(() => { + if (!service) return let cancelled = false // 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState Promise.resolve() @@ -62,20 +66,12 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) { if (cancelled) return setLoading(true) setError(null) - return getQuestionsAction(debouncedFilters) + return service.getQuestions(debouncedFilters) }) .then((res) => { if (cancelled || !res) return if (res.success && res.data) { - const data = res.data.data - setQuestions( - data.map((q) => ({ - id: q.id, - type: q.type, - difficulty: q.difficulty, - content: q.content, - })), - ) + setQuestions(res.data.data) } else { setError(res.message ?? t("error.loadFailed")) } @@ -91,9 +87,9 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) { return () => { cancelled = true } - }, [debouncedFilters, t]) + }, [debouncedFilters, t, service]) - function add(q: QuestionRow) { + function add(q: QuestionPickerItem) { if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return setPicked((prev) => [ ...prev, diff --git a/src/modules/lesson-preparation/components/template-picker.tsx b/src/modules/lesson-preparation/components/template-picker.tsx index 204c768..55b3dd0 100644 --- a/src/modules/lesson-preparation/components/template-picker.tsx +++ b/src/modules/lesson-preparation/components/template-picker.tsx @@ -3,38 +3,25 @@ import { useEffect, useState, useMemo, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { createLessonPlanAction, getTextbooksForPickerAction, getChaptersForPickerAction } from "../actions"; import { useRouter } from "next/navigation"; +import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; +import type { TextbookPickerOption, ChapterPickerOption } from "../providers/lesson-plan-provider"; import { Button } from "@/shared/components/ui/button"; import { SYSTEM_TEMPLATES } from "../constants"; -import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; import { Book, ChevronRight, FileText, Loader2 } from "lucide-react"; - -interface TextbookOption { - id: string; - title: string; - subject: string; - grade: string | null; -} - -interface ChapterOption { - id: string; - title: string; - parentId: string | null; - order: number | null; - content?: string | null; - children?: unknown[]; -} +import type { LessonPlanTemplate } from "../types"; export function TemplatePicker() { const t = useTranslations("lessonPreparation"); const router = useRouter(); + const ctx = useLessonPlanContextSafe(); + const service = ctx?.service ?? null; const tracker = useLessonPlanTrackerSafe(); const searchParams = useSearchParams(); - const [textbooks, setTextbooks] = useState([]); + const [textbooks, setTextbooks] = useState([]); const [textbookId, setTextbookId] = useState(""); - const [chapters, setChapters] = useState([]); + const [chapters, setChapters] = useState([]); const [chapterId, setChapterId] = useState( () => searchParams.get("chapterId") ?? "", ); @@ -43,14 +30,17 @@ export function TemplatePicker() { const [title, setTitle] = useState(""); const [error, setError] = useState(null); const [loadingTextbooks, setLoadingTextbooks] = useState(true); + // P1-6:个人模板 + const [personalTemplates, setPersonalTemplates] = useState([]); // 派生:当前教材的章节是否正在加载 const loadingChapters = !!textbookId && textbookId !== loadedTextbookId; - // 初始加载教材列表 + URL 参数预选 + // 初始加载教材列表 + URL 参数预选 + 个人模板(P1-6) useEffect(() => { + if (!service) return; let cancelled = false; - getTextbooksForPickerAction() + service.getTextbooksForPicker() .then((res) => { if (cancelled) return; if (res.success && res.data) { @@ -68,18 +58,31 @@ export function TemplatePicker() { .finally(() => { if (!cancelled) setLoadingTextbooks(false); }); + + // P1-6:加载个人模板 + service.getLessonPlanTemplates() + .then((res) => { + if (cancelled) return; + if (res.success && res.data) { + const personal = res.data.templates.filter((tpl) => tpl.type === "personal"); + setPersonalTemplates(personal); + } + }) + .catch((e) => { + console.error("[TemplatePicker] load personal templates failed", e); + }); return () => { cancelled = true; }; - }, [searchParams]); + }, [searchParams, service]); // 教材变化时加载章节 useEffect(() => { - if (!textbookId) { + if (!textbookId || !service) { return; } let cancelled = false; - getChaptersForPickerAction(textbookId) + service.getChaptersForPicker(textbookId) .then((res) => { if (cancelled) return; if (res.success && res.data) { @@ -93,16 +96,16 @@ export function TemplatePicker() { return () => { cancelled = true; }; - }, [textbookId]); + }, [textbookId, service]); // 扁平化章节列表(用于下拉选择,带缩进前缀) const flattenedChapters = useMemo(() => { const result: { id: string; title: string; depth: number }[] = []; - function walk(list: ChapterOption[], depth: number) { + function walk(list: ChapterPickerOption[], depth: number) { for (const ch of list) { result.push({ id: ch.id, title: ch.title, depth }); if (ch.children && Array.isArray(ch.children) && ch.children.length > 0) { - walk(ch.children as ChapterOption[], depth + 1); + walk(ch.children as ChapterPickerOption[], depth + 1); } } } @@ -130,6 +133,7 @@ export function TemplatePicker() { const canSubmit = !!selected && !!title && !!textbookId && !!chapterId; async function handleSubmit(formData: FormData) { + if (!service) return; setError(null); if (!textbookId || !chapterId) { setError(t("picker.errorTextbookChapterRequired")); @@ -140,7 +144,7 @@ export function TemplatePicker() { formData.set("textbookId", textbookId); formData.set("chapterId", chapterId); try { - const res = await createLessonPlanAction(null, formData); + const res = await service.createLessonPlan(null, formData); if (res.success && res.data) { tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected }); router.push(`/teacher/lesson-plans/${res.data.planId}/edit`); @@ -247,6 +251,10 @@ export function TemplatePicker() { {/* 步骤 4:模板 */}
+ {/* 系统模板 */} +
+ {t("template.systemSection")} +
{SYSTEM_TEMPLATES.map((tpl) => (
+ + {/* 个人模板(P1-6)*/} +
+ {t("template.personalSection")} +
+ {personalTemplates.length === 0 ? ( +

+ {t("template.noPersonalTemplates")} +

+ ) : ( +
+ {personalTemplates.map((tpl) => ( + + ))} +
+ )} {selectedTextbook && selectedChapter && (

{t("picker.skeletonHint")} diff --git a/src/modules/lesson-preparation/components/version-history-drawer.tsx b/src/modules/lesson-preparation/components/version-history-drawer.tsx index b40ce56..eefaf2e 100644 --- a/src/modules/lesson-preparation/components/version-history-drawer.tsx +++ b/src/modules/lesson-preparation/components/version-history-drawer.tsx @@ -3,11 +3,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; -import { - getLessonPlanVersionsAction, - revertLessonPlanVersionAction, -} from "../actions"; -import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; +import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; import { Button } from "@/shared/components/ui/button"; import { AlertDialog, @@ -37,6 +33,8 @@ export function VersionHistoryDrawer({ onReverted, }: Props) { const t = useTranslations("lessonPreparation"); + const ctx = useLessonPlanContextSafe(); + const service = ctx?.service ?? null; const tracker = useLessonPlanTrackerSafe(); const [versions, setVersions] = useState([]); const [loading, setLoading] = useState(false); @@ -47,8 +45,9 @@ export function VersionHistoryDrawer({ // 用微任务延迟避免同步 setState 触发级联渲染 queueMicrotask(() => { if (cancelled) return; + if (!service) return; setLoading(true); - getLessonPlanVersionsAction(planId) + service.getLessonPlanVersions(planId) .then((res) => { if (cancelled) return; if (res.success && res.data) setVersions(res.data.versions); @@ -63,11 +62,12 @@ export function VersionHistoryDrawer({ return () => { cancelled = true; }; - }, [open, planId]); + }, [open, planId, service]); async function handleRevert(versionNo: number) { + if (!service) return; try { - const res = await revertLessonPlanVersionAction({ planId, versionNo }); + const res = await service.revertLessonPlanVersion({ planId, versionNo }); if (res.success) { tracker.track("lesson_plan.revert", { planId, versionNo }); toast.success(t("version.revertSuccess", { versionNo })); diff --git a/src/modules/lesson-preparation/config/block-registry.tsx b/src/modules/lesson-preparation/config/block-registry.tsx index 71500f5..17b7b5e 100644 --- a/src/modules/lesson-preparation/config/block-registry.tsx +++ b/src/modules/lesson-preparation/config/block-registry.tsx @@ -1,19 +1,21 @@ import type { ReactElement } from "react"; import type { - BlackboardBlockData, BlockData, BlockType, - ExerciseBlockData, - HomeworkBlockData, - ImportBlockData, - KeyPointBlockData, - NewTeachingBlockData, - ObjectiveBlockData, - ReflectionBlockData, - RichTextBlockData, - SummaryBlockData, - TextStudyBlockData, } from "../types"; +import { + isBlackboardBlockData, + isExerciseBlockData, + isHomeworkBlockData, + isImportBlockData, + isKeyPointBlockData, + isNewTeachingBlockData, + isObjectiveBlockData, + isReflectionBlockData, + isRichTextBlockData, + isSummaryBlockData, + isTextStudyBlockData, +} from "../lib/type-guards"; import { RichTextBlock } from "../components/blocks/rich-text-block"; import { ExerciseBlock } from "../components/blocks/exercise-block"; import { TextStudyBlock } from "../components/blocks/text-study-block"; @@ -75,92 +77,117 @@ export function isRichTextBlock(type: BlockType): boolean { * 根据 type 从注册表查找并渲染对应 Block,所有组件引用均为模块顶层静态声明, * 满足 react-hooks/static-components 规则。 * 新增 Block 类型时,在此 switch 中添加对应 case 即可。 + * + * V3 修复:使用类型守卫替代 `as` 断言,安全收窄 BlockData 联合类型。 + * 类型守卫失败时返回 null(理论上不会发生,因为 type 与 data 由调用方保证一致)。 */ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null { const { type, ...rest } = props; switch (type) { - case "objective": + case "objective": { + if (!isObjectiveBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "key_point": + } + case "key_point": { + if (!isKeyPointBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "import": + } + case "import": { + if (!isImportBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "new_teaching": + } + case "new_teaching": { + if (!isNewTeachingBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "summary": + } + case "summary": { + if (!isSummaryBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "homework": + } + case "homework": { + if (!isHomeworkBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "blackboard": + } + case "blackboard": { + if (!isBlackboardBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "reflection": + } + case "reflection": { + if (!isReflectionBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); - case "exercise": + } + case "exercise": { + if (!isExerciseBlockData(rest.data)) return null; return ( ); - case "text_study": - return ; + } + case "text_study": { + if (!isTextStudyBlockData(rest.data)) return null; + return ; + } case "rich_text": - case "consolidation": + case "consolidation": { + if (!isRichTextBlockData(rest.data)) return null; return ( rest.onUpdate(d)} /> ); + } default: return null; } diff --git a/src/modules/lesson-preparation/data-access-knowledge.ts b/src/modules/lesson-preparation/data-access-knowledge.ts index 948ea28..384809a 100644 --- a/src/modules/lesson-preparation/data-access-knowledge.ts +++ b/src/modules/lesson-preparation/data-access-knowledge.ts @@ -46,6 +46,8 @@ export async function getLessonPlansByKnowledgePoint( subjectName: null, gradeName: null, creatorName: null, + versionCount: 1, + versions: [], })); } @@ -87,5 +89,7 @@ export async function getLessonPlansByQuestion( subjectName: null, gradeName: null, creatorName: null, + versionCount: 1, + versions: [], })); } diff --git a/src/modules/lesson-preparation/data-access-versions.ts b/src/modules/lesson-preparation/data-access-versions.ts index 9508740..2038bfb 100644 --- a/src/modules/lesson-preparation/data-access-versions.ts +++ b/src/modules/lesson-preparation/data-access-versions.ts @@ -114,6 +114,8 @@ export async function revertToVersion( planId: string, versionNo: number, userId: string, + // V3 修复:由 actions 层传入 i18n 翻译的回退标签,避免 data-access 硬编码中文 + revertLabel: string, ): Promise<{ newVersionNo: number } | null> { const content = await getVersionContent(planId, versionNo, userId); if (!content) return null; @@ -138,7 +140,7 @@ export async function revertToVersion( id: createId(), planId, versionNo: newNo, - label: `回退到 v${versionNo}`, + label: revertLabel, content, isAuto: false, creatorId: userId, diff --git a/src/modules/lesson-preparation/data-access.ts b/src/modules/lesson-preparation/data-access.ts index 7dac3ee..a84ddd4 100644 --- a/src/modules/lesson-preparation/data-access.ts +++ b/src/modules/lesson-preparation/data-access.ts @@ -30,6 +30,7 @@ import type { LessonPlanListItem, LessonPlanTemplate, LessonPlanStatus, + LessonPlanVersionSummary, TemplateType, TemplateScope, } from "./types"; @@ -126,6 +127,8 @@ function mapRowToListItem(row: { subjectName: row.subjectName, gradeName: row.gradeName, creatorName: row.creatorName, + versionCount: 1, + versions: [], }; } @@ -192,14 +195,12 @@ function buildScopeCondition(scope: DataScope, userId: string): SQL[] { ]; } case "class_members": { - // 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧) - const publishedFilter = sql`(${lessonPlans.status} = 'published')`; - return [publishedFilter]; + // 学生:仅查看 published 课案 + return [sql`(${lessonPlans.status} = 'published')`]; } case "children": { - // 家长:仅查看 published 课案(同学生) - const publishedFilter = sql`(${lessonPlans.status} = 'published')`; - return [publishedFilter]; + // 家长:仅查看 published 课案 + return [sql`(${lessonPlans.status} = 'published')`]; } } } @@ -266,7 +267,45 @@ export const getLessonPlans = cache( .orderBy(desc(lessonPlans.updatedAt)); const items = rows.map(mapRowToListItem); - return items; + + // 版本聚合:按 textbookId + chapterId + creatorId 分组(同一教师对同一章节的多个课案视为版本) + // 无 textbookId/chapterId 的课案各自独立成组 + const groups = new Map(); + for (const item of items) { + const key = + item.textbookId && item.chapterId + ? `${item.textbookId}|${item.chapterId}|${item.creatorId}` + : `__single__${item.id}`; + const arr = groups.get(key); + if (arr) { + arr.push(item); + } else { + groups.set(key, [item]); + } + } + + // 每组取第一个(updatedAt 最新,因已按 updatedAt desc 排序)作为代表,附加版本信息 + const grouped: LessonPlanListItem[] = []; + for (const groupItems of groups.values()) { + const representative = groupItems[0]; + if (!representative) continue; + const versions: LessonPlanVersionSummary[] = groupItems + .map((v) => ({ + id: v.id, + title: v.title, + status: v.status, + updatedAt: v.updatedAt, + lastSavedAt: v.lastSavedAt, + })) + .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); + grouped.push({ + ...representative, + versionCount: groupItems.length, + versions, + }); + } + + return grouped; }, ); @@ -437,10 +476,50 @@ export async function softDeleteLessonPlan( } } +// ---- 发布 / 撤回发布 ---- +// P0-1 修复:课案发布机制,发布后学生/家长可查看 +export async function publishLessonPlan( + planId: string, + userId: string, +): Promise { + const result = await db + .update(lessonPlans) + .set({ status: "published", lastSavedAt: new Date() }) + .where( + and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)), + ); + + if (result[0].affectedRows === 0) { + throw new LessonPlanDataError("NOT_FOUND"); + } +} + +export async function unpublishLessonPlan( + planId: string, + userId: string, +): Promise { + const result = await db + .update(lessonPlans) + .set({ status: "draft", lastSavedAt: new Date() }) + .where( + and( + eq(lessonPlans.id, planId), + eq(lessonPlans.creatorId, userId), + eq(lessonPlans.status, "published"), + ), + ); + + if (result[0].affectedRows === 0) { + throw new LessonPlanDataError("NOT_FOUND"); + } +} + // ---- 复制 ---- +// V3 修复:duplicateSuffix 由 actions 层 i18n 翻译后传入,避免 data-access 硬编码中文 export async function duplicateLessonPlan( planId: string, userId: string, + duplicateSuffix: string = " - Copy", ): Promise<{ newPlanId: string }> { const src = await getLessonPlanById(planId, userId); if (!src) throw new LessonPlanDataError("NOT_FOUND"); @@ -448,7 +527,7 @@ export async function duplicateLessonPlan( const newId = createId(); await db.insert(lessonPlans).values({ id: newId, - title: `${src.title} - 副本`, + title: `${src.title}${duplicateSuffix}`, textbookId: src.textbookId, chapterId: src.chapterId, subjectId: src.subjectId, @@ -463,6 +542,29 @@ export async function duplicateLessonPlan( return { newPlanId: newId }; } +// ---- 统计:各状态课案数量(供管理员看板使用,避免 app 层直查 DB)---- +export interface LessonPlanStats { + total: number; + published: number; + draft: number; + archived: number; +} + +export async function getLessonPlanStats(): Promise { + const rows = await db + .select({ status: lessonPlans.status, count: sql`count(*)` }) + .from(lessonPlans) + .where(sql`${lessonPlans.status} != 'archived'`) + .groupBy(lessonPlans.status); + const map = new Map(rows.map((r) => [r.status, Number(r.count)])); + return { + total: Array.from(map.values()).reduce((a, b) => a + b, 0), + published: map.get("published") ?? 0, + draft: map.get("draft") ?? 0, + archived: 0, // 已排除 archived + }; +} + // ---- 模板查询(内部)---- export async function getTemplateById( templateId: string, diff --git a/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts b/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts index 2aee2e3..81d7d2e 100644 --- a/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts +++ b/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts @@ -31,7 +31,8 @@ interface EditorState { hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void; addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string; - updateNode: (id: string, patch: Partial) => void; + // V3 修复:patch 排除 type 字段,防止改变节点类型,同时消除 as 断言 + updateNode: (id: string, patch: Omit, "type">) => void; updateNodePosition: (id: string, position: { x: number; y: number }) => void; removeNode: (id: string) => void; @@ -127,11 +128,14 @@ export const useLessonPlanEditor = create((set, get) => ({ set((s) => ({ doc: { ...s.doc, + // V3 修复:patch 已排除 type 字段,但 TypeScript 仍会因 spread 拓宽 data 类型 + // (BlockData 联合不包含 TextbookContentNodeData)而报错,此处 as 为必要断言。 + // 实际安全:调用方不会对 textbook_content 节点通过 updateNode 传入 data。 nodes: s.doc.nodes.map((n) => n.id === id ? n.type === "textbook_content" - ? { ...n, ...patch } as TextbookContentNode - : { ...n, ...patch } as LessonPlanNode + ? ({ ...n, ...patch } as TextbookContentNode) + : ({ ...n, ...patch } as LessonPlanNode) : n, ), }, @@ -143,11 +147,12 @@ export const useLessonPlanEditor = create((set, get) => ({ set((s) => ({ doc: { ...s.doc, + // 同 updateNode:spread 后 TypeScript 拓宽类型,需 as 断言收窄 nodes: s.doc.nodes.map((n) => n.id === id ? n.type === "textbook_content" - ? { ...n, position } as TextbookContentNode - : { ...n, position } as LessonPlanNode + ? ({ ...n, position } as TextbookContentNode) + : ({ ...n, position } as LessonPlanNode) : n, ), }, diff --git a/src/modules/lesson-preparation/lib/document-migration.ts b/src/modules/lesson-preparation/lib/document-migration.ts index cf659f1..eaca139 100644 --- a/src/modules/lesson-preparation/lib/document-migration.ts +++ b/src/modules/lesson-preparation/lib/document-migration.ts @@ -226,28 +226,29 @@ export function buildDefaultSkeleton( translateTitle?: (key: string) => string, ): LessonPlanDocument { const textbookContentNodeId = createId(); + // P2-5:正文节点居中,左右列增大间距避免重叠 const textbookNode: TextbookContentNode = { id: textbookContentNodeId, type: "textbook_content", title: "textbook_content", data: { chapterId, content: chapterContent, zoom: 1 }, order: -1, - position: { x: 400, y: 200 }, + position: { x: 500, y: 250 }, draggable: false, }; - // 默认 10 节点骨架(标题使用 i18n 键 blockType.${type}) + // P2-5:左列 x=80,右列 x=900,避免与正文节点(宽 480)重叠 const skeleton: { type: BlockType; position: { x: number; y: number } }[] = [ { type: "objective", position: { x: 80, y: 80 } }, { type: "key_point", position: { x: 80, y: 200 } }, { type: "import", position: { x: 80, y: 320 } }, { type: "text_study", position: { x: 80, y: 440 } }, - { type: "new_teaching", position: { x: 720, y: 80 } }, - { type: "exercise", position: { x: 720, y: 200 } }, - { type: "summary", position: { x: 720, y: 320 } }, + { type: "new_teaching", position: { x: 900, y: 80 } }, + { type: "exercise", position: { x: 900, y: 200 } }, + { type: "summary", position: { x: 900, y: 320 } }, { type: "homework", position: { x: 80, y: 560 } }, - { type: "blackboard", position: { x: 720, y: 440 } }, - { type: "reflection", position: { x: 720, y: 560 } }, + { type: "blackboard", position: { x: 900, y: 440 } }, + { type: "reflection", position: { x: 900, y: 560 } }, ]; const nodes: LessonPlanNode[] = skeleton.map((s, i) => ({ diff --git a/src/modules/lesson-preparation/lib/i18n-errors.ts b/src/modules/lesson-preparation/lib/i18n-errors.ts new file mode 100644 index 0000000..7deb399 --- /dev/null +++ b/src/modules/lesson-preparation/lib/i18n-errors.ts @@ -0,0 +1,50 @@ +import "server-only"; +import type { z } from "zod"; +import { getTranslations } from "next-intl/server"; + +/** + * 将 Zod 校验失败的 fieldErrors 中的 i18n 键翻译为实际消息。 + * + * schema.ts 中错误消息存储为 i18n 键(如 "error.titleRequired"), + * 此函数在 actions 层调用,将键翻译为当前语言的文本。 + * + * 返回类型为 Record(不含 undefined), + * 因为只有非 undefined 的字段才会被加入结果。 + */ +export async function translateFieldErrors( + errors: Record, +): Promise> { + const t = await getTranslations("lessonPreparation"); + const result: Record = {}; + for (const [field, messages] of Object.entries(errors)) { + if (!messages) continue; + result[field] = messages.map((msg) => { + // 仅翻译以 "error." 开头的 i18n 键,其他保持原样 + if (msg.startsWith("error.")) { + return t(msg as Parameters[0]); + } + return msg; + }); + } + return result; +} + +/** + * 安全解析 Zod 结果并返回 ActionState 错误格式(带 i18n 翻译)。 + * 若校验失败,返回翻译后的 fieldErrors;若成功,返回 parsed.data。 + */ +export async function safeParseWithI18n( + schema: z.ZodType, + input: unknown, +): Promise< + | { success: true; data: T } + | { success: false; errors: Record } +> { + const result = schema.safeParse(input); + if (!result.success) { + const errors = result.error.flatten().fieldErrors; + const translated = await translateFieldErrors(errors); + return { success: false, errors: translated }; + } + return { success: true, data: result.data }; +} diff --git a/src/modules/lesson-preparation/lib/rf-mappers.ts b/src/modules/lesson-preparation/lib/rf-mappers.ts index 62485dd..289a274 100644 --- a/src/modules/lesson-preparation/lib/rf-mappers.ts +++ b/src/modules/lesson-preparation/lib/rf-mappers.ts @@ -2,10 +2,9 @@ import type { Node, Edge } from "@xyflow/react"; import type { AnyLessonPlanEdge, AnyLessonPlanNode, - LessonPlanNode, NodeAnchor, - TextbookContentNode, } from "../types"; +import { getNodeColor } from "./node-summary"; /** * 纯函数:将课案 nodes/edges 映射为 React Flow 格式。 @@ -20,6 +19,8 @@ import type { export interface ToRfNodesContext { anchors: NodeAnchor[]; selectedNodeId: string | null; + /** 可锚定的教学节点列表(P1-1:用于节点选择器)*/ + anchorableNodes?: { id: string; title: string; type: string }[]; onAddRangeAnchor?: (params: { nodeId: string; start: number; @@ -30,8 +31,14 @@ export interface ToRfNodesContext { nodeId: string; start: number; }) => void; + /** 创建新节点并锚定(P1-1)*/ + onCreateNewNode?: (params: { + anchorType: "range" | "point"; + start: number; + end?: number; + textPreview?: string; + }) => void; onSelectNode?: (id: string | null) => void; - onZoomChange?: (zoom: number) => void; } export function toRfNodes( @@ -39,10 +46,25 @@ export function toRfNodes( selectedNodeId: string | null, ctx?: ToRfNodesContext, ): Node[] { + // 当有选中节点时,收集所有与选中节点相关的节点 ID(通过锚点关联) + const relatedNodeIds = new Set(); + if (selectedNodeId && ctx?.anchors) { + for (const a of ctx.anchors) { + if (a.nodeId === selectedNodeId) { + relatedNodeIds.add(a.nodeId); + } + } + // 正文节点始终相关(因为锚点在正文上) + const textbookNode = nodes.find((n) => n.type === "textbook_content"); + if (textbookNode) relatedNodeIds.add(textbookNode.id); + relatedNodeIds.add(selectedNodeId); + } + return nodes.map((n) => { - // 正文节点 + // 正文节点:n.type === "textbook_content" 已收窄为 TextbookContentNode if (n.type === "textbook_content") { - const tbNode = n as TextbookContentNode; + const tbNode = n; + const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(tbNode.id); return { id: tbNode.id, type: "textbook_content", @@ -51,24 +73,28 @@ export function toRfNodes( node: tbNode, anchors: ctx?.anchors ?? [], selectedNodeId, + anchorableNodes: ctx?.anchorableNodes ?? [], onAddRangeAnchor: ctx?.onAddRangeAnchor, onAddPointAnchor: ctx?.onAddPointAnchor, + onCreateNewNode: ctx?.onCreateNewNode, onSelectNode: ctx?.onSelectNode, - onZoomChange: ctx?.onZoomChange, } as Record, selected: tbNode.id === selectedNodeId, draggable: false, + style: isDimmed ? { opacity: 0.3 } : undefined, }; } - // 教学节点 - const lessonNode = n as LessonPlanNode; + // 教学节点:textbook_content 分支已上方 return,此处 n 已收窄为 LessonPlanNode + const lessonNode = n; + const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(lessonNode.id); return { id: lessonNode.id, type: "lesson", position: lessonNode.position, data: { node: lessonNode } as Record, selected: lessonNode.id === selectedNodeId, + style: isDimmed ? { opacity: 0.3 } : undefined, }; }); } @@ -80,36 +106,39 @@ export function toRfEdges( ): Edge[] { return edges.map((e) => { if (e.type === "anchor") { - // 锚点边:默认 10% 透明度,选中关联节点时 100% + // 锚点边:默认 40% 透明度,选中关联节点时 100% const anchor = anchors.find((a) => a.id === e.anchorId); const isActive = anchor && anchor.nodeId === selectedNodeId; + // P1-4 修复:使用锚点关联节点的颜色,而非硬编码蓝色 + const strokeColor = anchor ? getNodeColor(anchor.nodeId) : "#9e9e9e"; return { ...e, - animated: false, + animated: isActive, className: isActive ? "anchor-edge active" : "anchor-edge", + // P1-3 修复:将 anchorId 存入 data,fromRfEdges 从 data 读取 + data: { anchorId: e.anchorId }, style: { - stroke: anchor ? getNodeColorForAnchor(anchor.nodeId) : "#9e9e9e", - strokeWidth: 2, - opacity: isActive ? 1 : 0.1, + stroke: strokeColor, + strokeWidth: isActive ? 3 : 2, + opacity: isActive ? 1 : 0.4, }, }; } // 流程边 + const isDimmed = selectedNodeId !== null && e.source !== selectedNodeId && e.target !== selectedNodeId; return { ...e, - animated: true, - style: { stroke: "#1976d2", strokeWidth: 2 }, + animated: !isDimmed, + style: { + stroke: "#1976d2", + strokeWidth: 2, + opacity: isDimmed ? 0.2 : 1, + }, }; }); } -// 简单的颜色查找(避免循环依赖 node-summary) -function getNodeColorForAnchor(_nodeId: string): string { - // 实际颜色由 CSS 类 .anchor-edge 设置,这里返回默认值 - return "#1976d2"; -} - /** * 将 React Flow edges 转回课案 edges 格式。 */ @@ -125,12 +154,13 @@ export function fromRfEdges( targetHandle: e.targetHandle ?? null, }; - // 保留原有的 type 信息(通过 className 判断或默认为 flow) - if (e.className?.includes("anchor-edge")) { + // P1-3 修复:优先从 data.anchorId 读取,回退到 className 判断 + const dataAnchorId = (e.data as { anchorId?: string } | undefined)?.anchorId; + if (dataAnchorId || e.className?.includes("anchor-edge")) { return { ...base, type: "anchor" as const, - anchorId: e.id, // 简化:用 edge id 作为 anchorId(实际应从 data 读取) + anchorId: dataAnchorId ?? e.id, }; } diff --git a/src/modules/lesson-preparation/lib/type-guards.ts b/src/modules/lesson-preparation/lib/type-guards.ts new file mode 100644 index 0000000..e605d2e --- /dev/null +++ b/src/modules/lesson-preparation/lib/type-guards.ts @@ -0,0 +1,213 @@ +// 备课模块集中类型守卫:替代 `as` 断言,安全收窄 unknown 联合类型 +import type { + BlackboardBlockData, + BlockData, + BlockType, + ExerciseBlockData, + ExercisePurpose, + HomeworkAssignment, + HomeworkBlockData, + ImportBlockData, + KeyPointBlockData, + KeyPointItem, + LessonPlanNode, + LessonPlanStatus, + NewTeachingBlockData, + ObjectiveBlockData, + ObjectiveItem, + ReflectionBlockData, + ReflectionItem, + RichTextBlockData, + SummaryBlockData, + TemplateScope, + TemplateType, + TextStudyBlockData, + TextbookContentNode, +} from "../types"; + +// ---- 基础类型守卫 ---- +const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const; +export function isLessonPlanStatus(v: string): v is LessonPlanStatus { + return (LESSON_PLAN_STATUSES as readonly string[]).includes(v); +} + +const TEMPLATE_TYPES = ["system", "personal"] as const; +export 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; +export function isTemplateScope(v: string): v is TemplateScope { + return (TEMPLATE_SCOPES as readonly string[]).includes(v); +} + +// ---- Block 数据类型守卫 ---- +// 各守卫通过检查该 Block 数据接口的"特征字段"来收窄联合类型 BlockData。 +// 特征字段选取接口中独有且必填的字段,避免与其他接口混淆。 + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +export function isRichTextBlockData(data: BlockData): data is RichTextBlockData { + return isObject(data) && typeof data.html === "string" && Array.isArray(data.knowledgePointIds); +} + +export function isTextStudyBlockData(data: BlockData): data is TextStudyBlockData { + return ( + isObject(data) && + typeof data.sourceText === "string" && + Array.isArray(data.annotations) && + Array.isArray(data.knowledgePointIds) + ); +} + +export function isExerciseBlockData(data: BlockData): data is ExerciseBlockData { + return ( + isObject(data) && + Array.isArray(data.items) && + (data.purpose === "class_practice" || data.purpose === "after_class_homework") + ); +} + +export function isObjectiveBlockData(data: BlockData): data is ObjectiveBlockData { + return isObject(data) && Array.isArray(data.objectives); +} + +export function isKeyPointBlockData(data: BlockData): data is KeyPointBlockData { + return isObject(data) && Array.isArray(data.keyPoints); +} + +export function isImportBlockData(data: BlockData): data is ImportBlockData { + return ( + isObject(data) && + typeof data.prompt === "string" && + typeof data.durationMin === "number" && + typeof data.method === "string" + ); +} + +export function isNewTeachingBlockData(data: BlockData): data is NewTeachingBlockData { + return isObject(data) && Array.isArray(data.teachingPoints); +} + +export function isSummaryBlockData(data: BlockData): data is SummaryBlockData { + return isObject(data) && Array.isArray(data.summaryPoints) && typeof data.homeworkPreview === "string"; +} + +export function isHomeworkBlockData(data: BlockData): data is HomeworkBlockData { + return isObject(data) && Array.isArray(data.assignments); +} + +export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockData { + return ( + isObject(data) && + typeof data.layout === "string" && + typeof data.content === "string" && + Array.isArray(data.knowledgePointIds) + ); +} + +export function isReflectionBlockData(data: BlockData): data is ReflectionBlockData { + return isObject(data) && Array.isArray(data.reflection); +} + +// ---- Block 字段值类型守卫(用于 select onChange 等场景,替代 `as` 断言)---- + +const BLACKBOARD_LAYOUTS = ["structure", "mindmap", "text"] as const; +export function isBlackboardLayout( + v: string, +): v is BlackboardBlockData["layout"] { + return (BLACKBOARD_LAYOUTS as readonly string[]).includes(v); +} + +const IMPORT_METHODS = ["question", "situation", "review", "other"] as const; +export function isImportMethod(v: string): v is ImportBlockData["method"] { + return (IMPORT_METHODS as readonly string[]).includes(v); +} + +const EXERCISE_PURPOSES = ["class_practice", "after_class_homework"] as const; +export function isExercisePurpose(v: string): v is ExercisePurpose { + return (EXERCISE_PURPOSES as readonly string[]).includes(v); +} + +const OBJECTIVE_DIMENSIONS = ["knowledge", "process", "emotion"] as const; +export function isObjectiveDimension( + v: string, +): v is ObjectiveItem["dimension"] { + return (OBJECTIVE_DIMENSIONS as readonly string[]).includes(v); +} + +const KEY_POINT_TYPES = ["key", "difficult"] as const; +export function isKeyPointType(v: string): v is KeyPointItem["type"] { + return (KEY_POINT_TYPES as readonly string[]).includes(v); +} + +const HOMEWORK_TYPES = ["exercise", "reading", "writing"] as const; +export function isHomeworkType(v: string): v is HomeworkAssignment["type"] { + return (HOMEWORK_TYPES as readonly string[]).includes(v); +} + +const REFLECTION_ASPECTS = [ + "effectiveness", + "problems", + "improvements", +] as const; +export function isReflectionAspect( + v: string, +): v is ReflectionItem["aspect"] { + return (REFLECTION_ASPECTS as readonly string[]).includes(v); +} + +// ---- 节点类型守卫 ---- +export function isTextbookContentNode( + node: { type: string }, +): node is TextbookContentNode { + return node.type === "textbook_content"; +} + +export function isLessonPlanNode( + node: { type: string }, +): node is LessonPlanNode { + return node.type !== "textbook_content"; +} + +// ---- 题目类型守卫 ---- +const VALID_QUESTION_TYPES = [ + "single_choice", + "multiple_choice", + "text", + "judgment", + "composite", +] as const; +export type ValidQuestionType = (typeof VALID_QUESTION_TYPES)[number]; + +export function isValidQuestionType(v: string): v is ValidQuestionType { + return (VALID_QUESTION_TYPES as readonly string[]).includes(v); +} + +// ---- BlockType 守卫 ---- +const VALID_BLOCK_TYPES: BlockType[] = [ + "objective", + "key_point", + "import", + "new_teaching", + "consolidation", + "summary", + "homework", + "blackboard", + "text_study", + "exercise", + "rich_text", + "reflection", +]; +export function isBlockType(v: string): v is BlockType { + return (VALID_BLOCK_TYPES as readonly string[]).includes(v); +} diff --git a/src/modules/lesson-preparation/providers/lesson-plan-provider-setup.tsx b/src/modules/lesson-preparation/providers/lesson-plan-provider-setup.tsx new file mode 100644 index 0000000..fb30655 --- /dev/null +++ b/src/modules/lesson-preparation/providers/lesson-plan-provider-setup.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useMemo, type ReactNode } from "react"; +import { + LessonPlanProvider, + TEACHER_ROLE_CONFIG, + type LessonPlanRoleConfig, +} from "./lesson-plan-provider"; +import { createDefaultDataService } from "../services/default-data-service"; + +/** + * 备课模块 Provider 设置组件(V3 新增)。 + * 在页面层包裹此组件,自动注入默认数据服务和角色配置。 + * 组件通过 useLessonPlanContextSafe() 获取 service,不直接 import actions。 + */ +export function LessonPlanProviderSetup({ + children, + roleConfig = TEACHER_ROLE_CONFIG, +}: { + children: ReactNode; + roleConfig?: LessonPlanRoleConfig; +}) { + const service = useMemo(() => createDefaultDataService(), []); + return ( + + {children} + + ); +} diff --git a/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx b/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx index 215abe4..a7603de 100644 --- a/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx +++ b/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx @@ -1,10 +1,68 @@ "use client"; import { createContext, useContext, useMemo, type ReactNode } from "react"; -import type { LessonPlanListItem, LessonPlanVersion } from "../types"; +import type { QuestionType } from "@/modules/questions/types"; +import type { + ActionState, + LessonPlan, + LessonPlanDocument, + LessonPlanListItem, + LessonPlanTemplate, + LessonPlanVersion, +} from "../types"; + +// ---- V3 扩展:picker/dialog 组件所需的数据类型 ---- + +/** 教材选项(template-picker 使用)*/ +export interface TextbookPickerOption { + id: string; + title: string; + subject: string; + grade: string | null; +} + +/** 章节选项(template-picker 使用)*/ +export interface ChapterPickerOption { + id: string; + title: string; + parentId: string | null; + order: number | null; + content?: string | null; + children?: unknown[]; +} + +/** 知识点选项(knowledge-point-picker 使用)*/ +export interface KnowledgePointOption { + id: string; + name: string; +} + +/** 发布作业输入(publish-homework-dialog 使用)*/ +export interface PublishHomeworkInput { + planId: string; + blockId: string; + classIds: string[]; + availableAt?: string; + dueAt?: string; +} + +/** 题库筛选参数(question-bank-picker 使用)*/ +export interface QuestionPickerParams { + q?: string; + type?: QuestionType; + difficulty?: number; +} + +/** 题库题目项(question-bank-picker 使用)*/ +export interface QuestionPickerItem { + id: string; + type: string; + difficulty: number; + content: unknown; +} /** - * 备课模块数据服务接口(P1-7)。 + * 备课模块数据服务接口(P1-7 / V3 扩展)。 * 抽象数据依赖,各角色/测试可提供不同实现,通过 LessonPlanProvider 注入。 * 组件不直接 import actions,只通过此接口调用。 */ @@ -18,6 +76,27 @@ export interface LessonPlanDataService { status?: string; }): Promise<{ success: boolean; data?: { items: LessonPlanListItem[] }; message?: string }>; + /** 获取单个课案(V3 新增)*/ + getLessonPlanById(planId: string): Promise<{ + success: boolean; + data?: { plan: LessonPlan }; + message?: string; + }>; + + /** 更新课案内容(自动保存,V3 新增)*/ + updateLessonPlan(input: { + planId: string; + title?: string; + content: LessonPlanDocument; + }): Promise<{ success: boolean; message?: string }>; + + /** 保存版本(V3 新增)*/ + saveLessonPlanVersion(input: { + planId: string; + content: LessonPlanDocument; + label?: string; + }): Promise<{ success: boolean; data?: { versionNo: number }; message?: string }>; + /** 获取课案版本列表 */ getLessonPlanVersions(planId: string): Promise<{ success: boolean; @@ -36,6 +115,67 @@ export interface LessonPlanDataService { /** 删除/归档课案 */ deleteLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>; + + /** 发布课案(V3 新增)*/ + publishLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>; + + /** 撤回发布(V3 新增)*/ + unpublishLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>; + + // ---- V3 新增:picker/dialog 组件所需方法 ---- + + /** 创建课案(template-picker 使用)*/ + createLessonPlan( + prevState: ActionState | null, + formData: FormData, + ): Promise<{ success: boolean; data?: { planId: string }; message?: string; errors?: Record }>; + + /** 获取教材列表(template-picker 使用)*/ + getTextbooksForPicker(): Promise<{ + success: boolean; + data?: { textbooks: TextbookPickerOption[] }; + message?: string; + }>; + + /** 获取章节列表(template-picker 使用)*/ + getChaptersForPicker(textbookId: string): Promise<{ + success: boolean; + data?: { chapters: ChapterPickerOption[] }; + message?: string; + }>; + + /** 获取模板列表(template-picker 使用)*/ + getLessonPlanTemplates(): Promise<{ + success: boolean; + data?: { templates: LessonPlanTemplate[] }; + message?: string; + }>; + + /** 获取知识点选项(knowledge-point-picker 使用)*/ + getKnowledgePointOptions(input: { + textbookId?: string; + chapterId?: string; + }): Promise<{ + success: boolean; + data?: { options: KnowledgePointOption[] }; + message?: string; + errors?: Record; + }>; + + /** 发布作业(publish-homework-dialog 使用)*/ + publishLessonPlanHomework(input: PublishHomeworkInput): Promise<{ + success: boolean; + data?: { examId: string; assignmentId: string }; + message?: string; + errors?: Record; + }>; + + /** 获取题库题目(question-bank-picker 使用,跨模块)*/ + getQuestions(params: QuestionPickerParams): Promise<{ + success: boolean; + data?: { data: QuestionPickerItem[] }; + message?: string; + }>; } /** @@ -167,7 +307,8 @@ export function useLessonPlanContextSafe(): LessonPlanContextValue | null { export function useLessonPlanContext(): LessonPlanContextValue { const ctx = useContext(LessonPlanContext); if (!ctx) { - throw new Error("useLessonPlanContext 必须在 LessonPlanProvider 内使用"); + // V3 修复:开发者错误消息改为英文(非用户可见) + throw new Error("useLessonPlanContext must be used within a LessonPlanProvider"); } return ctx; } diff --git a/src/modules/lesson-preparation/publish-service.ts b/src/modules/lesson-preparation/publish-service.ts index ff4f762..f385fab 100644 --- a/src/modules/lesson-preparation/publish-service.ts +++ b/src/modules/lesson-preparation/publish-service.ts @@ -10,7 +10,8 @@ import { persistExamDraft, addExamQuestions } from "@/modules/exams/data-access" import { createHomeworkAssignment } from "@/modules/homework/data-access-write"; import { getStudentIdsByClassIds } from "@/modules/classes/data-access"; import { normalizeDocument } from "./data-access"; -import type { LessonPlanDocument, ExerciseBlockData, LessonPlan, LessonPlanStatus } from "./types"; +import { isExerciseBlockData, isLessonPlanStatus, isValidQuestionType } from "./lib/type-guards"; +import type { LessonPlanDocument, LessonPlan, LessonPlanStatus } from "./types"; interface PublishInput { planId: string; @@ -19,6 +20,10 @@ interface PublishInput { classIds: string[]; availableAt?: Date; dueAt?: Date; + /** 作业标题(由 actions 层 i18n 翻译后传入)*/ + homeworkTitle: string; + /** 作业描述(由 actions 层 i18n 翻译后传入)*/ + homeworkDescription: string; } interface PublishResult { @@ -27,12 +32,6 @@ interface PublishResult { updatedContent: LessonPlanDocument; } -// 类型守卫:安全地将 string 收窄为 LessonPlanStatus -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); -} - /** * publish-service 错误:使用错误码替代硬编码中文, * 由 actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息。 @@ -44,7 +43,8 @@ export type PublishErrorCode = | "NO_QUESTIONS" | "ALREADY_PUBLISHED" | "NO_SUBJECT_OR_GRADE" - | "NO_STUDENTS"; + | "NO_STUDENTS" + | "INVALID_QUESTION_TYPE"; export class PublishServiceError extends Error { constructor(public readonly code: PublishErrorCode) { @@ -64,7 +64,10 @@ export async function publishLessonPlanHomework( .limit(1); if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND"); const row = rows[0]; - // 类型守卫:从 Drizzle 推导类型收窄为 LessonPlan 所需字段 + // 使用类型守卫收窄(替代 as 断言) + const status: LessonPlanStatus = isLessonPlanStatus(row.status) + ? row.status + : "draft"; const plan: LessonPlan = { id: row.id, title: row.title, @@ -76,7 +79,7 @@ export async function publishLessonPlanHomework( templateId: row.templateId, templateName: row.templateName, content: normalizeDocument(row.content), - status: isLessonPlanStatus(row.status) ? row.status : "draft", + status, creatorId: row.creatorId, lastSavedAt: row.lastSavedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), @@ -85,13 +88,15 @@ export async function publishLessonPlanHomework( if (plan.creatorId !== input.userId) throw new PublishServiceError("NO_PERMISSION"); - // 2. 定位 exercise block + // 2. 定位 exercise block(使用类型守卫替代 as 断言) const block = plan.content.nodes.find((b) => b.id === input.blockId); if (!block || block.type !== "exercise") throw new PublishServiceError("NO_EXERCISE_BLOCK"); - const data = block.data as ExerciseBlockData; - if (data.items.length === 0) throw new PublishServiceError("NO_QUESTIONS"); - if (data.publishedAssignmentId) + if (!isExerciseBlockData(block.data)) + throw new PublishServiceError("NO_EXERCISE_BLOCK"); + if (block.data.items.length === 0) + throw new PublishServiceError("NO_QUESTIONS"); + if (block.data.publishedAssignmentId) throw new PublishServiceError("ALREADY_PUBLISHED"); // 3. inline 题目入库,替换占位 ID @@ -99,22 +104,22 @@ export async function publishLessonPlanHomework( const newBlock = newContent.nodes.find((b) => b.id === input.blockId); if (!newBlock || newBlock.type !== "exercise") throw new PublishServiceError("NO_EXERCISE_BLOCK"); - const newData = newBlock.data as ExerciseBlockData; + if (!isExerciseBlockData(newBlock.data)) + throw new PublishServiceError("NO_EXERCISE_BLOCK"); + const newData = newBlock.data; for (let i = 0; i < newData.items.length; i++) { const item = newData.items[i]; if (item.source === "inline" && item.inlineContent) { - // 类型守卫:确保 inline 题目类型合法 - const validTypes = ["single_choice", "multiple_choice", "text", "judgment", "composite"] as const; const qt = item.inlineContent.type; - if (!validTypes.includes(qt as typeof validTypes[number])) { - throw new Error(`无效的题目类型: ${qt}`); + // 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误) + if (!isValidQuestionType(qt)) { + throw new PublishServiceError("INVALID_QUESTION_TYPE"); } - const questionType = qt as typeof validTypes[number]; const questionId = await createQuestionWithRelations( { content: item.inlineContent.content, - type: questionType, + type: qt, difficulty: item.inlineContent.difficulty, knowledgePointIds: item.inlineContent.knowledgePointIds, }, @@ -128,19 +133,19 @@ export async function publishLessonPlanHomework( } } - // 4. 打包 exam 草稿 + // 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入) const examId = createId(); if (!plan.subjectId || !plan.gradeId) { throw new PublishServiceError("NO_SUBJECT_OR_GRADE"); } await persistExamDraft({ examId, - title: `${plan.title} - 作业`, + title: input.homeworkTitle, creatorId: input.userId, subjectId: plan.subjectId, gradeId: plan.gradeId, scheduledAt: undefined, - description: `来自课案:${plan.title}`, + description: input.homeworkDescription, }); // 插入 examQuestions(通过 exams data-access 跨模块接口) await addExamQuestions( @@ -161,8 +166,8 @@ export async function publishLessonPlanHomework( await createHomeworkAssignment({ assignmentId, sourceExamId: examId, - title: `${plan.title} - 作业`, - description: `来自课案:${plan.title}`, + title: input.homeworkTitle, + description: input.homeworkDescription, structure: null, status: "published", creatorId: input.userId, diff --git a/src/modules/lesson-preparation/schema.ts b/src/modules/lesson-preparation/schema.ts index 87b3acf..7094cae 100644 --- a/src/modules/lesson-preparation/schema.ts +++ b/src/modules/lesson-preparation/schema.ts @@ -1,12 +1,13 @@ import { z } from "zod"; +// V3 修复:Zod 错误消息使用 i18n 键,由 actions 层通过 translateFieldErrors() 翻译 export const createLessonPlanSchema = z.object({ - title: z.string().min(1, "请输入课案标题").max(255), + title: z.string().min(1, "error.titleRequired").max(255, "error.titleTooLong"), textbookId: z.string().optional(), chapterId: z.string().optional(), subjectId: z.string().optional(), gradeId: z.string().optional(), - templateId: z.string().min(1, "请选择模板"), + templateId: z.string().min(1, "error.templateRequired"), }); export const updateLessonPlanContentSchema = z.object({ @@ -47,12 +48,12 @@ export const getKnowledgePointOptionsSchema = z.object({ // 发布作业输入校验 const dateStringSchema = z .string() - .refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format"); + .refine((v) => !Number.isNaN(new Date(v).getTime()), "error.invalidDate"); export const publishLessonPlanHomeworkSchema = z.object({ planId: z.string().min(1), blockId: z.string().min(1), - classIds: z.array(z.string().min(1)).min(1, "至少选择一个班级"), + classIds: z.array(z.string().min(1)).min(1, "error.classRequired"), availableAt: dateStringSchema.optional(), dueAt: dateStringSchema.optional(), }); diff --git a/src/modules/lesson-preparation/services/default-data-service.ts b/src/modules/lesson-preparation/services/default-data-service.ts index 8c965d4..479248f 100644 --- a/src/modules/lesson-preparation/services/default-data-service.ts +++ b/src/modules/lesson-preparation/services/default-data-service.ts @@ -2,17 +2,33 @@ import { getLessonPlansAction, + getLessonPlanByIdAction, + updateLessonPlanAction, + saveLessonPlanVersionAction, getLessonPlanVersionsAction, revertLessonPlanVersionAction, duplicateLessonPlanAction, deleteLessonPlanAction, + publishLessonPlanAction, + unpublishLessonPlanAction, + createLessonPlanAction, + getTextbooksForPickerAction, + getChaptersForPickerAction, + getLessonPlanTemplatesAction, } from "../actions"; +import { getKnowledgePointOptionsAction } from "../actions-kp"; +import { publishLessonPlanHomeworkAction } from "../actions-publish"; +import { getQuestionsAction } from "@/modules/questions/actions"; import type { LessonPlanDataService } from "../providers/lesson-plan-provider"; /** * 默认数据服务实现:包装现有 Server Actions。 * 通过 LessonPlanProvider 注入,组件不直接 import actions。 * 测试时可替换为 mock 实现。 + * + * V3 扩展:新增 picker/dialog 组件所需方法(createLessonPlan / getTextbooksForPicker / + * getChaptersForPicker / getLessonPlanTemplates / getKnowledgePointOptions / + * publishLessonPlanHomework / getQuestions)。 */ export function createDefaultDataService(): LessonPlanDataService { return { @@ -24,6 +40,27 @@ export function createDefaultDataService(): LessonPlanDataService { return { success: false, message: res.message }; }, + async getLessonPlanById(planId) { + const res = await getLessonPlanByIdAction(planId); + if (res.success && res.data) { + return { success: true, data: { plan: res.data.plan } }; + } + return { success: false, message: res.message }; + }, + + async updateLessonPlan(input) { + const res = await updateLessonPlanAction(input); + return { success: res.success, message: res.message }; + }, + + async saveLessonPlanVersion(input) { + const res = await saveLessonPlanVersionAction(input); + if (res.success && res.data) { + return { success: true, data: { versionNo: res.data.versionNo } }; + } + return { success: false, message: res.message }; + }, + async getLessonPlanVersions(planId) { const res = await getLessonPlanVersionsAction(planId); if (res.success && res.data) { @@ -46,5 +83,83 @@ export function createDefaultDataService(): LessonPlanDataService { const res = await deleteLessonPlanAction(planId); return { success: res.success, message: res.message }; }, + + async publishLessonPlan(planId) { + const res = await publishLessonPlanAction(planId); + return { success: res.success, message: res.message }; + }, + + async unpublishLessonPlan(planId) { + const res = await unpublishLessonPlanAction(planId); + return { success: res.success, message: res.message }; + }, + + // ---- V3 新增:picker/dialog 组件所需方法 ---- + + async createLessonPlan(prevState, formData) { + const res = await createLessonPlanAction(prevState, formData); + if (res.success && res.data) { + return { success: true, data: { planId: res.data.planId } }; + } + return { success: false, message: res.message, errors: res.errors }; + }, + + async getTextbooksForPicker() { + const res = await getTextbooksForPickerAction(); + if (res.success && res.data) { + return { success: true, data: { textbooks: res.data.textbooks } }; + } + return { success: false, message: res.message }; + }, + + async getChaptersForPicker(textbookId) { + const res = await getChaptersForPickerAction(textbookId); + if (res.success && res.data) { + return { success: true, data: { chapters: res.data.chapters } }; + } + return { success: false, message: res.message }; + }, + + async getLessonPlanTemplates() { + const res = await getLessonPlanTemplatesAction(); + if (res.success && res.data) { + return { success: true, data: { templates: res.data.templates } }; + } + return { success: false, message: res.message }; + }, + + async getKnowledgePointOptions(input) { + const res = await getKnowledgePointOptionsAction(input); + if (res.success && res.data) { + return { success: true, data: { options: res.data.options } }; + } + return { success: false, message: res.message, errors: res.errors }; + }, + + async publishLessonPlanHomework(input) { + const res = await publishLessonPlanHomeworkAction(input); + if (res.success && res.data) { + return { + success: true, + data: { examId: res.data.examId, assignmentId: res.data.assignmentId }, + }; + } + return { success: false, message: res.message, errors: res.errors }; + }, + + async getQuestions(params) { + const res = await getQuestionsAction(params); + if (res.success && res.data) { + // 从 questions 模块的返回结构中提取 picker 所需字段 + const items = res.data.data.map((q) => ({ + id: q.id, + type: q.type, + difficulty: q.difficulty, + content: q.content, + })); + return { success: true, data: { data: items } }; + } + return { success: false, message: res.message }; + }, }; } diff --git a/src/modules/lesson-preparation/types.ts b/src/modules/lesson-preparation/types.ts index cbdb961..6e02193 100644 --- a/src/modules/lesson-preparation/types.ts +++ b/src/modules/lesson-preparation/types.ts @@ -314,6 +314,15 @@ export interface LessonPlanTemplate { updatedAt: string; } +// 版本摘要(用于卡片上的版本选择器) +export interface LessonPlanVersionSummary { + id: string; + title: string; + status: LessonPlanStatus; + updatedAt: string; + lastSavedAt: string | null; +} + // 列表项(带教材/章节名) export interface LessonPlanListItem extends LessonPlan { textbookTitle: string | null; @@ -321,6 +330,10 @@ export interface LessonPlanListItem extends LessonPlan { subjectName: string | null; gradeName: string | null; creatorName: string | null; + /** 同一备课的版本数(≥1,=1 表示无多版本)*/ + versionCount: number; + /** 同一备课的所有版本摘要(按 updatedAt 降序)*/ + versions: LessonPlanVersionSummary[]; } // ActionState(与项目现有约定一致)