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
This commit is contained in:
@@ -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<ActionState<NodeContentUpdate>> {
|
||||
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<ActionState<NodeContentUpdate>> {
|
||||
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<ActionState<NodeDifferentiationUpdate>> {
|
||||
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<ActionState<{ turns: QATurn[] }>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, StandardsCoverageCell>();
|
||||
@@ -99,8 +101,8 @@ export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Ele
|
||||
|
||||
{/* 图例 */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs">
|
||||
<span className="text-muted-foreground">覆盖率图例:</span>
|
||||
<LegendItem color="bg-muted/40" label="无数据" />
|
||||
<span className="text-muted-foreground">{t("v4.heatmap.legendTitle")}</span>
|
||||
<LegendItem color="bg-muted/40" label={t("v4.heatmap.noData")} />
|
||||
<LegendItem color="bg-red-100 dark:bg-red-950/50" label="0-25%" />
|
||||
<LegendItem color="bg-amber-100 dark:bg-amber-950/50" label="25-50%" />
|
||||
<LegendItem color="bg-blue-100 dark:bg-blue-950/50" label="50-75%" />
|
||||
|
||||
@@ -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 (
|
||||
<aside
|
||||
@@ -51,7 +58,7 @@ export function DetailPanel() {
|
||||
<DetailProps node={node} />
|
||||
|
||||
<div style={{ flex: 1, padding: 20, overflowY: "auto" }}>
|
||||
{node.type === "interaction" ? (
|
||||
{isInteraction ? (
|
||||
<QaEditor nodeId={node.id} data={node.data as InteractionBlockData} />
|
||||
) : (
|
||||
<BlockRenderer
|
||||
@@ -81,28 +88,72 @@ export function DetailPanel() {
|
||||
✦ {t("v4.detail.aiAssist")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<AiButton label={t("v4.detail.aiGenerateLayered")} />
|
||||
<AiButton label={t("v4.detail.aiFillExpected")} />
|
||||
<AiButton label={t("v4.detail.aiOptimizeFollowup")} />
|
||||
<AiButton label={t("v4.detail.aiDifferentiation")} />
|
||||
{isInteraction ? (
|
||||
<>
|
||||
<AiButton
|
||||
label={t("v4.detail.aiGenerateLayered")}
|
||||
disabled={isRunning}
|
||||
onClick={() => trigger("layered")}
|
||||
/>
|
||||
<AiButton
|
||||
label={t("v4.detail.aiFillExpected")}
|
||||
disabled={isRunning}
|
||||
onClick={() => trigger("generate")}
|
||||
/>
|
||||
<AiButton
|
||||
label={t("v4.detail.aiOptimizeFollowup")}
|
||||
disabled={isRunning}
|
||||
onClick={() => trigger("optimize")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AiButton
|
||||
label={t("v4.contextMenu.aiGenerate")}
|
||||
disabled={isRunning}
|
||||
onClick={() => trigger("generate")}
|
||||
/>
|
||||
<AiButton
|
||||
label={t("v4.contextMenu.aiOptimize")}
|
||||
disabled={isRunning}
|
||||
onClick={() => trigger("optimize")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AiButton
|
||||
label={t("v4.detail.aiDifferentiation")}
|
||||
disabled={isRunning}
|
||||
onClick={() => trigger("differentiation")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function AiButton({ label }: { label: string }) {
|
||||
function AiButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: "5px 10px",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
borderRadius: 4,
|
||||
fontSize: 11.5,
|
||||
cursor: "pointer",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
color: "var(--foreground)",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Block, DifferentiationLevel, TeachingStage } from "../../types";
|
||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
|
||||
@@ -11,6 +12,7 @@ const STAGES: TeachingStage[] = ["import", "new_teaching", "consolidation", "sum
|
||||
const LEVELS: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
|
||||
|
||||
export function DetailProps({ node }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const updateNode = useLessonPlanEditor((s) => s.updateNode);
|
||||
|
||||
return (
|
||||
@@ -25,13 +27,13 @@ export function DetailProps({ node }: Props) {
|
||||
}}
|
||||
>
|
||||
<PropItem
|
||||
label="教学阶段"
|
||||
label={t("v4.detail.stageLabel")}
|
||||
value={node.stage ?? "—"}
|
||||
options={STAGES}
|
||||
onChange={(v) => updateNode(node.id, { stage: v as TeachingStage })}
|
||||
/>
|
||||
<PropItem
|
||||
label="差异化"
|
||||
label={t("v4.detail.differentiationLabel")}
|
||||
value={node.differentiation ?? "—"}
|
||||
options={LEVELS}
|
||||
onChange={(v) => updateNode(node.id, { differentiation: v as DifferentiationLevel })}
|
||||
|
||||
@@ -208,7 +208,7 @@ function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMove
|
||||
value={turn.content}
|
||||
onChange={(e) => onUpdate({ content: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="内容"
|
||||
placeholder={t("v4.detail.turnContentPlaceholder")}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "none",
|
||||
|
||||
@@ -73,6 +73,7 @@ export function InlineNode({ node }: Props) {
|
||||
* 这里给出最常见的几种的简化摘要,完整编辑在右栏详情面板。
|
||||
*/
|
||||
function InlineNodeBody({ node }: { node: Block }) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const data = node.data;
|
||||
// 按类型渲染摘要
|
||||
switch (node.type) {
|
||||
@@ -100,7 +101,7 @@ function InlineNodeBody({ node }: { node: Block }) {
|
||||
}
|
||||
case "exercise": {
|
||||
const d = data as { items: unknown[] };
|
||||
return <p style={{ fontSize: 13 }}>{d.items?.length ?? 0} 道练习</p>;
|
||||
return <p style={{ fontSize: 13 }}>{d.items?.length ?? 0} {t("v4.detail.exerciseCount")}</p>;
|
||||
}
|
||||
default:
|
||||
return <p style={{ fontSize: 13, color: "var(--muted-foreground)" }}>{node.title}</p>;
|
||||
|
||||
@@ -83,7 +83,7 @@ function QaTurnView({ turn, index }: { turn: QATurn; index: number }) {
|
||||
<div className="lp-qa-content">
|
||||
{turn.content}
|
||||
{turn.role === "teacher" && turn.expectedAnswer && (
|
||||
<span className="lp-qa-prompt">[预期:{turn.expectedAnswer}]</span>
|
||||
<span className="lp-qa-prompt">[{t("v4.detail.expectedAnswer")}:{turn.expectedAnswer}]</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ContextMenuState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
@@ -142,6 +144,7 @@ export function PaperEditor({ readonly }: { readonly?: boolean }) {
|
||||
<PaperContextMenu
|
||||
state={contextMenu}
|
||||
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
|
||||
onAiAction={(action, nodeId) => void runNodeAi(action, nodeId)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Partial<Block>, "type">) => void;
|
||||
removeNode: (id: string) => void;
|
||||
/** V4:复制节点(深拷贝数据 + 新 id + 标题加"副本" + order 置末) */
|
||||
duplicateNode: (id: string) => string | null;
|
||||
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => 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) => ({
|
||||
|
||||
111
src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
Normal file
111
src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
Normal file
@@ -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();
|
||||
* <button onClick={() => run("generate", nodeId)}>生成</button>
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
250
src/modules/lesson-preparation/lib/ai-node-assist.ts
Normal file
250
src/modules/lesson-preparation/lib/ai-node-assist.ts
Normal file
@@ -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<BlockData>;
|
||||
}
|
||||
|
||||
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 字符串,可含 <p>/<ul>/<h3> 等基础标签)
|
||||
- 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<NodeContentUpdate | null> {
|
||||
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<BlockData> };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2. 优化表达 ----
|
||||
|
||||
const OPTIMIZE_PROMPT = `你是教学设计专家。请优化以下课案节点的表达,使其更清晰、专业、符合教学规范。
|
||||
|
||||
节点信息:
|
||||
---
|
||||
{node}
|
||||
---
|
||||
|
||||
要求:
|
||||
- 保留原有结构,仅优化文字表达
|
||||
- 不改变字段集合,只修改字段值
|
||||
- 学科术语使用准确
|
||||
- 中文课案用中文优化,英文课案用英文优化
|
||||
|
||||
返回 JSON 对象:{ patch: { 字段名: 优化后的值 } }`;
|
||||
|
||||
export async function optimizeNodeExpression(
|
||||
node: LessonPlanNode,
|
||||
): Promise<NodeContentUpdate | null> {
|
||||
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<BlockData> };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 3. 差异化建议 ----
|
||||
|
||||
const DIFFERENTIATION_PROMPT = `你是教学设计专家。请根据以下课案节点内容,判定该节点最适合的差异化教学层次。
|
||||
|
||||
节点信息:
|
||||
---
|
||||
{node}
|
||||
---
|
||||
|
||||
判定标准:
|
||||
- basic(基础):知识点为基础概念或技能,所有学生必学,节点内容偏识记与模仿
|
||||
- intermediate(提高):知识点为学科核心内容,多数学生需掌握,节点内容偏理解与应用
|
||||
- advanced(拓展):知识点为延伸拓展或综合应用,适合学有余力的学生
|
||||
|
||||
返回 JSON 对象:{ level: "basic"|"intermediate"|"advanced", reason: "判定依据(一句话)" }`;
|
||||
|
||||
export async function suggestNodeDifferentiation(
|
||||
node: LessonPlanNode,
|
||||
): Promise<NodeDifferentiationUpdate | null> {
|
||||
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<QATurn[] | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof t>[0]);
|
||||
// 运行时守卫:先用 t.has() 检查键是否存在,避免 as 断言绕过类型检查
|
||||
// 若键不存在(schema 中误写不存在的键),返回原消息而非抛出 MISSING_MESSAGE
|
||||
if (t.has(msg)) { return t(msg as Parameters<typeof t>[0]); } return msg;
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "无数据"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user