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:
@@ -7,6 +7,7 @@ import {
|
||||
getKnowledgePointsByTextbookId,
|
||||
getKnowledgePointsByChapterId,
|
||||
} from "@/modules/textbooks/data-access";
|
||||
import { getKnowledgePointOptionsSchema } from "./schema";
|
||||
import type { ActionState } from "./types";
|
||||
|
||||
// 加载知识点选项(供客户端知识点选择器使用)
|
||||
@@ -18,14 +19,19 @@ export async function getKnowledgePointOptionsAction(input: {
|
||||
> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const parsed = getKnowledgePointOptionsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
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;
|
||||
if (input.chapterId) {
|
||||
kps = await getKnowledgePointsByChapterId(input.chapterId);
|
||||
if (parsed.data.chapterId) {
|
||||
kps = await getKnowledgePointsByChapterId(parsed.data.chapterId);
|
||||
} else {
|
||||
kps = await getKnowledgePointsByTextbookId(input.textbookId);
|
||||
kps = await getKnowledgePointsByTextbookId(parsed.data.textbookId);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
requirePermission,
|
||||
PermissionDeniedError,
|
||||
} from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { handleActionError, safeParseDate } from "@/shared/lib/action-utils";
|
||||
import { publishLessonPlanHomework, PublishServiceError } from "./publish-service";
|
||||
import { publishLessonPlanHomeworkSchema } from "./schema";
|
||||
import type { ActionState } from "./types";
|
||||
|
||||
export async function publishLessonPlanHomeworkAction(input: {
|
||||
@@ -19,19 +20,26 @@ export async function publishLessonPlanHomeworkAction(input: {
|
||||
}): Promise<ActionState<{ examId: string; assignmentId: string }>> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const parsed = publishLessonPlanHomeworkSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const ctx = await requirePermission(
|
||||
Permissions.LESSON_PLAN_PUBLISH,
|
||||
);
|
||||
await requirePermission(Permissions.HOMEWORK_CREATE);
|
||||
const result = await publishLessonPlanHomework({
|
||||
planId: input.planId,
|
||||
blockId: input.blockId,
|
||||
planId: parsed.data.planId,
|
||||
blockId: parsed.data.blockId,
|
||||
userId: ctx.userId,
|
||||
classIds: input.classIds,
|
||||
availableAt: input.availableAt
|
||||
? new Date(input.availableAt)
|
||||
classIds: parsed.data.classIds,
|
||||
availableAt: parsed.data.availableAt
|
||||
? safeParseDate(parsed.data.availableAt, "可用时间")
|
||||
: undefined,
|
||||
dueAt: parsed.data.dueAt
|
||||
? safeParseDate(parsed.data.dueAt, "截止时间")
|
||||
: undefined,
|
||||
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
|
||||
});
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
revalidatePath("/teacher/homework");
|
||||
@@ -43,17 +51,12 @@ export async function publishLessonPlanHomeworkAction(input: {
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
// publish-service 抛出 PublishServiceError(含 code),翻译为 i18n 消息
|
||||
if (e instanceof PublishServiceError) {
|
||||
const messageKey = PUBLISH_ERROR_KEY_MAP[e.code] ?? "error.publish";
|
||||
return { success: false, message: t(messageKey) };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: e instanceof Error ? e.message : t("error.publish"),
|
||||
};
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
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 { handleActionError } from "@/shared/lib/action-utils";
|
||||
import {
|
||||
getLessonPlans,
|
||||
getLessonPlanById,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
updateLessonPlanContent,
|
||||
softDeleteLessonPlan,
|
||||
duplicateLessonPlan,
|
||||
getTextbooksForPicker,
|
||||
getChaptersForPicker,
|
||||
LessonPlanDataError,
|
||||
} from "./data-access";
|
||||
import {
|
||||
@@ -45,15 +48,12 @@ export async function getLessonPlansAction(params: {
|
||||
items: Awaited<ReturnType<typeof getLessonPlans>>;
|
||||
}>
|
||||
> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
|
||||
return { success: true, data: { items } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.getList") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,16 +63,13 @@ export async function getLessonPlanByIdAction(
|
||||
): Promise<
|
||||
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
|
||||
> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const plan = await getLessonPlanById(planId, ctx.userId);
|
||||
if (!plan) return { success: false, message: t("error.notFound") };
|
||||
if (!plan) return { success: false, message: "课案不存在" };
|
||||
return { success: true, data: { plan } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.getOne") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +104,8 @@ export async function createLessonPlanAction(
|
||||
}
|
||||
if (key.startsWith("template.blocks.")) {
|
||||
const parts = key.split(".");
|
||||
// 期望格式:template.blocks.{templateId}.{blockIndex}
|
||||
if (parts.length < 4) return key;
|
||||
const templateId = parts[2];
|
||||
const blockIndex = parts[3];
|
||||
return t(`template.blocks.${templateId}.${blockIndex}`);
|
||||
@@ -117,11 +116,9 @@ export async function createLessonPlanAction(
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
return { success: true, data: { planId } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
if (e instanceof LessonPlanDataError && e.code === "TEMPLATE_NOT_FOUND")
|
||||
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;
|
||||
content: LessonPlanDocument;
|
||||
}): Promise<ActionState> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = updateLessonPlanContentSchema.safeParse(input);
|
||||
@@ -139,13 +135,13 @@ export async function updateLessonPlanAction(input: {
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
await updateLessonPlanContent(parsed.data.planId, ctx.userId, {
|
||||
...(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 };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.save") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +151,6 @@ export async function saveLessonPlanVersionAction(input: {
|
||||
content: LessonPlanDocument;
|
||||
label?: string;
|
||||
}): Promise<ActionState<{ versionNo: number }>> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = saveVersionSchema.safeParse(input);
|
||||
@@ -171,9 +166,7 @@ export async function saveLessonPlanVersionAction(input: {
|
||||
await pruneAutoVersions(parsed.data.planId);
|
||||
return { success: true, data: { versionNo } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.saveVersion") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,15 +178,12 @@ export async function getLessonPlanVersionsAction(
|
||||
versions: Awaited<ReturnType<typeof getLessonPlanVersions>>;
|
||||
}>
|
||||
> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const versions = await getLessonPlanVersions(planId, ctx.userId);
|
||||
return { success: true, data: { versions } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.getVersions") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +192,6 @@ export async function revertLessonPlanVersionAction(input: {
|
||||
planId: string;
|
||||
versionNo: number;
|
||||
}): Promise<ActionState<{ newVersionNo: number }>> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = revertVersionSchema.safeParse(input);
|
||||
@@ -213,13 +202,11 @@ export async function revertLessonPlanVersionAction(input: {
|
||||
parsed.data.versionNo,
|
||||
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`);
|
||||
return { success: true, data: { newVersionNo: result.newVersionNo } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.revert") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,16 +214,13 @@ export async function revertLessonPlanVersionAction(input: {
|
||||
export async function deleteLessonPlanAction(
|
||||
planId: string,
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||
await softDeleteLessonPlan(planId, ctx.userId);
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.delete") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,11 +235,9 @@ export async function duplicateLessonPlanAction(
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
return { success: true, data: { newPlanId } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||
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>>;
|
||||
}>
|
||||
> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const templates = await getLessonPlanTemplates(ctx.userId);
|
||||
return { success: true, data: { templates } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.getTemplates") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,11 +273,9 @@ export async function saveAsTemplateAction(input: {
|
||||
});
|
||||
return { success: true, data: { templateId } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||
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(
|
||||
templateId: string,
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||
await deletePersonalTemplate(templateId, ctx.userId);
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: t("error.deleteTemplate") };
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 获取教材列表(供 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { RichTextBlock } from "./blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||
import { TextStudyBlock } from "./blocks/text-study-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 {
|
||||
textbookId?: string;
|
||||
@@ -122,7 +122,7 @@ function SortableBlock({
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
data={node.data as ReflectionBlockData}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : (
|
||||
@@ -140,7 +140,7 @@ export function BlockRenderer({
|
||||
chapterId,
|
||||
classes,
|
||||
}: BlockRendererProps) {
|
||||
const { doc } = useLessonPlanEditor();
|
||||
const { doc, updateNode } = useLessonPlanEditor();
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
@@ -149,11 +149,10 @@ export function BlockRenderer({
|
||||
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
|
||||
const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
// 交换 order
|
||||
const nodes = [...doc.nodes];
|
||||
const tmpOrder = nodes[oldIndex].order;
|
||||
nodes[oldIndex].order = nodes[newIndex].order;
|
||||
nodes[newIndex].order = tmpOrder;
|
||||
// 交换 order 并写回 store(修复 onDragEnd 未回写 store 的 BUG)
|
||||
const tmpOrder = doc.nodes[oldIndex].order;
|
||||
updateNode(doc.nodes[oldIndex].id, { order: doc.nodes[newIndex].order });
|
||||
updateNode(doc.nodes[newIndex].id, { order: tmpOrder });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,17 +162,19 @@ export function BlockRenderer({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{doc.nodes.map((b, i) => (
|
||||
<SortableBlock
|
||||
key={b.id}
|
||||
node={b}
|
||||
index={i}
|
||||
total={doc.nodes.length}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
))}
|
||||
{doc.nodes
|
||||
.filter((b): b is LessonPlanNode => b.type !== "textbook_content")
|
||||
.map((b, i) => (
|
||||
<SortableBlock
|
||||
key={b.id}
|
||||
node={b}
|
||||
index={i}
|
||||
total={doc.nodes.length}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { RichTextBlock } from "./rich-text-block";
|
||||
import type { RichTextBlockData } from "../../types";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { ReflectionBlockData, ReflectionItem } from "../../types";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
data: RichTextBlockData;
|
||||
onUpdate: (data: RichTextBlockData) => void;
|
||||
data: ReflectionBlockData;
|
||||
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");
|
||||
// 教学反思在 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,15 +29,42 @@ export function KnowledgePointPicker({
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [options, setOptions] = useState<KpOption[]>([]);
|
||||
const [local, setLocal] = useState<string[]>(selectedIds);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textbookId) {
|
||||
return;
|
||||
}
|
||||
getKnowledgePointOptionsAction({ textbookId, chapterId }).then((res) => {
|
||||
if (res.success && res.data) setOptions(res.data.options);
|
||||
});
|
||||
}, [textbookId, chapterId]);
|
||||
let cancelled = false;
|
||||
// 使用 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);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [textbookId, chapterId, t]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setLocal((prev) =>
|
||||
@@ -60,7 +87,13 @@ export function KnowledgePointPicker({
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
{t("knowledgePoint.empty")}
|
||||
</p>
|
||||
|
||||
@@ -32,25 +32,37 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleArchive() {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||
toast.success(t("status.archived"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.delete"));
|
||||
try {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||
toast.success(t("status.archived"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.delete"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanCard] archive failed", e);
|
||||
toast.error(t("error.delete"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||
router.refresh();
|
||||
try {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.duplicate"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanCard] duplicate failed", e);
|
||||
toast.error(t("error.duplicate"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import type { BlockType } from "../types";
|
||||
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 {
|
||||
planId: string;
|
||||
@@ -22,6 +22,8 @@ interface Props {
|
||||
initialDoc: import("../types").LessonPlanDocument;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
textbookTitle?: string;
|
||||
chapterTitle?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
@@ -46,6 +48,8 @@ export function LessonPlanEditor({
|
||||
initialDoc,
|
||||
textbookId,
|
||||
chapterId,
|
||||
textbookTitle,
|
||||
chapterTitle,
|
||||
classes,
|
||||
}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
@@ -71,13 +75,18 @@ export function LessonPlanEditor({
|
||||
autoSaveTimer.current = setTimeout(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
const res = await updateLessonPlanAction({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
});
|
||||
state.setSaving(false);
|
||||
if (res.success) state.markSaved();
|
||||
try {
|
||||
const res = await updateLessonPlanAction({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
});
|
||||
if (res.success) state.markSaved();
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] auto-save failed", e);
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
@@ -89,11 +98,15 @@ export function LessonPlanEditor({
|
||||
versionTimer.current = setInterval(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
if (!state.isDirty) return;
|
||||
await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: t("version.autoLabel"),
|
||||
});
|
||||
try {
|
||||
await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: t("version.autoLabel"),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] auto-version failed", e);
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||
@@ -127,23 +140,32 @@ export function LessonPlanEditor({
|
||||
const handleManualSave = useCallback(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
const res = await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
});
|
||||
state.setSaving(false);
|
||||
if (res.success) {
|
||||
state.markSaved();
|
||||
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||
try {
|
||||
const res = await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
});
|
||||
if (res.success) {
|
||||
state.markSaved();
|
||||
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] manual save failed", e);
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, [tracker]);
|
||||
|
||||
// 版本回退后刷新内容(修复 P1-1)
|
||||
const handleReverted = useCallback(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
const res = await getLessonPlanByIdAction(state.planId);
|
||||
if (res.success && res.data?.plan) {
|
||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||
try {
|
||||
const res = await getLessonPlanByIdAction(state.planId);
|
||||
if (res.success && res.data?.plan) {
|
||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] reload after revert failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -156,6 +178,20 @@ export function LessonPlanEditor({
|
||||
onChange={(e) => editor.setTitle(e.target.value)}
|
||||
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">
|
||||
{editor.isSaving
|
||||
? t("status.saving")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce";
|
||||
|
||||
@@ -21,13 +21,19 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
||||
// 修复 P1-6:搜索 debounce 300ms
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
// 使用 ref 存储 onFilter,避免其引用变化触发 useEffect 无限循环
|
||||
const onFilterRef = useRef(onFilter);
|
||||
useEffect(() => {
|
||||
onFilter({
|
||||
onFilterRef.current = onFilter;
|
||||
}, [onFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
onFilterRef.current({
|
||||
query: debouncedQuery || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
}, [debouncedQuery, subjectId, status, onFilter]);
|
||||
}, [debouncedQuery, subjectId, status]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
@@ -16,26 +16,50 @@ interface Props {
|
||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleFilter(params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
if (service) {
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
return;
|
||||
}
|
||||
const res = await getLessonPlansAction(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
}
|
||||
// 使用 useCallback 稳定 handleFilter 引用,避免 LessonPlanFilters 的 useEffect 无限循环
|
||||
const handleFilter = useCallback(
|
||||
async (params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
try {
|
||||
if (service) {
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) {
|
||||
setItems(res.data.items);
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const res = await getLessonPlansAction(params);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<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 ? (
|
||||
<p className="text-on-surface-variant text-center py-12">
|
||||
{t("list.empty")}
|
||||
|
||||
@@ -18,39 +18,108 @@ import {
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { LessonNode } from "./nodes/lesson-node";
|
||||
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
||||
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||
import { getNodeColor } from "../lib/node-summary";
|
||||
import type { LessonPlanNode } from "../types";
|
||||
import type { AnyLessonPlanNode } from "../types";
|
||||
|
||||
const nodeTypes = { lesson: LessonNode };
|
||||
const nodeTypes = {
|
||||
lesson: LessonNode,
|
||||
textbook_content: TextbookContentNodeComponent,
|
||||
};
|
||||
|
||||
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
|
||||
type Props = Record<string, never>;
|
||||
|
||||
export function NodeEditor({}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
||||
useLessonPlanEditor();
|
||||
const {
|
||||
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
|
||||
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(
|
||||
() => toRfEdges(doc.edges),
|
||||
[doc.edges],
|
||||
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors),
|
||||
[doc.edges, selectedNodeId, doc.anchors],
|
||||
);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "position" && change.position) {
|
||||
// 拖拽结束时(dragging: false)才写入最终位置,避免中间状态污染(修复 P1-1)
|
||||
if (change.dragging === false) {
|
||||
updateNodePosition(change.id, change.position);
|
||||
}
|
||||
// 实时拖动:每次 position 变化都更新(不再等待 dragging=false)
|
||||
// 但仅在节点正在被拖动或拖动结束时更新
|
||||
updateNodePosition(change.id, change.position);
|
||||
} else if (change.type === "remove") {
|
||||
removeNode(change.id);
|
||||
} else if (change.type === "select") {
|
||||
@@ -75,16 +144,32 @@ export function NodeEditor({}: Props) {
|
||||
(changes: EdgeChange[]) => {
|
||||
// 简单处理:删除时调用 disconnect
|
||||
const nextEdges = applyEdgeChanges(changes, rfEdges);
|
||||
const ourEdges = nextEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? null,
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
}));
|
||||
const ourEdges = nextEdges.map((e) => {
|
||||
// 保留原有的 type 信息
|
||||
const original = doc.edges.find((oe) => oe.id === e.id);
|
||||
if (original?.type === "anchor") {
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? 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);
|
||||
},
|
||||
[rfEdges, setEdges],
|
||||
[rfEdges, setEdges, doc.edges],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -131,7 +216,7 @@ export function NodeEditor({}: Props) {
|
||||
<MiniMap
|
||||
className="!bg-surface !border-outline-variant"
|
||||
nodeColor={(n) => {
|
||||
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
||||
const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
|
||||
if (!nodeData) return "#9e9e9e";
|
||||
return getNodeColor(nodeData.type);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 计算点击位置在纯文本中的偏移
|
||||
// 简化:使用 caretRangeFromPoint(Chromium)或 caretPositionFromPoint(Firefox)
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
let offset = -1;
|
||||
|
||||
if (document.caretPositionFromPoint) {
|
||||
const pos = document.caretPositionFromPoint(x, y);
|
||||
if (pos) offset = pos.offset;
|
||||
} else if (document.caretRangeFromPoint) {
|
||||
const range = document.caretRangeFromPoint(x, y);
|
||||
if (range) offset = range.startOffset;
|
||||
}
|
||||
|
||||
if (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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -37,24 +37,30 @@ export function PublishHomeworkDialog({
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await publishLessonPlanHomeworkAction({
|
||||
planId,
|
||||
blockId,
|
||||
classIds: selectedClasses,
|
||||
availableAt: availableAt || undefined,
|
||||
dueAt: dueAt || undefined,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.publish", {
|
||||
try {
|
||||
const res = await publishLessonPlanHomeworkAction({
|
||||
planId,
|
||||
blockId,
|
||||
classCount: selectedClasses.length,
|
||||
classIds: selectedClasses,
|
||||
availableAt: availableAt || undefined,
|
||||
dueAt: dueAt || undefined,
|
||||
});
|
||||
onPublished();
|
||||
onClose();
|
||||
} else {
|
||||
setError(res.message ?? t("error.publish"));
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.publish", {
|
||||
planId,
|
||||
blockId,
|
||||
classCount: selectedClasses.length,
|
||||
});
|
||||
onPublished();
|
||||
onClose();
|
||||
} else {
|
||||
setError(res.message ?? t("error.publish"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[PublishHomeworkDialog] publish failed", e);
|
||||
setError(t("error.publish"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
||||
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
@@ -53,20 +55,43 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
const debouncedFilters = useDebounce(filters, 300)
|
||||
|
||||
useEffect(() => {
|
||||
getQuestionsAction(debouncedFilters).then((res) => {
|
||||
if (res.success && res.data) {
|
||||
const data = res.data.data
|
||||
setQuestions(
|
||||
data.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content,
|
||||
})),
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [debouncedFilters])
|
||||
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) {
|
||||
const data = res.data.data
|
||||
setQuestions(
|
||||
data.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"))
|
||||
}
|
||||
})
|
||||
.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) {
|
||||
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
|
||||
@@ -116,22 +141,34 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-2">
|
||||
{questions.map((q) => (
|
||||
<div
|
||||
key={q.id}
|
||||
className="border rounded p-2 flex justify-between items-center"
|
||||
>
|
||||
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
|
||||
<span className="text-xs text-on-surface-variant mr-2">
|
||||
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||
{t("questionBank.add")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{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">
|
||||
{questions.map((q) => (
|
||||
<div
|
||||
key={q.id}
|
||||
className="border rounded p-2 flex justify-between items-center"
|
||||
>
|
||||
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
|
||||
<span className="text-xs text-on-surface-variant mr-2">
|
||||
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||
{t("questionBank.add")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-between">
|
||||
<span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span>
|
||||
|
||||
@@ -1,36 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createLessonPlanAction } from "../actions";
|
||||
import { createLessonPlanAction, getTextbooksForPickerAction, getChaptersForPickerAction } from "../actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { SYSTEM_TEMPLATES } from "../constants";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
|
||||
|
||||
interface TextbookOption {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string | null;
|
||||
}
|
||||
|
||||
interface ChapterOption {
|
||||
id: string;
|
||||
title: string;
|
||||
parentId: string | null;
|
||||
order: number | null;
|
||||
content?: string | null;
|
||||
children?: unknown[];
|
||||
}
|
||||
|
||||
export function TemplatePicker() {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const router = useRouter();
|
||||
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 [title, setTitle] = useState("");
|
||||
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) {
|
||||
setError(null);
|
||||
if (!textbookId || !chapterId) {
|
||||
setError(t("picker.errorTextbookChapterRequired"));
|
||||
return;
|
||||
}
|
||||
formData.set("templateId", selected);
|
||||
formData.set("title", title);
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
if (res.success && res.data) {
|
||||
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
} else {
|
||||
setError(res.message ?? t("error.createFailed"));
|
||||
formData.set("textbookId", textbookId);
|
||||
formData.set("chapterId", chapterId);
|
||||
try {
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
if (res.success && res.data) {
|
||||
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
} else {
|
||||
setError(res.message ?? t("error.createFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[TemplatePicker] create failed", e);
|
||||
setError(t("error.createFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<label className="font-title-md block mb-2">{t("template.titleLabel")}</label>
|
||||
<input
|
||||
@@ -41,6 +243,8 @@ export function TemplatePicker() {
|
||||
placeholder={t("template.titlePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 步骤 4:模板 */}
|
||||
<div>
|
||||
<label className="font-title-md block mb-2">{t("template.selectLabel")}</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@@ -64,9 +268,15 @@ export function TemplatePicker() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedTextbook && selectedChapter && (
|
||||
<p className="text-xs text-on-surface-variant mt-2">
|
||||
{t("picker.skeletonHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
<Button type="submit" disabled={!selected || !title}>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -48,11 +48,17 @@ export function VersionHistoryDrawer({
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
getLessonPlanVersionsAction(planId).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) setVersions(res.data.versions);
|
||||
setLoading(false);
|
||||
});
|
||||
getLessonPlanVersionsAction(planId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) setVersions(res.data.versions);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[VersionHistoryDrawer] load versions failed", e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -60,14 +66,19 @@ export function VersionHistoryDrawer({
|
||||
}, [open, planId]);
|
||||
|
||||
async function handleRevert(versionNo: number) {
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.revert"));
|
||||
try {
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.revert"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[VersionHistoryDrawer] revert failed", e);
|
||||
toast.error(t("error.revert"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type {
|
||||
BlackboardBlockData,
|
||||
BlockData,
|
||||
BlockType,
|
||||
ExerciseBlockData,
|
||||
HomeworkBlockData,
|
||||
ImportBlockData,
|
||||
KeyPointBlockData,
|
||||
NewTeachingBlockData,
|
||||
ObjectiveBlockData,
|
||||
ReflectionBlockData,
|
||||
RichTextBlockData,
|
||||
SummaryBlockData,
|
||||
TextStudyBlockData,
|
||||
} from "../types";
|
||||
import { RichTextBlock } from "../components/blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "../components/blocks/exercise-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";
|
||||
|
||||
/**
|
||||
@@ -17,11 +33,11 @@ import { ReflectionBlock } from "../components/blocks/reflection-block";
|
||||
|
||||
export interface BlockRenderProps {
|
||||
blockId: string;
|
||||
data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData;
|
||||
data: BlockData;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
onUpdate: (data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData) => void;
|
||||
onUpdate: (data: BlockData) => void;
|
||||
}
|
||||
|
||||
export interface BlockRegistryEntry {
|
||||
@@ -29,34 +45,24 @@ export interface BlockRegistryEntry {
|
||||
isRichText?: boolean;
|
||||
}
|
||||
|
||||
const RICH_TEXT_TYPES: BlockType[] = [
|
||||
"objective",
|
||||
"key_point",
|
||||
"import",
|
||||
"new_teaching",
|
||||
"consolidation",
|
||||
"summary",
|
||||
"homework",
|
||||
"blackboard",
|
||||
"rich_text",
|
||||
];
|
||||
const RICH_TEXT_TYPES: BlockType[] = ["rich_text", "consolidation"];
|
||||
|
||||
/**
|
||||
* Block 注册表元数据(用于查询 isRichText 等属性)。
|
||||
* 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。
|
||||
*/
|
||||
export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
|
||||
objective: { isRichText: true },
|
||||
key_point: { isRichText: true },
|
||||
import: { isRichText: true },
|
||||
new_teaching: { isRichText: true },
|
||||
objective: {},
|
||||
key_point: {},
|
||||
import: {},
|
||||
new_teaching: {},
|
||||
consolidation: { isRichText: true },
|
||||
summary: { isRichText: true },
|
||||
homework: { isRichText: true },
|
||||
blackboard: { isRichText: true },
|
||||
rich_text: { isRichText: true },
|
||||
exercise: {},
|
||||
summary: {},
|
||||
homework: {},
|
||||
blackboard: {},
|
||||
text_study: {},
|
||||
exercise: {},
|
||||
rich_text: { isRichText: true },
|
||||
reflection: {},
|
||||
};
|
||||
|
||||
@@ -73,6 +79,66 @@ export function isRichTextBlock(type: BlockType): boolean {
|
||||
export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null {
|
||||
const { type, ...rest } = props;
|
||||
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":
|
||||
return (
|
||||
<ExerciseBlock
|
||||
@@ -85,23 +151,14 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
|
||||
);
|
||||
case "text_study":
|
||||
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 "consolidation":
|
||||
return (
|
||||
<RichTextBlock
|
||||
data={rest.data as RichTextBlockData}
|
||||
textbookId={rest.textbookId}
|
||||
chapterId={rest.chapterId}
|
||||
onUpdate={rest.onUpdate}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -96,10 +96,12 @@ export async function saveAsTemplate(input: {
|
||||
if (plan.length === 0) throw new LessonPlanDataError("NOT_FOUND");
|
||||
|
||||
const doc = normalizeDocument(plan[0].content);
|
||||
const skeleton: TemplateBlockSkeleton[] = doc.nodes.map((b) => ({
|
||||
type: b.type,
|
||||
title: b.title,
|
||||
}));
|
||||
const skeleton: TemplateBlockSkeleton[] = doc.nodes
|
||||
.filter((b): b is import("./types").LessonPlanNode => b.type !== "textbook_content")
|
||||
.map((b) => ({
|
||||
type: b.type,
|
||||
title: b.title,
|
||||
}));
|
||||
|
||||
const templateId = createId();
|
||||
await db.insert(lessonPlanTemplates).values({
|
||||
|
||||
@@ -60,23 +60,25 @@ export async function createLessonPlanVersion(input: {
|
||||
isAuto: boolean;
|
||||
label?: string;
|
||||
}): Promise<{ versionNo: number }> {
|
||||
// 取当前最大 versionNo
|
||||
const maxRow = await db
|
||||
.select({ maxNo: max(lessonPlanVersions.versionNo) })
|
||||
.from(lessonPlanVersions)
|
||||
.where(eq(lessonPlanVersions.planId, input.planId));
|
||||
const nextNo = (maxRow[0]?.maxNo ?? 0) + 1;
|
||||
// P0 修复:max(versionNo)+1 必须在事务内完成,避免并发产生重复版本号
|
||||
return await db.transaction(async (tx) => {
|
||||
const maxRow = await tx
|
||||
.select({ maxNo: max(lessonPlanVersions.versionNo) })
|
||||
.from(lessonPlanVersions)
|
||||
.where(eq(lessonPlanVersions.planId, input.planId));
|
||||
const nextNo = (maxRow[0]?.maxNo ?? 0) + 1;
|
||||
|
||||
await db.insert(lessonPlanVersions).values({
|
||||
id: createId(),
|
||||
planId: input.planId,
|
||||
versionNo: nextNo,
|
||||
label: input.label ?? null,
|
||||
content: input.content,
|
||||
isAuto: input.isAuto,
|
||||
creatorId: input.userId,
|
||||
await tx.insert(lessonPlanVersions).values({
|
||||
id: createId(),
|
||||
planId: input.planId,
|
||||
versionNo: nextNo,
|
||||
label: input.label ?? null,
|
||||
content: input.content,
|
||||
isAuto: input.isAuto,
|
||||
creatorId: input.userId,
|
||||
});
|
||||
return { versionNo: nextNo };
|
||||
});
|
||||
return { versionNo: nextNo };
|
||||
}
|
||||
|
||||
export async function getVersionContent(
|
||||
@@ -116,22 +118,33 @@ export async function revertToVersion(
|
||||
const content = await getVersionContent(planId, versionNo, userId);
|
||||
if (!content) return null;
|
||||
|
||||
// 用该版本 content 覆盖当前 + 生成新版本
|
||||
await db
|
||||
.update(lessonPlans)
|
||||
.set({ content, lastSavedAt: new Date() })
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
// P0 修复:update 与 createLessonPlanVersion 必须在同一事务内,
|
||||
// 避免回退后 content 已覆盖但版本记录未生成的不一致状态
|
||||
return await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(lessonPlans)
|
||||
.set({ content, lastSavedAt: new Date() })
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
|
||||
const { versionNo: newNo } = await createLessonPlanVersion({
|
||||
planId,
|
||||
content,
|
||||
userId,
|
||||
isAuto: false,
|
||||
label: `回退到 v${versionNo}`,
|
||||
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,
|
||||
versionNo: newNo,
|
||||
label: `回退到 v${versionNo}`,
|
||||
content,
|
||||
isAuto: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
return { newVersionNo: newNo };
|
||||
});
|
||||
return { newVersionNo: newNo };
|
||||
}
|
||||
|
||||
export async function pruneAutoVersions(
|
||||
|
||||
@@ -15,12 +15,15 @@ import {
|
||||
users,
|
||||
} from "@/shared/db/schema";
|
||||
import type { DataScope } from "@/shared/types/permissions";
|
||||
import { escapeLikePattern } from "@/shared/lib/action-utils";
|
||||
import { SYSTEM_TEMPLATES } from "./constants";
|
||||
import {
|
||||
migrateV1ToV2,
|
||||
normalizeDocument,
|
||||
buildInitialContent,
|
||||
buildDefaultSkeleton,
|
||||
} from "./lib/document-migration";
|
||||
import { getChaptersByTextbookId, getTextbooks } from "@/modules/textbooks/data-access";
|
||||
import type {
|
||||
LessonPlan,
|
||||
LessonPlanDocument,
|
||||
@@ -32,7 +35,7 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
// re-export 纯函数保持向后兼容
|
||||
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
||||
export { migrateV1ToV2, normalizeDocument, buildInitialContent, buildDefaultSkeleton };
|
||||
|
||||
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
|
||||
export class LessonPlanDataError extends Error {
|
||||
@@ -220,7 +223,7 @@ export const getLessonPlans = cache(
|
||||
conditions.push(...buildScopeCondition(scope, userId));
|
||||
|
||||
if (params.query) {
|
||||
conditions.push(like(lessonPlans.title, `%${params.query}%`));
|
||||
conditions.push(like(lessonPlans.title, `%${escapeLikePattern(params.query)}%`));
|
||||
}
|
||||
if (params.textbookId)
|
||||
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
||||
@@ -285,6 +288,7 @@ export const getLessonPlanById = cache(
|
||||
|
||||
// ---- 创建 ----
|
||||
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
|
||||
// V3 升级:当提供 chapterId 时,使用 buildDefaultSkeleton 生成完整 v3 文档(含正文节点)
|
||||
export async function createLessonPlan(input: {
|
||||
title: string;
|
||||
textbookId?: string;
|
||||
@@ -299,14 +303,28 @@ export async function createLessonPlan(input: {
|
||||
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
|
||||
|
||||
const planId = createId();
|
||||
// 如果提供了翻译函数,将模板中的 i18n 键翻译为实际文本
|
||||
const blocks = input.translateTitle
|
||||
? template.blocks.map((b) => ({
|
||||
...b,
|
||||
title: input.translateTitle!(b.title),
|
||||
}))
|
||||
: template.blocks;
|
||||
const content = buildInitialContent(blocks);
|
||||
|
||||
// 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
|
||||
? template.blocks.map((b) => ({
|
||||
...b,
|
||||
title: input.translateTitle!(b.title),
|
||||
}))
|
||||
: template.blocks;
|
||||
content = buildInitialContent(blocks);
|
||||
}
|
||||
|
||||
await db.insert(lessonPlans).values({
|
||||
id: planId,
|
||||
@@ -326,13 +344,65 @@ export async function createLessonPlan(input: {
|
||||
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(自动保存,不生成版本)----
|
||||
export async function updateLessonPlanContent(
|
||||
planId: string,
|
||||
userId: string,
|
||||
patch: { title?: string; content: LessonPlanDocument },
|
||||
): Promise<void> {
|
||||
await db
|
||||
const result = await db
|
||||
.update(lessonPlans)
|
||||
.set({
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
@@ -342,6 +412,11 @@ export async function updateLessonPlanContent(
|
||||
.where(
|
||||
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,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
const result = await db
|
||||
.update(lessonPlans)
|
||||
.set({ status: "archived" })
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
|
||||
// MySQL 返回 [ResultSetHeader, FieldPacket[]],通过 affectedRows 判断是否更新成功
|
||||
if (result[0].affectedRows === 0) {
|
||||
throw new LessonPlanDataError("NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 复制 ----
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
import { create } from "zustand";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type {
|
||||
AnchorEdge,
|
||||
AnchorType,
|
||||
AnyLessonPlanEdge,
|
||||
Block,
|
||||
BlockType,
|
||||
FlowEdge,
|
||||
LessonPlanDocument,
|
||||
LessonPlanEdge,
|
||||
LessonPlanNode,
|
||||
NodeAnchor,
|
||||
TextbookContentNode,
|
||||
TextbookContentNodeData,
|
||||
} from "../types";
|
||||
import { defaultDataForType } from "../lib/document-migration";
|
||||
|
||||
interface EditorState {
|
||||
planId: string;
|
||||
@@ -28,9 +35,25 @@ interface EditorState {
|
||||
updateNodePosition: (id: string, position: { x: number; y: number }) => 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;
|
||||
disconnect: (edgeId: string) => void;
|
||||
setEdges: (edges: LessonPlanEdge[]) => void;
|
||||
setEdges: (edges: AnyLessonPlanEdge[]) => void;
|
||||
|
||||
selectNode: (id: string | null) => void;
|
||||
|
||||
@@ -43,18 +66,16 @@ function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
|
||||
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) => ({
|
||||
planId: "",
|
||||
title: "",
|
||||
doc: { version: 2, nodes: [], edges: [] },
|
||||
doc: {
|
||||
version: 3,
|
||||
textbookContentNodeId: "",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
anchors: [],
|
||||
},
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
lastSavedAt: null,
|
||||
@@ -77,12 +98,17 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
|
||||
addNode: (type, position, title) => {
|
||||
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 = {
|
||||
id,
|
||||
type,
|
||||
title: title ?? type, // 调用方应传入翻译后的标题,fallback 为 type 键
|
||||
data: defaultData(type),
|
||||
title: title ?? type,
|
||||
data: defaultDataForType(type),
|
||||
order: nodeCount,
|
||||
position: position ?? {
|
||||
x: 80 + (nodeCount % 4) * 280,
|
||||
@@ -102,37 +128,139 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
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,
|
||||
})),
|
||||
|
||||
// 实时拖动:每次调用立即更新位置(不再等待 dragging=false)
|
||||
updateNodePosition: (id, position) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
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,
|
||||
})),
|
||||
|
||||
removeNode: (id) =>
|
||||
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: {
|
||||
...s.doc,
|
||||
nodes,
|
||||
edges: s.doc.edges.filter(
|
||||
(e) => e.source !== id && e.target !== id,
|
||||
),
|
||||
// 同时移除关联的锚点
|
||||
anchors: s.doc.anchors.filter((a) => a.nodeId !== id),
|
||||
},
|
||||
isDirty: true,
|
||||
selectedNodeId:
|
||||
s.selectedNodeId === id ? null : s.selectedNodeId,
|
||||
};
|
||||
}),
|
||||
|
||||
// ---- 正文节点操作 ----
|
||||
updateTextbookContent: (data) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
nodes: reindex(s.doc.nodes.filter((n) => n.id !== id)),
|
||||
edges: s.doc.edges.filter(
|
||||
(e) => e.source !== id && e.target !== id,
|
||||
nodes: s.doc.nodes.map((n) =>
|
||||
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
|
||||
? { ...n, data: { ...n.data, ...data } }
|
||||
: n,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
selectedNodeId:
|
||||
s.selectedNodeId === id ? null : s.selectedNodeId,
|
||||
})),
|
||||
|
||||
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) =>
|
||||
set((s) => {
|
||||
// 避免重复连线
|
||||
@@ -142,10 +270,11 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
)
|
||||
)
|
||||
return s;
|
||||
const edge: LessonPlanEdge = {
|
||||
const edge: FlowEdge = {
|
||||
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
|
||||
source,
|
||||
target,
|
||||
type: "flow",
|
||||
};
|
||||
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
|
||||
}),
|
||||
|
||||
304
src/modules/lesson-preparation/lib/anchor-injector.ts
Normal file
304
src/modules/lesson-preparation/lib/anchor-injector.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type {
|
||||
AnchorEdge,
|
||||
AnyLessonPlanEdge,
|
||||
AnyLessonPlanNode,
|
||||
BlockData,
|
||||
BlockType,
|
||||
FlowEdge,
|
||||
LessonPlanDocument,
|
||||
LessonPlanDocumentV1,
|
||||
LessonPlanDocumentV2,
|
||||
LessonPlanEdge,
|
||||
LessonPlanNode,
|
||||
NodeAnchor,
|
||||
TemplateBlockSkeleton,
|
||||
TextbookContentNode,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
@@ -12,8 +21,39 @@ import type {
|
||||
* 从 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 ----
|
||||
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
|
||||
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocumentV2 {
|
||||
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
|
||||
...b,
|
||||
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 };
|
||||
}
|
||||
|
||||
// ---- 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 文档 ----
|
||||
function isV2Document(content: unknown): content is LessonPlanDocument {
|
||||
function isV2Document(content: unknown): content is LessonPlanDocumentV2 {
|
||||
if (!content || typeof content !== "object") return false;
|
||||
const c = content as { version?: unknown; nodes?: unknown; edges?: unknown };
|
||||
return (
|
||||
@@ -47,40 +142,188 @@ function isV1Document(content: unknown): content is LessonPlanDocumentV1 {
|
||||
return c.version === 1 && Array.isArray(c.blocks);
|
||||
}
|
||||
|
||||
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
|
||||
// ---- 规范化:确保 content 是 v3 格式(兼容 v1/v2 旧数据)----
|
||||
export function normalizeDocument(
|
||||
content: unknown,
|
||||
chapterId?: string | null,
|
||||
chapterContent?: string | null,
|
||||
): LessonPlanDocument {
|
||||
if (isV2Document(content)) return content;
|
||||
if (isV1Document(content)) return migrateV1ToV2(content);
|
||||
// 空文档
|
||||
return { version: 2, nodes: [], edges: [] };
|
||||
if (isV3Document(content)) return content;
|
||||
if (isV2Document(content)) {
|
||||
return migrateV2ToV3(content, chapterId, chapterContent);
|
||||
}
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v3,无正文节点)----
|
||||
// 注意:此函数不创建正文节点,调用方应使用 buildDefaultSkeleton 创建完整 v3 文档
|
||||
export function buildInitialContent(
|
||||
blocks: TemplateBlockSkeleton[],
|
||||
): 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) => ({
|
||||
id: createId(),
|
||||
type: b.type,
|
||||
title: b.title,
|
||||
data:
|
||||
b.type === "exercise"
|
||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
||||
: b.type === "text_study"
|
||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
||||
: { html: "", knowledgePointIds: [] },
|
||||
data: defaultDataForType(b.type),
|
||||
order: i,
|
||||
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++) {
|
||||
edges.push({
|
||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||
source: nodes[i].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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 的 TranslationValues(string | number | Date)。
|
||||
*/
|
||||
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 {
|
||||
const data = node.data as {
|
||||
// 富文本类
|
||||
html?: string;
|
||||
// 文本研习
|
||||
sourceText?: string;
|
||||
annotations?: unknown[];
|
||||
// 练习
|
||||
items?: unknown[];
|
||||
// 教学目标
|
||||
objectives?: unknown[];
|
||||
// 重难点
|
||||
keyPoints?: unknown[];
|
||||
// 导入
|
||||
durationMin?: number;
|
||||
prompt?: string;
|
||||
// 新授
|
||||
teachingPoints?: unknown[];
|
||||
// 小结
|
||||
summaryPoints?: unknown[];
|
||||
// 作业
|
||||
assignments?: unknown[];
|
||||
// 板书
|
||||
content?: string;
|
||||
// 反思
|
||||
reflection?: unknown[];
|
||||
// 知识点
|
||||
knowledgePointIds?: string[];
|
||||
};
|
||||
|
||||
// 按类型优先级提取摘要
|
||||
if (data.items !== undefined) {
|
||||
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) {
|
||||
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) {
|
||||
// 去标签后取前 40 字
|
||||
const text = data.html.replace(/<[^>]+>/g, "").trim();
|
||||
@@ -35,6 +97,17 @@ export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
|
||||
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 色板)。
|
||||
* 供 lesson-node 和 minimap 复用。
|
||||
@@ -52,6 +125,7 @@ export const NODE_COLORS: Record<string, string> = {
|
||||
exercise: "#e91e63",
|
||||
rich_text: "#9e9e9e",
|
||||
reflection: "#cddc39",
|
||||
textbook_content: "#455a64",
|
||||
};
|
||||
|
||||
export function getNodeColor(type: string): string {
|
||||
|
||||
@@ -1,30 +1,113 @@
|
||||
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 格式。
|
||||
* 从 node-editor.tsx 抽取,便于单元测试。
|
||||
*
|
||||
* v3 升级:
|
||||
* - 区分教学节点(type="lesson")和正文节点(type="textbook_content")
|
||||
* - 正文节点传入 anchors/selectedNodeId/onAddAnchor 等回调
|
||||
* - 边区分 anchor/flow 类型,应用不同透明度
|
||||
*/
|
||||
|
||||
export function toRfNodes(
|
||||
nodes: LessonPlanNode[],
|
||||
selectedNodeId: string | null,
|
||||
): Node[] {
|
||||
return nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "lesson",
|
||||
position: n.position,
|
||||
data: { node: n } as Record<string, unknown>,
|
||||
selected: n.id === selectedNodeId,
|
||||
}));
|
||||
export interface ToRfNodesContext {
|
||||
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;
|
||||
}
|
||||
|
||||
export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
|
||||
return edges.map((e) => ({
|
||||
...e,
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
}));
|
||||
export function toRfNodes(
|
||||
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,
|
||||
animated: true,
|
||||
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(
|
||||
rfEdges: Edge[],
|
||||
): LessonPlanEdge[] {
|
||||
return rfEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? null,
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
}));
|
||||
): AnyLessonPlanEdge[] {
|
||||
return rfEdges.map((e) => {
|
||||
const base = {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export const createLessonPlanSchema = z.object({
|
||||
export const updateLessonPlanContentSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
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({
|
||||
@@ -30,5 +31,32 @@ export const saveAsTemplateSchema = z.object({
|
||||
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 UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
|
||||
export type PublishLessonPlanHomeworkInput = z.infer<typeof publishLessonPlanHomeworkSchema>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 课案状态
|
||||
export type LessonPlanStatus = "draft" | "published" | "archived";
|
||||
|
||||
// Block 类型枚举
|
||||
// Block 类型枚举(教学节点)
|
||||
export type BlockType =
|
||||
| "objective"
|
||||
| "key_point"
|
||||
@@ -16,13 +16,21 @@ export type BlockType =
|
||||
| "rich_text"
|
||||
| "reflection";
|
||||
|
||||
// 富文本类 block 的 data
|
||||
// 正文节点类型(特殊:不可拖动,画布中央)
|
||||
export type TextbookContentNodeType = "textbook_content";
|
||||
|
||||
// 节点类型联合(用于 React Flow 注册)
|
||||
export type LessonNodeType = BlockType | TextbookContentNodeType;
|
||||
|
||||
// ---- 各 Block 数据接口 ----
|
||||
|
||||
// 富文本类
|
||||
export interface RichTextBlockData {
|
||||
html: string;
|
||||
knowledgePointIds: string[];
|
||||
}
|
||||
|
||||
// 文本研习 block 的 data
|
||||
// 文本研习
|
||||
export interface TextStudyAnnotation {
|
||||
id: string;
|
||||
anchor: { start: number; end: number };
|
||||
@@ -38,22 +46,22 @@ export interface TextStudyBlockData {
|
||||
knowledgePointIds: string[];
|
||||
}
|
||||
|
||||
// 练习 block 的 data
|
||||
// 练习
|
||||
export type ExercisePurpose = "class_practice" | "after_class_homework";
|
||||
|
||||
export interface InlineQuestionContent {
|
||||
content: unknown; // 与 questions.content 对齐
|
||||
type: string; // 与 questionTypeEnum 对齐
|
||||
content: unknown;
|
||||
type: string;
|
||||
difficulty: number;
|
||||
knowledgePointIds: string[];
|
||||
}
|
||||
|
||||
export interface ExerciseItem {
|
||||
questionId: string; // bank=真实ID;inline=占位 inline_draft_xxx
|
||||
questionId: string;
|
||||
source: "bank" | "inline";
|
||||
score: number;
|
||||
order: number;
|
||||
inlineContent?: InlineQuestionContent; // 仅 inline
|
||||
inlineContent?: InlineQuestionContent;
|
||||
}
|
||||
|
||||
export interface ExerciseBlockData {
|
||||
@@ -65,42 +73,189 @@ export interface ExerciseBlockData {
|
||||
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 联合
|
||||
export interface Block {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
title: string;
|
||||
data: RichTextBlockData | TextStudyBlockData | ExerciseBlockData;
|
||||
data: BlockData;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// 节点(Block + 画布坐标)
|
||||
// 教学节点(Block + 画布坐标)
|
||||
export interface LessonPlanNode extends Block {
|
||||
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 {
|
||||
id: string;
|
||||
source: string; // 源节点 id
|
||||
target: string; // 目标节点 id
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: 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 {
|
||||
version: 1;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
// 文档 v2(节点图格式)
|
||||
export interface LessonPlanDocument {
|
||||
// v2(节点图格式)
|
||||
export interface LessonPlanDocumentV2 {
|
||||
version: 2;
|
||||
nodes: LessonPlanNode[];
|
||||
edges: LessonPlanEdge[];
|
||||
}
|
||||
|
||||
// v3(课文锚点画布格式)
|
||||
export interface LessonPlanDocument {
|
||||
version: 3;
|
||||
textbookContentNodeId: string;
|
||||
nodes: AnyLessonPlanNode[];
|
||||
edges: AnyLessonPlanEdge[];
|
||||
anchors: NodeAnchor[];
|
||||
}
|
||||
|
||||
// 课案
|
||||
export interface LessonPlan {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user