feat(lesson-preparation): add anchor canvas design, new blocks, and textbook content node

- Add anchor injector for canvas-based anchor positioning

- Add new block components: blackboard, homework, import, key-point, new-teaching, objective, summary

- Add textbook content node for React Flow canvas

- Update actions (kp, publish, main), data-access (templates, versions, main)

- Update editor, node-editor, block-renderer, and picker components

- Update schema, types, hooks, and lib utilities (document-migration, node-summary, rf-mappers)
This commit is contained in:
SpecialX
2026-06-23 17:37:19 +08:00
parent 1fcef5c3aa
commit 2197e68069
34 changed files with 3190 additions and 402 deletions

View File

@@ -7,6 +7,7 @@ import {
getKnowledgePointsByTextbookId, getKnowledgePointsByTextbookId,
getKnowledgePointsByChapterId, getKnowledgePointsByChapterId,
} from "@/modules/textbooks/data-access"; } from "@/modules/textbooks/data-access";
import { getKnowledgePointOptionsSchema } from "./schema";
import type { ActionState } from "./types"; import type { ActionState } from "./types";
// 加载知识点选项(供客户端知识点选择器使用) // 加载知识点选项(供客户端知识点选择器使用)
@@ -18,14 +19,19 @@ export async function getKnowledgePointOptionsAction(input: {
> { > {
const t = await getTranslations("lessonPreparation"); const t = await getTranslations("lessonPreparation");
try { try {
const parsed = getKnowledgePointOptionsSchema.safeParse(input);
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
await requirePermission(Permissions.LESSON_PLAN_READ); await requirePermission(Permissions.LESSON_PLAN_READ);
if (!input.textbookId) return { success: true, data: { options: [] } }; if (!parsed.data.textbookId) return { success: true, data: { options: [] } };
let kps; let kps;
if (input.chapterId) { if (parsed.data.chapterId) {
kps = await getKnowledgePointsByChapterId(input.chapterId); kps = await getKnowledgePointsByChapterId(parsed.data.chapterId);
} else { } else {
kps = await getKnowledgePointsByTextbookId(input.textbookId); kps = await getKnowledgePointsByTextbookId(parsed.data.textbookId);
} }
return { return {
success: true, success: true,

View File

@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
requirePermission, requirePermission,
PermissionDeniedError,
} from "@/shared/lib/auth-guard"; } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions"; import { Permissions } from "@/shared/types/permissions";
import { handleActionError, safeParseDate } from "@/shared/lib/action-utils";
import { publishLessonPlanHomework, PublishServiceError } from "./publish-service"; import { publishLessonPlanHomework, PublishServiceError } from "./publish-service";
import { publishLessonPlanHomeworkSchema } from "./schema";
import type { ActionState } from "./types"; import type { ActionState } from "./types";
export async function publishLessonPlanHomeworkAction(input: { export async function publishLessonPlanHomeworkAction(input: {
@@ -19,19 +20,26 @@ export async function publishLessonPlanHomeworkAction(input: {
}): Promise<ActionState<{ examId: string; assignmentId: string }>> { }): Promise<ActionState<{ examId: string; assignmentId: string }>> {
const t = await getTranslations("lessonPreparation"); const t = await getTranslations("lessonPreparation");
try { try {
const parsed = publishLessonPlanHomeworkSchema.safeParse(input);
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
const ctx = await requirePermission( const ctx = await requirePermission(
Permissions.LESSON_PLAN_PUBLISH, Permissions.LESSON_PLAN_PUBLISH,
); );
await requirePermission(Permissions.HOMEWORK_CREATE); await requirePermission(Permissions.HOMEWORK_CREATE);
const result = await publishLessonPlanHomework({ const result = await publishLessonPlanHomework({
planId: input.planId, planId: parsed.data.planId,
blockId: input.blockId, blockId: parsed.data.blockId,
userId: ctx.userId, userId: ctx.userId,
classIds: input.classIds, classIds: parsed.data.classIds,
availableAt: input.availableAt availableAt: parsed.data.availableAt
? new Date(input.availableAt) ? safeParseDate(parsed.data.availableAt, "可用时间")
: undefined,
dueAt: parsed.data.dueAt
? safeParseDate(parsed.data.dueAt, "截止时间")
: undefined, : undefined,
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
}); });
revalidatePath("/teacher/lesson-plans"); revalidatePath("/teacher/lesson-plans");
revalidatePath("/teacher/homework"); revalidatePath("/teacher/homework");
@@ -43,17 +51,12 @@ export async function publishLessonPlanHomeworkAction(input: {
}, },
}; };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
// publish-service 抛出 PublishServiceError含 code翻译为 i18n 消息 // publish-service 抛出 PublishServiceError含 code翻译为 i18n 消息
if (e instanceof PublishServiceError) { if (e instanceof PublishServiceError) {
const messageKey = PUBLISH_ERROR_KEY_MAP[e.code] ?? "error.publish"; const messageKey = PUBLISH_ERROR_KEY_MAP[e.code] ?? "error.publish";
return { success: false, message: t(messageKey) }; return { success: false, message: t(messageKey) };
} }
return { return handleActionError(e);
success: false,
message: e instanceof Error ? e.message : t("error.publish"),
};
} }
} }

View File

@@ -2,8 +2,9 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"; import { requirePermission } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions"; import { Permissions } from "@/shared/types/permissions";
import { handleActionError } from "@/shared/lib/action-utils";
import { import {
getLessonPlans, getLessonPlans,
getLessonPlanById, getLessonPlanById,
@@ -11,6 +12,8 @@ import {
updateLessonPlanContent, updateLessonPlanContent,
softDeleteLessonPlan, softDeleteLessonPlan,
duplicateLessonPlan, duplicateLessonPlan,
getTextbooksForPicker,
getChaptersForPicker,
LessonPlanDataError, LessonPlanDataError,
} from "./data-access"; } from "./data-access";
import { import {
@@ -45,15 +48,12 @@ export async function getLessonPlansAction(params: {
items: Awaited<ReturnType<typeof getLessonPlans>>; items: Awaited<ReturnType<typeof getLessonPlans>>;
}> }>
> { > {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId); const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
return { success: true, data: { items } }; return { success: true, data: { items } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.getList") };
} }
} }
@@ -63,16 +63,13 @@ export async function getLessonPlanByIdAction(
): Promise< ): Promise<
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }> ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
> { > {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
const plan = await getLessonPlanById(planId, ctx.userId); const plan = await getLessonPlanById(planId, ctx.userId);
if (!plan) return { success: false, message: t("error.notFound") }; if (!plan) return { success: false, message: "课案不存在" };
return { success: true, data: { plan } }; return { success: true, data: { plan } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.getOne") };
} }
} }
@@ -107,6 +104,8 @@ export async function createLessonPlanAction(
} }
if (key.startsWith("template.blocks.")) { if (key.startsWith("template.blocks.")) {
const parts = key.split("."); const parts = key.split(".");
// 期望格式template.blocks.{templateId}.{blockIndex}
if (parts.length < 4) return key;
const templateId = parts[2]; const templateId = parts[2];
const blockIndex = parts[3]; const blockIndex = parts[3];
return t(`template.blocks.${templateId}.${blockIndex}`); return t(`template.blocks.${templateId}.${blockIndex}`);
@@ -117,11 +116,9 @@ export async function createLessonPlanAction(
revalidatePath("/teacher/lesson-plans"); revalidatePath("/teacher/lesson-plans");
return { success: true, data: { planId } }; return { success: true, data: { planId } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
if (e instanceof LessonPlanDataError && e.code === "TEMPLATE_NOT_FOUND") if (e instanceof LessonPlanDataError && e.code === "TEMPLATE_NOT_FOUND")
return { success: false, message: t("error.templateNotFound") }; return { success: false, message: t("error.templateNotFound") };
return { success: false, message: t("error.create") }; return handleActionError(e);
} }
} }
@@ -131,7 +128,6 @@ export async function updateLessonPlanAction(input: {
title?: string; title?: string;
content: LessonPlanDocument; content: LessonPlanDocument;
}): Promise<ActionState> { }): Promise<ActionState> {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
const parsed = updateLessonPlanContentSchema.safeParse(input); const parsed = updateLessonPlanContentSchema.safeParse(input);
@@ -139,13 +135,13 @@ export async function updateLessonPlanAction(input: {
return { success: false, errors: parsed.error.flatten().fieldErrors }; return { success: false, errors: parsed.error.flatten().fieldErrors };
await updateLessonPlanContent(parsed.data.planId, ctx.userId, { await updateLessonPlanContent(parsed.data.planId, ctx.userId, {
...(parsed.data.title ? { title: parsed.data.title } : {}), ...(parsed.data.title ? { title: parsed.data.title } : {}),
content: parsed.data.content as LessonPlanDocument, // 从 unknown 转换Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫
content: parsed.data.content as unknown as LessonPlanDocument,
}); });
revalidatePath("/teacher/lesson-plans");
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.save") };
} }
} }
@@ -155,7 +151,6 @@ export async function saveLessonPlanVersionAction(input: {
content: LessonPlanDocument; content: LessonPlanDocument;
label?: string; label?: string;
}): Promise<ActionState<{ versionNo: number }>> { }): Promise<ActionState<{ versionNo: number }>> {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
const parsed = saveVersionSchema.safeParse(input); const parsed = saveVersionSchema.safeParse(input);
@@ -171,9 +166,7 @@ export async function saveLessonPlanVersionAction(input: {
await pruneAutoVersions(parsed.data.planId); await pruneAutoVersions(parsed.data.planId);
return { success: true, data: { versionNo } }; return { success: true, data: { versionNo } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.saveVersion") };
} }
} }
@@ -185,15 +178,12 @@ export async function getLessonPlanVersionsAction(
versions: Awaited<ReturnType<typeof getLessonPlanVersions>>; versions: Awaited<ReturnType<typeof getLessonPlanVersions>>;
}> }>
> { > {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
const versions = await getLessonPlanVersions(planId, ctx.userId); const versions = await getLessonPlanVersions(planId, ctx.userId);
return { success: true, data: { versions } }; return { success: true, data: { versions } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.getVersions") };
} }
} }
@@ -202,7 +192,6 @@ export async function revertLessonPlanVersionAction(input: {
planId: string; planId: string;
versionNo: number; versionNo: number;
}): Promise<ActionState<{ newVersionNo: number }>> { }): Promise<ActionState<{ newVersionNo: number }>> {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
const parsed = revertVersionSchema.safeParse(input); const parsed = revertVersionSchema.safeParse(input);
@@ -213,13 +202,11 @@ export async function revertLessonPlanVersionAction(input: {
parsed.data.versionNo, parsed.data.versionNo,
ctx.userId, ctx.userId,
); );
if (!result) return { success: false, message: t("error.versionNotFound") }; if (!result) return { success: false, message: "版本不存在" };
revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`); revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`);
return { success: true, data: { newVersionNo: result.newVersionNo } }; return { success: true, data: { newVersionNo: result.newVersionNo } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.revert") };
} }
} }
@@ -227,16 +214,13 @@ export async function revertLessonPlanVersionAction(input: {
export async function deleteLessonPlanAction( export async function deleteLessonPlanAction(
planId: string, planId: string,
): Promise<ActionState> { ): Promise<ActionState> {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE); const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
await softDeleteLessonPlan(planId, ctx.userId); await softDeleteLessonPlan(planId, ctx.userId);
revalidatePath("/teacher/lesson-plans"); revalidatePath("/teacher/lesson-plans");
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.delete") };
} }
} }
@@ -251,11 +235,9 @@ export async function duplicateLessonPlanAction(
revalidatePath("/teacher/lesson-plans"); revalidatePath("/teacher/lesson-plans");
return { success: true, data: { newPlanId } }; return { success: true, data: { newPlanId } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND") if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
return { success: false, message: t("error.notFound") }; return { success: false, message: t("error.notFound") };
return { success: false, message: t("error.duplicate") }; return handleActionError(e);
} }
} }
@@ -265,15 +247,12 @@ export async function getLessonPlanTemplatesAction(): Promise<
templates: Awaited<ReturnType<typeof getLessonPlanTemplates>>; templates: Awaited<ReturnType<typeof getLessonPlanTemplates>>;
}> }>
> { > {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
const templates = await getLessonPlanTemplates(ctx.userId); const templates = await getLessonPlanTemplates(ctx.userId);
return { success: true, data: { templates } }; return { success: true, data: { templates } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message };
return { success: false, message: t("error.getTemplates") };
} }
} }
@@ -294,11 +273,9 @@ export async function saveAsTemplateAction(input: {
}); });
return { success: true, data: { templateId } }; return { success: true, data: { templateId } };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND") if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
return { success: false, message: t("error.notFound") }; return { success: false, message: t("error.notFound") };
return { success: false, message: t("error.saveTemplate") }; return handleActionError(e);
} }
} }
@@ -306,14 +283,51 @@ export async function saveAsTemplateAction(input: {
export async function deleteTemplateAction( export async function deleteTemplateAction(
templateId: string, templateId: string,
): Promise<ActionState> { ): Promise<ActionState> {
const t = await getTranslations("lessonPreparation");
try { try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE); const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
await deletePersonalTemplate(templateId, ctx.userId); await deletePersonalTemplate(templateId, ctx.userId);
revalidatePath("/teacher/lesson-plans");
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return handleActionError(e);
return { success: false, message: e.message }; }
return { success: false, message: t("error.deleteTemplate") }; }
// ---- 获取教材列表(供 picker 使用)----
export async function getTextbooksForPickerAction(): Promise<
ActionState<{
textbooks: { id: string; title: string; subject: string; grade: string | null }[];
}>
> {
try {
await requirePermission(Permissions.LESSON_PLAN_READ);
const textbooks = await getTextbooksForPicker();
return { success: true, data: { textbooks } };
} catch (e) {
return handleActionError(e);
}
}
// ---- 获取章节列表(供 picker 使用)----
export async function getChaptersForPickerAction(
textbookId: string,
): Promise<
ActionState<{
chapters: {
id: string;
title: string;
parentId: string | null;
order: number | null;
content?: string | null;
children?: unknown[];
}[];
}>
> {
try {
await requirePermission(Permissions.LESSON_PLAN_READ);
const chapters = await getChaptersForPicker(textbookId);
return { success: true, data: { chapters } };
} catch (e) {
return handleActionError(e);
} }
} }

View File

