From e962b67050bac4915bfa950cf32e0d3f7ce24079 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:02:04 +0800 Subject: [PATCH] feat(lesson-preparation): update paper-editor, detail-panel, AI node assist, i18n - Update actions-ai.ts and curriculum-map-view.tsx - Update detail-panel: detail-panel, detail-props, qa-editor - Update paper-editor: inline-node, inline-qa-dialog, paper-context-menu, paper-editor, textbook-tiptap-editor - Update hooks/editor-slice.ts and lib/i18n-errors.ts - Add hooks/use-node-ai-assist.ts and lib/ai-node-assist.ts - Update en/zh-CN lesson-preparation i18n messages --- src/modules/lesson-preparation/actions-ai.ts | 107 +++++++- .../components/curriculum-map-view.tsx | 6 +- .../components/detail-panel/detail-panel.tsx | 65 ++++- .../components/detail-panel/detail-props.tsx | 6 +- .../components/detail-panel/qa-editor.tsx | 2 +- .../components/paper-editor/inline-node.tsx | 3 +- .../paper-editor/inline-qa-dialog.tsx | 2 +- .../paper-editor/paper-context-menu.tsx | 24 +- .../components/paper-editor/paper-editor.tsx | 3 + .../paper-editor/textbook-tiptap-editor.tsx | 2 + .../lesson-preparation/hooks/editor-slice.ts | 52 ++++ .../hooks/use-node-ai-assist.ts | 111 ++++++++ .../lesson-preparation/lib/ai-node-assist.ts | 250 ++++++++++++++++++ .../lesson-preparation/lib/i18n-errors.ts | 7 +- .../i18n/messages/en/lesson-preparation.json | 210 ++++----------- .../messages/zh-CN/lesson-preparation.json | 172 +++--------- 16 files changed, 695 insertions(+), 327 deletions(-) create mode 100644 src/modules/lesson-preparation/hooks/use-node-ai-assist.ts create mode 100644 src/modules/lesson-preparation/lib/ai-node-assist.ts diff --git a/src/modules/lesson-preparation/actions-ai.ts b/src/modules/lesson-preparation/actions-ai.ts index 48a6f8e..0196775 100644 --- a/src/modules/lesson-preparation/actions-ai.ts +++ b/src/modules/lesson-preparation/actions-ai.ts @@ -16,7 +16,15 @@ import { type CurriculumCheckItem, type ExplainableAssessment, } from "./lib/ai-differentiation"; -import type { LessonPlanDocument } from "./types"; +import { + generateNodeContent, + optimizeNodeExpression, + suggestNodeDifferentiation, + generateLayeredQuestions, + type NodeContentUpdate, + type NodeDifferentiationUpdate, +} from "./lib/ai-node-assist"; +import type { LessonPlanDocument, LessonPlanNode, QATurn } from "./types"; import type { ActionState } from "@/shared/types/action-state"; export async function suggestKnowledgePointsAction(input: { @@ -152,3 +160,100 @@ export async function getKnowledgePointsForAlignmentAction( return handleActionError(e); } } + +// ---- V4 节点级 AI 协助(4 项) ---- + +/** + * V4-N1:生成本节点内容。 + * 根据节点 type 生成对应字段(html / objectives / turns 等),返回 patch 由前端合并到 node.data。 + */ +export async function generateNodeContentAction( + node: LessonPlanNode, +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.LESSON_PLAN_CREATE), + requirePermission(Permissions.AI_CHAT), + ]); + const result = await generateNodeContent(node); + if (!result) { + return { success: false, message: "AI_GENERATE_FAILED" }; + } + return { success: true, data: result }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * V4-N2:优化节点表达。 + * 保留结构,仅优化字段值。 + */ +export async function optimizeNodeExpressionAction( + node: LessonPlanNode, +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.LESSON_PLAN_CREATE), + requirePermission(Permissions.AI_CHAT), + ]); + const result = await optimizeNodeExpression(node); + if (!result) { + return { success: false, message: "AI_OPTIMIZE_FAILED" }; + } + return { success: true, data: result }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * V4-N3:差异化建议。 + * 判定该节点适合的差异化层次,前端据此设置 node.differentiation。 + */ +export async function suggestNodeDifferentiationAction( + node: LessonPlanNode, +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.LESSON_PLAN_CREATE), + requirePermission(Permissions.AI_CHAT), + ]); + const result = await suggestNodeDifferentiation(node); + if (!result) { + return { success: false, message: "AI_DIFFERENTIATION_FAILED" }; + } + return { success: true, data: result }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * V4-N4:生成分层提问(仅 interaction 节点)。 + * 返回 3 轮 teacher/student 对话,前端追加到 turns。 + */ +export async function generateLayeredQuestionsAction( + node: LessonPlanNode, +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.LESSON_PLAN_CREATE), + requirePermission(Permissions.AI_CHAT), + ]); + if (node.type !== "interaction") { + return { success: false, message: "AI_LAYERED_REQUIRES_INTERACTION" }; + } + const turns = await generateLayeredQuestions(node); + if (!turns) { + return { success: false, message: "AI_LAYERED_FAILED" }; + } + return { success: true, data: { turns } }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/components/curriculum-map-view.tsx b/src/modules/lesson-preparation/components/curriculum-map-view.tsx index 3325783..48b05ba 100644 --- a/src/modules/lesson-preparation/components/curriculum-map-view.tsx +++ b/src/modules/lesson-preparation/components/curriculum-map-view.tsx @@ -1,5 +1,6 @@ import type { JSX } from "react"; import { useMemo } from "react"; +import { useTranslations } from "next-intl"; import { cn } from "@/shared/lib/utils"; import type { GradeOption, SubjectOption } from "@/modules/school/data-access"; import type { StandardsCoverageCell } from "@/modules/lesson-preparation/data-access-analytics"; @@ -20,6 +21,7 @@ function getCoverageColor(percent: number): string { } export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Element { + const t = useTranslations("lessonPreparation"); // 构建查找表:subjectId|gradeId -> cell const cellMap = useMemo(() => { const map = new Map(); @@ -99,8 +101,8 @@ export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Ele {/* 图例 */}
- 覆盖率图例: - + {t("v4.heatmap.legendTitle")} + diff --git a/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx b/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx index cccecf1..04aad8f 100644 --- a/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx +++ b/src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; +import { useNodeAiAssist, type NodeAiAction } from "../../hooks/use-node-ai-assist"; import { DetailHead } from "./detail-head"; import { DetailProps } from "./detail-props"; import { QaEditor } from "./qa-editor"; @@ -14,6 +15,7 @@ export function DetailPanel() { const selectedNodeId = useLessonPlanEditor((s) => s.selectedNodeId); const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds); const updateNode = useLessonPlanEditor((s) => s.updateNode); + const { run: runNodeAi, isRunning } = useNodeAiAssist(); const node = doc.nodes.find( (n): n is LessonPlanNode => n.id === selectedNodeId && n.type !== "textbook_content", @@ -36,6 +38,11 @@ export function DetailPanel() { } const isExpanded = expandedNodeIds.includes(node.id); + const isInteraction = node.type === "interaction"; + + const trigger = (action: NodeAiAction) => { + void runNodeAi(action, node.id); + }; return ( ); } -function AiButton({ label }: { label: string }) { +function AiButton({ + label, + onClick, + disabled, +}: { + label: string; + onClick: () => void; + disabled?: boolean; +}) { return (
diff --git a/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx index 0375e6b..66f52ca 100644 --- a/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx +++ b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx @@ -17,16 +17,15 @@ export interface ContextMenuState { interface Props { state: ContextMenuState; onClose: () => void; - /** AI 协助回调(V1: 仅 toast 提示"功能开发中",V2 接入 actions-ai.ts) */ + /** AI 协助回调(V2:接入 actions-ai.ts 的节点级 AI 服务) */ onAiAction?: (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => void; } /** * V4 右键菜单:根据 state.nodeId 区分节点菜单 vs 锚定菜单。 * - * V1 范围:展开/收起、删除、锚定 已实现。 - * V1 占位:上下移动、复制节点、AI 协助 4 项(按钮显示,点击 toast 提示)。 - * 这些占位功能在 spec §15 YAGNI 边界内,V2 接入。 + * 已实现:展开/收起、删除、上下移动、复制节点、锚定。 + * V2 接入:AI 协助 4 项(通过 onAiAction 回调,由 PaperEditor 注入 useNodeAiAssist)。 */ export function PaperContextMenu({ state, onClose, onAiAction }: Props) { const t = useTranslations("lessonPreparation"); @@ -36,6 +35,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) { updateNode, removeNode, addAnchor, + duplicateNode, doc, } = useLessonPlanEditor(); @@ -54,18 +54,22 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) { updateNode(b.id, { order: a.order }); }; - const copyNode = (nodeId: string) => { - void nodeId; - // V1 占位:toast 提示 - void import("sonner").then(({ toast }) => toast.info("复制节点功能将在 V2 提供")); + const copyNode = async (nodeId: string) => { + const newId = duplicateNode(nodeId); + const { toast } = await import("sonner"); + if (newId) { + toast.success(t("v4.contextMenu.copied")); + } else { + toast.error(t("v4.contextMenu.copyFailed")); + } }; - // AI 协助默认实现(V1 占位) + // AI 协助:交由 PaperEditor 注入的 onAiAction 回调处理(useNodeAiAssist) const handleAi = (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => { if (onAiAction) { onAiAction(action, nodeId); } else { - void import("sonner").then(({ toast }) => toast.info("AI 协助功能将在 V2 提供")); + void import("sonner").then(({ toast }) => toast.info(t("v4.contextMenu.aiComingSoon"))); } }; diff --git a/src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx b/src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx index 255dcc3..3812f8b 100644 --- a/src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx +++ b/src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; +import { useNodeAiAssist } from "../../hooks/use-node-ai-assist"; import { TextbookTiptapEditor } from "./textbook-tiptap-editor"; import { InlineNode } from "./inline-node"; import { PaperContextMenu, type ContextMenuState } from "./paper-context-menu"; @@ -23,6 +24,7 @@ export function PaperEditor({ readonly }: { readonly?: boolean }) { const t = useTranslations("lessonPreparation"); const doc = useLessonPlanEditor((s) => s.doc); const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds); + const { run: runNodeAi } = useNodeAiAssist(); const [contextMenu, setContextMenu] = useState({ visible: false, x: 0, @@ -142,6 +144,7 @@ export function PaperEditor({ readonly }: { readonly?: boolean }) { setContextMenu((s) => ({ ...s, visible: false }))} + onAiAction={(action, nodeId) => void runNodeAi(action, nodeId)} /> ); diff --git a/src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx b/src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx index feec564..9ab4b39 100644 --- a/src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx +++ b/src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx @@ -43,6 +43,8 @@ export function TextbookTiptapEditor({ content, readonly }: Props) { ], content, editable: !readonly, + // SSR 必填:避免 hydration mismatches(Tiptap v2.4+ 要求显式设置) + immediatelyRender: false, editorProps: { attributes: { class: "lp-textbook-editor prose prose-sm max-w-none focus:outline-none", diff --git a/src/modules/lesson-preparation/hooks/editor-slice.ts b/src/modules/lesson-preparation/hooks/editor-slice.ts index a0aa423..00eab57 100644 --- a/src/modules/lesson-preparation/hooks/editor-slice.ts +++ b/src/modules/lesson-preparation/hooks/editor-slice.ts @@ -3,7 +3,9 @@ import { createId } from "@paralleldrive/cuid2"; import type { AnchorType, Block, + BlockData, BlockType, + InteractionBlockData, LessonPlanDocument, LessonPlanNode, NodeAnchor, @@ -13,6 +15,23 @@ import type { import { defaultDataForType } from "../lib/document-migration"; import type { EditorState } from "./use-lesson-plan-editor"; +/** + * V4:深拷贝 BlockData,为 interaction 节点的 turns 重新生成 id(避免 id 冲突)。 + * 其他类型直接结构化克隆(JSON 序列化足够,数据全是 POJO)。 + */ +function cloneBlockData(data: BlockData, type: BlockType): BlockData { + if (type === "interaction") { + const d = data as InteractionBlockData; + return { + designIntent: d.designIntent, + knowledgePointIds: [...d.knowledgePointIds], + turns: d.turns.map((t) => ({ ...t, id: createId() })), + }; + } + // 其他类型:JSON 深拷贝(数据全是 POJO,无函数/Date/Symbol) + return JSON.parse(JSON.stringify(data)) as BlockData; +} + export interface EditorSlice { planId: string; title: string; @@ -22,6 +41,8 @@ export interface EditorSlice { addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string; updateNode: (id: string, patch: Omit, "type">) => void; removeNode: (id: string) => void; + /** V4:复制节点(深拷贝数据 + 新 id + 标题加"副本" + order 置末) */ + duplicateNode: (id: string) => string | null; updateTextbookContent: (data: Partial) => void; getTextbookContentNode: () => TextbookContentNode | undefined; /** @@ -141,6 +162,37 @@ export const createEditorSlice: StateCreator< }); }, + duplicateNode: (id) => { + const state = get(); + const src = state.doc.nodes.find( + (n): n is LessonPlanNode => n.id === id && n.type !== "textbook_content", + ); + if (!src) return null; + state.pushHistory(); + const newId = createId(); + const teachingNodes = state.doc.nodes.filter( + (n): n is LessonPlanNode => n.type !== "textbook_content", + ); + // 深拷贝 data(避免引用共享);interaction 节点的 turns 内 id 重新生成 + const clonedData = cloneBlockData(src.data, src.type); + const newNode: LessonPlanNode = { + id: newId, + type: src.type, + title: `${src.title}(副本)`, + data: clonedData, + order: teachingNodes.length, + position: { x: 0, y: 0 }, + stage: src.stage, + differentiation: src.differentiation, + }; + set((s) => ({ + doc: { ...s.doc, nodes: [...s.doc.nodes, newNode] }, + isDirty: true, + selectedNodeId: newId, + })); + return newId; + }, + updateTextbookContent: (data) => { get().pushHistory(); // V5-2:撤销/重做 set((s) => ({ diff --git a/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts b/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts new file mode 100644 index 0000000..8665124 --- /dev/null +++ b/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts @@ -0,0 +1,111 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { useLessonPlanEditor } from "./use-lesson-plan-editor"; +import { + generateNodeContentAction, + optimizeNodeExpressionAction, + suggestNodeDifferentiationAction, + generateLayeredQuestionsAction, +} from "../actions-ai"; +import type { + BlockData, + InteractionBlockData, + LessonPlanNode, +} from "../types"; + +export type NodeAiAction = "generate" | "optimize" | "differentiation" | "layered"; + +/** + * V4 节点级 AI 协助 hook。 + * + * - 调用对应 Server Action 获取 AI 结果 + * - 将结果合并到 node.data 或 node.differentiation + * - 通过 toast 反馈运行/成功/失败状态 + * + * 用法: + * const { run, isRunning } = useNodeAiAssist(); + * + */ +export function useNodeAiAssist() { + const t = useTranslations("lessonPreparation"); + const doc = useLessonPlanEditor((s) => s.doc); + const updateNode = useLessonPlanEditor((s) => s.updateNode); + const [isRunning, setIsRunning] = useState(false); + + const run = useCallback( + async (action: NodeAiAction, nodeId: string) => { + const node = doc.nodes.find( + (n): n is LessonPlanNode => n.id === nodeId && n.type !== "textbook_content", + ); + if (!node) return; + + setIsRunning(true); + toast.info(t("v4.contextMenu.aiRunning")); + + try { + if (action === "generate") { + const res = await generateNodeContentAction(node); + if (res.success && res.data) { + // AI patch 合并到 node.data;patch 字段由 lib/ai-node-assist.ts 的 Zod schema 保证类型安全 + const merged = { ...node.data, ...res.data.data } as BlockData; + updateNode(nodeId, { data: merged }); + toast.success(t("v4.contextMenu.aiSuccess")); + } else { + toast.error(t("v4.contextMenu.aiFailed")); + } + return; + } + + if (action === "optimize") { + const res = await optimizeNodeExpressionAction(node); + if (res.success && res.data) { + const merged = { ...node.data, ...res.data.data } as BlockData; + updateNode(nodeId, { data: merged }); + toast.success(t("v4.contextMenu.aiSuccess")); + } else { + toast.error(t("v4.contextMenu.aiFailed")); + } + return; + } + + if (action === "differentiation") { + const res = await suggestNodeDifferentiationAction(node); + if (res.success && res.data) { + updateNode(nodeId, { differentiation: res.data.level }); + toast.success(`${t("v4.contextMenu.aiSuccess")}:${res.data.reason}`); + } else { + toast.error(t("v4.contextMenu.aiFailed")); + } + return; + } + + // layered + if (node.type !== "interaction") { + toast.error(t("v4.contextMenu.aiFailed")); + return; + } + const res = await generateLayeredQuestionsAction(node); + if (res.success && res.data) { + // interaction 节点 data 类型收窄(已通过 node.type 守卫) + const interactionData = node.data as InteractionBlockData; + const merged = { + ...node.data, + turns: [...(interactionData.turns ?? []), ...res.data.turns], + } as BlockData; + updateNode(nodeId, { data: merged }); + toast.success(t("v4.contextMenu.aiSuccess")); + } else { + toast.error(t("v4.contextMenu.aiFailed")); + } + } finally { + setIsRunning(false); + } + }, + [doc.nodes, updateNode, t], + ); + + return { run, isRunning }; +} diff --git a/src/modules/lesson-preparation/lib/ai-node-assist.ts b/src/modules/lesson-preparation/lib/ai-node-assist.ts new file mode 100644 index 0000000..bc7e6cc --- /dev/null +++ b/src/modules/lesson-preparation/lib/ai-node-assist.ts @@ -0,0 +1,250 @@ +/** + * V4 节点级 AI 协助:4 项功能。 + * + * - generate:根据节点类型生成默认内容(写入 data) + * - optimize:优化节点现有表达(覆盖 data 中对应字段) + * - differentiation:判定该节点适合的差异化层次(写入 node.differentiation) + * - layered:为 interaction 节点生成分层提问(追加到 turns) + * + * 全部纯服务端,复用 createAiChatCompletion。 + * 失败时返回 null,由调用方提示用户。 + */ +import "server-only"; +import { env } from "@/env.mjs"; +import { createAiChatCompletion } from "@/shared/lib/ai"; +import { isRecord } from "@/shared/lib/type-guards"; +import { z } from "zod"; +import type { + BlockData, + DifferentiationLevel, + InteractionBlockData, + LessonPlanNode, + QATurn, +} from "../types"; +import { createId } from "@paralleldrive/cuid2"; + +// ---- 公共返回类型 ---- + +export interface NodeContentUpdate { + /** 合并到 node.data 的 patch */ + data: Partial; +} + +export interface NodeDifferentiationUpdate { + level: DifferentiationLevel; + reason: string; +} + +// ---- Zod schemas ---- + +const GenerateResultSchema = z.object({ + /** 生成的字段路径 → 值(如 { html: "..." } / { designIntent: "..." } / { objectives: [...] }) */ + patch: z.record(z.string(), z.unknown()), +}); + +const OptimizeResultSchema = z.object({ + patch: z.record(z.string(), z.unknown()), +}); + +const DifferentiationResultSchema = z.object({ + level: z.enum(["basic", "intermediate", "advanced"]), + reason: z.string(), +}); + +const LayeredQuestionsResultSchema = z.object({ + turns: z.array( + z.object({ + role: z.enum(["teacher", "student"]), + content: z.string(), + expectedAnswer: z.string().optional(), + }), + ), +}); + +// ---- 节点摘要辅助 ---- + +function extractNodeText(node: LessonPlanNode): string { + const data = node.data as unknown; + if (!isRecord(data)) return ""; + const html = typeof data.html === "string" ? data.html : ""; + const sourceText = typeof data.sourceText === "string" ? data.sourceText : ""; + const designIntent = typeof data.designIntent === "string" ? data.designIntent : ""; + return html || sourceText || designIntent || ""; +} + +function nodeSummary(node: LessonPlanNode): string { + return JSON.stringify({ + id: node.id, + type: node.type, + title: node.title, + stage: node.stage, + differentiation: node.differentiation, + text: extractNodeText(node).slice(0, 500), + data: node.data, + }); +} + +// ---- 1. 生成本节点内容 ---- + +const GENERATE_PROMPT = `你是资深教学设计专家。请根据课案节点信息生成对应内容。 + +节点信息: +--- +{node} +--- + +要求: +- 根据节点 type 生成对应字段: + - rich_text / consolidation / summary / homework / reflection / blackboard / import / new_teaching:生成 html 字段(HTML 字符串,可含

/

    /

    等基础标签) + - objective:生成 objectives 数组(含 dimension: knowledge|process|emotion 与 text) + - key_point:生成 keyPoints 数组(含 type: key|difficult 与 text) + - interaction:生成 designIntent 字符串 + turns 数组(每项含 role: teacher|student / content / 可选 expectedAnswer) + - exercise:生成 items 空数组占位即可 +- 内容要紧扣节点标题与类型,体现教学设计专业性 +- 中文课案用中文生成,英文课案用英文生成 + +返回 JSON 对象:{ patch: { 字段名: 值 } }`; + +export async function generateNodeContent( + node: LessonPlanNode, +): Promise { + const prompt = GENERATE_PROMPT.replace("{node}", nodeSummary(node)); + try { + const { content } = await createAiChatCompletion({ + messages: [{ role: "user", content: prompt }], + model: env.AI_MODEL ?? "gpt-4o-mini", + temperature: 0.6, + }); + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (!jsonMatch) return null; + const parsed: unknown = JSON.parse(jsonMatch[0]); + const validated = GenerateResultSchema.safeParse(parsed); + if (!validated.success) return null; + return { data: validated.data.patch as Partial }; + } catch { + return null; + } +} + +// ---- 2. 优化表达 ---- + +const OPTIMIZE_PROMPT = `你是教学设计专家。请优化以下课案节点的表达,使其更清晰、专业、符合教学规范。 + +节点信息: +--- +{node} +--- + +要求: +- 保留原有结构,仅优化文字表达 +- 不改变字段集合,只修改字段值 +- 学科术语使用准确 +- 中文课案用中文优化,英文课案用英文优化 + +返回 JSON 对象:{ patch: { 字段名: 优化后的值 } }`; + +export async function optimizeNodeExpression( + node: LessonPlanNode, +): Promise { + const prompt = OPTIMIZE_PROMPT.replace("{node}", nodeSummary(node)); + try { + const { content } = await createAiChatCompletion({ + messages: [{ role: "user", content: prompt }], + model: env.AI_MODEL ?? "gpt-4o-mini", + temperature: 0.4, + }); + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (!jsonMatch) return null; + const parsed: unknown = JSON.parse(jsonMatch[0]); + const validated = OptimizeResultSchema.safeParse(parsed); + if (!validated.success) return null; + return { data: validated.data.patch as Partial }; + } catch { + return null; + } +} + +// ---- 3. 差异化建议 ---- + +const DIFFERENTIATION_PROMPT = `你是教学设计专家。请根据以下课案节点内容,判定该节点最适合的差异化教学层次。 + +节点信息: +--- +{node} +--- + +判定标准: +- basic(基础):知识点为基础概念或技能,所有学生必学,节点内容偏识记与模仿 +- intermediate(提高):知识点为学科核心内容,多数学生需掌握,节点内容偏理解与应用 +- advanced(拓展):知识点为延伸拓展或综合应用,适合学有余力的学生 + +返回 JSON 对象:{ level: "basic"|"intermediate"|"advanced", reason: "判定依据(一句话)" }`; + +export async function suggestNodeDifferentiation( + node: LessonPlanNode, +): Promise { + const prompt = DIFFERENTIATION_PROMPT.replace("{node}", nodeSummary(node)); + try { + const { content } = await createAiChatCompletion({ + messages: [{ role: "user", content: prompt }], + model: env.AI_MODEL ?? "gpt-4o-mini", + temperature: 0.3, + }); + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (!jsonMatch) return null; + const parsed: unknown = JSON.parse(jsonMatch[0]); + const validated = DifferentiationResultSchema.safeParse(parsed); + if (!validated.success) return null; + return { level: validated.data.level, reason: validated.data.reason }; + } catch { + return null; + } +} + +// ---- 4. 生成分层提问(仅 interaction 节点) ---- + +const LAYERED_PROMPT = `你是教学设计专家。请为以下师生交互节点生成分层提问,覆盖基础/提高/拓展三个层次。 + +节点信息: +--- +{node} +--- + +要求: +- 生成 3 轮对话(basic / intermediate / advanced 各一轮) +- 每轮含 teacher 提问 + student 预期回答 +- 提问层层递进,从识记 → 理解 → 应用 +- 中文课案用中文生成,英文课案用英文生成 + +返回 JSON 对象:{ turns: [{ role: "teacher"|"student", content: "提问/回答", expectedAnswer: "可选,仅 teacher 轮需要" }] }`; + +export async function generateLayeredQuestions( + node: LessonPlanNode, +): Promise { + if (node.type !== "interaction") return null; + const prompt = LAYERED_PROMPT.replace("{node}", nodeSummary(node)); + try { + const { content } = await createAiChatCompletion({ + messages: [{ role: "user", content: prompt }], + model: env.AI_MODEL ?? "gpt-4o-mini", + temperature: 0.5, + }); + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (!jsonMatch) return null; + const parsed: unknown = JSON.parse(jsonMatch[0]); + const validated = LayeredQuestionsResultSchema.safeParse(parsed); + if (!validated.success) return null; + // 为每条 turn 分配 id + order + const existing = (node.data as InteractionBlockData).turns ?? []; + let order = existing.length; + return validated.data.turns.map((t) => ({ + id: createId(), + role: t.role, + content: t.content, + expectedAnswer: t.expectedAnswer, + order: order++, + })); + } catch { + return null; + } +} diff --git a/src/modules/lesson-preparation/lib/i18n-errors.ts b/src/modules/lesson-preparation/lib/i18n-errors.ts index e343927..bbc3807 100644 --- a/src/modules/lesson-preparation/lib/i18n-errors.ts +++ b/src/modules/lesson-preparation/lib/i18n-errors.ts @@ -21,10 +21,9 @@ export async function translateFieldErrors( result[field] = messages.map((msg) => { // 仅翻译以 "error." 开头的 i18n 键,其他保持原样 if (msg.startsWith("error.")) { - // V4 P1-13 修复:next-intl 的 t 函数对键有字面量类型约束, - // 动态字符串需断言为键类型。msg 已通过 startsWith 校验为合法 i18n 键前缀, - // 此处 as 属于"从 string 收窄到字面量联合类型"的类型收窄,符合项目规则例外。 - return t(msg as Parameters[0]); + // 运行时守卫:先用 t.has() 检查键是否存在,避免 as 断言绕过类型检查 + // 若键不存在(schema 中误写不存在的键),返回原消息而非抛出 MISSING_MESSAGE + if (t.has(msg)) { return t(msg as Parameters[0]); } return msg; } return msg; }); diff --git a/src/shared/i18n/messages/en/lesson-preparation.json b/src/shared/i18n/messages/en/lesson-preparation.json index 1c3d020..a691b94 100644 --- a/src/shared/i18n/messages/en/lesson-preparation.json +++ b/src/shared/i18n/messages/en/lesson-preparation.json @@ -208,10 +208,6 @@ "archived": "Archived" } }, - "gradeHead": { - "title": "Grade Lesson Plans", - "description": "View lesson plans from teachers in your grade" - }, "library": { "title": "School Lesson Plan Library", "description": "Browse outstanding lesson plans shared by teachers in your school", @@ -316,7 +312,12 @@ "revertConfirm": "Revert to v{versionNo}? A new version will be created.", "revertSuccess": "Reverted to v{versionNo}", "autoLabel": "Auto version", - "revertLabel": "Revert to v{versionNo}" + "revertLabel": "Revert to v{versionNo}", + "diff": { + "title": "Version v{versionNo} comparison", + "summary": "Added {added}, removed {removed}, unchanged {unchanged}", + "noChanges": "No differences between versions" + } }, "diff": { "summary": "Added {added} · Removed {removed} · Modified {modified}", @@ -602,20 +603,31 @@ "unauthorized": "Unauthorized operation", "planIdRequired": "Plan ID is required", "classIdRequired": "Class ID is required", - "dateRequired": "Date is required" + "dateRequired": "Date is required", + "labelTooLong": "Label cannot exceed 100 characters", + "versionNoInvalid": "Version number must be a positive integer", + "nameRequired": "Name is required", + "nameTooLong": "Name cannot exceed 100 characters", + "queryTooLong": "Search query cannot exceed 200 characters", + "blockIdRequired": "Block ID is required", + "commentTooLong": "Comment cannot exceed 500 characters", + "reviewerRequired": "Reviewer ID is required" }, "confirm": { "archive": "Archive this lesson plan?", "archiveTitle": "Confirm Archive" }, "calendar": { - "title": "Calendar View", - "week": "Week", - "month": "Month", + "title": "Lesson Calendar", + "description": "View lesson preparation activities by date", + "weekView": "Week", + "monthView": "Month", "today": "Today", + "prev": "Previous", + "next": "Next", "noEvents": "No events", - "loading": "Loading calendar...", - "error": "Failed to load calendar events", + "loading": "Loading...", + "loadFailed": "Failed to load calendar", "eventType": { "created": "Created", "updated": "Updated", @@ -623,106 +635,17 @@ "submitted": "Submitted", "published": "Published", "reviewed": "Reviewed" - } - }, - "review": { - "title": "Review Workflow", - "submit": "Submit for Review", - "approve": "Approve", - "reject": "Reject", - "pending": "Pending Review", - "approved": "Approved", - "rejected": "Rejected", - "submitConfirm": "Are you sure you want to submit this lesson plan for review?", - "submitSuccess": "Lesson plan submitted for review", - "submitFailed": "Failed to submit for review", - "approveConfirm": "Are you sure you want to approve this lesson plan?", - "approveSuccess": "Lesson plan approved", - "approveFailed": "Failed to approve lesson plan", - "rejectConfirm": "Are you sure you want to reject this lesson plan?", - "rejectSuccess": "Lesson plan rejected", - "rejectFailed": "Failed to reject lesson plan", - "records": "Review Records", - "noRecords": "No review records", - "reviewer": "Reviewer", - "decision": "Decision", - "comment": "Comment", - "time": "Time", - "pendingReviews": "Pending Reviews", - "noPending": "No pending reviews" - }, - "comment": { - "title": "Comments", - "add": "Add Comment", - "edit": "Edit", - "delete": "Delete", - "reply": "Reply", - "resolve": "Resolve", - "resolved": "Resolved", - "reopen": "Reopen", - "noComments": "No comments yet", - "placeholder": "Write a comment...", - "blockComment": "Block Comment", - "deleteConfirm": "Are you sure you want to delete this comment?", - "deleteSuccess": "Comment deleted", - "deleteFailed": "Failed to delete comment", - "createSuccess": "Comment added", - "createFailed": "Failed to add comment", - "updateSuccess": "Comment updated", - "updateFailed": "Failed to update comment", - "resolveSuccess": "Comment resolved", - "resolveFailed": "Failed to resolve comment" - }, - "formative": { - "title": "Formative Assessment", - "add": "Add Interaction", - "edit": "Edit", - "delete": "Delete", - "noItems": "No formative assessment items", - "interactionType": { - "question": "Question", - "poll": "Poll", - "discussion": "Discussion", - "reflection": "Reflection" }, - "question": "Question", - "options": "Options", - "correctAnswer": "Correct Answer", - "pollQuestion": "Poll Question", - "pollOptions": "Poll Options", - "discussionTopic": "Discussion Topic", - "reflectionPrompt": "Reflection Prompt", - "responses": "Responses", - "noResponses": "No responses yet", - "deleteConfirm": "Are you sure you want to delete this interaction?", - "deleteSuccess": "Interaction deleted", - "deleteFailed": "Failed to delete interaction", - "createSuccess": "Interaction added", - "createFailed": "Failed to add interaction", - "updateSuccess": "Interaction updated", - "updateFailed": "Failed to update interaction", - "recordSuccess": "Response recorded", - "recordFailed": "Failed to record response" - }, - "substitute": { - "title": "Substitute Teacher", - "add": "Add Assignment", - "edit": "Edit", - "delete": "Delete", - "noAssignments": "No substitute assignments", - "originalTeacher": "Original Teacher", - "substituteTeacher": "Substitute Teacher", - "startDate": "Start Date", - "endDate": "End Date", - "classes": "Classes", - "subject": "Subject", - "deleteConfirm": "Are you sure you want to delete this substitute assignment?", - "deleteSuccess": "Substitute assignment deleted", - "deleteFailed": "Failed to delete substitute assignment", - "createSuccess": "Substitute assignment created", - "createFailed": "Failed to create substitute assignment", - "updateSuccess": "Substitute assignment updated", - "updateFailed": "Failed to update substitute assignment" + "eventMeta": "v{versionNo}", + "weekDays": { + "sun": "Sun", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat" + } }, "schedule": { "title": "Schedule Lesson", @@ -742,22 +665,6 @@ "addSuccess": "Schedule binding added", "deleteSuccess": "Schedule binding deleted" }, - "evaluation": { - "title": "AI Evaluation", - "generate": "Generate Evaluation", - "regenerate": "Regenerate", - "score": "Score", - "suggestions": "Suggestions", - "differentiation": "Differentiation Strategies", - "noEvaluation": "No AI evaluation yet", - "loading": "Generating AI evaluation...", - "generateConfirm": "Generate AI evaluation for this lesson plan?", - "generateSuccess": "AI evaluation generated", - "generateFailed": "Failed to generate AI evaluation", - "strengths": "Strengths", - "weaknesses": "Areas for Improvement", - "recommendations": "Recommendations" - }, "analytics": { "title": "Analytics Dashboard", "teacherInvestment": "Teacher Investment", @@ -777,36 +684,11 @@ "loading": "Loading analytics...", "heatmap": "Coverage Heatmap", "subject": "Subject", - "grade": "Grade" - }, - "standards": { - "title": "Standards Alignment", - "add": "Add Standard", - "edit": "Edit", - "delete": "Delete", - "search": "Search standards...", - "noStandards": "No standards found", - "code": "Code", - "description": "Description", - "subject": "Subject", "grade": "Grade", - "parent": "Parent Standard", - "linkToPlan": "Link to Lesson Plan", - "unlink": "Unlink", - "linkedStandards": "Linked Standards", - "availableStandards": "Available Standards", - "tree": "Standards Tree", - "deleteConfirm": "Are you sure you want to delete this standard?", - "deleteSuccess": "Standard deleted", - "deleteFailed": "Failed to delete standard", - "createSuccess": "Standard created", - "createFailed": "Failed to create standard", - "updateSuccess": "Standard updated", - "updateFailed": "Failed to update standard", - "linkSuccess": "Standard linked to lesson plan", - "linkFailed": "Failed to link standard", - "unlinkSuccess": "Standard unlinked from lesson plan", - "unlinkFailed": "Failed to unlink standard" + "totalPublished": "Published", + "totalSubmitted": "Submitted", + "totalStandardsLinked": "Standards Linked", + "loadFailed": "Failed to load analytics" }, "v4": { "tree": { @@ -833,7 +715,11 @@ "aiFillExpected": "Fill expected answers", "aiOptimizeFollowup": "Optimize follow-up", "aiDifferentiation": "Differentiation suggestions", - "updated": "Updated {time}" + "updated": "Updated {time}", + "stageLabel": "Teaching stage", + "differentiationLabel": "Differentiation", + "exerciseCount": "exercises", + "turnContentPlaceholder": "Content" }, "contextMenu": { "nodeOps": "Node operations", @@ -849,7 +735,13 @@ "copyNode": "Copy node", "deleteNode": "Delete node", "anchorToNode": "Anchor to node", - "insertPointAnchor": "Insert point anchor" + "insertPointAnchor": "Insert point anchor", + "copied": "Node duplicated", + "copyFailed": "Copy failed: node not found", + "aiComingSoon": "AI assist coming soon", + "aiRunning": "AI is processing...", + "aiSuccess": "AI updated node content", + "aiFailed": "AI processing failed, please retry" }, "interaction": { "label": "Interaction", @@ -862,6 +754,10 @@ "legacyAnchorTitle": "Anchor format upgrade", "legacyAnchorBody": "This plan uses a legacy anchor format. Some anchors are invalid. Please re-anchor.", "legacyAnchorDismiss": "Got it" + }, + "heatmap": { + "legendTitle": "Coverage legend:", + "noData": "No data" } } } diff --git a/src/shared/i18n/messages/zh-CN/lesson-preparation.json b/src/shared/i18n/messages/zh-CN/lesson-preparation.json index cc5108f..bd150e2 100644 --- a/src/shared/i18n/messages/zh-CN/lesson-preparation.json +++ b/src/shared/i18n/messages/zh-CN/lesson-preparation.json @@ -208,10 +208,6 @@ "archived": "已归档" } }, - "gradeHead": { - "title": "年级备课", - "description": "查看本年级教师的备课" - }, "library": { "title": "校内课案库", "description": "浏览本校教师分享的优秀课案", @@ -316,7 +312,12 @@ "revertConfirm": "确认回退到 v{versionNo}?将生成新版本。", "revertSuccess": "已回退到 v{versionNo}", "autoLabel": "自动版本", - "revertLabel": "回退到 v{versionNo}" + "revertLabel": "回退到 v{versionNo}", + "diff": { + "title": "版本 v{versionNo} 对比", + "summary": "新增 {added} 项,删除 {removed} 项,未变 {unchanged} 项", + "noChanges": "两个版本无差异" + } }, "diff": { "summary": "新增 {added} · 删除 {removed} · 修改 {modified}", @@ -602,7 +603,15 @@ "unauthorized": "未授权操作", "planIdRequired": "课案 ID 必填", "classIdRequired": "班级 ID 必填", - "dateRequired": "日期必填" + "dateRequired": "日期必填", + "labelTooLong": "标签不能超过 100 个字符", + "versionNoInvalid": "版本号必须为正整数", + "nameRequired": "请输入名称", + "nameTooLong": "名称不能超过 100 个字符", + "queryTooLong": "搜索词不能超过 200 个字符", + "blockIdRequired": "节点 ID 必填", + "commentTooLong": "评论不能超过 500 个字符", + "reviewerRequired": "审核人 ID 必填" }, "confirm": { "archive": "确认归档此课案?", @@ -638,100 +647,6 @@ "sat": "周六" } }, - "review": { - "title": "审核工作流", - "submit": "提交审核", - "withdraw": "撤回提交", - "approve": "通过", - "reject": "驳回", - "submitConfirm": "确认提交审核?提交后不可编辑,等待教研组长审核。", - "withdrawConfirm": "确认撤回提交?撤回后将回到草稿状态。", - "approveConfirm": "确认通过此课案审核?", - "rejectConfirm": "确认驳回此课案?教师可修改后重新提交。", - "commentLabel": "审核意见", - "commentPlaceholder": "请输入审核意见(可选)", - "submitted": "已提交审核", - "approved": "审核通过", - "rejected": "审核驳回", - "withdrawn": "已撤回", - "reviewerLabel": "审核人:{name}", - "reviewAt": "审核时间:{time}", - "records": "审核记录", - "noRecords": "暂无审核记录", - "pendingTitle": "待审核课案", - "pendingEmpty": "暂无待审核课案", - "invalidTransition": "状态变更不合法", - "notSubmitted": "当前状态不可提交审核", - "notReviewable": "当前状态不可审核", - "submitSuccess": "已提交审核", - "withdrawSuccess": "已撤回提交", - "approveSuccess": "审核已通过", - "rejectSuccess": "已驳回" - }, - "comment": { - "title": "协同评论", - "add": "发表评论", - "reply": "回复", - "placeholder": "输入评论内容...", - "empty": "暂无评论", - "resolve": "标记已解决", - "reopen": "重新打开", - "resolved": "已解决", - "unresolved": "未解决", - "delete": "删除", - "deleteConfirm": "确认删除此评论?子回复将一并删除。", - "edit": "编辑", - "unresolvedCount": "{count} 条未解决", - "blockLabel": "节点:{title}", - "createSuccess": "评论已发表", - "deleteSuccess": "已删除", - "resolveSuccess": "已标记为解决", - "createFailed": "发表评论失败", - "deleteFailed": "删除评论失败" - }, - "formative": { - "title": "形成性评价", - "add": "添加互动组件", - "empty": "暂无互动组件", - "interactionType": { - "poll": "投票", - "quiz": "随堂测", - "exit_ticket": "出口票" - }, - "promptLabel": "题目", - "promptPlaceholder": "输入互动题目...", - "optionsLabel": "选项", - "addOption": "+ 添加选项", - "correctAnswer": "正确答案", - "durationLabel": "时长(秒)", - "responses": "学生作答", - "noResponses": "暂无作答", - "stats": "统计", - "totalResponses": "{count} 人作答", - "correctRate": "正确率 {rate}%", - "avgDuration": "平均用时 {sec} 秒", - "createSuccess": "已添加", - "updateSuccess": "已更新", - "deleteSuccess": "已删除" - }, - "substitute": { - "title": "代课教师", - "add": "添加代课", - "empty": "暂无代课记录", - "originalTeacher": "原任教师", - "substituteTeacher": "代课教师", - "startDate": "开始日期", - "endDate": "结束日期", - "status": { - "active": "生效中", - "expired": "已过期", - "cancelled": "已取消" - }, - "createSuccess": "代课已安排", - "updateSuccess": "代课状态已更新", - "deleteSuccess": "代课已删除", - "cancelConfirm": "确认取消此代课安排?" - }, "schedule": { "title": "安排课时", "boundList": "已绑定课时", @@ -750,30 +665,6 @@ "addSuccess": "已添加课时绑定", "deleteSuccess": "已删除课时绑定" }, - "evaluation": { - "title": "AI 课案评估", - "run": "开始评估", - "running": "评估中...", - "empty": "暂无评估记录", - "overallScore": "综合评分", - "dimensions": { - "clarity": "目标清晰度", - "alignment": "课标对齐", - "engagement": "学生参与", - "differentiation": "分层设计", - "assessment": "评估设计" - }, - "suggestions": "改进建议", - "noSuggestions": "暂无改进建议", - "createSuccess": "评估完成", - "suggestion": { - "addMoreNodeDetails": "建议在教学节点中补充更多细节", - "linkMoreStandards": "建议关联更多课标条目", - "addFormativeItems": "建议增加课堂互动组件", - "addPracticeBlock": "建议增加课堂练习环节", - "addHomeworkBlock": "建议明确课后作业要求" - } - }, "analytics": { "title": "备课分析", "teacherInvestment": "教师投入", @@ -788,21 +679,6 @@ "noData": "暂无数据", "loadFailed": "加载分析数据失败" }, - "standards": { - "title": "课标对标", - "link": "关联课标", - "unlink": "取消关联", - "linked": "已关联 {count} 条", - "empty": "暂无关联课标", - "searchPlaceholder": "搜索课标...", - "level": { - "national": "国家标准", - "curriculum": "课程标准", - "custom": "校本标准" - }, - "linkSuccess": "已关联课标", - "unlinkSuccess": "已取消关联" - }, "v4": { "tree": { "title": "结构", @@ -828,7 +704,11 @@ "aiFillExpected": "补充预期回答", "aiOptimizeFollowup": "优化追问", "aiDifferentiation": "差异化建议", - "updated": "更新于 {time}" + "updated": "更新于 {time}", + "stageLabel": "教学阶段", + "differentiationLabel": "差异化", + "exerciseCount": "道练习", + "turnContentPlaceholder": "内容" }, "contextMenu": { "nodeOps": "节点操作", @@ -844,7 +724,13 @@ "copyNode": "复制节点", "deleteNode": "删除节点", "anchorToNode": "锚定到节点", - "insertPointAnchor": "插入点锚点" + "insertPointAnchor": "插入点锚点", + "copied": "已复制节点", + "copyFailed": "复制失败:节点不存在", + "aiComingSoon": "AI 协助功能即将上线", + "aiRunning": "AI 正在处理...", + "aiSuccess": "AI 已更新节点内容", + "aiFailed": "AI 处理失败,请重试" }, "interaction": { "label": "师生互动", @@ -857,6 +743,10 @@ "legacyAnchorTitle": "锚点格式升级提示", "legacyAnchorBody": "此课案使用旧版锚点格式,部分锚点已失效,请重新锚定。", "legacyAnchorDismiss": "知道了" + }, + "heatmap": { + "legendTitle": "覆盖率图例:", + "noData": "无数据" } } }