@@ -27,7 +27,7 @@ import { RichTextBlock } from "./blocks/rich-text-block";
import { ExerciseBlock } from "./blocks/exercise-block"; import { ExerciseBlock } from "./blocks/exercise-block";
import { TextStudyBlock } from "./blocks/text-study-block"; import { TextStudyBlock } from "./blocks/text-study-block";
import { ReflectionBlock } from "./blocks/reflection-block"; import { ReflectionBlock } from "./blocks/reflection-block";
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData } from "../types"; import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData, ReflectionBlockData } from "../types";
interface BlockRendererProps { interface BlockRendererProps {
textbookId?: string; textbookId?: string;
@@ -122,7 +122,7 @@ function SortableBlock({
/> />
) : node.type === "reflection" ? ( ) : node.type === "reflection" ? (
<ReflectionBlock <ReflectionBlock
data={node.data as RichTextBlockData} data={node.data as ReflectionBlockData}
onUpdate={(d) => updateNode(node.id, { data: d })} onUpdate={(d) => updateNode(node.id, { data: d })}
/> />
) : ( ) : (
@@ -140,7 +140,7 @@ export function BlockRenderer({
chapterId, chapterId,
classes, classes,
}: BlockRendererProps) { }: BlockRendererProps) {
const { doc } = useLessonPlanEditor(); const { doc, updateNode } = useLessonPlanEditor();
function onDragEnd(e: DragEndEvent) { function onDragEnd(e: DragEndEvent) {
const { active, over } = e; const { active, over } = e;
@@ -149,11 +149,10 @@ export function BlockRenderer({
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id); const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
const newIndex = doc.nodes.findIndex((b) => b.id === over.id); const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
if (oldIndex === -1 || newIndex === -1) return; if (oldIndex === -1 || newIndex === -1) return;
// 交换 order // 交换 order 并写回 store修复 onDragEnd 未回写 store 的 BUG
const nodes = [...doc.nodes]; const tmpOrder = doc.nodes[oldIndex].order;
const tmpOrder = nodes[oldIndex].order; updateNode(doc.nodes[oldIndex].id, { order: doc.nodes[newIndex].order });
nodes[oldIndex].order = nodes[newIndex].order; updateNode(doc.nodes[newIndex].id, { order: tmpOrder });
nodes[newIndex].order = tmpOrder;
} }
return ( return (
@@ -163,7 +162,9 @@ export function BlockRenderer({
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{doc.nodes.map((b, i) => ( {doc.nodes
.filter((b): b is LessonPlanNode => b.type !== "textbook_content")
.map((b, i) => (
<SortableBlock <SortableBlock
key={b.id} key={b.id}
node={b} node={b}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Tag } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
interface Props {
data: BlackboardBlockData;
textbookId?: string;
chapterId?: string;
onUpdate: (data: BlackboardBlockData) => void;
}
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [showKpPicker, setShowKpPicker] = useState(false);
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("blackboard.hint")}
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.layoutLabel")}
</label>
<select
value={data.layout}
onChange={(e) =>
onUpdate({
...data,
layout: e.target.value as BlackboardBlockData["layout"],
})
}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
>
{LAYOUTS.map((l) => (
<option key={l} value={l}>
{t(`blackboard.layout.${l}`)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.contentLabel")}
</label>
<textarea
value={data.content}
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[120px] font-mono"
placeholder={t("blackboard.contentPlaceholder")}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{data.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.linked", { count: data.knowledgePointIds.length })}
</span>
)}
<button
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
{t("knowledgePoint.annotate")}
</button>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { HomeworkAssignment, HomeworkBlockData } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: HomeworkBlockData;
onUpdate: (data: HomeworkBlockData) => void;
}
const TYPES: HomeworkAssignment["type"][] = ["exercise", "reading", "writing"];
export function HomeworkBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updateItem(index: number, patch: Partial<HomeworkAssignment>) {
const next = data.assignments.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, assignments: next });
}
function addItem() {
onUpdate({
...data,
assignments: [...data.assignments, { type: "exercise", description: "" }],
});
}
function removeItem(index: number) {
onUpdate({
...data,
assignments: data.assignments.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("homework.hint")}
</div>
{data.assignments.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) =>
updateItem(idx, {
type: e.target.value as HomeworkAssignment["type"],
})
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{t(`homework.type.${tp}`)}
</option>
))}
</select>
<input
type="text"
value={item.refId ?? ""}
onChange={(e) => updateItem(idx, { refId: e.target.value || undefined })}
className="w-32 text-sm border border-outline-variant rounded px-2 py-1"
placeholder={t("homework.refIdPlaceholder")}
/>
<input
type="text"
value={item.description}
onChange={(e) => updateItem(idx, { description: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1"
placeholder={t("homework.descriptionPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("homework.addItem")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useTranslations } from "next-intl";
import type { ImportBlockData } from "../../types";
interface Props {
data: ImportBlockData;
onUpdate: (data: ImportBlockData) => void;
}
const METHODS: ImportBlockData["method"][] = ["question", "situation", "review", "other"];
export function ImportBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("import.hint")}
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("import.methodLabel")}
</label>
<select
value={data.method}
onChange={(e) =>
onUpdate({ ...data, method: e.target.value as ImportBlockData["method"] })
}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
>
{METHODS.map((m) => (
<option key={m} value={m}>
{t(`import.method.${m}`)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("import.promptLabel")}
</label>
<textarea
value={data.prompt}
onChange={(e) => onUpdate({ ...data, prompt: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[60px]"
placeholder={t("import.promptPlaceholder")}
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("import.durationLabel")}
</label>
<input
type="number"
min={1}
max={30}
value={data.durationMin}
onChange={(e) =>
onUpdate({ ...data, durationMin: Number(e.target.value) || 0 })
}
className="w-20 text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
/>
<span className="text-xs text-on-surface-variant ml-2">
{t("import.durationUnit")}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { KeyPointBlockData, KeyPointItem } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: KeyPointBlockData;
onUpdate: (data: KeyPointBlockData) => void;
}
const TYPES: KeyPointItem["type"][] = ["key", "difficult"];
export function KeyPointBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updateItem(index: number, patch: Partial<KeyPointItem>) {
const next = data.keyPoints.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, keyPoints: next });
}
function addItem() {
onUpdate({
...data,
keyPoints: [...data.keyPoints, { type: "key", text: "" }],
});
}
function removeItem(index: number) {
onUpdate({
...data,
keyPoints: data.keyPoints.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("keyPoint.hint")}
</div>
{data.keyPoints.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) =>
updateItem(idx, { type: e.target.value as KeyPointItem["type"] })
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{t(`keyPoint.type.${tp}`)}
</option>
))}
</select>
<textarea
value={item.text}
onChange={(e) => updateItem(idx, { text: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("keyPoint.textPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("keyPoint.addItem")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Plus, Trash2, Tag } from "lucide-react";
import type { NewTeachingBlockData, NewTeachingPoint } from "../../types";
import { Button } from "@/shared/components/ui/button";
import { KnowledgePointPicker } from "../knowledge-point-picker";
interface Props {
data: NewTeachingBlockData;
textbookId?: string;
chapterId?: string;
onUpdate: (data: NewTeachingBlockData) => void;
}
export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [pickerFor, setPickerFor] = useState<number | null>(null);
function updatePoint(index: number, patch: Partial<NewTeachingPoint>) {
const next = data.teachingPoints.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, teachingPoints: next });
}
function addPoint() {
onUpdate({
...data,
teachingPoints: [
...data.teachingPoints,
{ knowledgePointIds: [], outline: "", boardNotes: "" },
],
});
}
function removePoint(index: number) {
onUpdate({
...data,
teachingPoints: data.teachingPoints.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("newTeaching.hint")}
</div>
{data.teachingPoints.map((point, idx) => (
<div key={idx} className="border border-outline-variant rounded p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
{t("newTeaching.pointIndex", { index: idx + 1 })}
</span>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removePoint(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<div>
<label className="text-xs block mb-1">
{t("newTeaching.outlineLabel")}
</label>
<textarea
value={point.outline}
onChange={(e) => updatePoint(idx, { outline: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[60px]"
placeholder={t("newTeaching.outlinePlaceholder")}
/>
</div>
<div>
<label className="text-xs block mb-1">
{t("newTeaching.boardNotesLabel")}
</label>
<textarea
value={point.boardNotes}
onChange={(e) => updatePoint(idx, { boardNotes: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("newTeaching.boardNotesPlaceholder")}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{point.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.linked", { count: point.knowledgePointIds.length })}
</span>
)}
<button
onClick={() => setPickerFor(idx)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
{t("knowledgePoint.annotate")}
</button>
</div>
</div>
))}
<Button variant="outline" size="sm" onClick={addPoint}>
<Plus className="w-3 h-3 mr-1" />
{t("newTeaching.addPoint")}
</Button>
{pickerFor !== null && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.teachingPoints[pickerFor]?.knowledgePointIds ?? []}
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
onClose={() => setPickerFor(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { ObjectiveBlockData, ObjectiveItem } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: ObjectiveBlockData;
onUpdate: (data: ObjectiveBlockData) => void;
}
const DIMENSIONS: ObjectiveItem["dimension"][] = ["knowledge", "process", "emotion"];
export function ObjectiveBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updateItem(index: number, patch: Partial<ObjectiveItem>) {
const next = data.objectives.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, objectives: next });
}
function addItem() {
onUpdate({
...data,
objectives: [
...data.objectives,
{ dimension: "knowledge", text: "" },
],
});
}
function removeItem(index: number) {
onUpdate({
...data,
objectives: data.objectives.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("objective.hint")}
</div>
{data.objectives.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.dimension}
onChange={(e) =>
updateItem(idx, {
dimension: e.target.value as ObjectiveItem["dimension"],
})
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{DIMENSIONS.map((d) => (
<option key={d} value={d}>
{t(`objective.dimension.${d}`)}
</option>
))}
</select>
<textarea
value={item.text}
onChange={(e) => updateItem(idx, { text: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("objective.textPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("objective.addItem")}
</Button>
</div>
);
}

View File

@@ -1,16 +1,84 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { RichTextBlock } from "./rich-text-block"; import { Plus, Trash2 } from "lucide-react";
import type { RichTextBlockData } from "../../types"; import type { ReflectionBlockData, ReflectionItem } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props { interface Props {
data: RichTextBlockData; data: ReflectionBlockData;
onUpdate: (data: RichTextBlockData) => void; onUpdate: (data: ReflectionBlockData) => void;
} }
export function ReflectionBlock(props: Props) { const ASPECTS: ReflectionItem["aspect"][] = ["effectiveness", "problems", "improvements"];
export function ReflectionBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation"); const t = useTranslations("lessonPreparation");
// 教学反思在 P1 阶段与普通富文本一致P3 再扩展学情数据嵌入
return <RichTextBlock {...props} hint={t("reflection.hint")} />; function updateItem(index: number, patch: Partial<ReflectionItem>) {
const next = data.reflection.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, reflection: next });
}
function addItem() {
onUpdate({
...data,
reflection: [...data.reflection, { aspect: "effectiveness", text: "" }],
});
}
function removeItem(index: number) {
onUpdate({
...data,
reflection: data.reflection.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("reflection.hint")}
</div>
{data.reflection.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.aspect}
onChange={(e) =>
updateItem(idx, {
aspect: e.target.value as ReflectionItem["aspect"],
})
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{ASPECTS.map((a) => (
<option key={a} value={a}>
{t(`reflection.aspect.${a}`)}
</option>
))}
</select>
<textarea
value={item.text}
onChange={(e) => updateItem(idx, { text: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[60px]"
placeholder={t("reflection.textPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("reflection.addItem")}
</Button>
</div>
);
} }

View File

@@ -0,0 +1,78 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { SummaryBlockData } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: SummaryBlockData;
onUpdate: (data: SummaryBlockData) => void;
}
export function SummaryBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updatePoint(index: number, value: string) {
const next = data.summaryPoints.map((p, i) => (i === index ? value : p));
onUpdate({ ...data, summaryPoints: next });
}
function addPoint() {
onUpdate({
...data,
summaryPoints: [...data.summaryPoints, ""],
});
}
function removePoint(index: number) {
onUpdate({
...data,
summaryPoints: data.summaryPoints.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("summary.hint")}
</div>
{data.summaryPoints.map((point, idx) => (
<div key={idx} className="flex items-start gap-2">
<span className="text-xs text-on-surface-variant mt-1">{idx + 1}.</span>
<input
type="text"
value={point}
onChange={(e) => updatePoint(idx, e.target.value)}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1"
placeholder={t("summary.pointPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removePoint(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addPoint}>
<Plus className="w-3 h-3 mr-1" />
{t("summary.addPoint")}
</Button>
<div>
<label className="text-xs font-medium block mb-1 mt-2">
{t("summary.homeworkPreviewLabel")}
</label>
<textarea
value={data.homeworkPreview}
onChange={(e) => onUpdate({ ...data, homeworkPreview: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("summary.homeworkPreviewPlaceholder")}
/>
</div>
</div>
);
}

View File

@@ -29,15 +29,42 @@ export function KnowledgePointPicker({
const t = useTranslations("lessonPreparation"); const t = useTranslations("lessonPreparation");
const [options, setOptions] = useState<KpOption[]>([]); const [options, setOptions] = useState<KpOption[]>([]);
const [local, setLocal] = useState<string[]>(selectedIds); const [local, setLocal] = useState<string[]>(selectedIds);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!textbookId) { if (!textbookId) {
return; return;
} }
getKnowledgePointOptionsAction({ textbookId, chapterId }).then((res) => { let cancelled = false;
if (res.success && res.data) setOptions(res.data.options); // 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
Promise.resolve()
.then(() => {
if (cancelled) return;
setLoading(true);
setError(null);
return getKnowledgePointOptionsAction({ textbookId, chapterId });
})
.then((res) => {
if (cancelled || !res) return;
if (res.success && res.data) {
setOptions(res.data.options);
} else {
setError(res.message ?? t("error.loadFailed"));
}
})
.catch((e) => {
if (cancelled) return;
console.error("[KnowledgePointPicker] load options failed", e);
setError(t("error.loadFailed"));
})
.finally(() => {
if (!cancelled) setLoading(false);
}); });
}, [textbookId, chapterId]); return () => {
cancelled = true;
};
}, [textbookId, chapterId, t]);
function toggle(id: string) { function toggle(id: string) {
setLocal((prev) => setLocal((prev) =>
@@ -60,7 +87,13 @@ export function KnowledgePointPicker({
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{options.length === 0 ? ( {loading ? (
<p className="text-on-surface-variant text-sm">
{t("knowledgePoint.loading")}
</p>
) : error ? (
<p className="text-error text-sm">{error}</p>
) : options.length === 0 ? (
<p className="text-on-surface-variant text-sm"> <p className="text-on-surface-variant text-sm">
{t("knowledgePoint.empty")} {t("knowledgePoint.empty")}
</p> </p>

View File

@@ -32,6 +32,7 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
const service = ctx?.service ?? null; const service = ctx?.service ?? null;
async function handleArchive() { async function handleArchive() {
try {
const res = service const res = service
? await service.deleteLessonPlan(plan.id) ? await service.deleteLessonPlan(plan.id)
: await deleteLessonPlanAction(plan.id); : await deleteLessonPlanAction(plan.id);
@@ -42,15 +43,26 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
} else { } else {
toast.error(res.message ?? t("error.delete")); toast.error(res.message ?? t("error.delete"));
} }
} catch (e) {
console.error("[LessonPlanCard] archive failed", e);
toast.error(t("error.delete"));
}
} }
async function handleDuplicate() { async function handleDuplicate() {
try {
const res = service const res = service
? await service.duplicateLessonPlan(plan.id) ? await service.duplicateLessonPlan(plan.id)
: await duplicateLessonPlanAction(plan.id); : await duplicateLessonPlanAction(plan.id);
if (res.success) { if (res.success) {
tracker.track("lesson_plan.duplicate", { planId: plan.id }); tracker.track("lesson_plan.duplicate", { planId: plan.id });
router.refresh(); router.refresh();
} else {
toast.error(res.message ?? t("error.duplicate"));
}
} catch (e) {
console.error("[LessonPlanCard] duplicate failed", e);
toast.error(t("error.duplicate"));
} }
} }

View File

@@ -14,7 +14,7 @@ import {
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import type { BlockType } from "../types"; import type { BlockType } from "../types";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Plus, Save, History } from "lucide-react"; import { Plus, Save, History, Book, FileText } from "lucide-react";
interface Props { interface Props {
planId: string; planId: string;
@@ -22,6 +22,8 @@ interface Props {
initialDoc: import("../types").LessonPlanDocument; initialDoc: import("../types").LessonPlanDocument;
textbookId?: string; textbookId?: string;
chapterId?: string; chapterId?: string;
textbookTitle?: string;
chapterTitle?: string;
classes?: { id: string; name: string }[]; classes?: { id: string; name: string }[];
} }
@@ -46,6 +48,8 @@ export function LessonPlanEditor({
initialDoc, initialDoc,
textbookId, textbookId,
chapterId, chapterId,
textbookTitle,
chapterTitle,
classes, classes,
}: Props) { }: Props) {
const t = useTranslations("lessonPreparation"); const t = useTranslations("lessonPreparation");
@@ -71,13 +75,18 @@ export function LessonPlanEditor({
autoSaveTimer.current = setTimeout(async () => { autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState(); const state = useLessonPlanEditor.getState();
state.setSaving(true); state.setSaving(true);
try {
const res = await updateLessonPlanAction({ const res = await updateLessonPlanAction({
planId: state.planId, planId: state.planId,
title: state.title, title: state.title,
content: state.doc, content: state.doc,
}); });
state.setSaving(false);
if (res.success) state.markSaved(); if (res.success) state.markSaved();
} catch (e) {
console.error("[LessonPlanEditor] auto-save failed", e);
} finally {
state.setSaving(false);
}
}, 3000); }, 3000);
return () => { return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
@@ -89,11 +98,15 @@ export function LessonPlanEditor({
versionTimer.current = setInterval(async () => { versionTimer.current = setInterval(async () => {
const state = useLessonPlanEditor.getState(); const state = useLessonPlanEditor.getState();
if (!state.isDirty) return; if (!state.isDirty) return;
try {
await saveLessonPlanVersionAction({ await saveLessonPlanVersionAction({
planId: state.planId, planId: state.planId,
content: state.doc, content: state.doc,
label: t("version.autoLabel"), label: t("version.autoLabel"),
}); });
} catch (e) {
console.error("[LessonPlanEditor] auto-version failed", e);
}
}, 30 * 60 * 1000); }, 30 * 60 * 1000);
return () => { return () => {
if (versionTimer.current) clearInterval(versionTimer.current); if (versionTimer.current) clearInterval(versionTimer.current);
@@ -127,24 +140,33 @@ export function LessonPlanEditor({
const handleManualSave = useCallback(async () => { const handleManualSave = useCallback(async () => {
const state = useLessonPlanEditor.getState(); const state = useLessonPlanEditor.getState();
state.setSaving(true); state.setSaving(true);
try {
const res = await saveLessonPlanVersionAction({ const res = await saveLessonPlanVersionAction({
planId: state.planId, planId: state.planId,
content: state.doc, content: state.doc,
}); });
state.setSaving(false);
if (res.success) { if (res.success) {
state.markSaved(); state.markSaved();
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" }); tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
} }
} catch (e) {
console.error("[LessonPlanEditor] manual save failed", e);
} finally {
state.setSaving(false);
}
}, [tracker]); }, [tracker]);
// 版本回退后刷新内容(修复 P1-1 // 版本回退后刷新内容(修复 P1-1
const handleReverted = useCallback(async () => { const handleReverted = useCallback(async () => {
const state = useLessonPlanEditor.getState(); const state = useLessonPlanEditor.getState();
try {
const res = await getLessonPlanByIdAction(state.planId); const res = await getLessonPlanByIdAction(state.planId);
if (res.success && res.data?.plan) { if (res.success && res.data?.plan) {
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content); state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
} }
} catch (e) {
console.error("[LessonPlanEditor] reload after revert failed", e);
}
}, []); }, []);
return ( return (
@@ -156,6 +178,20 @@ export function LessonPlanEditor({
onChange={(e) => editor.setTitle(e.target.value)} onChange={(e) => editor.setTitle(e.target.value)}
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none" className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
/> />
{/* 教材/章节指示器 */}
{textbookTitle && (
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high">
<Book className="w-3 h-3" />
<span className="max-w-[120px] truncate">{textbookTitle}</span>
{chapterTitle && (
<>
<span className="text-on-surface-variant/50">/</span>
<FileText className="w-3 h-3" />
<span className="max-w-[120px] truncate">{chapterTitle}</span>
</>
)}
</div>
)}
<span className="text-on-surface-variant text-sm"> <span className="text-on-surface-variant text-sm">
{editor.isSaving {editor.isSaving
? t("status.saving") ? t("status.saving")

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useDebounce } from "@/shared/hooks/use-debounce"; import { useDebounce } from "@/shared/hooks/use-debounce";
@@ -21,13 +21,19 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
// 修复 P1-6搜索 debounce 300ms // 修复 P1-6搜索 debounce 300ms
const debouncedQuery = useDebounce(query, 300); const debouncedQuery = useDebounce(query, 300);
// 使用 ref 存储 onFilter避免其引用变化触发 useEffect 无限循环
const onFilterRef = useRef(onFilter);
useEffect(() => { useEffect(() => {
onFilter({ onFilterRef.current = onFilter;
}, [onFilter]);
useEffect(() => {
onFilterRef.current({
query: debouncedQuery || undefined, query: debouncedQuery || undefined,
subjectId: subjectId || undefined, subjectId: subjectId || undefined,
status: status || undefined, status: status || undefined,
}); });
}, [debouncedQuery, subjectId, status, onFilter]); }, [debouncedQuery, subjectId, status]);
return ( return (
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { LessonPlanCard } from "./lesson-plan-card"; import { LessonPlanCard } from "./lesson-plan-card";
import { LessonPlanFilters } from "./lesson-plan-filters"; import { LessonPlanFilters } from "./lesson-plan-filters";
@@ -16,26 +16,50 @@ interface Props {
export function LessonPlanList({ initialItems, subjects }: Props) { export function LessonPlanList({ initialItems, subjects }: Props) {
const t = useTranslations("lessonPreparation"); const t = useTranslations("lessonPreparation");
const [items, setItems] = useState(initialItems); const [items, setItems] = useState(initialItems);
const [error, setError] = useState<string | null>(null);
const ctx = useLessonPlanContextSafe(); const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null; const service = ctx?.service ?? null;
async function handleFilter(params: { // 使用 useCallback 稳定 handleFilter 引用,避免 LessonPlanFilters 的 useEffect 无限循环
const handleFilter = useCallback(
async (params: {
query?: string; query?: string;
subjectId?: string; subjectId?: string;
status?: string; status?: string;
}) { }) => {
setError(null);
try {
if (service) { if (service) {
const res = await service.getLessonPlans(params); const res = await service.getLessonPlans(params);
if (res.success && res.data) setItems(res.data.items); if (res.success && res.data) {
setItems(res.data.items);
} else {
setError(res.message ?? t("error.loadFailed"));
}
return; return;
} }
const res = await getLessonPlansAction(params); const res = await getLessonPlansAction(params);
if (res.success && res.data) setItems(res.data.items); if (res.success && res.data) {
setItems(res.data.items);
} else {
setError(res.message ?? t("error.loadFailed"));
} }
} catch (e) {
console.error("[LessonPlanList] filter failed", e);
setError(t("error.loadFailed"));
}
},
[service, t],
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} /> <LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
{error && (
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
{error}
</p>
)}
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-on-surface-variant text-center py-12"> <p className="text-on-surface-variant text-center py-12">
{t("list.empty")} {t("list.empty")}

View File

@@ -18,39 +18,108 @@ import {
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor"; import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { LessonNode } from "./nodes/lesson-node"; import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
import { toRfNodes, toRfEdges } from "../lib/rf-mappers"; import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
import { getNodeColor } from "../lib/node-summary"; import { getNodeColor } from "../lib/node-summary";
import type { LessonPlanNode } from "../types"; import type { AnyLessonPlanNode } from "../types";
const nodeTypes = { lesson: LessonNode }; const nodeTypes = {
lesson: LessonNode,
textbook_content: TextbookContentNodeComponent,
};
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理 // NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
type Props = Record<string, never>; type Props = Record<string, never>;
export function NodeEditor({}: Props) { export function NodeEditor({}: Props) {
const t = useTranslations("lessonPreparation"); const t = useTranslations("lessonPreparation");
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } = const {
useLessonPlanEditor(); doc,
selectedNodeId,
updateNodePosition,
removeNode,
connect,
selectNode,
setEdges,
addAnchor,
updateTextbookContent,
} = useLessonPlanEditor();
// 锚点添加回调(正文节点使用)
const handleAddRangeAnchor = useCallback(
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
// 如果 nodeId 是 __selected__使用当前选中节点
// 如果是 __new__提示用户先创建节点
const actualNodeId =
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId || actualNodeId === "__new__") {
// 简化:不自动创建新节点,提示用户先选中或创建
return;
}
addAnchor({
nodeId: actualNodeId,
type: "range",
start: params.start,
end: params.end,
textPreview: params.textPreview,
});
},
[addAnchor, selectedNodeId],
);
const handleAddPointAnchor = useCallback(
(params: { nodeId: string; start: number }) => {
const actualNodeId =
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId || actualNodeId === "__new__") {
return;
}
addAnchor({
nodeId: actualNodeId,
type: "point",
start: params.start,
});
},
[addAnchor, selectedNodeId],
);
const handleZoomChange = useCallback(
(zoom: number) => {
updateTextbookContent({ zoom });
},
[updateTextbookContent],
);
// 使用纯函数映射 nodes/edges // 使用纯函数映射 nodes/edges
const rfNodes: Node[] = useMemo( const rfNodes: Node[] = useMemo(
() => toRfNodes(doc.nodes, selectedNodeId), () =>
[doc.nodes, selectedNodeId], toRfNodes(doc.nodes, selectedNodeId, {
anchors: doc.anchors,
selectedNodeId,
onAddRangeAnchor: handleAddRangeAnchor,
onAddPointAnchor: handleAddPointAnchor,
onSelectNode: selectNode,
onZoomChange: handleZoomChange,
}),
[doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange],
); );
const rfEdges: Edge[] = useMemo( const rfEdges: Edge[] = useMemo(
() => toRfEdges(doc.edges), () => toRfEdges(doc.edges, selectedNodeId, doc.anchors),
[doc.edges], [doc.edges, selectedNodeId, doc.anchors],
); );
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange[]) => { (changes: NodeChange[]) => {
changes.forEach((change) => { changes.forEach((change) => {
if (change.type === "position" && change.position) { if (change.type === "position" && change.position) {
// 拖拽结束时(dragging: false才写入最终位置,避免中间状态污染(修复 P1-1 // 实时拖动:每次 position 变化都更新(不再等待 dragging=false
if (change.dragging === false) { // 但仅在节点正在被拖动或拖动结束时更新
updateNodePosition(change.id, change.position); updateNodePosition(change.id, change.position);
}
} else if (change.type === "remove") { } else if (change.type === "remove") {
removeNode(change.id); removeNode(change.id);
} else if (change.type === "select") { } else if (change.type === "select") {
@@ -75,16 +144,32 @@ export function NodeEditor({}: Props) {
(changes: EdgeChange[]) => { (changes: EdgeChange[]) => {
// 简单处理:删除时调用 disconnect // 简单处理:删除时调用 disconnect
const nextEdges = applyEdgeChanges(changes, rfEdges); const nextEdges = applyEdgeChanges(changes, rfEdges);
const ourEdges = nextEdges.map((e) => ({ const ourEdges = nextEdges.map((e) => {
// 保留原有的 type 信息
const original = doc.edges.find((oe) => oe.id === e.id);
if (original?.type === "anchor") {
return {
id: e.id, id: e.id,
source: e.source, source: e.source,
target: e.target, target: e.target,
sourceHandle: e.sourceHandle ?? null, sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null, targetHandle: e.targetHandle ?? null,
})); type: "anchor" as const,
anchorId: original.anchorId,
};
}
return {
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
type: "flow" as const,
};
});
setEdges(ourEdges); setEdges(ourEdges);
}, },
[rfEdges, setEdges], [rfEdges, setEdges, doc.edges],
); );
return ( return (
@@ -131,7 +216,7 @@ export function NodeEditor({}: Props) {
<MiniMap <MiniMap
className="!bg-surface !border-outline-variant" className="!bg-surface !border-outline-variant"
nodeColor={(n) => { nodeColor={(n) => {
const nodeData = (n.data as { node?: LessonPlanNode }).node; const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
if (!nodeData) return "#9e9e9e"; if (!nodeData) return "#9e9e9e";
return getNodeColor(nodeData.type); return getNodeColor(nodeData.type);
}} }}

View File

@@ -0,0 +1,440 @@
"use client";
import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react";
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 {
injectPlaceholders,
parseAnchoredText,
toCircledNumber,
getNextPointIndex,
} from "../../lib/anchor-injector";
import { getNodeColor } from "../../lib/node-summary";
import { Button } from "@/shared/components/ui/button";
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;
}
export const TextbookContentNode = memo(function TextbookContentNode({
data,
selected,
}: NodeProps) {
const t = useTranslations("lessonPreparation");
const props = (data as unknown as TextbookContentNodeProps["data"]).node
? (data as unknown as TextbookContentNodeProps["data"])
: null;
const contentRef = useRef<HTMLDivElement>(null);
const [showAnchorMenu, setShowAnchorMenu] = useState<{
x: number;
y: number;
selection: { start: number; end: number; text: string } | null;
point: number | null;
} | null>(null);
const node = props?.node;
const anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]);
const selectedNodeId = props?.selectedNodeId ?? null;
// 注入锚点标记后的 Markdown
const injectedContent = useMemo(() => {
if (!node) return "";
return injectPlaceholders(node.data.content, anchors);
}, [node, anchors]);
// 解析为段落数组(用于自定义渲染)
const segments = useMemo(
() => parseAnchoredText(injectedContent),
[injectedContent],
);
// 选中节点的激活锚点 ID 集合
const activeAnchorIds = useMemo(() => {
if (!selectedNodeId) return new Set<string>();
return new Set(
anchors.filter((a) => a.nodeId === selectedNodeId).map((a) => a.id),
);
}, [anchors, selectedNodeId]);
// 获取锚点对应的节点颜色
const getAnchorNodeColor = useCallback(
(anchorId: string): string => {
const anchor = anchors.find((a) => a.id === anchorId);
if (!anchor) return "#9e9e9e";
return getNodeColor(anchor.nodeId);
},
[anchors],
);
// 处理文本选择
const handleMouseUp = useCallback(() => {
if (!node) return;
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
// 点击空白处:尝试计算点击位置偏移
return;
}
const text = selection.toString();
if (!text) return;
// 计算纯文本偏移量
const range = selection.getRangeAt(0);
const plainText = node.data.content;
const startContainer = range.startContainer;
const endContainer = range.endContainer;
// 简化:用 selection 的 anchorOffset 和 focusOffset
// 注意:这是近似值,对于复杂 DOM 结构可能不准确
const startOffset = range.startOffset;
const endOffset = range.endOffset;
// 如果在同一文本节点
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();
setShowAnchorMenu({
x: rect.left + rect.width / 2,
y: rect.top - 10,
selection: { start: absoluteStart, end: absoluteEnd, text: selectedText },
point: null,
});
}
}
selection.removeAllRanges();
}, [node]);
// 处理点击(点锚定)
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (!node) return;
// 如果有选中文本,不处理点击
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
// 计算点击位置在纯文本中的偏移
// 简化:使用 caretRangeFromPointChromium或 caretPositionFromPointFirefox
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 (offset < 0) return;
setShowAnchorMenu({
x,
y,
selection: null,
point: offset,
});
},
[node],
);
// 关闭锚点菜单
useEffect(() => {
if (!showAnchorMenu) return;
function handleOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest("[data-anchor-menu]")) {
setShowAnchorMenu(null);
}
}
document.addEventListener("mousedown", handleOutside);
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]);
const handleZoomOut = useCallback(() => {
if (!node || !props?.onZoomChange) return;
const newZoom = Math.max(0.5, node.data.zoom - 0.1);
props.onZoomChange(newZoom);
}, [node, props]);
if (!node) {
return (
<div className="rounded-lg border-2 border-outline-variant bg-surface p-4 text-on-surface-variant">
{t("editor.textbookContentMissing")}
</div>
);
}
const nextPointNumber = getNextPointIndex(anchors);
return (
<div
className="rounded-lg border-2 bg-surface shadow-lg"
style={{
borderColor: selected ? "#1976d2" : "#455a64",
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
width: 480,
}}
>
{/* 头部 */}
<div
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center justify-between"
style={{ backgroundColor: "#455a64" }}
>
<span>{t("editor.textbookContent")}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="!p-1 !h-6 !w-6 text-white hover:bg-white/20"
onClick={handleZoomOut}
aria-label={t("editor.zoomOut")}
>
<ZoomOut className="w-3 h-3" />
</Button>
<span className="text-xs">{Math.round(node.data.zoom * 100)}%</span>
<Button
variant="ghost"
size="sm"
className="!p-1 !h-6 !w-6 text-white hover:bg-white/20"
onClick={handleZoomIn}
aria-label={t("editor.zoomIn")}
>
<ZoomIn className="w-3 h-3" />
</Button>
</div>
</div>
{/* 正文内容 */}
<div
ref={contentRef}
className="px-4 py-3 max-h-[60vh] overflow-y-auto"
style={{
transform: `scale(${node.data.zoom})`,
transformOrigin: "top left",
}}
onMouseUp={handleMouseUp}
onClick={handleClick}
>
{node.data.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeSanitize]}
components={{
p: ({ children }) => {
// 将段落中的锚点标记渲染为 span
return <p>{renderChildrenWithAnchors(children, segments, activeAnchorIds, getAnchorNodeColor, props?.onSelectNode, anchors)}</p>;
},
}}
>
{injectedContent}
</ReactMarkdown>
</div>
) : (
<div className="text-on-surface-variant text-sm py-8 text-center">
{t("editor.textbookContentEmpty")}
</div>
)}
</div>
{/* 锚点浮动菜单 */}
{showAnchorMenu && (
<div
data-anchor-menu
className="fixed z-50 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 min-w-[200px]"
style={{
left: showAnchorMenu.x,
top: showAnchorMenu.y,
transform: "translate(-50%, -100%)",
}}
>
{showAnchorMenu.selection ? (
<div>
<div className="text-xs text-on-surface-variant mb-2 px-2">
{t("editor.rangeAnchorTitle")}
</div>
<AnchorNodeSelector
t={t}
onSelect={(nodeId) => {
if (props?.onAddRangeAnchor && showAnchorMenu.selection) {
props.onAddRangeAnchor({
nodeId,
start: showAnchorMenu.selection.start,
end: showAnchorMenu.selection.end,
textPreview: showAnchorMenu.selection.text,
});
}
setShowAnchorMenu(null);
}}
/>
</div>
) : showAnchorMenu.point !== null ? (
<div>
<div className="text-xs text-on-surface-variant mb-2 px-2">
{t("editor.pointAnchorTitle", { number: toCircledNumber(nextPointNumber) })}
</div>
<AnchorNodeSelector
t={t}
onSelect={(nodeId) => {
if (props?.onAddPointAnchor && showAnchorMenu.point !== null) {
props.onAddPointAnchor({
nodeId,
start: showAnchorMenu.point,
});
}
setShowAnchorMenu(null);
}}
/>
</div>
) : null}
</div>
)}
</div>
);
});
/**
* 锚点节点选择器(简化版:由父组件传入节点列表)
* 实际节点列表通过 context 或 props 传入,这里仅渲染触发按钮
*/
function AnchorNodeSelector({
t,
onSelect,
}: {
t: ReturnType<typeof useTranslations>;
onSelect: (nodeId: string) => void;
}) {
// 简化:直接调用 onAddRangeAnchor/onAddPointAnchor 时由父组件决定 nodeId
// 这里提供一个输入框让用户输入节点 ID 或选择
// 实际实现中应从父组件获取可锚定节点列表
return (
<div className="space-y-1">
<button
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
onClick={() => onSelect("__selected__")}
>
{t("editor.anchorToSelectedNode")}
</button>
<button
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
onClick={() => onSelect("__new__")}
>
{t("editor.anchorToNewNode")}
</button>
</div>
);
}
/**
* 渲染带锚点标记的子节点。
* 由于 ReactMarkdown 的 components 自定义渲染较为复杂,
* 这里采用简化方案:在文本节点中查找锚点标记并替换为 span。
*/
function renderChildrenWithAnchors(
children: React.ReactNode,
segments: ReturnType<typeof parseAnchoredText>,
activeAnchorIds: Set<string>,
getAnchorNodeColor: (anchorId: string) => string,
onSelectNode?: (id: string | null) => void,
anchors?: NodeAnchor[],
): React.ReactNode {
// 简化:直接遍历 segments 渲染
return segments.map((seg, idx) => {
if (seg.type === "text") {
return <span key={idx}>{seg.content}</span>;
}
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 (
<span
key={idx}
className={`range-anchor ${isActive ? "active" : ""}`}
style={
{
backgroundColor: color,
"--node-color": color,
} as React.CSSProperties
}
onClick={(e) => {
e.stopPropagation();
if (anchor && onSelectNode) {
onSelectNode(anchor.nodeId);
}
}}
>
{seg.content}
</span>
);
}
// 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 (
<span
key={idx}
className={`point-anchor ${isActive ? "active" : ""}`}
style={
{
backgroundColor: color,
"--node-color": color,
} as React.CSSProperties
}
onClick={(e) => {
e.stopPropagation();
if (anchor && onSelectNode) {
onSelectNode(anchor.nodeId);
}
}}
>
{toCircledNumber(pointIndex ?? 1)}
</span>
);
});
}

View File

@@ -37,6 +37,7 @@ export function PublishHomeworkDialog({
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try {
const res = await publishLessonPlanHomeworkAction({ const res = await publishLessonPlanHomeworkAction({
planId, planId,
blockId, blockId,
@@ -44,7 +45,6 @@ export function PublishHomeworkDialog({
availableAt: availableAt || undefined, availableAt: availableAt || undefined,
dueAt: dueAt || undefined, dueAt: dueAt || undefined,
}); });
setLoading(false);
if (res.success) { if (res.success) {
tracker.track("lesson_plan.publish", { tracker.track("lesson_plan.publish", {
planId, planId,
@@ -56,6 +56,12 @@ export function PublishHomeworkDialog({
} else { } else {
setError(res.message ?? t("error.publish")); setError(res.message ?? t("error.publish"));
} }
} catch (e) {
console.error("[PublishHomeworkDialog] publish failed", e);
setError(t("error.publish"));
} finally {
setLoading(false);
}
} }
return ( return (

View File

@@ -27,6 +27,8 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const t = useTranslations("lessonPreparation") const t = useTranslations("lessonPreparation")
const [questions, setQuestions] = useState<QuestionRow[]>([]) const [questions, setQuestions] = useState<QuestionRow[]>([])
const [picked, setPicked] = useState<ExerciseItem[]>([]) const [picked, setPicked] = useState<ExerciseItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象 // QuestionBankFilters 使用字符串值,这里转换为 filters 对象
const [searchValue, setSearchValue] = useState("") const [searchValue, setSearchValue] = useState("")
@@ -53,7 +55,17 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const debouncedFilters = useDebounce(filters, 300) const debouncedFilters = useDebounce(filters, 300)
useEffect(() => { useEffect(() => {
getQuestionsAction(debouncedFilters).then((res) => { let cancelled = false
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
Promise.resolve()
.then(() => {
if (cancelled) return
setLoading(true)
setError(null)
return getQuestionsAction(debouncedFilters)
})
.then((res) => {
if (cancelled || !res) return
if (res.success && res.data) { if (res.success && res.data) {
const data = res.data.data const data = res.data.data
setQuestions( setQuestions(
@@ -64,9 +76,22 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
content: q.content, content: q.content,
})), })),
) )
} else {
setError(res.message ?? t("error.loadFailed"))
} }
}) })
}, [debouncedFilters]) .catch((e) => {
if (cancelled) return
console.error("[QuestionBankPicker] load questions failed", e)
setError(t("error.loadFailed"))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [debouncedFilters, t])
function add(q: QuestionRow) { function add(q: QuestionRow) {
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
@@ -116,6 +141,17 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
/> />
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-on-surface-variant text-sm text-center py-8">
{t("questionBank.loading")}
</p>
) : error ? (
<p className="text-error text-sm text-center py-8">{error}</p>
) : questions.length === 0 ? (
<p className="text-on-surface-variant text-sm text-center py-8">
{t("questionBank.empty")}
</p>
) : (
<div className="space-y-2"> <div className="space-y-2">
{questions.map((q) => ( {questions.map((q) => (
<div <div
@@ -132,6 +168,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
</div> </div>
))} ))}
</div> </div>
)}
</div> </div>
<div className="p-4 border-t flex justify-between"> <div className="p-4 border-t flex justify-between">
<span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span> <span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span>

View File

@@ -1,25 +1,145 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState, useMemo, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { createLessonPlanAction } from "../actions"; import { createLessonPlanAction, getTextbooksForPickerAction, getChaptersForPickerAction } from "../actions";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { SYSTEM_TEMPLATES } from "../constants"; import { SYSTEM_TEMPLATES } from "../constants";
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; 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[];
}
export function TemplatePicker() { export function TemplatePicker() {
const t = useTranslations("lessonPreparation"); const t = useTranslations("lessonPreparation");
const router = useRouter(); const router = useRouter();
const tracker = useLessonPlanTrackerSafe(); const tracker = useLessonPlanTrackerSafe();
const searchParams = useSearchParams();
const [textbooks, setTextbooks] = useState<TextbookOption[]>([]);
const [textbookId, setTextbookId] = useState<string>("");
const [chapters, setChapters] = useState<ChapterOption[]>([]);
const [chapterId, setChapterId] = useState<string>(
() => searchParams.get("chapterId") ?? "",
);
const [loadedTextbookId, setLoadedTextbookId] = useState<string | null>(null);
const [selected, setSelected] = useState<string>(""); const [selected, setSelected] = useState<string>("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
// 派生:当前教材的章节是否正在加载
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
// 初始加载教材列表 + URL 参数预选
useEffect(() => {
let cancelled = false;
getTextbooksForPickerAction()
.then((res) => {
if (cancelled) return;
if (res.success && res.data) {
setTextbooks(res.data.textbooks);
// URL 参数预选
const urlTextbookId = searchParams.get("textbookId");
if (urlTextbookId && res.data.textbooks.some((tb) => tb.id === urlTextbookId)) {
setTextbookId(urlTextbookId);
}
}
})
.catch((e) => {
console.error("[TemplatePicker] load textbooks failed", e);
})
.finally(() => {
if (!cancelled) setLoadingTextbooks(false);
});
return () => {
cancelled = true;
};
}, [searchParams]);
// 教材变化时加载章节
useEffect(() => {
if (!textbookId) {
return;
}
let cancelled = false;
getChaptersForPickerAction(textbookId)
.then((res) => {
if (cancelled) return;
if (res.success && res.data) {
setChapters(res.data.chapters);
setLoadedTextbookId(textbookId);
}
})
.catch((e) => {
console.error("[TemplatePicker] load chapters failed", e);
});
return () => {
cancelled = true;
};
}, [textbookId]);
// 扁平化章节列表(用于下拉选择,带缩进前缀)
const flattenedChapters = useMemo(() => {
const result: { id: string; title: string; depth: number }[] = [];
function walk(list: ChapterOption[], 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(chapters, 0);
return result;
}, [chapters]);
// 选中章节时自动填充标题(如果标题为空)
const selectedChapter = useMemo(
() => flattenedChapters.find((c) => c.id === chapterId),
[flattenedChapters, chapterId],
);
const handleChapterChange = useCallback((id: string) => {
setChapterId(id);
// 如果标题为空,用章节标题预填
const ch = flattenedChapters.find((c) => c.id === id);
if (ch && !title) {
setTitle(ch.title);
}
}, [flattenedChapters, title]);
const selectedTextbook = textbooks.find((tb) => tb.id === textbookId);
const canSubmit = !!selected && !!title && !!textbookId && !!chapterId;
async function handleSubmit(formData: FormData) { async function handleSubmit(formData: FormData) {
setError(null); setError(null);
if (!textbookId || !chapterId) {
setError(t("picker.errorTextbookChapterRequired"));
return;
}
formData.set("templateId", selected); formData.set("templateId", selected);
formData.set("title", title); formData.set("title", title);
formData.set("textbookId", textbookId);
formData.set("chapterId", chapterId);
try {
const res = await createLessonPlanAction(null, formData); const res = await createLessonPlanAction(null, formData);
if (res.success && res.data) { if (res.success && res.data) {
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected }); tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
@@ -27,10 +147,92 @@ export function TemplatePicker() {
} else { } else {
setError(res.message ?? t("error.createFailed")); setError(res.message ?? t("error.createFailed"));
} }
} catch (e) {
console.error("[TemplatePicker] create failed", e);
setError(t("error.createFailed"));
}
} }
return ( return (
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6"> <form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
{/* 步骤 1选择教材 */}
<div>
<label className="font-title-md block mb-2 flex items-center gap-2">
<Book className="w-4 h-4" />
{t("picker.textbookLabel")}
<span className="text-error text-sm">*</span>
</label>
{loadingTextbooks ? (
<div className="flex items-center gap-2 text-on-surface-variant text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
{t("picker.loadingTextbooks")}
</div>
) : textbooks.length === 0 ? (
<p className="text-on-surface-variant text-sm">{t("picker.noTextbooks")}</p>
) : (
<select
value={textbookId}
onChange={(e) => {
setTextbookId(e.target.value);
setChapterId("");
}}
required
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
>
<option value="">{t("picker.selectTextbook")}</option>
{textbooks.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.title}
{tb.subject ? ` · ${tb.subject}` : ""}
{tb.grade ? ` · ${tb.grade}` : ""}
</option>
))}
</select>
)}
</div>
{/* 步骤 2选择章节 */}
<div>
<label className="font-title-md block mb-2 flex items-center gap-2">
<FileText className="w-4 h-4" />
{t("picker.chapterLabel")}
<span className="text-error text-sm">*</span>
</label>
{!textbookId ? (
<p className="text-on-surface-variant text-sm">{t("picker.selectTextbookFirst")}</p>
) : loadingChapters ? (
<div className="flex items-center gap-2 text-on-surface-variant text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
{t("picker.loadingChapters")}
</div>
) : flattenedChapters.length === 0 ? (
<p className="text-on-surface-variant text-sm">{t("picker.noChapters")}</p>
) : (
<select
value={chapterId}
onChange={(e) => handleChapterChange(e.target.value)}
required
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
>
<option value="">{t("picker.selectChapter")}</option>
{flattenedChapters.map((ch) => (
<option key={ch.id} value={ch.id}>
{" ".repeat(ch.depth)}
{ch.depth > 0 ? "└ " : ""}
{ch.title}
</option>
))}
</select>
)}
{selectedChapter && (
<p className="text-xs text-on-surface-variant mt-1 flex items-center gap-1">
<ChevronRight className="w-3 h-3" />
{t("picker.selectedChapter", { chapter: selectedChapter.title })}
</p>
)}
</div>
{/* 步骤 3标题 */}
<div> <div>
<label className="font-title-md block mb-2">{t("template.titleLabel")}</label> <label className="font-title-md block mb-2">{t("template.titleLabel")}</label>
<input <input
@@ -41,6 +243,8 @@ export function TemplatePicker() {
placeholder={t("template.titlePlaceholder")} placeholder={t("template.titlePlaceholder")}
/> />
</div> </div>
{/* 步骤 4模板 */}
<div> <div>
<label className="font-title-md block mb-2">{t("template.selectLabel")}</label> <label className="font-title-md block mb-2">{t("template.selectLabel")}</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -64,9 +268,15 @@ export function TemplatePicker() {
</button> </button>
))} ))}
</div> </div>
{selectedTextbook && selectedChapter && (
<p className="text-xs text-on-surface-variant mt-2">
{t("picker.skeletonHint")}
</p>
)}
</div> </div>
{error && <p className="text-error text-sm">{error}</p>} {error && <p className="text-error text-sm">{error}</p>}
<Button type="submit" disabled={!selected || !title}> <Button type="submit" disabled={!canSubmit}>
{t("action.create")} {t("action.create")}
</Button> </Button>
</form> </form>

View File

@@ -48,10 +48,16 @@ export function VersionHistoryDrawer({
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return; if (cancelled) return;
setLoading(true); setLoading(true);
getLessonPlanVersionsAction(planId).then((res) => { getLessonPlanVersionsAction(planId)
.then((res) => {
if (cancelled) return; if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions); if (res.success && res.data) setVersions(res.data.versions);
setLoading(false); })
.catch((e) => {
console.error("[VersionHistoryDrawer] load versions failed", e);
})
.finally(() => {
if (!cancelled) setLoading(false);
}); });
}); });
return () => { return () => {
@@ -60,6 +66,7 @@ export function VersionHistoryDrawer({
}, [open, planId]); }, [open, planId]);
async function handleRevert(versionNo: number) { async function handleRevert(versionNo: number) {
try {
const res = await revertLessonPlanVersionAction({ planId, versionNo }); const res = await revertLessonPlanVersionAction({ planId, versionNo });
if (res.success) { if (res.success) {
tracker.track("lesson_plan.revert", { planId, versionNo }); tracker.track("lesson_plan.revert", { planId, versionNo });
@@ -69,6 +76,10 @@ export function VersionHistoryDrawer({
} else { } else {
toast.error(res.message ?? t("error.revert")); toast.error(res.message ?? t("error.revert"));
} }
} catch (e) {
console.error("[VersionHistoryDrawer] revert failed", e);
toast.error(t("error.revert"));
}
} }
if (!open) return null; if (!open) return null;

View File

@@ -1,13 +1,29 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import type { import type {
BlackboardBlockData,
BlockData,
BlockType, BlockType,
ExerciseBlockData, ExerciseBlockData,
HomeworkBlockData,
ImportBlockData,
KeyPointBlockData,
NewTeachingBlockData,
ObjectiveBlockData,
ReflectionBlockData,
RichTextBlockData, RichTextBlockData,
SummaryBlockData,
TextStudyBlockData, TextStudyBlockData,
} from "../types"; } from "../types";
import { RichTextBlock } from "../components/blocks/rich-text-block"; import { RichTextBlock } from "../components/blocks/rich-text-block";
import { ExerciseBlock } from "../components/blocks/exercise-block"; import { ExerciseBlock } from "../components/blocks/exercise-block";
import { TextStudyBlock } from "../components/blocks/text-study-block"; import { TextStudyBlock } from "../components/blocks/text-study-block";
import { ObjectiveBlock } from "../components/blocks/objective-block";
import { KeyPointBlock } from "../components/blocks/key-point-block";
import { ImportBlock } from "../components/blocks/import-block";
import { NewTeachingBlock } from "../components/blocks/new-teaching-block";
import { SummaryBlock } from "../components/blocks/summary-block";
import { HomeworkBlock } from "../components/blocks/homework-block";
import { BlackboardBlock } from "../components/blocks/blackboard-block";
import { ReflectionBlock } from "../components/blocks/reflection-block"; import { ReflectionBlock } from "../components/blocks/reflection-block";
/** /**
@@ -17,11 +33,11 @@ import { ReflectionBlock } from "../components/blocks/reflection-block";
export interface BlockRenderProps { export interface BlockRenderProps {
blockId: string; blockId: string;
data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData; data: BlockData;
textbookId?: string; textbookId?: string;
chapterId?: string; chapterId?: string;
classes?: { id: string; name: string }[]; classes?: { id: string; name: string }[];
onUpdate: (data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData) => void; onUpdate: (data: BlockData) => void;
} }
export interface BlockRegistryEntry { export interface BlockRegistryEntry {
@@ -29,34 +45,24 @@ export interface BlockRegistryEntry {
isRichText?: boolean; isRichText?: boolean;
} }
const RICH_TEXT_TYPES: BlockType[] = [ const RICH_TEXT_TYPES: BlockType[] = ["rich_text", "consolidation"];
"objective",
"key_point",
"import",
"new_teaching",
"consolidation",
"summary",
"homework",
"blackboard",
"rich_text",
];
/** /**
* Block 注册表元数据(用于查询 isRichText 等属性)。 * Block 注册表元数据(用于查询 isRichText 等属性)。
* 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。 * 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。
*/ */
export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = { export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
objective: { isRichText: true }, objective: {},
key_point: { isRichText: true }, key_point: {},
import: { isRichText: true }, import: {},
new_teaching: { isRichText: true }, new_teaching: {},
consolidation: { isRichText: true }, consolidation: { isRichText: true },
summary: { isRichText: true }, summary: {},
homework: { isRichText: true }, homework: {},
blackboard: { isRichText: true }, blackboard: {},
rich_text: { isRichText: true },
exercise: {},
text_study: {}, text_study: {},
exercise: {},
rich_text: { isRichText: true },
reflection: {}, reflection: {},
}; };
@@ -73,6 +79,66 @@ export function isRichTextBlock(type: BlockType): boolean {
export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null { export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null {
const { type, ...rest } = props; const { type, ...rest } = props;
switch (type) { switch (type) {
case "objective":
return (
<ObjectiveBlock
data={rest.data as ObjectiveBlockData}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "key_point":
return (
<KeyPointBlock
data={rest.data as KeyPointBlockData}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "import":
return (
<ImportBlock
data={rest.data as ImportBlockData}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "new_teaching":
return (
<NewTeachingBlock
data={rest.data as NewTeachingBlockData}
textbookId={rest.textbookId}
chapterId={rest.chapterId}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "summary":
return (
<SummaryBlock
data={rest.data as SummaryBlockData}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "homework":
return (
<HomeworkBlock
data={rest.data as HomeworkBlockData}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "blackboard":
return (
<BlackboardBlock
data={rest.data as BlackboardBlockData}
textbookId={rest.textbookId}
chapterId={rest.chapterId}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "reflection":
return (
<ReflectionBlock
data={rest.data as ReflectionBlockData}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
case "exercise": case "exercise":
return ( return (
<ExerciseBlock <ExerciseBlock
@@ -85,23 +151,14 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
); );
case "text_study": case "text_study":
return <TextStudyBlock blockId={rest.blockId} data={rest.data as TextStudyBlockData} />; return <TextStudyBlock blockId={rest.blockId} data={rest.data as TextStudyBlockData} />;
case "reflection":
return <ReflectionBlock data={rest.data as RichTextBlockData} onUpdate={rest.onUpdate} />;
case "objective":
case "key_point":
case "import":
case "new_teaching":
case "consolidation":
case "summary":
case "homework":
case "blackboard":
case "rich_text": case "rich_text":
case "consolidation":
return ( return (
<RichTextBlock <RichTextBlock
data={rest.data as RichTextBlockData} data={rest.data as RichTextBlockData}
textbookId={rest.textbookId} textbookId={rest.textbookId}
chapterId={rest.chapterId} chapterId={rest.chapterId}
onUpdate={rest.onUpdate} onUpdate={(d) => rest.onUpdate(d)}
/> />
); );
default: default:

View File

@@ -96,7 +96,9 @@ export async function saveAsTemplate(input: {
if (plan.length === 0) throw new LessonPlanDataError("NOT_FOUND"); if (plan.length === 0) throw new LessonPlanDataError("NOT_FOUND");
const doc = normalizeDocument(plan[0].content); const doc = normalizeDocument(plan[0].content);
const skeleton: TemplateBlockSkeleton[] = doc.nodes.map((b) => ({ const skeleton: TemplateBlockSkeleton[] = doc.nodes
.filter((b): b is import("./types").LessonPlanNode => b.type !== "textbook_content")
.map((b) => ({
type: b.type, type: b.type,
title: b.title, title: b.title,
})); }));

View File

@@ -60,14 +60,15 @@ export async function createLessonPlanVersion(input: {
isAuto: boolean; isAuto: boolean;
label?: string; label?: string;
}): Promise<{ versionNo: number }> { }): Promise<{ versionNo: number }> {
// 取当前最大 versionNo // P0 修复max(versionNo)+1 必须在事务内完成,避免并发产生重复版本号
const maxRow = await db return await db.transaction(async (tx) => {
const maxRow = await tx
.select({ maxNo: max(lessonPlanVersions.versionNo) }) .select({ maxNo: max(lessonPlanVersions.versionNo) })
.from(lessonPlanVersions) .from(lessonPlanVersions)
.where(eq(lessonPlanVersions.planId, input.planId)); .where(eq(lessonPlanVersions.planId, input.planId));
const nextNo = (maxRow[0]?.maxNo ?? 0) + 1; const nextNo = (maxRow[0]?.maxNo ?? 0) + 1;
await db.insert(lessonPlanVersions).values({ await tx.insert(lessonPlanVersions).values({
id: createId(), id: createId(),
planId: input.planId, planId: input.planId,
versionNo: nextNo, versionNo: nextNo,
@@ -77,6 +78,7 @@ export async function createLessonPlanVersion(input: {
creatorId: input.userId, creatorId: input.userId,
}); });
return { versionNo: nextNo }; return { versionNo: nextNo };
});
} }
export async function getVersionContent( export async function getVersionContent(
@@ -116,22 +118,33 @@ export async function revertToVersion(
const content = await getVersionContent(planId, versionNo, userId); const content = await getVersionContent(planId, versionNo, userId);
if (!content) return null; if (!content) return null;
// 用该版本 content 覆盖当前 + 生成新版本 // P0 修复update 与 createLessonPlanVersion 必须在同一事务内,
await db // 避免回退后 content 已覆盖但版本记录未生成的不一致状态
return await db.transaction(async (tx) => {
await tx
.update(lessonPlans) .update(lessonPlans)
.set({ content, lastSavedAt: new Date() }) .set({ content, lastSavedAt: new Date() })
.where( .where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)), and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
); );
const { versionNo: newNo } = await createLessonPlanVersion({ const maxRow = await tx
.select({ maxNo: max(lessonPlanVersions.versionNo) })
.from(lessonPlanVersions)
.where(eq(lessonPlanVersions.planId, planId));
const newNo = (maxRow[0]?.maxNo ?? 0) + 1;
await tx.insert(lessonPlanVersions).values({
id: createId(),
planId, planId,
content, versionNo: newNo,
userId,
isAuto: false,
label: `回退到 v${versionNo}`, label: `回退到 v${versionNo}`,
content,
isAuto: false,
creatorId: userId,
}); });
return { newVersionNo: newNo }; return { newVersionNo: newNo };
});
} }
export async function pruneAutoVersions( export async function pruneAutoVersions(

View File

@@ -15,12 +15,15 @@ import {
users, users,
} from "@/shared/db/schema"; } from "@/shared/db/schema";
import type { DataScope } from "@/shared/types/permissions"; import type { DataScope } from "@/shared/types/permissions";
import { escapeLikePattern } from "@/shared/lib/action-utils";
import { SYSTEM_TEMPLATES } from "./constants"; import { SYSTEM_TEMPLATES } from "./constants";
import { import {
migrateV1ToV2, migrateV1ToV2,
normalizeDocument, normalizeDocument,
buildInitialContent, buildInitialContent,
buildDefaultSkeleton,
} from "./lib/document-migration"; } from "./lib/document-migration";
import { getChaptersByTextbookId, getTextbooks } from "@/modules/textbooks/data-access";
import type { import type {
LessonPlan, LessonPlan,
LessonPlanDocument, LessonPlanDocument,
@@ -32,7 +35,7 @@ import type {
} from "./types"; } from "./types";
// re-export 纯函数保持向后兼容 // re-export 纯函数保持向后兼容
export { migrateV1ToV2, normalizeDocument, buildInitialContent }; export { migrateV1ToV2, normalizeDocument, buildInitialContent, buildDefaultSkeleton };
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)---- // ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
export class LessonPlanDataError extends Error { export class LessonPlanDataError extends Error {
@@ -220,7 +223,7 @@ export const getLessonPlans = cache(
conditions.push(...buildScopeCondition(scope, userId)); conditions.push(...buildScopeCondition(scope, userId));
if (params.query) { if (params.query) {
conditions.push(like(lessonPlans.title, `%${params.query}%`)); conditions.push(like(lessonPlans.title, `%${escapeLikePattern(params.query)}%`));
} }
if (params.textbookId) if (params.textbookId)
conditions.push(eq(lessonPlans.textbookId, params.textbookId)); conditions.push(eq(lessonPlans.textbookId, params.textbookId));
@@ -285,6 +288,7 @@ export const getLessonPlanById = cache(
// ---- 创建 ---- // ---- 创建 ----
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本 // V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
// V3 升级:当提供 chapterId 时,使用 buildDefaultSkeleton 生成完整 v3 文档(含正文节点)
export async function createLessonPlan(input: { export async function createLessonPlan(input: {
title: string; title: string;
textbookId?: string; textbookId?: string;
@@ -299,14 +303,28 @@ export async function createLessonPlan(input: {
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND"); if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
const planId = createId(); const planId = createId();
// 如果提供了翻译函数,将模板中的 i18n 键翻译为实际文本
// V3如果提供了 chapterId拉取章节正文并生成默认骨架
let content: LessonPlanDocument;
if (input.chapterId && input.textbookId) {
const chapters = await getChaptersByTextbookId(input.textbookId);
const chapter = findChapterById(chapters, input.chapterId);
const chapterContent = chapter?.content ?? "";
content = buildDefaultSkeleton(
input.chapterId,
chapterContent,
input.translateTitle,
);
} else {
// 无章节使用模板生成v3 格式,正文节点内容为空)
const blocks = input.translateTitle const blocks = input.translateTitle
? template.blocks.map((b) => ({ ? template.blocks.map((b) => ({
...b, ...b,
title: input.translateTitle!(b.title), title: input.translateTitle!(b.title),
})) }))
: template.blocks; : template.blocks;
const content = buildInitialContent(blocks); content = buildInitialContent(blocks);
}
await db.insert(lessonPlans).values({ await db.insert(lessonPlans).values({
id: planId, id: planId,
@@ -326,13 +344,65 @@ export async function createLessonPlan(input: {
return { planId }; return { planId };
} }
// ---- 工具:在章节树中查找章节(含子章节)----
function findChapterById(
chapters: Awaited<ReturnType<typeof getChaptersByTextbookId>>,
chapterId: string,
): { content?: string | null } | null {
for (const ch of chapters) {
if (ch.id === chapterId) return ch;
if (ch.children && ch.children.length > 0) {
const found = findChapterById(ch.children, chapterId);
if (found) return found;
}
}
return null;
}
// ---- 获取教材列表(供 picker 使用)----
export async function getTextbooksForPicker(): Promise<
{ id: string; title: string; subject: string; grade: string | null }[]
> {
const textbooks = await getTextbooks();
return textbooks.map((t) => ({
id: t.id,
title: t.title,
subject: t.subject,
grade: t.grade,
}));
}
// ---- 获取章节列表(供 picker 使用)----
export async function getChaptersForPicker(
textbookId: string,
): Promise<
{
id: string;
title: string;
parentId: string | null;
order: number | null;
content?: string | null;
children?: unknown[];
}[]
> {
const chapters = await getChaptersByTextbookId(textbookId);
return chapters.map((c) => ({
id: c.id,
title: c.title,
parentId: c.parentId,
order: c.order,
content: c.content,
children: c.children,
}));
}
// ---- 更新 content自动保存不生成版本---- // ---- 更新 content自动保存不生成版本----
export async function updateLessonPlanContent( export async function updateLessonPlanContent(
planId: string, planId: string,
userId: string, userId: string,
patch: { title?: string; content: LessonPlanDocument }, patch: { title?: string; content: LessonPlanDocument },
): Promise<void> { ): Promise<void> {
await db const result = await db
.update(lessonPlans) .update(lessonPlans)
.set({ .set({
...(patch.title ? { title: patch.title } : {}), ...(patch.title ? { title: patch.title } : {}),
@@ -342,6 +412,11 @@ export async function updateLessonPlanContent(
.where( .where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)), and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
); );
// MySQL 返回 [ResultSetHeader, FieldPacket[]],通过 affectedRows 判断是否更新成功
if (result[0].affectedRows === 0) {
throw new LessonPlanDataError("NOT_FOUND");
}
} }
// ---- 软删除 ---- // ---- 软删除 ----
@@ -349,12 +424,17 @@ export async function softDeleteLessonPlan(
planId: string, planId: string,
userId: string, userId: string,
): Promise<void> { ): Promise<void> {
await db const result = await db
.update(lessonPlans) .update(lessonPlans)
.set({ status: "archived" }) .set({ status: "archived" })
.where( .where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)), and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
); );
// MySQL 返回 [ResultSetHeader, FieldPacket[]],通过 affectedRows 判断是否更新成功
if (result[0].affectedRows === 0) {
throw new LessonPlanDataError("NOT_FOUND");
}
} }
// ---- 复制 ---- // ---- 复制 ----

View File

@@ -3,12 +3,19 @@
import { create } from "zustand"; import { create } from "zustand";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import type { import type {
AnchorEdge,
AnchorType,
AnyLessonPlanEdge,
Block, Block,
BlockType, BlockType,
FlowEdge,
LessonPlanDocument, LessonPlanDocument,
LessonPlanEdge,
LessonPlanNode, LessonPlanNode,
NodeAnchor,
TextbookContentNode,
TextbookContentNodeData,
} from "../types"; } from "../types";
import { defaultDataForType } from "../lib/document-migration";
interface EditorState { interface EditorState {
planId: string; planId: string;
@@ -28,9 +35,25 @@ interface EditorState {
updateNodePosition: (id: string, position: { x: number; y: number }) => void; updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void; removeNode: (id: string) => void;
// 正文节点操作
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
getTextbookContentNode: () => TextbookContentNode | undefined;
// 锚点操作
addAnchor: (params: {
nodeId: string;
type: AnchorType;
start: number;
end?: number;
textPreview?: string;
}) => string;
removeAnchor: (anchorId: string) => void;
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
// 连线
connect: (source: string, target: string) => void; connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void; disconnect: (edgeId: string) => void;
setEdges: (edges: LessonPlanEdge[]) => void; setEdges: (edges: AnyLessonPlanEdge[]) => void;
selectNode: (id: string | null) => void; selectNode: (id: string | null) => void;
@@ -43,18 +66,16 @@ function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
return nodes.map((n, i) => ({ ...n, order: i })); return nodes.map((n, i) => ({ ...n, order: i }));
} }
function defaultData(type: BlockType): Block["data"] {
return type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] };
}
export const useLessonPlanEditor = create<EditorState>((set, get) => ({ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
planId: "", planId: "",
title: "", title: "",
doc: { version: 2, nodes: [], edges: [] }, doc: {
version: 3,
textbookContentNodeId: "",
nodes: [],
edges: [],
anchors: [],
},
isDirty: false, isDirty: false,
isSaving: false, isSaving: false,
lastSavedAt: null, lastSavedAt: null,
@@ -77,12 +98,17 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
addNode: (type, position, title) => { addNode: (type, position, title) => {
const id = createId(); const id = createId();
const nodeCount = get().doc.nodes.length; const state = get();
// 教学节点 order 从 0 开始(正文节点 order=-1 不计入)
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
const nodeCount = teachingNodes.length;
const node: LessonPlanNode = { const node: LessonPlanNode = {
id, id,
type, type,
title: title ?? type, // 调用方应传入翻译后的标题fallback 为 type 键 title: title ?? type,
data: defaultData(type), data: defaultDataForType(type),
order: nodeCount, order: nodeCount,
position: position ?? { position: position ?? {
x: 80 + (nodeCount % 4) * 280, x: 80 + (nodeCount % 4) * 280,
@@ -102,37 +128,139 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
doc: { doc: {
...s.doc, ...s.doc,
nodes: s.doc.nodes.map((n) => nodes: s.doc.nodes.map((n) =>
n.id === id ? { ...n, ...patch } : n, n.id === id
? n.type === "textbook_content"
? { ...n, ...patch } as TextbookContentNode
: { ...n, ...patch } as LessonPlanNode
: n,
), ),
}, },
isDirty: true, isDirty: true,
})), })),
// 实时拖动:每次调用立即更新位置(不再等待 dragging=false
updateNodePosition: (id, position) => updateNodePosition: (id, position) =>
set((s) => ({ set((s) => ({
doc: { doc: {
...s.doc, ...s.doc,
nodes: s.doc.nodes.map((n) => nodes: s.doc.nodes.map((n) =>
n.id === id ? { ...n, position } : n, n.id === id
? n.type === "textbook_content"
? { ...n, position } as TextbookContentNode
: { ...n, position } as LessonPlanNode
: n,
), ),
}, },
isDirty: true, isDirty: true,
})), })),
removeNode: (id) => removeNode: (id) =>
set((s) => ({ set((s) => {
const remainingTeachingNodes = reindex(
s.doc.nodes.filter(
(n): n is LessonPlanNode => n.id !== id && n.type !== "textbook_content",
),
);
const textbookNode = s.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
const nodes = textbookNode ? [textbookNode, ...remainingTeachingNodes] : remainingTeachingNodes;
return {
doc: { doc: {
...s.doc, ...s.doc,
nodes: reindex(s.doc.nodes.filter((n) => n.id !== id)), nodes,
edges: s.doc.edges.filter( edges: s.doc.edges.filter(
(e) => e.source !== id && e.target !== id, (e) => e.source !== id && e.target !== id,
), ),
// 同时移除关联的锚点
anchors: s.doc.anchors.filter((a) => a.nodeId !== id),
}, },
isDirty: true, isDirty: true,
selectedNodeId: selectedNodeId:
s.selectedNodeId === id ? null : s.selectedNodeId, s.selectedNodeId === id ? null : s.selectedNodeId,
};
}),
// ---- 正文节点操作 ----
updateTextbookContent: (data) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
? { ...n, data: { ...n.data, ...data } }
: n,
),
},
isDirty: true,
})), })),
getTextbookContentNode: () => {
const state = get();
return state.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
},
// ---- 锚点操作 ----
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
const anchorId = createId();
const state = get();
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
id: anchorId,
nodeId,
type,
start,
...(end !== undefined ? { end } : {}),
...(textPreview ? { textPreview } : {}),
};
const edge: AnchorEdge = {
id: `ae_${nodeId}_${textbookNodeId}_${anchorId.slice(0, 6)}`,
source: nodeId,
target: textbookNodeId,
type: "anchor",
anchorId,
};
set((s) => ({
doc: {
...s.doc,
anchors: [...s.doc.anchors, anchor],
edges: [...s.doc.edges, edge],
},
isDirty: true,
}));
return anchorId;
},
removeAnchor: (anchorId) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.filter((a) => a.id !== anchorId),
edges: s.doc.edges.filter(
(e) => !(e.type === "anchor" && e.anchorId === anchorId),
),
},
isDirty: true,
})),
updateAnchor: (anchorId, patch) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.map((a) =>
a.id === anchorId ? { ...a, ...patch } : a,
),
},
isDirty: true,
})),
// ---- 连线 ----
connect: (source, target) => connect: (source, target) =>
set((s) => { set((s) => {
// 避免重复连线 // 避免重复连线
@@ -142,10 +270,11 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
) )
) )
return s; return s;
const edge: LessonPlanEdge = { const edge: FlowEdge = {
id: `e_${source}_${target}_${createId().slice(0, 6)}`, id: `e_${source}_${target}_${createId().slice(0, 6)}`,
source, source,
target, target,
type: "flow",
}; };
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true }; return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
}), }),

View File

@@ -0,0 +1,304 @@
import type { NodeAnchor } from "../types";
/**
* 锚点注入算法:将锚点信息注入到 Markdown 文本中,生成带标记的纯文本。
*
* 策略:
* - 由于 Markdown 渲染后是 HTML纯文本偏移量无法直接对应 DOM 节点
* - 简化方案:将 Markdown 视为纯文本(去除 markdown 语法符号),在纯文本上做偏移注入
* - 注入特殊标记符号(如 ①②③ 或 [anchor:id]),由 ReactMarkdown 的 components 自定义渲染
*
* 对于 range 锚定:在 [start, end] 范围包裹 [[anchor:id]]...[[/anchor]] 标记
* 对于 point 锚定:在 start 位置插入 [[point:id]] 标记
*
* 渲染时由 textbook-content-node.tsx 的 components 自定义解析这些标记。
*/
// 标记格式:[[anchor:id]]range text[[/anchor]] 或 [[point:id]]
const ANCHOR_RANGE_START = (id: string) => `[[anchor:${id}]]`;
const ANCHOR_RANGE_END = `[[/anchor]]`;
const ANCHOR_POINT = (id: string) => `[[point:${id}]]`;
/**
* 将 Markdown 文本简化为纯文本(去除常见 markdown 语法符号)。
* 仅用于锚点偏移计算,不影响实际渲染。
*/
export function markdownToPlainText(markdown: string): string {
return markdown
// 去除标题标记
.replace(/^#{1,6}\s+/gm, "")
// 去除强调符号
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/__(.+?)__/g, "$1")
.replace(/_(.+?)_/g, "$1")
// 去除链接,保留文本
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
// 去除图片
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
// 去除代码块
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
// 去除引用标记
.replace(/^>\s+/gm, "")
// 去除列表标记
.replace(/^[\s]*[-*+]\s+/gm, "")
.replace(/^[\s]*\d+\.\s+/gm, "")
// 去除水平分割线
.replace(/^---+$/gm, "")
// 去除 HTML 标签
.replace(/<[^>]+>/g, "");
}
/**
* 在 Markdown 文本中注入锚点标记。
*
* 注意偏移量基于纯文本markdownToPlainText 的输出)。
* 由于 Markdown 语法符号的存在,纯文本偏移与 Markdown 原文偏移不一致。
* 此函数通过构建偏移映射,将纯文本偏移转换为 Markdown 原文偏移。
*
* @param markdown 原始 Markdown 文本
* @param anchors 锚点列表
* @returns 注入标记后的 Markdown 文本
*/
export function injectPlaceholders(
markdown: string,
anchors: NodeAnchor[],
): string {
if (anchors.length === 0) return markdown;
// 构建偏移映射plainText[i] → markdown 原文位置
const { plainToMd } = buildOffsetMap(markdown);
// 过滤失效锚点,按 markdown 偏移排序(倒序注入,避免偏移变化)
const validAnchors = anchors
.filter((a) => !a.invalid && a.start >= 0)
.map((a) => {
const mdStart = plainToMd.get(a.start) ?? a.start;
const mdEnd = a.end !== undefined
? plainToMd.get(a.end) ?? a.end
: undefined;
return { ...a, mdStart, mdEnd };
})
.sort((a, b) => b.mdStart - a.mdStart);
let result = markdown;
for (const anchor of validAnchors) {
if (anchor.type === "range" && anchor.mdEnd !== undefined && anchor.mdEnd > anchor.mdStart) {
// 范围锚定:包裹标记
const before = result.slice(0, anchor.mdStart);
const middle = result.slice(anchor.mdStart, anchor.mdEnd);
const after = result.slice(anchor.mdEnd);
result = before + ANCHOR_RANGE_START(anchor.id) + middle + ANCHOR_RANGE_END + after;
} else if (anchor.type === "point") {
// 点锚定:插入标记
const before = result.slice(0, anchor.mdStart);
const after = result.slice(anchor.mdStart);
result = before + ANCHOR_POINT(anchor.id) + after;
}
}
return result;
}
/**
* 构建 Markdown → 纯文本的偏移映射。
* 返回 plainToMd纯文本位置 → Markdown 原文位置)。
*/
function buildOffsetMap(markdown: string): {
plainToMd: Map<number, number>;
} {
const plainToMd = new Map<number, number>();
let mdIdx = 0;
let plainIdx = 0;
// 简化映射:逐字符遍历 Markdown跳过被去除的字符
// 这里采用与 markdownToPlainText 一致的简化逻辑
const skipPatterns: RegExp[] = [
/^#{1,6}\s+/m,
/^\s*[-*+]\s+/m,
/^\s*\d+\.\s+/m,
/^\s*>\s+/m,
/^---+$/m,
];
while (mdIdx < markdown.length) {
// 检查是否处于需要跳过的模式
let skipped = false;
for (const pattern of skipPatterns) {
const rest = markdown.slice(mdIdx);
const match = rest.match(pattern);
if (match && match.index === 0) {
mdIdx += match[0].length;
skipped = true;
break;
}
}
if (skipped) continue;
// 处理行内标记(**、*、`、_
const ch = markdown[mdIdx];
if (ch === "*" || ch === "_" || ch === "`") {
// 跳过成对的标记符号
if (markdown[mdIdx + 1] === ch) {
mdIdx += 2; // 跳过 ** 或 __ 或 ``
continue;
}
mdIdx += 1; // 跳过单个 * 或 _ 或 `
continue;
}
// 处理 HTML 标签
if (ch === "<") {
const closeIdx = markdown.indexOf(">", mdIdx);
if (closeIdx !== -1) {
mdIdx = closeIdx + 1;
continue;
}
}
// 处理链接 [text](url)
if (ch === "[") {
const closeBracket = markdown.indexOf("]", mdIdx);
if (closeBracket !== -1 && markdown[closeBracket + 1] === "(") {
const closeParen = markdown.indexOf(")", closeBracket + 2);
if (closeParen !== -1) {
// 链接文本部分映射到纯文本
const linkText = markdown.slice(mdIdx + 1, closeBracket);
for (const _ of linkText) {
plainToMd.set(plainIdx, mdIdx + 1 + plainIdx);
plainIdx++;
}
mdIdx = closeParen + 1;
continue;
}
}
}
// 普通字符:建立映射
plainToMd.set(plainIdx, mdIdx);
plainIdx++;
mdIdx++;
}
return { plainToMd };
}
/**
* 解析注入标记后的文本,提取锚点段。
* 用于 ReactMarkdown 自定义渲染。
*/
export interface ParsedSegment {
type: "text" | "anchor-range" | "anchor-point";
content: string;
anchorId?: string;
}
export function parseAnchoredText(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// 匹配 [[anchor:id]]...[[/anchor]] 或 [[point:id]]
const pattern = /\[\[(anchor|point):([^\]]+)\]\](?:([\s\S]*?)\[\[\/anchor\]\])?/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
// 前面的普通文本
if (match.index > lastIndex) {
segments.push({
type: "text",
content: text.slice(lastIndex, match.index),
});
}
if (match[1] === "anchor") {
segments.push({
type: "anchor-range",
content: match[3] ?? "",
anchorId: match[2],
});
} else {
// point
segments.push({
type: "anchor-point",
content: "",
anchorId: match[2],
});
}
lastIndex = pattern.lastIndex;
}
// 剩余文本
if (lastIndex < text.length) {
segments.push({
type: "text",
content: text.slice(lastIndex),
});
}
return segments;
}
/**
* 根据锚点 ID 获取对应的节点颜色。
*/
export function getAnchorColor(
anchorId: string,
anchors: NodeAnchor[],
getNodeColorFn: (nodeId: string) => string,
): string {
const anchor = anchors.find((a) => a.id === anchorId);
if (!anchor) return "#9e9e9e";
return getNodeColorFn(anchor.nodeId);
}
/**
* 生成下一个点锚定的序号(①②③...)。
* 使用 anchors 中 point 类型的数量 + 1。
*/
export function getNextPointIndex(anchors: NodeAnchor[]): number {
const pointCount = anchors.filter((a) => a.type === "point").length;
return pointCount + 1;
}
/**
* 将数字转换为带圈数字(①②③...⑨⑩等)。
* 超过 20 时回退为 [1] [2] 格式。
*/
export function toCircledNumber(n: number): string {
if (n < 1) return "";
if (n > 20) return `[${n}]`;
// Unicode 带圈数字 ①=U+2460
return String.fromCharCode(0x2460 + n - 1);
}
/**
* 正文内容变更后,尝试用 textPreview 重新定位锚点。
* 无法定位的标记为 invalid。
*/
export function relocateAnchors(
anchors: NodeAnchor[],
newPlainText: string,
): NodeAnchor[] {
return anchors.map((anchor) => {
if (anchor.type === "range" && anchor.textPreview) {
const newStart = newPlainText.indexOf(anchor.textPreview);
if (newStart >= 0) {
return {
...anchor,
start: newStart,
end: newStart + anchor.textPreview.length,
invalid: false,
};
}
}
// point 锚定无法自动重定位(位置语义已变)
if (anchor.type === "point") {
// 如果 start 仍在范围内,保留
if (anchor.start >= 0 && anchor.start <= newPlainText.length) {
return { ...anchor, invalid: false };
}
}
return { ...anchor, invalid: true };
});
}

View File

@@ -1,10 +1,19 @@
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import type { import type {
AnchorEdge,
AnyLessonPlanEdge,
AnyLessonPlanNode,
BlockData,
BlockType,
FlowEdge,
LessonPlanDocument, LessonPlanDocument,
LessonPlanDocumentV1, LessonPlanDocumentV1,
LessonPlanDocumentV2,
LessonPlanEdge, LessonPlanEdge,
LessonPlanNode, LessonPlanNode,
NodeAnchor,
TemplateBlockSkeleton, TemplateBlockSkeleton,
TextbookContentNode,
} from "../types"; } from "../types";
/** /**
@@ -12,8 +21,39 @@ import type {
* 从 data-access.ts 抽取,便于单元测试。 * 从 data-access.ts 抽取,便于单元测试。
*/ */
// ---- 默认数据生成器:为每种 BlockType 提供初始 data ----
export function defaultDataForType(type: BlockType): BlockData {
switch (type) {
case "objective":
return { objectives: [] };
case "key_point":
return { keyPoints: [] };
case "import":
return { method: "question", prompt: "", durationMin: 5 };
case "new_teaching":
return { teachingPoints: [] };
case "summary":
return { summaryPoints: [], homeworkPreview: "" };
case "homework":
return { assignments: [] };
case "blackboard":
return { layout: "text", content: "", knowledgePointIds: [] };
case "reflection":
return { reflection: [] };
case "exercise":
return { items: [], purpose: "class_practice", knowledgePointIds: [] };
case "text_study":
return { sourceText: "", annotations: [], knowledgePointIds: [] };
case "rich_text":
case "consolidation":
default:
// consolidation 暂复用富文本结构(向后兼容旧模板)
return { html: "", knowledgePointIds: [] };
}
}
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ---- // ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument { export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocumentV2 {
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({ const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
...b, ...b,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 }, position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
@@ -29,8 +69,63 @@ export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
return { version: 2, nodes, edges }; return { version: 2, nodes, edges };
} }
// ---- v2 → v3 迁移:注入正文节点 + 锚点数组 + 边类型 ----
export function migrateV2ToV3(
doc: LessonPlanDocumentV2,
chapterId?: string | null,
chapterContent?: string | null,
): LessonPlanDocument {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: {
chapterId: chapterId ?? "",
content: chapterContent ?? "",
zoom: 1,
},
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
// 旧 edges 转为 flow 类型
const flowEdges: FlowEdge[] = doc.edges.map((e) => ({
...e,
type: "flow" as const,
}));
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode, ...doc.nodes],
edges: flowEdges,
anchors: [],
};
}
// ---- 类型守卫:判断是否为 v3 文档 ----
function isV3Document(content: unknown): content is LessonPlanDocument {
if (!content || typeof content !== "object") return false;
const c = content as {
version?: unknown;
textbookContentNodeId?: unknown;
nodes?: unknown;
edges?: unknown;
anchors?: unknown;
};
return (
c.version === 3 &&
typeof c.textbookContentNodeId === "string" &&
Array.isArray(c.nodes) &&
Array.isArray(c.edges) &&
Array.isArray(c.anchors)
);
}
// ---- 类型守卫:判断是否为 v2 文档 ---- // ---- 类型守卫:判断是否为 v2 文档 ----
function isV2Document(content: unknown): content is LessonPlanDocument { function isV2Document(content: unknown): content is LessonPlanDocumentV2 {
if (!content || typeof content !== "object") return false; if (!content || typeof content !== "object") return false;
const c = content as { version?: unknown; nodes?: unknown; edges?: unknown }; const c = content as { version?: unknown; nodes?: unknown; edges?: unknown };
return ( return (
@@ -47,40 +142,188 @@ function isV1Document(content: unknown): content is LessonPlanDocumentV1 {
return c.version === 1 && Array.isArray(c.blocks); return c.version === 1 && Array.isArray(c.blocks);
} }
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)---- // ---- 规范化:确保 content 是 v3 格式(兼容 v1/v2 旧数据)----
export function normalizeDocument( export function normalizeDocument(
content: unknown, content: unknown,
chapterId?: string | null,
chapterContent?: string | null,
): LessonPlanDocument { ): LessonPlanDocument {
if (isV2Document(content)) return content; if (isV3Document(content)) return content;
if (isV1Document(content)) return migrateV1ToV2(content); if (isV2Document(content)) {
// 空文档 return migrateV2ToV3(content, chapterId, chapterContent);
return { version: 2, nodes: [], edges: [] }; }
if (isV1Document(content)) {
return migrateV2ToV3(migrateV1ToV2(content), chapterId, chapterContent);
}
// 空文档:创建一个无正文的 v3
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: { chapterId: chapterId ?? "", content: chapterContent ?? "", zoom: 1 },
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode],
edges: [],
anchors: [],
};
} }
// ---- 模板初始化:根据骨架生成初始 contentv2---- // ---- 模板初始化:根据骨架生成初始 contentv3无正文节点----
// 注意:此函数不创建正文节点,调用方应使用 buildDefaultSkeleton 创建完整 v3 文档
export function buildInitialContent( export function buildInitialContent(
blocks: TemplateBlockSkeleton[], blocks: TemplateBlockSkeleton[],
): LessonPlanDocument { ): LessonPlanDocument {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: { chapterId: "", content: "", zoom: 1 },
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({ const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
id: createId(), id: createId(),
type: b.type, type: b.type,
title: b.title, title: b.title,
data: data: defaultDataForType(b.type),
b.type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: b.type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] },
order: i, order: i,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 }, position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
})); }));
const edges: LessonPlanEdge[] = [];
const edges: FlowEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) { for (let i = 0; i < nodes.length - 1; i++) {
edges.push({ edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`, id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id, source: nodes[i].id,
target: nodes[i + 1].id, target: nodes[i + 1].id,
type: "flow",
}); });
} }
return { version: 2, nodes, edges };
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode, ...nodes],
edges,
anchors: [],
};
}
// ---- 默认骨架10 节点 + 1 正文节点(创建课案时使用)----
export function buildDefaultSkeleton(
chapterId: string,
chapterContent: string,
translateTitle?: (key: string) => string,
): LessonPlanDocument {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: { chapterId, content: chapterContent, zoom: 1 },
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
// 默认 10 节点骨架(标题使用 i18n 键 blockType.${type}
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: "homework", position: { x: 80, y: 560 } },
{ type: "blackboard", position: { x: 720, y: 440 } },
{ type: "reflection", position: { x: 720, y: 560 } },
];
const nodes: LessonPlanNode[] = skeleton.map((s, i) => ({
id: createId(),
type: s.type,
title: translateTitle
? translateTitle(`blockType.${s.type}`)
: `blockType.${s.type}`,
data: defaultDataForType(s.type),
order: i,
position: s.position,
}));
// 默认流程连线:导入→文本研习→新授→练习→小结
const flowPairs: [number, number][] = [
[2, 3], // import → text_study
[3, 4], // text_study → new_teaching
[4, 5], // new_teaching → exercise
[5, 6], // exercise → summary
];
const edges: FlowEdge[] = flowPairs.map(([from, to]) => ({
id: `e_${nodes[from].id}_${nodes[to].id}`,
source: nodes[from].id,
target: nodes[to].id,
type: "flow",
}));
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode, ...nodes],
edges,
anchors: [],
};
}
// ---- 工具函数:判断节点是否为正文节点 ----
export function isTextbookContentNode(
node: AnyLessonPlanNode,
): node is TextbookContentNode {
return node.type === "textbook_content";
}
// ---- 工具函数:判断边是否为锚点边 ----
export function isAnchorEdge(
edge: AnyLessonPlanEdge,
): edge is AnchorEdge {
return edge.type === "anchor";
}
// ---- 工具函数:获取节点的关联锚点 ----
export function getAnchorsForNode(
anchors: NodeAnchor[],
nodeId: string,
): NodeAnchor[] {
return anchors.filter((a) => a.nodeId === nodeId);
}
// ---- 工具函数:根据选中节点获取激活的锚点 ID 集合 ----
export function getActiveAnchorIds(
anchors: NodeAnchor[],
selectedNodeId: string | null,
): Set<string> {
if (!selectedNodeId) return new Set();
return new Set(
anchors.filter((a) => a.nodeId === selectedNodeId).map((a) => a.id),
);
}
// ---- 工具函数:根据锚点 ID 获取对应的边 ----
export function getAnchorEdges(
edges: AnyLessonPlanEdge[],
anchorIds: Set<string>,
): AnchorEdge[] {
return edges.filter(
(e): e is AnchorEdge =>
e.type === "anchor" && anchorIds.has(e.anchorId),
);
} }

View File

@@ -1,4 +1,4 @@
import type { LessonPlanNode } from "../types"; import type { LessonPlanNode, TextbookContentNode } from "../types";
/** /**
* 节点摘要翻译函数接口。 * 节点摘要翻译函数接口。
@@ -6,7 +6,17 @@ import type { LessonPlanNode } from "../types";
* values 类型对齐 next-intl 的 TranslationValuesstring | number | Date * values 类型对齐 next-intl 的 TranslationValuesstring | number | Date
*/ */
export interface NodeSummaryT { export interface NodeSummaryT {
(key: "editor.questionCount" | "editor.charCount" | "editor.nodeSummaryEmpty", values?: Record<string, string | number | Date>): string; (
key:
| "editor.questionCount"
| "editor.charCount"
| "editor.nodeSummaryEmpty"
| "editor.itemCount"
| "editor.pointCount"
| "editor.assignmentCount"
| "editor.durationMin",
values?: Record<string, string | number | Date>,
): string;
} }
/** /**
@@ -16,17 +26,69 @@ export interface NodeSummaryT {
*/ */
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string { export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
const data = node.data as { const data = node.data as {
// 富文本类
html?: string; html?: string;
// 文本研习
sourceText?: string; sourceText?: string;
annotations?: unknown[];
// 练习
items?: unknown[]; items?: unknown[];
// 教学目标
objectives?: unknown[];
// 重难点
keyPoints?: unknown[];
// 导入
durationMin?: number;
prompt?: string;
// 新授
teachingPoints?: unknown[];
// 小结
summaryPoints?: unknown[];
// 作业
assignments?: unknown[];
// 板书
content?: string;
// 反思
reflection?: unknown[];
// 知识点
knowledgePointIds?: string[]; knowledgePointIds?: string[];
}; };
// 按类型优先级提取摘要
if (data.items !== undefined) { if (data.items !== undefined) {
return t("editor.questionCount", { count: data.items.length }); return t("editor.questionCount", { count: data.items.length });
} }
if (data.objectives !== undefined) {
return t("editor.itemCount", { count: data.objectives.length });
}
if (data.keyPoints !== undefined) {
return t("editor.itemCount", { count: data.keyPoints.length });
}
if (data.teachingPoints !== undefined) {
return t("editor.pointCount", { count: data.teachingPoints.length });
}
if (data.summaryPoints !== undefined) {
return t("editor.itemCount", { count: data.summaryPoints.length });
}
if (data.assignments !== undefined) {
return t("editor.assignmentCount", { count: data.assignments.length });
}
if (data.reflection !== undefined) {
return t("editor.itemCount", { count: data.reflection.length });
}
if (data.durationMin !== undefined) {
return t("editor.durationMin", { count: data.durationMin });
}
if (data.annotations !== undefined && data.sourceText !== undefined) {
return t("editor.charCount", { count: data.sourceText.length });
}
if (data.sourceText !== undefined && data.sourceText) { if (data.sourceText !== undefined && data.sourceText) {
return t("editor.charCount", { count: data.sourceText.length }); return t("editor.charCount", { count: data.sourceText.length });
} }
if (data.content !== undefined && data.content) {
const text = data.content.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
}
if (data.html) { if (data.html) {
// 去标签后取前 40 字 // 去标签后取前 40 字
const text = data.html.replace(/<[^>]+>/g, "").trim(); const text = data.html.replace(/<[^>]+>/g, "").trim();
@@ -35,6 +97,17 @@ export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
return t("editor.nodeSummaryEmpty"); return t("editor.nodeSummaryEmpty");
} }
/**
* 纯函数:获取正文节点摘要。
*/
export function getTextbookContentSummary(
node: TextbookContentNode,
t: NodeSummaryT,
): string {
if (!node.data.content) return t("editor.nodeSummaryEmpty");
return t("editor.charCount", { count: node.data.content.length });
}
/** /**
* 节点类型 → 图标颜色Material Design 色板)。 * 节点类型 → 图标颜色Material Design 色板)。
* 供 lesson-node 和 minimap 复用。 * 供 lesson-node 和 minimap 复用。
@@ -52,6 +125,7 @@ export const NODE_COLORS: Record<string, string> = {
exercise: "#e91e63", exercise: "#e91e63",
rich_text: "#9e9e9e", rich_text: "#9e9e9e",
reflection: "#cddc39", reflection: "#cddc39",
textbook_content: "#455a64",
}; };
export function getNodeColor(type: string): string { export function getNodeColor(type: string): string {

View File

@@ -1,30 +1,113 @@
import type { Node, Edge } from "@xyflow/react"; import type { Node, Edge } from "@xyflow/react";
import type { LessonPlanNode, LessonPlanEdge } from "../types"; import type {
AnyLessonPlanEdge,
AnyLessonPlanNode,
LessonPlanNode,
NodeAnchor,
TextbookContentNode,
} from "../types";
/** /**
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。 * 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
* 从 node-editor.tsx 抽取,便于单元测试。 * 从 node-editor.tsx 抽取,便于单元测试。
*
* v3 升级:
* - 区分教学节点type="lesson"和正文节点type="textbook_content"
* - 正文节点传入 anchors/selectedNodeId/onAddAnchor 等回调
* - 边区分 anchor/flow 类型,应用不同透明度
*/ */
export function toRfNodes( export interface ToRfNodesContext {
nodes: LessonPlanNode[], anchors: NodeAnchor[];
selectedNodeId: string | null, selectedNodeId: string | null;
): Node[] { onAddRangeAnchor?: (params: {
return nodes.map((n) => ({ nodeId: string;
id: n.id, start: number;
type: "lesson", end: number;
position: n.position, textPreview: string;
data: { node: n } as Record<string, unknown>, }) => void;
selected: n.id === selectedNodeId, onAddPointAnchor?: (params: {
})); nodeId: string;
start: number;
}) => void;
onSelectNode?: (id: string | null) => void;
onZoomChange?: (zoom: number) => void;
} }
export function toRfEdges(edges: LessonPlanEdge[]): Edge[] { export function toRfNodes(
return edges.map((e) => ({ nodes: AnyLessonPlanNode[],
selectedNodeId: string | null,
ctx?: ToRfNodesContext,
): Node[] {
return nodes.map((n) => {
// 正文节点
if (n.type === "textbook_content") {
const tbNode = n as TextbookContentNode;
return {
id: tbNode.id,
type: "textbook_content",
position: tbNode.position,
data: {
node: tbNode,
anchors: ctx?.anchors ?? [],
selectedNodeId,
onAddRangeAnchor: ctx?.onAddRangeAnchor,
onAddPointAnchor: ctx?.onAddPointAnchor,
onSelectNode: ctx?.onSelectNode,
onZoomChange: ctx?.onZoomChange,
} as Record<string, unknown>,
selected: tbNode.id === selectedNodeId,
draggable: false,
};
}
// 教学节点
const lessonNode = n as LessonPlanNode;
return {
id: lessonNode.id,
type: "lesson",
position: lessonNode.position,
data: { node: lessonNode } as Record<string, unknown>,
selected: lessonNode.id === selectedNodeId,
};
});
}
export function toRfEdges(
edges: AnyLessonPlanEdge[],
selectedNodeId: string | null,
anchors: NodeAnchor[],
): Edge[] {
return edges.map((e) => {
if (e.type === "anchor") {
// 锚点边:默认 10% 透明度,选中关联节点时 100%
const anchor = anchors.find((a) => a.id === e.anchorId);
const isActive = anchor && anchor.nodeId === selectedNodeId;
return {
...e,
animated: false,
className: isActive ? "anchor-edge active" : "anchor-edge",
style: {
stroke: anchor ? getNodeColorForAnchor(anchor.nodeId) : "#9e9e9e",
strokeWidth: 2,
opacity: isActive ? 1 : 0.1,
},
};
}
// 流程边
return {
...e, ...e,
animated: true, animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 }, style: { stroke: "#1976d2", strokeWidth: 2 },
})); };
});
}
// 简单的颜色查找(避免循环依赖 node-summary
function getNodeColorForAnchor(_nodeId: string): string {
// 实际颜色由 CSS 类 .anchor-edge 设置,这里返回默认值
return "#1976d2";
} }
/** /**
@@ -32,12 +115,28 @@ export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
*/ */
export function fromRfEdges( export function fromRfEdges(
rfEdges: Edge[], rfEdges: Edge[],
): LessonPlanEdge[] { ): AnyLessonPlanEdge[] {
return rfEdges.map((e) => ({ return rfEdges.map((e) => {
const base = {
id: e.id, id: e.id,
source: e.source, source: e.source,
target: e.target, target: e.target,
sourceHandle: e.sourceHandle ?? null, sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null, targetHandle: e.targetHandle ?? null,
})); };
// 保留原有的 type 信息(通过 className 判断或默认为 flow
if (e.className?.includes("anchor-edge")) {
return {
...base,
type: "anchor" as const,
anchorId: e.id, // 简化:用 edge id 作为 anchorId实际应从 data 读取)
};
}
return {
...base,
type: "flow" as const,
};
});
} }

View File

@@ -12,7 +12,8 @@ export const createLessonPlanSchema = z.object({
export const updateLessonPlanContentSchema = z.object({ export const updateLessonPlanContentSchema = z.object({
planId: z.string().min(1), planId: z.string().min(1),
title: z.string().min(1).max(255).optional(), title: z.string().min(1).max(255).optional(),
content: z.unknown(), // Block 文档结构由 types 守卫,运行时只校验存在 // Block 文档结构由 types 守卫,运行时只校验是对象
content: z.record(z.string(), z.unknown()),
}); });
export const saveVersionSchema = z.object({ export const saveVersionSchema = z.object({
@@ -30,5 +31,32 @@ export const saveAsTemplateSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
}); });
// AI 知识点推荐输入校验
export const suggestKnowledgePointsSchema = z.object({
doc: z.record(z.string(), z.unknown()),
textbookId: z.string().optional(),
chapterId: z.string().optional(),
});
// 知识点选项查询输入校验
export const getKnowledgePointOptionsSchema = z.object({
textbookId: z.string().optional(),
chapterId: z.string().optional(),
});
// 发布作业输入校验
const dateStringSchema = z
.string()
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format");
export const publishLessonPlanHomeworkSchema = z.object({
planId: z.string().min(1),
blockId: z.string().min(1),
classIds: z.array(z.string().min(1)).min(1, "至少选择一个班级"),
availableAt: dateStringSchema.optional(),
dueAt: dateStringSchema.optional(),
});
export type CreateLessonPlanInput = z.infer<typeof createLessonPlanSchema>; export type CreateLessonPlanInput = z.infer<typeof createLessonPlanSchema>;
export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>; export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
export type PublishLessonPlanHomeworkInput = z.infer<typeof publishLessonPlanHomeworkSchema>;

View File

@@ -1,7 +1,7 @@
// 课案状态 // 课案状态
export type LessonPlanStatus = "draft" | "published" | "archived"; export type LessonPlanStatus = "draft" | "published" | "archived";
// Block 类型枚举 // Block 类型枚举(教学节点)
export type BlockType = export type BlockType =
| "objective" | "objective"
| "key_point" | "key_point"
@@ -16,13 +16,21 @@ export type BlockType =
| "rich_text" | "rich_text"
| "reflection"; | "reflection";
// 富文本类 block 的 data // 正文节点类型(特殊:不可拖动,画布中央)
export type TextbookContentNodeType = "textbook_content";
// 节点类型联合(用于 React Flow 注册)
export type LessonNodeType = BlockType | TextbookContentNodeType;
// ---- 各 Block 数据接口 ----
// 富文本类
export interface RichTextBlockData { export interface RichTextBlockData {
html: string; html: string;
knowledgePointIds: string[]; knowledgePointIds: string[];
} }
// 文本研习 block 的 data // 文本研习
export interface TextStudyAnnotation { export interface TextStudyAnnotation {
id: string; id: string;
anchor: { start: number; end: number }; anchor: { start: number; end: number };
@@ -38,22 +46,22 @@ export interface TextStudyBlockData {
knowledgePointIds: string[]; knowledgePointIds: string[];
} }
// 练习 block 的 data // 练习
export type ExercisePurpose = "class_practice" | "after_class_homework"; export type ExercisePurpose = "class_practice" | "after_class_homework";
export interface InlineQuestionContent { export interface InlineQuestionContent {
content: unknown; // 与 questions.content 对齐 content: unknown;
type: string; // 与 questionTypeEnum 对齐 type: string;
difficulty: number; difficulty: number;
knowledgePointIds: string[]; knowledgePointIds: string[];
} }
export interface ExerciseItem { export interface ExerciseItem {
questionId: string; // bank=真实IDinline=占位 inline_draft_xxx questionId: string;
source: "bank" | "inline"; source: "bank" | "inline";
score: number; score: number;
order: number; order: number;
inlineContent?: InlineQuestionContent; // 仅 inline inlineContent?: InlineQuestionContent;
} }
export interface ExerciseBlockData { export interface ExerciseBlockData {
@@ -65,42 +73,189 @@ export interface ExerciseBlockData {
publishedAt?: string; publishedAt?: string;
} }
// 教学目标(三维目标)
export interface ObjectiveItem {
dimension: "knowledge" | "process" | "emotion";
text: string;
}
export interface ObjectiveBlockData {
objectives: ObjectiveItem[];
}
// 重难点
export interface KeyPointItem {
type: "key" | "difficult";
text: string;
}
export interface KeyPointBlockData {
keyPoints: KeyPointItem[];
}
// 导入
export interface ImportBlockData {
method: "question" | "situation" | "review" | "other";
prompt: string;
durationMin: number;
}
// 新授
export interface NewTeachingPoint {
knowledgePointIds: string[];
outline: string;
boardNotes: string;
}
export interface NewTeachingBlockData {
teachingPoints: NewTeachingPoint[];
}
// 小结
export interface SummaryBlockData {
summaryPoints: string[];
homeworkPreview: string;
}
// 作业
export interface HomeworkAssignment {
type: "exercise" | "reading" | "writing";
refId?: string;
description: string;
}
export interface HomeworkBlockData {
assignments: HomeworkAssignment[];
}
// 板书设计
export interface BlackboardBlockData {
layout: "structure" | "mindmap" | "text";
content: string;
knowledgePointIds: string[];
}
// 教学反思
export interface ReflectionItem {
aspect: "effectiveness" | "problems" | "improvements";
text: string;
}
export interface ReflectionBlockData {
reflection: ReflectionItem[];
}
// 正文节点数据
export interface TextbookContentNodeData {
chapterId: string;
content: string; // Markdown 正文(缓存)
zoom: number; // 缩放比例 0.5-2.0
}
// Block 数据联合类型
export type BlockData =
| RichTextBlockData
| TextStudyBlockData
| ExerciseBlockData
| ObjectiveBlockData
| KeyPointBlockData
| ImportBlockData
| NewTeachingBlockData
| SummaryBlockData
| HomeworkBlockData
| BlackboardBlockData
| ReflectionBlockData;
// Block 联合 // Block 联合
export interface Block { export interface Block {
id: string; id: string;
type: BlockType; type: BlockType;
title: string; title: string;
data: RichTextBlockData | TextStudyBlockData | ExerciseBlockData; data: BlockData;
order: number; order: number;
} }
// 节点Block + 画布坐标) // 教学节点Block + 画布坐标)
export interface LessonPlanNode extends Block { export interface LessonPlanNode extends Block {
position: { x: number; y: number }; position: { x: number; y: number };
} }
// 连线(节点间数据流/流程顺序 // 正文节点(不可拖动
export interface TextbookContentNode {
id: string;
type: TextbookContentNodeType;
title: string;
data: TextbookContentNodeData;
order: number;
position: { x: number; y: number };
draggable: false;
}
// 节点联合
export type AnyLessonPlanNode = LessonPlanNode | TextbookContentNode;
// ---- 锚点 ----
export type AnchorType = "range" | "point";
export interface NodeAnchor {
id: string;
nodeId: string; // 关联的教学节点 ID
type: AnchorType;
start: number; // 正文纯文本偏移量
end?: number; // range 锚定的结束偏移
textPreview?: string; // range 锚定的文字预览(用于失效重定位)
invalid?: boolean; // 正文变更后无法重定位时标记为失效
}
// ---- 边 ----
export type LessonEdgeType = "anchor" | "flow";
// 基础边
export interface LessonPlanEdge { export interface LessonPlanEdge {
id: string; id: string;
source: string; // 源节点 id source: string;
target: string; // 目标节点 id target: string;
sourceHandle?: string | null; sourceHandle?: string | null;
targetHandle?: string | null; targetHandle?: string | null;
} }
// 文档 v1旧格式向后兼容读取 // 锚点连线(教学节点 → 正文节点
export interface AnchorEdge extends LessonPlanEdge {
type: "anchor";
anchorId: string;
}
// 流程连线(教学节点 → 教学节点)
export interface FlowEdge extends LessonPlanEdge {
type: "flow";
}
export type AnyLessonPlanEdge = AnchorEdge | FlowEdge;
// ---- 文档版本 ----
// v1旧格式向后兼容读取
export interface LessonPlanDocumentV1 { export interface LessonPlanDocumentV1 {
version: 1; version: 1;
blocks: Block[]; blocks: Block[];
} }
// 文档 v2节点图格式 // v2节点图格式
export interface LessonPlanDocument { export interface LessonPlanDocumentV2 {
version: 2; version: 2;
nodes: LessonPlanNode[]; nodes: LessonPlanNode[];
edges: LessonPlanEdge[]; edges: LessonPlanEdge[];
} }
// v3课文锚点画布格式
export interface LessonPlanDocument {
version: 3;
textbookContentNodeId: string;
nodes: AnyLessonPlanNode[];
edges: AnyLessonPlanEdge[];
anchors: NodeAnchor[];
}
// 课案 // 课案
export interface LessonPlan { export interface LessonPlan {
id: string; id: string;