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,
|
getKnowledgePointsByTextbookId,
|
||||||
getKnowledgePointsByChapterId,
|
getKnowledgePointsByChapterId,
|
||||||
} from "@/modules/textbooks/data-access";
|
} from "@/modules/textbooks/data-access";
|
||||||
|
import { getKnowledgePointOptionsSchema } from "./schema";
|
||||||
import type { ActionState } from "./types";
|
import type { ActionState } from "./types";
|
||||||
|
|
||||||
// 加载知识点选项(供客户端知识点选择器使用)
|
// 加载知识点选项(供客户端知识点选择器使用)
|
||||||
@@ -18,14 +19,19 @@ export async function getKnowledgePointOptionsAction(input: {
|
|||||||
> {
|
> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
|
const parsed = getKnowledgePointOptionsSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||||
|
}
|
||||||
|
|
||||||
await requirePermission(Permissions.LESSON_PLAN_READ);
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
if (!input.textbookId) return { success: true, data: { options: [] } };
|
if (!parsed.data.textbookId) return { success: true, data: { options: [] } };
|
||||||
|
|
||||||
let kps;
|
let kps;
|
||||||
if (input.chapterId) {
|
if (parsed.data.chapterId) {
|
||||||
kps = await getKnowledgePointsByChapterId(input.chapterId);
|
kps = await getKnowledgePointsByChapterId(parsed.data.chapterId);
|
||||||
} else {
|
} else {
|
||||||
kps = await getKnowledgePointsByTextbookId(input.textbookId);
|
kps = await getKnowledgePointsByTextbookId(parsed.data.textbookId);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
requirePermission,
|
requirePermission,
|
||||||
PermissionDeniedError,
|
|
||||||
} from "@/shared/lib/auth-guard";
|
} from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { handleActionError, safeParseDate } from "@/shared/lib/action-utils";
|
||||||
import { publishLessonPlanHomework, PublishServiceError } from "./publish-service";
|
import { publishLessonPlanHomework, PublishServiceError } from "./publish-service";
|
||||||
|
import { publishLessonPlanHomeworkSchema } from "./schema";
|
||||||
import type { ActionState } from "./types";
|
import type { ActionState } from "./types";
|
||||||
|
|
||||||
export async function publishLessonPlanHomeworkAction(input: {
|
export async function publishLessonPlanHomeworkAction(input: {
|
||||||
@@ -19,19 +20,26 @@ export async function publishLessonPlanHomeworkAction(input: {
|
|||||||
}): Promise<ActionState<{ examId: string; assignmentId: string }>> {
|
}): Promise<ActionState<{ examId: string; assignmentId: string }>> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
|
const parsed = publishLessonPlanHomeworkSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = await requirePermission(
|
const ctx = await requirePermission(
|
||||||
Permissions.LESSON_PLAN_PUBLISH,
|
Permissions.LESSON_PLAN_PUBLISH,
|
||||||
);
|
);
|
||||||
await requirePermission(Permissions.HOMEWORK_CREATE);
|
await requirePermission(Permissions.HOMEWORK_CREATE);
|
||||||
const result = await publishLessonPlanHomework({
|
const result = await publishLessonPlanHomework({
|
||||||
planId: input.planId,
|
planId: parsed.data.planId,
|
||||||
blockId: input.blockId,
|
blockId: parsed.data.blockId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
classIds: input.classIds,
|
classIds: parsed.data.classIds,
|
||||||
availableAt: input.availableAt
|
availableAt: parsed.data.availableAt
|
||||||
? new Date(input.availableAt)
|
? safeParseDate(parsed.data.availableAt, "可用时间")
|
||||||
|
: undefined,
|
||||||
|
dueAt: parsed.data.dueAt
|
||||||
|
? safeParseDate(parsed.data.dueAt, "截止时间")
|
||||||
: undefined,
|
: undefined,
|
||||||
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
|
|
||||||
});
|
});
|
||||||
revalidatePath("/teacher/lesson-plans");
|
revalidatePath("/teacher/lesson-plans");
|
||||||
revalidatePath("/teacher/homework");
|
revalidatePath("/teacher/homework");
|
||||||
@@ -43,17 +51,12 @@ export async function publishLessonPlanHomeworkAction(input: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
// publish-service 抛出 PublishServiceError(含 code),翻译为 i18n 消息
|
// publish-service 抛出 PublishServiceError(含 code),翻译为 i18n 消息
|
||||||
if (e instanceof PublishServiceError) {
|
if (e instanceof PublishServiceError) {
|
||||||
const messageKey = PUBLISH_ERROR_KEY_MAP[e.code] ?? "error.publish";
|
const messageKey = PUBLISH_ERROR_KEY_MAP[e.code] ?? "error.publish";
|
||||||
return { success: false, message: t(messageKey) };
|
return { success: false, message: t(messageKey) };
|
||||||
}
|
}
|
||||||
return {
|
return handleActionError(e);
|
||||||
success: false,
|
|
||||||
message: e instanceof Error ? e.message : t("error.publish"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils";
|
||||||
import {
|
import {
|
||||||
getLessonPlans,
|
getLessonPlans,
|
||||||
getLessonPlanById,
|
getLessonPlanById,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
updateLessonPlanContent,
|
updateLessonPlanContent,
|
||||||
softDeleteLessonPlan,
|
softDeleteLessonPlan,
|
||||||
duplicateLessonPlan,
|
duplicateLessonPlan,
|
||||||
|
getTextbooksForPicker,
|
||||||
|
getChaptersForPicker,
|
||||||
LessonPlanDataError,
|
LessonPlanDataError,
|
||||||
} from "./data-access";
|
} from "./data-access";
|
||||||
import {
|
import {
|
||||||
@@ -45,15 +48,12 @@ export async function getLessonPlansAction(params: {
|
|||||||
items: Awaited<ReturnType<typeof getLessonPlans>>;
|
items: Awaited<ReturnType<typeof getLessonPlans>>;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
|
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
|
||||||
return { success: true, data: { items } };
|
return { success: true, data: { items } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.getList") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,16 +63,13 @@ export async function getLessonPlanByIdAction(
|
|||||||
): Promise<
|
): Promise<
|
||||||
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
|
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
|
||||||
> {
|
> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const plan = await getLessonPlanById(planId, ctx.userId);
|
const plan = await getLessonPlanById(planId, ctx.userId);
|
||||||
if (!plan) return { success: false, message: t("error.notFound") };
|
if (!plan) return { success: false, message: "课案不存在" };
|
||||||
return { success: true, data: { plan } };
|
return { success: true, data: { plan } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.getOne") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +104,8 @@ export async function createLessonPlanAction(
|
|||||||
}
|
}
|
||||||
if (key.startsWith("template.blocks.")) {
|
if (key.startsWith("template.blocks.")) {
|
||||||
const parts = key.split(".");
|
const parts = key.split(".");
|
||||||
|
// 期望格式:template.blocks.{templateId}.{blockIndex}
|
||||||
|
if (parts.length < 4) return key;
|
||||||
const templateId = parts[2];
|
const templateId = parts[2];
|
||||||
const blockIndex = parts[3];
|
const blockIndex = parts[3];
|
||||||
return t(`template.blocks.${templateId}.${blockIndex}`);
|
return t(`template.blocks.${templateId}.${blockIndex}`);
|
||||||
@@ -117,11 +116,9 @@ export async function createLessonPlanAction(
|
|||||||
revalidatePath("/teacher/lesson-plans");
|
revalidatePath("/teacher/lesson-plans");
|
||||||
return { success: true, data: { planId } };
|
return { success: true, data: { planId } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
if (e instanceof LessonPlanDataError && e.code === "TEMPLATE_NOT_FOUND")
|
if (e instanceof LessonPlanDataError && e.code === "TEMPLATE_NOT_FOUND")
|
||||||
return { success: false, message: t("error.templateNotFound") };
|
return { success: false, message: t("error.templateNotFound") };
|
||||||
return { success: false, message: t("error.create") };
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +128,6 @@ export async function updateLessonPlanAction(input: {
|
|||||||
title?: string;
|
title?: string;
|
||||||
content: LessonPlanDocument;
|
content: LessonPlanDocument;
|
||||||
}): Promise<ActionState> {
|
}): Promise<ActionState> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
const parsed = updateLessonPlanContentSchema.safeParse(input);
|
const parsed = updateLessonPlanContentSchema.safeParse(input);
|
||||||
@@ -139,13 +135,13 @@ export async function updateLessonPlanAction(input: {
|
|||||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||||
await updateLessonPlanContent(parsed.data.planId, ctx.userId, {
|
await updateLessonPlanContent(parsed.data.planId, ctx.userId, {
|
||||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||||
content: parsed.data.content as LessonPlanDocument,
|
// 从 unknown 转换:Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫
|
||||||
|
content: parsed.data.content as unknown as LessonPlanDocument,
|
||||||
});
|
});
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.save") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +151,6 @@ export async function saveLessonPlanVersionAction(input: {
|
|||||||
content: LessonPlanDocument;
|
content: LessonPlanDocument;
|
||||||
label?: string;
|
label?: string;
|
||||||
}): Promise<ActionState<{ versionNo: number }>> {
|
}): Promise<ActionState<{ versionNo: number }>> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
const parsed = saveVersionSchema.safeParse(input);
|
const parsed = saveVersionSchema.safeParse(input);
|
||||||
@@ -171,9 +166,7 @@ export async function saveLessonPlanVersionAction(input: {
|
|||||||
await pruneAutoVersions(parsed.data.planId);
|
await pruneAutoVersions(parsed.data.planId);
|
||||||
return { success: true, data: { versionNo } };
|
return { success: true, data: { versionNo } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.saveVersion") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,15 +178,12 @@ export async function getLessonPlanVersionsAction(
|
|||||||
versions: Awaited<ReturnType<typeof getLessonPlanVersions>>;
|
versions: Awaited<ReturnType<typeof getLessonPlanVersions>>;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const versions = await getLessonPlanVersions(planId, ctx.userId);
|
const versions = await getLessonPlanVersions(planId, ctx.userId);
|
||||||
return { success: true, data: { versions } };
|
return { success: true, data: { versions } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.getVersions") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +192,6 @@ export async function revertLessonPlanVersionAction(input: {
|
|||||||
planId: string;
|
planId: string;
|
||||||
versionNo: number;
|
versionNo: number;
|
||||||
}): Promise<ActionState<{ newVersionNo: number }>> {
|
}): Promise<ActionState<{ newVersionNo: number }>> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
const parsed = revertVersionSchema.safeParse(input);
|
const parsed = revertVersionSchema.safeParse(input);
|
||||||
@@ -213,13 +202,11 @@ export async function revertLessonPlanVersionAction(input: {
|
|||||||
parsed.data.versionNo,
|
parsed.data.versionNo,
|
||||||
ctx.userId,
|
ctx.userId,
|
||||||
);
|
);
|
||||||
if (!result) return { success: false, message: t("error.versionNotFound") };
|
if (!result) return { success: false, message: "版本不存在" };
|
||||||
revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`);
|
revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`);
|
||||||
return { success: true, data: { newVersionNo: result.newVersionNo } };
|
return { success: true, data: { newVersionNo: result.newVersionNo } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.revert") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,16 +214,13 @@ export async function revertLessonPlanVersionAction(input: {
|
|||||||
export async function deleteLessonPlanAction(
|
export async function deleteLessonPlanAction(
|
||||||
planId: string,
|
planId: string,
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||||
await softDeleteLessonPlan(planId, ctx.userId);
|
await softDeleteLessonPlan(planId, ctx.userId);
|
||||||
revalidatePath("/teacher/lesson-plans");
|
revalidatePath("/teacher/lesson-plans");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.delete") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,11 +235,9 @@ export async function duplicateLessonPlanAction(
|
|||||||
revalidatePath("/teacher/lesson-plans");
|
revalidatePath("/teacher/lesson-plans");
|
||||||
return { success: true, data: { newPlanId } };
|
return { success: true, data: { newPlanId } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||||
return { success: false, message: t("error.notFound") };
|
return { success: false, message: t("error.notFound") };
|
||||||
return { success: false, message: t("error.duplicate") };
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,15 +247,12 @@ export async function getLessonPlanTemplatesAction(): Promise<
|
|||||||
templates: Awaited<ReturnType<typeof getLessonPlanTemplates>>;
|
templates: Awaited<ReturnType<typeof getLessonPlanTemplates>>;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const templates = await getLessonPlanTemplates(ctx.userId);
|
const templates = await getLessonPlanTemplates(ctx.userId);
|
||||||
return { success: true, data: { templates } };
|
return { success: true, data: { templates } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
|
||||||
return { success: false, message: t("error.getTemplates") };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,11 +273,9 @@ export async function saveAsTemplateAction(input: {
|
|||||||
});
|
});
|
||||||
return { success: true, data: { templateId } };
|
return { success: true, data: { templateId } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||||
return { success: false, message: t("error.notFound") };
|
return { success: false, message: t("error.notFound") };
|
||||||
return { success: false, message: t("error.saveTemplate") };
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,14 +283,51 @@ export async function saveAsTemplateAction(input: {
|
|||||||
export async function deleteTemplateAction(
|
export async function deleteTemplateAction(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
const t = await getTranslations("lessonPreparation");
|
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||||
await deletePersonalTemplate(templateId, ctx.userId);
|
await deletePersonalTemplate(templateId, ctx.userId);
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
return handleActionError(e);
|
||||||
return { success: false, message: e.message };
|
}
|
||||||
return { success: false, message: t("error.deleteTemplate") };
|
}
|
||||||
|
|
||||||
|
// ---- 获取教材列表(供 picker 使用)----
|
||||||
|
export async function getTextbooksForPickerAction(): Promise<
|
||||||
|
ActionState<{
|
||||||
|
textbooks: { id: string; title: string; subject: string; grade: string | null }[];
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
const textbooks = await getTextbooksForPicker();
|
||||||
|
return { success: true, data: { textbooks } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 获取章节列表(供 picker 使用)----
|
||||||
|
export async function getChaptersForPickerAction(
|
||||||
|
textbookId: string,
|
||||||
|
): Promise<
|
||||||
|
ActionState<{
|
||||||
|
chapters: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string | null;
|
||||||
|
order: number | null;
|
||||||
|
content?: string | null;
|
||||||
|
children?: unknown[];
|
||||||
|
}[];
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
const chapters = await getChaptersForPicker(textbookId);
|
||||||
|
return { success: true, data: { chapters } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { RichTextBlock } from "./blocks/rich-text-block";
|
|||||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||||
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData } from "../types";
|
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData, ReflectionBlockData } from "../types";
|
||||||
|
|
||||||
interface BlockRendererProps {
|
interface BlockRendererProps {
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
@@ -122,7 +122,7 @@ function SortableBlock({
|
|||||||
/>
|
/>
|
||||||
) : node.type === "reflection" ? (
|
) : node.type === "reflection" ? (
|
||||||
<ReflectionBlock
|
<ReflectionBlock
|
||||||
data={node.data as RichTextBlockData}
|
data={node.data as ReflectionBlockData}
|
||||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -140,7 +140,7 @@ export function BlockRenderer({
|
|||||||
chapterId,
|
chapterId,
|
||||||
classes,
|
classes,
|
||||||
}: BlockRendererProps) {
|
}: BlockRendererProps) {
|
||||||
const { doc } = useLessonPlanEditor();
|
const { doc, updateNode } = useLessonPlanEditor();
|
||||||
|
|
||||||
function onDragEnd(e: DragEndEvent) {
|
function onDragEnd(e: DragEndEvent) {
|
||||||
const { active, over } = e;
|
const { active, over } = e;
|
||||||
@@ -149,11 +149,10 @@ export function BlockRenderer({
|
|||||||
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
|
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
|
||||||
const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
|
const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
// 交换 order
|
// 交换 order 并写回 store(修复 onDragEnd 未回写 store 的 BUG)
|
||||||
const nodes = [...doc.nodes];
|
const tmpOrder = doc.nodes[oldIndex].order;
|
||||||
const tmpOrder = nodes[oldIndex].order;
|
updateNode(doc.nodes[oldIndex].id, { order: doc.nodes[newIndex].order });
|
||||||
nodes[oldIndex].order = nodes[newIndex].order;
|
updateNode(doc.nodes[newIndex].id, { order: tmpOrder });
|
||||||
nodes[newIndex].order = tmpOrder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +162,9 @@ export function BlockRenderer({
|
|||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{doc.nodes.map((b, i) => (
|
{doc.nodes
|
||||||
|
.filter((b): b is LessonPlanNode => b.type !== "textbook_content")
|
||||||
|
.map((b, i) => (
|
||||||
<SortableBlock
|
<SortableBlock
|
||||||
key={b.id}
|
key={b.id}
|
||||||
node={b}
|
node={b}
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { RichTextBlock } from "./rich-text-block";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import type { RichTextBlockData } from "../../types";
|
import type { ReflectionBlockData, ReflectionItem } from "../../types";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: RichTextBlockData;
|
data: ReflectionBlockData;
|
||||||
onUpdate: (data: RichTextBlockData) => void;
|
onUpdate: (data: ReflectionBlockData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReflectionBlock(props: Props) {
|
const ASPECTS: ReflectionItem["aspect"][] = ["effectiveness", "problems", "improvements"];
|
||||||
|
|
||||||
|
export function ReflectionBlock({ data, onUpdate }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
// 教学反思在 P1 阶段与普通富文本一致,P3 再扩展学情数据嵌入
|
|
||||||
return <RichTextBlock {...props} hint={t("reflection.hint")} />;
|
function updateItem(index: number, patch: Partial<ReflectionItem>) {
|
||||||
|
const next = data.reflection.map((it, i) =>
|
||||||
|
i === index ? { ...it, ...patch } : it,
|
||||||
|
);
|
||||||
|
onUpdate({ ...data, reflection: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
onUpdate({
|
||||||
|
...data,
|
||||||
|
reflection: [...data.reflection, { aspect: "effectiveness", text: "" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
onUpdate({
|
||||||
|
...data,
|
||||||
|
reflection: data.reflection.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-on-surface-variant">
|
||||||
|
{t("reflection.hint")}
|
||||||
|
</div>
|
||||||
|
{data.reflection.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-2">
|
||||||
|
<select
|
||||||
|
value={item.aspect}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(idx, {
|
||||||
|
aspect: e.target.value as ReflectionItem["aspect"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||||
|
>
|
||||||
|
{ASPECTS.map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{t(`reflection.aspect.${a}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => updateItem(idx, { text: e.target.value })}
|
||||||
|
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[60px]"
|
||||||
|
placeholder={t("reflection.textPlaceholder")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="!p-1 text-error"
|
||||||
|
onClick={() => removeItem(idx)}
|
||||||
|
aria-label={t("action.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={addItem}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
{t("reflection.addItem")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 t = useTranslations("lessonPreparation");
|
||||||
const [options, setOptions] = useState<KpOption[]>([]);
|
const [options, setOptions] = useState<KpOption[]>([]);
|
||||||
const [local, setLocal] = useState<string[]>(selectedIds);
|
const [local, setLocal] = useState<string[]>(selectedIds);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textbookId) {
|
if (!textbookId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
getKnowledgePointOptionsAction({ textbookId, chapterId }).then((res) => {
|
let cancelled = false;
|
||||||
if (res.success && res.data) setOptions(res.data.options);
|
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
return getKnowledgePointOptionsAction({ textbookId, chapterId });
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled || !res) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setOptions(res.data.options);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("error.loadFailed"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error("[KnowledgePointPicker] load options failed", e);
|
||||||
|
setError(t("error.loadFailed"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
}, [textbookId, chapterId]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [textbookId, chapterId, t]);
|
||||||
|
|
||||||
function toggle(id: string) {
|
function toggle(id: string) {
|
||||||
setLocal((prev) =>
|
setLocal((prev) =>
|
||||||
@@ -60,7 +87,13 @@ export function KnowledgePointPicker({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{options.length === 0 ? (
|
{loading ? (
|
||||||
|
<p className="text-on-surface-variant text-sm">
|
||||||
|
{t("knowledgePoint.loading")}
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-error text-sm">{error}</p>
|
||||||
|
) : options.length === 0 ? (
|
||||||
<p className="text-on-surface-variant text-sm">
|
<p className="text-on-surface-variant text-sm">
|
||||||
{t("knowledgePoint.empty")}
|
{t("knowledgePoint.empty")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
const service = ctx?.service ?? null;
|
const service = ctx?.service ?? null;
|
||||||
|
|
||||||
async function handleArchive() {
|
async function handleArchive() {
|
||||||
|
try {
|
||||||
const res = service
|
const res = service
|
||||||
? await service.deleteLessonPlan(plan.id)
|
? await service.deleteLessonPlan(plan.id)
|
||||||
: await deleteLessonPlanAction(plan.id);
|
: await deleteLessonPlanAction(plan.id);
|
||||||
@@ -42,15 +43,26 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.delete"));
|
toast.error(res.message ?? t("error.delete"));
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanCard] archive failed", e);
|
||||||
|
toast.error(t("error.delete"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDuplicate() {
|
async function handleDuplicate() {
|
||||||
|
try {
|
||||||
const res = service
|
const res = service
|
||||||
? await service.duplicateLessonPlan(plan.id)
|
? await service.duplicateLessonPlan(plan.id)
|
||||||
: await duplicateLessonPlanAction(plan.id);
|
: await duplicateLessonPlanAction(plan.id);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.duplicate"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanCard] duplicate failed", e);
|
||||||
|
toast.error(t("error.duplicate"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
import type { BlockType } from "../types";
|
import type { BlockType } from "../types";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Plus, Save, History } from "lucide-react";
|
import { Plus, Save, History, Book, FileText } from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -22,6 +22,8 @@ interface Props {
|
|||||||
initialDoc: import("../types").LessonPlanDocument;
|
initialDoc: import("../types").LessonPlanDocument;
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
chapterId?: string;
|
chapterId?: string;
|
||||||
|
textbookTitle?: string;
|
||||||
|
chapterTitle?: string;
|
||||||
classes?: { id: string; name: string }[];
|
classes?: { id: string; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +48,8 @@ export function LessonPlanEditor({
|
|||||||
initialDoc,
|
initialDoc,
|
||||||
textbookId,
|
textbookId,
|
||||||
chapterId,
|
chapterId,
|
||||||
|
textbookTitle,
|
||||||
|
chapterTitle,
|
||||||
classes,
|
classes,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
@@ -71,13 +75,18 @@ export function LessonPlanEditor({
|
|||||||
autoSaveTimer.current = setTimeout(async () => {
|
autoSaveTimer.current = setTimeout(async () => {
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
state.setSaving(true);
|
state.setSaving(true);
|
||||||
|
try {
|
||||||
const res = await updateLessonPlanAction({
|
const res = await updateLessonPlanAction({
|
||||||
planId: state.planId,
|
planId: state.planId,
|
||||||
title: state.title,
|
title: state.title,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
});
|
});
|
||||||
state.setSaving(false);
|
|
||||||
if (res.success) state.markSaved();
|
if (res.success) state.markSaved();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanEditor] auto-save failed", e);
|
||||||
|
} finally {
|
||||||
|
state.setSaving(false);
|
||||||
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
return () => {
|
return () => {
|
||||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||||
@@ -89,11 +98,15 @@ export function LessonPlanEditor({
|
|||||||
versionTimer.current = setInterval(async () => {
|
versionTimer.current = setInterval(async () => {
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
if (!state.isDirty) return;
|
if (!state.isDirty) return;
|
||||||
|
try {
|
||||||
await saveLessonPlanVersionAction({
|
await saveLessonPlanVersionAction({
|
||||||
planId: state.planId,
|
planId: state.planId,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
label: t("version.autoLabel"),
|
label: t("version.autoLabel"),
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanEditor] auto-version failed", e);
|
||||||
|
}
|
||||||
}, 30 * 60 * 1000);
|
}, 30 * 60 * 1000);
|
||||||
return () => {
|
return () => {
|
||||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||||
@@ -127,24 +140,33 @@ export function LessonPlanEditor({
|
|||||||
const handleManualSave = useCallback(async () => {
|
const handleManualSave = useCallback(async () => {
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
state.setSaving(true);
|
state.setSaving(true);
|
||||||
|
try {
|
||||||
const res = await saveLessonPlanVersionAction({
|
const res = await saveLessonPlanVersionAction({
|
||||||
planId: state.planId,
|
planId: state.planId,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
});
|
});
|
||||||
state.setSaving(false);
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
state.markSaved();
|
state.markSaved();
|
||||||
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanEditor] manual save failed", e);
|
||||||
|
} finally {
|
||||||
|
state.setSaving(false);
|
||||||
|
}
|
||||||
}, [tracker]);
|
}, [tracker]);
|
||||||
|
|
||||||
// 版本回退后刷新内容(修复 P1-1)
|
// 版本回退后刷新内容(修复 P1-1)
|
||||||
const handleReverted = useCallback(async () => {
|
const handleReverted = useCallback(async () => {
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
|
try {
|
||||||
const res = await getLessonPlanByIdAction(state.planId);
|
const res = await getLessonPlanByIdAction(state.planId);
|
||||||
if (res.success && res.data?.plan) {
|
if (res.success && res.data?.plan) {
|
||||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanEditor] reload after revert failed", e);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,6 +178,20 @@ export function LessonPlanEditor({
|
|||||||
onChange={(e) => editor.setTitle(e.target.value)}
|
onChange={(e) => editor.setTitle(e.target.value)}
|
||||||
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
|
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
{/* 教材/章节指示器 */}
|
||||||
|
{textbookTitle && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high">
|
||||||
|
<Book className="w-3 h-3" />
|
||||||
|
<span className="max-w-[120px] truncate">{textbookTitle}</span>
|
||||||
|
{chapterTitle && (
|
||||||
|
<>
|
||||||
|
<span className="text-on-surface-variant/50">/</span>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
<span className="max-w-[120px] truncate">{chapterTitle}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className="text-on-surface-variant text-sm">
|
<span className="text-on-surface-variant text-sm">
|
||||||
{editor.isSaving
|
{editor.isSaving
|
||||||
? t("status.saving")
|
? t("status.saving")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useDebounce } from "@/shared/hooks/use-debounce";
|
import { useDebounce } from "@/shared/hooks/use-debounce";
|
||||||
|
|
||||||
@@ -21,13 +21,19 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
|||||||
// 修复 P1-6:搜索 debounce 300ms
|
// 修复 P1-6:搜索 debounce 300ms
|
||||||
const debouncedQuery = useDebounce(query, 300);
|
const debouncedQuery = useDebounce(query, 300);
|
||||||
|
|
||||||
|
// 使用 ref 存储 onFilter,避免其引用变化触发 useEffect 无限循环
|
||||||
|
const onFilterRef = useRef(onFilter);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFilter({
|
onFilterRef.current = onFilter;
|
||||||
|
}, [onFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFilterRef.current({
|
||||||
query: debouncedQuery || undefined,
|
query: debouncedQuery || undefined,
|
||||||
subjectId: subjectId || undefined,
|
subjectId: subjectId || undefined,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
});
|
});
|
||||||
}, [debouncedQuery, subjectId, status, onFilter]);
|
}, [debouncedQuery, subjectId, status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { LessonPlanCard } from "./lesson-plan-card";
|
import { LessonPlanCard } from "./lesson-plan-card";
|
||||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||||
@@ -16,26 +16,50 @@ interface Props {
|
|||||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const [items, setItems] = useState(initialItems);
|
const [items, setItems] = useState(initialItems);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const ctx = useLessonPlanContextSafe();
|
const ctx = useLessonPlanContextSafe();
|
||||||
const service = ctx?.service ?? null;
|
const service = ctx?.service ?? null;
|
||||||
|
|
||||||
async function handleFilter(params: {
|
// 使用 useCallback 稳定 handleFilter 引用,避免 LessonPlanFilters 的 useEffect 无限循环
|
||||||
|
const handleFilter = useCallback(
|
||||||
|
async (params: {
|
||||||
query?: string;
|
query?: string;
|
||||||
subjectId?: string;
|
subjectId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}) {
|
}) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
if (service) {
|
if (service) {
|
||||||
const res = await service.getLessonPlans(params);
|
const res = await service.getLessonPlans(params);
|
||||||
if (res.success && res.data) setItems(res.data.items);
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("error.loadFailed"));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await getLessonPlansAction(params);
|
const res = await getLessonPlansAction(params);
|
||||||
if (res.success && res.data) setItems(res.data.items);
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("error.loadFailed"));
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanList] filter failed", e);
|
||||||
|
setError(t("error.loadFailed"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[service, t],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
|
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
|
||||||
|
{error && (
|
||||||
|
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-on-surface-variant text-center py-12">
|
<p className="text-on-surface-variant text-center py-12">
|
||||||
{t("list.empty")}
|
{t("list.empty")}
|
||||||
|
|||||||
@@ -18,39 +18,108 @@ import {
|
|||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { LessonNode } from "./nodes/lesson-node";
|
import { LessonNode } from "./nodes/lesson-node";
|
||||||
|
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
||||||
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||||
import { getNodeColor } from "../lib/node-summary";
|
import { getNodeColor } from "../lib/node-summary";
|
||||||
import type { LessonPlanNode } from "../types";
|
import type { AnyLessonPlanNode } from "../types";
|
||||||
|
|
||||||
const nodeTypes = { lesson: LessonNode };
|
const nodeTypes = {
|
||||||
|
lesson: LessonNode,
|
||||||
|
textbook_content: TextbookContentNodeComponent,
|
||||||
|
};
|
||||||
|
|
||||||
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
|
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
|
||||||
type Props = Record<string, never>;
|
type Props = Record<string, never>;
|
||||||
|
|
||||||
export function NodeEditor({}: Props) {
|
export function NodeEditor({}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
const {
|
||||||
useLessonPlanEditor();
|
doc,
|
||||||
|
selectedNodeId,
|
||||||
|
updateNodePosition,
|
||||||
|
removeNode,
|
||||||
|
connect,
|
||||||
|
selectNode,
|
||||||
|
setEdges,
|
||||||
|
addAnchor,
|
||||||
|
updateTextbookContent,
|
||||||
|
} = useLessonPlanEditor();
|
||||||
|
|
||||||
|
// 锚点添加回调(正文节点使用)
|
||||||
|
const handleAddRangeAnchor = useCallback(
|
||||||
|
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
|
||||||
|
// 如果 nodeId 是 __selected__,使用当前选中节点
|
||||||
|
// 如果是 __new__,提示用户先创建节点
|
||||||
|
const actualNodeId =
|
||||||
|
params.nodeId === "__selected__"
|
||||||
|
? selectedNodeId ?? ""
|
||||||
|
: params.nodeId;
|
||||||
|
if (!actualNodeId || actualNodeId === "__new__") {
|
||||||
|
// 简化:不自动创建新节点,提示用户先选中或创建
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addAnchor({
|
||||||
|
nodeId: actualNodeId,
|
||||||
|
type: "range",
|
||||||
|
start: params.start,
|
||||||
|
end: params.end,
|
||||||
|
textPreview: params.textPreview,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[addAnchor, selectedNodeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddPointAnchor = useCallback(
|
||||||
|
(params: { nodeId: string; start: number }) => {
|
||||||
|
const actualNodeId =
|
||||||
|
params.nodeId === "__selected__"
|
||||||
|
? selectedNodeId ?? ""
|
||||||
|
: params.nodeId;
|
||||||
|
if (!actualNodeId || actualNodeId === "__new__") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addAnchor({
|
||||||
|
nodeId: actualNodeId,
|
||||||
|
type: "point",
|
||||||
|
start: params.start,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[addAnchor, selectedNodeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleZoomChange = useCallback(
|
||||||
|
(zoom: number) => {
|
||||||
|
updateTextbookContent({ zoom });
|
||||||
|
},
|
||||||
|
[updateTextbookContent],
|
||||||
|
);
|
||||||
|
|
||||||
// 使用纯函数映射 nodes/edges
|
// 使用纯函数映射 nodes/edges
|
||||||
const rfNodes: Node[] = useMemo(
|
const rfNodes: Node[] = useMemo(
|
||||||
() => toRfNodes(doc.nodes, selectedNodeId),
|
() =>
|
||||||
[doc.nodes, selectedNodeId],
|
toRfNodes(doc.nodes, selectedNodeId, {
|
||||||
|
anchors: doc.anchors,
|
||||||
|
selectedNodeId,
|
||||||
|
onAddRangeAnchor: handleAddRangeAnchor,
|
||||||
|
onAddPointAnchor: handleAddPointAnchor,
|
||||||
|
onSelectNode: selectNode,
|
||||||
|
onZoomChange: handleZoomChange,
|
||||||
|
}),
|
||||||
|
[doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rfEdges: Edge[] = useMemo(
|
const rfEdges: Edge[] = useMemo(
|
||||||
() => toRfEdges(doc.edges),
|
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors),
|
||||||
[doc.edges],
|
[doc.edges, selectedNodeId, doc.anchors],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
changes.forEach((change) => {
|
changes.forEach((change) => {
|
||||||
if (change.type === "position" && change.position) {
|
if (change.type === "position" && change.position) {
|
||||||
// 拖拽结束时(dragging: false)才写入最终位置,避免中间状态污染(修复 P1-1)
|
// 实时拖动:每次 position 变化都更新(不再等待 dragging=false)
|
||||||
if (change.dragging === false) {
|
// 但仅在节点正在被拖动或拖动结束时更新
|
||||||
updateNodePosition(change.id, change.position);
|
updateNodePosition(change.id, change.position);
|
||||||
}
|
|
||||||
} else if (change.type === "remove") {
|
} else if (change.type === "remove") {
|
||||||
removeNode(change.id);
|
removeNode(change.id);
|
||||||
} else if (change.type === "select") {
|
} else if (change.type === "select") {
|
||||||
@@ -75,16 +144,32 @@ export function NodeEditor({}: Props) {
|
|||||||
(changes: EdgeChange[]) => {
|
(changes: EdgeChange[]) => {
|
||||||
// 简单处理:删除时调用 disconnect
|
// 简单处理:删除时调用 disconnect
|
||||||
const nextEdges = applyEdgeChanges(changes, rfEdges);
|
const nextEdges = applyEdgeChanges(changes, rfEdges);
|
||||||
const ourEdges = nextEdges.map((e) => ({
|
const ourEdges = nextEdges.map((e) => {
|
||||||
|
// 保留原有的 type 信息
|
||||||
|
const original = doc.edges.find((oe) => oe.id === e.id);
|
||||||
|
if (original?.type === "anchor") {
|
||||||
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
source: e.source,
|
source: e.source,
|
||||||
target: e.target,
|
target: e.target,
|
||||||
sourceHandle: e.sourceHandle ?? null,
|
sourceHandle: e.sourceHandle ?? null,
|
||||||
targetHandle: e.targetHandle ?? null,
|
targetHandle: e.targetHandle ?? null,
|
||||||
}));
|
type: "anchor" as const,
|
||||||
|
anchorId: original.anchorId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
sourceHandle: e.sourceHandle ?? null,
|
||||||
|
targetHandle: e.targetHandle ?? null,
|
||||||
|
type: "flow" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
setEdges(ourEdges);
|
setEdges(ourEdges);
|
||||||
},
|
},
|
||||||
[rfEdges, setEdges],
|
[rfEdges, setEdges, doc.edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,7 +216,7 @@ export function NodeEditor({}: Props) {
|
|||||||
<MiniMap
|
<MiniMap
|
||||||
className="!bg-surface !border-outline-variant"
|
className="!bg-surface !border-outline-variant"
|
||||||
nodeColor={(n) => {
|
nodeColor={(n) => {
|
||||||
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
|
||||||
if (!nodeData) return "#9e9e9e";
|
if (!nodeData) return "#9e9e9e";
|
||||||
return getNodeColor(nodeData.type);
|
return getNodeColor(nodeData.type);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,6 +37,7 @@ export function PublishHomeworkDialog({
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
try {
|
||||||
const res = await publishLessonPlanHomeworkAction({
|
const res = await publishLessonPlanHomeworkAction({
|
||||||
planId,
|
planId,
|
||||||
blockId,
|
blockId,
|
||||||
@@ -44,7 +45,6 @@ export function PublishHomeworkDialog({
|
|||||||
availableAt: availableAt || undefined,
|
availableAt: availableAt || undefined,
|
||||||
dueAt: dueAt || undefined,
|
dueAt: dueAt || undefined,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.publish", {
|
tracker.track("lesson_plan.publish", {
|
||||||
planId,
|
planId,
|
||||||
@@ -56,6 +56,12 @@ export function PublishHomeworkDialog({
|
|||||||
} else {
|
} else {
|
||||||
setError(res.message ?? t("error.publish"));
|
setError(res.message ?? t("error.publish"));
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[PublishHomeworkDialog] publish failed", e);
|
||||||
|
setError(t("error.publish"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
const t = useTranslations("lessonPreparation")
|
const t = useTranslations("lessonPreparation")
|
||||||
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
||||||
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
|
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
|
||||||
const [searchValue, setSearchValue] = useState("")
|
const [searchValue, setSearchValue] = useState("")
|
||||||
@@ -53,7 +55,17 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
const debouncedFilters = useDebounce(filters, 300)
|
const debouncedFilters = useDebounce(filters, 300)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getQuestionsAction(debouncedFilters).then((res) => {
|
let cancelled = false
|
||||||
|
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
return getQuestionsAction(debouncedFilters)
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled || !res) return
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
const data = res.data.data
|
const data = res.data.data
|
||||||
setQuestions(
|
setQuestions(
|
||||||
@@ -64,9 +76,22 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
content: q.content,
|
content: q.content,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("error.loadFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [debouncedFilters])
|
.catch((e) => {
|
||||||
|
if (cancelled) return
|
||||||
|
console.error("[QuestionBankPicker] load questions failed", e)
|
||||||
|
setError(t("error.loadFailed"))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [debouncedFilters, t])
|
||||||
|
|
||||||
function add(q: QuestionRow) {
|
function add(q: QuestionRow) {
|
||||||
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
|
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
|
||||||
@@ -116,6 +141,17 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-on-surface-variant text-sm text-center py-8">
|
||||||
|
{t("questionBank.loading")}
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-error text-sm text-center py-8">{error}</p>
|
||||||
|
) : questions.length === 0 ? (
|
||||||
|
<p className="text-on-surface-variant text-sm text-center py-8">
|
||||||
|
{t("questionBank.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{questions.map((q) => (
|
{questions.map((q) => (
|
||||||
<div
|
<div
|
||||||
@@ -132,6 +168,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t flex justify-between">
|
<div className="p-4 border-t flex justify-between">
|
||||||
<span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span>
|
<span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span>
|
||||||
|
|||||||
@@ -1,25 +1,145 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { createLessonPlanAction } from "../actions";
|
import { createLessonPlanAction, getTextbooksForPickerAction, getChaptersForPickerAction } from "../actions";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { SYSTEM_TEMPLATES } from "../constants";
|
import { SYSTEM_TEMPLATES } from "../constants";
|
||||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
|
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface TextbookOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
grade: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChapterOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string | null;
|
||||||
|
order: number | null;
|
||||||
|
content?: string | null;
|
||||||
|
children?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
export function TemplatePicker() {
|
export function TemplatePicker() {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [textbooks, setTextbooks] = useState<TextbookOption[]>([]);
|
||||||
|
const [textbookId, setTextbookId] = useState<string>("");
|
||||||
|
const [chapters, setChapters] = useState<ChapterOption[]>([]);
|
||||||
|
const [chapterId, setChapterId] = useState<string>(
|
||||||
|
() => searchParams.get("chapterId") ?? "",
|
||||||
|
);
|
||||||
|
const [loadedTextbookId, setLoadedTextbookId] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<string>("");
|
const [selected, setSelected] = useState<string>("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
|
||||||
|
|
||||||
|
// 派生:当前教材的章节是否正在加载
|
||||||
|
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
|
||||||
|
|
||||||
|
// 初始加载教材列表 + URL 参数预选
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getTextbooksForPickerAction()
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setTextbooks(res.data.textbooks);
|
||||||
|
// URL 参数预选
|
||||||
|
const urlTextbookId = searchParams.get("textbookId");
|
||||||
|
if (urlTextbookId && res.data.textbooks.some((tb) => tb.id === urlTextbookId)) {
|
||||||
|
setTextbookId(urlTextbookId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("[TemplatePicker] load textbooks failed", e);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoadingTextbooks(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// 教材变化时加载章节
|
||||||
|
useEffect(() => {
|
||||||
|
if (!textbookId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
getChaptersForPickerAction(textbookId)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setChapters(res.data.chapters);
|
||||||
|
setLoadedTextbookId(textbookId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("[TemplatePicker] load chapters failed", e);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [textbookId]);
|
||||||
|
|
||||||
|
// 扁平化章节列表(用于下拉选择,带缩进前缀)
|
||||||
|
const flattenedChapters = useMemo(() => {
|
||||||
|
const result: { id: string; title: string; depth: number }[] = [];
|
||||||
|
function walk(list: ChapterOption[], depth: number) {
|
||||||
|
for (const ch of list) {
|
||||||
|
result.push({ id: ch.id, title: ch.title, depth });
|
||||||
|
if (ch.children && Array.isArray(ch.children) && ch.children.length > 0) {
|
||||||
|
walk(ch.children as ChapterOption[], depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(chapters, 0);
|
||||||
|
return result;
|
||||||
|
}, [chapters]);
|
||||||
|
|
||||||
|
// 选中章节时自动填充标题(如果标题为空)
|
||||||
|
const selectedChapter = useMemo(
|
||||||
|
() => flattenedChapters.find((c) => c.id === chapterId),
|
||||||
|
[flattenedChapters, chapterId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChapterChange = useCallback((id: string) => {
|
||||||
|
setChapterId(id);
|
||||||
|
// 如果标题为空,用章节标题预填
|
||||||
|
const ch = flattenedChapters.find((c) => c.id === id);
|
||||||
|
if (ch && !title) {
|
||||||
|
setTitle(ch.title);
|
||||||
|
}
|
||||||
|
}, [flattenedChapters, title]);
|
||||||
|
|
||||||
|
const selectedTextbook = textbooks.find((tb) => tb.id === textbookId);
|
||||||
|
|
||||||
|
const canSubmit = !!selected && !!title && !!textbookId && !!chapterId;
|
||||||
|
|
||||||
async function handleSubmit(formData: FormData) {
|
async function handleSubmit(formData: FormData) {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!textbookId || !chapterId) {
|
||||||
|
setError(t("picker.errorTextbookChapterRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
formData.set("templateId", selected);
|
formData.set("templateId", selected);
|
||||||
formData.set("title", title);
|
formData.set("title", title);
|
||||||
|
formData.set("textbookId", textbookId);
|
||||||
|
formData.set("chapterId", chapterId);
|
||||||
|
try {
|
||||||
const res = await createLessonPlanAction(null, formData);
|
const res = await createLessonPlanAction(null, formData);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||||
@@ -27,10 +147,92 @@ export function TemplatePicker() {
|
|||||||
} else {
|
} else {
|
||||||
setError(res.message ?? t("error.createFailed"));
|
setError(res.message ?? t("error.createFailed"));
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[TemplatePicker] create failed", e);
|
||||||
|
setError(t("error.createFailed"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
|
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
|
{/* 步骤 1:选择教材 */}
|
||||||
|
<div>
|
||||||
|
<label className="font-title-md block mb-2 flex items-center gap-2">
|
||||||
|
<Book className="w-4 h-4" />
|
||||||
|
{t("picker.textbookLabel")}
|
||||||
|
<span className="text-error text-sm">*</span>
|
||||||
|
</label>
|
||||||
|
{loadingTextbooks ? (
|
||||||
|
<div className="flex items-center gap-2 text-on-surface-variant text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{t("picker.loadingTextbooks")}
|
||||||
|
</div>
|
||||||
|
) : textbooks.length === 0 ? (
|
||||||
|
<p className="text-on-surface-variant text-sm">{t("picker.noTextbooks")}</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={textbookId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTextbookId(e.target.value);
|
||||||
|
setChapterId("");
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
|
||||||
|
>
|
||||||
|
<option value="">{t("picker.selectTextbook")}</option>
|
||||||
|
{textbooks.map((tb) => (
|
||||||
|
<option key={tb.id} value={tb.id}>
|
||||||
|
{tb.title}
|
||||||
|
{tb.subject ? ` · ${tb.subject}` : ""}
|
||||||
|
{tb.grade ? ` · ${tb.grade}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤 2:选择章节 */}
|
||||||
|
<div>
|
||||||
|
<label className="font-title-md block mb-2 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
{t("picker.chapterLabel")}
|
||||||
|
<span className="text-error text-sm">*</span>
|
||||||
|
</label>
|
||||||
|
{!textbookId ? (
|
||||||
|
<p className="text-on-surface-variant text-sm">{t("picker.selectTextbookFirst")}</p>
|
||||||
|
) : loadingChapters ? (
|
||||||
|
<div className="flex items-center gap-2 text-on-surface-variant text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{t("picker.loadingChapters")}
|
||||||
|
</div>
|
||||||
|
) : flattenedChapters.length === 0 ? (
|
||||||
|
<p className="text-on-surface-variant text-sm">{t("picker.noChapters")}</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={chapterId}
|
||||||
|
onChange={(e) => handleChapterChange(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
|
||||||
|
>
|
||||||
|
<option value="">{t("picker.selectChapter")}</option>
|
||||||
|
{flattenedChapters.map((ch) => (
|
||||||
|
<option key={ch.id} value={ch.id}>
|
||||||
|
{" ".repeat(ch.depth)}
|
||||||
|
{ch.depth > 0 ? "└ " : ""}
|
||||||
|
{ch.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{selectedChapter && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 flex items-center gap-1">
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
{t("picker.selectedChapter", { chapter: selectedChapter.title })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤 3:标题 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="font-title-md block mb-2">{t("template.titleLabel")}</label>
|
<label className="font-title-md block mb-2">{t("template.titleLabel")}</label>
|
||||||
<input
|
<input
|
||||||
@@ -41,6 +243,8 @@ export function TemplatePicker() {
|
|||||||
placeholder={t("template.titlePlaceholder")}
|
placeholder={t("template.titlePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤 4:模板 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="font-title-md block mb-2">{t("template.selectLabel")}</label>
|
<label className="font-title-md block mb-2">{t("template.selectLabel")}</label>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
@@ -64,9 +268,15 @@ export function TemplatePicker() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedTextbook && selectedChapter && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-2">
|
||||||
|
{t("picker.skeletonHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-error text-sm">{error}</p>}
|
{error && <p className="text-error text-sm">{error}</p>}
|
||||||
<Button type="submit" disabled={!selected || !title}>
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -48,10 +48,16 @@ export function VersionHistoryDrawer({
|
|||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getLessonPlanVersionsAction(planId).then((res) => {
|
getLessonPlanVersionsAction(planId)
|
||||||
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.success && res.data) setVersions(res.data.versions);
|
if (res.success && res.data) setVersions(res.data.versions);
|
||||||
setLoading(false);
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("[VersionHistoryDrawer] load versions failed", e);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -60,6 +66,7 @@ export function VersionHistoryDrawer({
|
|||||||
}, [open, planId]);
|
}, [open, planId]);
|
||||||
|
|
||||||
async function handleRevert(versionNo: number) {
|
async function handleRevert(versionNo: number) {
|
||||||
|
try {
|
||||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||||
@@ -69,6 +76,10 @@ export function VersionHistoryDrawer({
|
|||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("error.revert"));
|
toast.error(res.message ?? t("error.revert"));
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[VersionHistoryDrawer] revert failed", e);
|
||||||
|
toast.error(t("error.revert"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import type {
|
import type {
|
||||||
|
BlackboardBlockData,
|
||||||
|
BlockData,
|
||||||
BlockType,
|
BlockType,
|
||||||
ExerciseBlockData,
|
ExerciseBlockData,
|
||||||
|
HomeworkBlockData,
|
||||||
|
ImportBlockData,
|
||||||
|
KeyPointBlockData,
|
||||||
|
NewTeachingBlockData,
|
||||||
|
ObjectiveBlockData,
|
||||||
|
ReflectionBlockData,
|
||||||
RichTextBlockData,
|
RichTextBlockData,
|
||||||
|
SummaryBlockData,
|
||||||
TextStudyBlockData,
|
TextStudyBlockData,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { RichTextBlock } from "../components/blocks/rich-text-block";
|
import { RichTextBlock } from "../components/blocks/rich-text-block";
|
||||||
import { ExerciseBlock } from "../components/blocks/exercise-block";
|
import { ExerciseBlock } from "../components/blocks/exercise-block";
|
||||||
import { TextStudyBlock } from "../components/blocks/text-study-block";
|
import { TextStudyBlock } from "../components/blocks/text-study-block";
|
||||||
|
import { ObjectiveBlock } from "../components/blocks/objective-block";
|
||||||
|
import { KeyPointBlock } from "../components/blocks/key-point-block";
|
||||||
|
import { ImportBlock } from "../components/blocks/import-block";
|
||||||
|
import { NewTeachingBlock } from "../components/blocks/new-teaching-block";
|
||||||
|
import { SummaryBlock } from "../components/blocks/summary-block";
|
||||||
|
import { HomeworkBlock } from "../components/blocks/homework-block";
|
||||||
|
import { BlackboardBlock } from "../components/blocks/blackboard-block";
|
||||||
import { ReflectionBlock } from "../components/blocks/reflection-block";
|
import { ReflectionBlock } from "../components/blocks/reflection-block";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,11 +33,11 @@ import { ReflectionBlock } from "../components/blocks/reflection-block";
|
|||||||
|
|
||||||
export interface BlockRenderProps {
|
export interface BlockRenderProps {
|
||||||
blockId: string;
|
blockId: string;
|
||||||
data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData;
|
data: BlockData;
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
chapterId?: string;
|
chapterId?: string;
|
||||||
classes?: { id: string; name: string }[];
|
classes?: { id: string; name: string }[];
|
||||||
onUpdate: (data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData) => void;
|
onUpdate: (data: BlockData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockRegistryEntry {
|
export interface BlockRegistryEntry {
|
||||||
@@ -29,34 +45,24 @@ export interface BlockRegistryEntry {
|
|||||||
isRichText?: boolean;
|
isRichText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RICH_TEXT_TYPES: BlockType[] = [
|
const RICH_TEXT_TYPES: BlockType[] = ["rich_text", "consolidation"];
|
||||||
"objective",
|
|
||||||
"key_point",
|
|
||||||
"import",
|
|
||||||
"new_teaching",
|
|
||||||
"consolidation",
|
|
||||||
"summary",
|
|
||||||
"homework",
|
|
||||||
"blackboard",
|
|
||||||
"rich_text",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block 注册表元数据(用于查询 isRichText 等属性)。
|
* Block 注册表元数据(用于查询 isRichText 等属性)。
|
||||||
* 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。
|
* 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。
|
||||||
*/
|
*/
|
||||||
export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
|
export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
|
||||||
objective: { isRichText: true },
|
objective: {},
|
||||||
key_point: { isRichText: true },
|
key_point: {},
|
||||||
import: { isRichText: true },
|
import: {},
|
||||||
new_teaching: { isRichText: true },
|
new_teaching: {},
|
||||||
consolidation: { isRichText: true },
|
consolidation: { isRichText: true },
|
||||||
summary: { isRichText: true },
|
summary: {},
|
||||||
homework: { isRichText: true },
|
homework: {},
|
||||||
blackboard: { isRichText: true },
|
blackboard: {},
|
||||||
rich_text: { isRichText: true },
|
|
||||||
exercise: {},
|
|
||||||
text_study: {},
|
text_study: {},
|
||||||
|
exercise: {},
|
||||||
|
rich_text: { isRichText: true },
|
||||||
reflection: {},
|
reflection: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +79,66 @@ export function isRichTextBlock(type: BlockType): boolean {
|
|||||||
export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null {
|
export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null {
|
||||||
const { type, ...rest } = props;
|
const { type, ...rest } = props;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case "objective":
|
||||||
|
return (
|
||||||
|
<ObjectiveBlock
|
||||||
|
data={rest.data as ObjectiveBlockData}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "key_point":
|
||||||
|
return (
|
||||||
|
<KeyPointBlock
|
||||||
|
data={rest.data as KeyPointBlockData}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "import":
|
||||||
|
return (
|
||||||
|
<ImportBlock
|
||||||
|
data={rest.data as ImportBlockData}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "new_teaching":
|
||||||
|
return (
|
||||||
|
<NewTeachingBlock
|
||||||
|
data={rest.data as NewTeachingBlockData}
|
||||||
|
textbookId={rest.textbookId}
|
||||||
|
chapterId={rest.chapterId}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "summary":
|
||||||
|
return (
|
||||||
|
<SummaryBlock
|
||||||
|
data={rest.data as SummaryBlockData}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "homework":
|
||||||
|
return (
|
||||||
|
<HomeworkBlock
|
||||||
|
data={rest.data as HomeworkBlockData}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "blackboard":
|
||||||
|
return (
|
||||||
|
<BlackboardBlock
|
||||||
|
data={rest.data as BlackboardBlockData}
|
||||||
|
textbookId={rest.textbookId}
|
||||||
|
chapterId={rest.chapterId}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "reflection":
|
||||||
|
return (
|
||||||
|
<ReflectionBlock
|
||||||
|
data={rest.data as ReflectionBlockData}
|
||||||
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "exercise":
|
case "exercise":
|
||||||
return (
|
return (
|
||||||
<ExerciseBlock
|
<ExerciseBlock
|
||||||
@@ -85,23 +151,14 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
|
|||||||
);
|
);
|
||||||
case "text_study":
|
case "text_study":
|
||||||
return <TextStudyBlock blockId={rest.blockId} data={rest.data as TextStudyBlockData} />;
|
return <TextStudyBlock blockId={rest.blockId} data={rest.data as TextStudyBlockData} />;
|
||||||
case "reflection":
|
|
||||||
return <ReflectionBlock data={rest.data as RichTextBlockData} onUpdate={rest.onUpdate} />;
|
|
||||||
case "objective":
|
|
||||||
case "key_point":
|
|
||||||
case "import":
|
|
||||||
case "new_teaching":
|
|
||||||
case "consolidation":
|
|
||||||
case "summary":
|
|
||||||
case "homework":
|
|
||||||
case "blackboard":
|
|
||||||
case "rich_text":
|
case "rich_text":
|
||||||
|
case "consolidation":
|
||||||
return (
|
return (
|
||||||
<RichTextBlock
|
<RichTextBlock
|
||||||
data={rest.data as RichTextBlockData}
|
data={rest.data as RichTextBlockData}
|
||||||
textbookId={rest.textbookId}
|
textbookId={rest.textbookId}
|
||||||
chapterId={rest.chapterId}
|
chapterId={rest.chapterId}
|
||||||
onUpdate={rest.onUpdate}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ export async function saveAsTemplate(input: {
|
|||||||
if (plan.length === 0) throw new LessonPlanDataError("NOT_FOUND");
|
if (plan.length === 0) throw new LessonPlanDataError("NOT_FOUND");
|
||||||
|
|
||||||
const doc = normalizeDocument(plan[0].content);
|
const doc = normalizeDocument(plan[0].content);
|
||||||
const skeleton: TemplateBlockSkeleton[] = doc.nodes.map((b) => ({
|
const skeleton: TemplateBlockSkeleton[] = doc.nodes
|
||||||
|
.filter((b): b is import("./types").LessonPlanNode => b.type !== "textbook_content")
|
||||||
|
.map((b) => ({
|
||||||
type: b.type,
|
type: b.type,
|
||||||
title: b.title,
|
title: b.title,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -60,14 +60,15 @@ export async function createLessonPlanVersion(input: {
|
|||||||
isAuto: boolean;
|
isAuto: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
}): Promise<{ versionNo: number }> {
|
}): Promise<{ versionNo: number }> {
|
||||||
// 取当前最大 versionNo
|
// P0 修复:max(versionNo)+1 必须在事务内完成,避免并发产生重复版本号
|
||||||
const maxRow = await db
|
return await db.transaction(async (tx) => {
|
||||||
|
const maxRow = await tx
|
||||||
.select({ maxNo: max(lessonPlanVersions.versionNo) })
|
.select({ maxNo: max(lessonPlanVersions.versionNo) })
|
||||||
.from(lessonPlanVersions)
|
.from(lessonPlanVersions)
|
||||||
.where(eq(lessonPlanVersions.planId, input.planId));
|
.where(eq(lessonPlanVersions.planId, input.planId));
|
||||||
const nextNo = (maxRow[0]?.maxNo ?? 0) + 1;
|
const nextNo = (maxRow[0]?.maxNo ?? 0) + 1;
|
||||||
|
|
||||||
await db.insert(lessonPlanVersions).values({
|
await tx.insert(lessonPlanVersions).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
planId: input.planId,
|
planId: input.planId,
|
||||||
versionNo: nextNo,
|
versionNo: nextNo,
|
||||||
@@ -77,6 +78,7 @@ export async function createLessonPlanVersion(input: {
|
|||||||
creatorId: input.userId,
|
creatorId: input.userId,
|
||||||
});
|
});
|
||||||
return { versionNo: nextNo };
|
return { versionNo: nextNo };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVersionContent(
|
export async function getVersionContent(
|
||||||
@@ -116,22 +118,33 @@ export async function revertToVersion(
|
|||||||
const content = await getVersionContent(planId, versionNo, userId);
|
const content = await getVersionContent(planId, versionNo, userId);
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
// 用该版本 content 覆盖当前 + 生成新版本
|
// P0 修复:update 与 createLessonPlanVersion 必须在同一事务内,
|
||||||
await db
|
// 避免回退后 content 已覆盖但版本记录未生成的不一致状态
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
.update(lessonPlans)
|
.update(lessonPlans)
|
||||||
.set({ content, lastSavedAt: new Date() })
|
.set({ content, lastSavedAt: new Date() })
|
||||||
.where(
|
.where(
|
||||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { versionNo: newNo } = await createLessonPlanVersion({
|
const maxRow = await tx
|
||||||
|
.select({ maxNo: max(lessonPlanVersions.versionNo) })
|
||||||
|
.from(lessonPlanVersions)
|
||||||
|
.where(eq(lessonPlanVersions.planId, planId));
|
||||||
|
const newNo = (maxRow[0]?.maxNo ?? 0) + 1;
|
||||||
|
|
||||||
|
await tx.insert(lessonPlanVersions).values({
|
||||||
|
id: createId(),
|
||||||
planId,
|
planId,
|
||||||
content,
|
versionNo: newNo,
|
||||||
userId,
|
|
||||||
isAuto: false,
|
|
||||||
label: `回退到 v${versionNo}`,
|
label: `回退到 v${versionNo}`,
|
||||||
|
content,
|
||||||
|
isAuto: false,
|
||||||
|
creatorId: userId,
|
||||||
});
|
});
|
||||||
return { newVersionNo: newNo };
|
return { newVersionNo: newNo };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pruneAutoVersions(
|
export async function pruneAutoVersions(
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from "@/shared/db/schema";
|
} from "@/shared/db/schema";
|
||||||
import type { DataScope } from "@/shared/types/permissions";
|
import type { DataScope } from "@/shared/types/permissions";
|
||||||
|
import { escapeLikePattern } from "@/shared/lib/action-utils";
|
||||||
import { SYSTEM_TEMPLATES } from "./constants";
|
import { SYSTEM_TEMPLATES } from "./constants";
|
||||||
import {
|
import {
|
||||||
migrateV1ToV2,
|
migrateV1ToV2,
|
||||||
normalizeDocument,
|
normalizeDocument,
|
||||||
buildInitialContent,
|
buildInitialContent,
|
||||||
|
buildDefaultSkeleton,
|
||||||
} from "./lib/document-migration";
|
} from "./lib/document-migration";
|
||||||
|
import { getChaptersByTextbookId, getTextbooks } from "@/modules/textbooks/data-access";
|
||||||
import type {
|
import type {
|
||||||
LessonPlan,
|
LessonPlan,
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
@@ -32,7 +35,7 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// re-export 纯函数保持向后兼容
|
// re-export 纯函数保持向后兼容
|
||||||
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
export { migrateV1ToV2, normalizeDocument, buildInitialContent, buildDefaultSkeleton };
|
||||||
|
|
||||||
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
|
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
|
||||||
export class LessonPlanDataError extends Error {
|
export class LessonPlanDataError extends Error {
|
||||||
@@ -220,7 +223,7 @@ export const getLessonPlans = cache(
|
|||||||
conditions.push(...buildScopeCondition(scope, userId));
|
conditions.push(...buildScopeCondition(scope, userId));
|
||||||
|
|
||||||
if (params.query) {
|
if (params.query) {
|
||||||
conditions.push(like(lessonPlans.title, `%${params.query}%`));
|
conditions.push(like(lessonPlans.title, `%${escapeLikePattern(params.query)}%`));
|
||||||
}
|
}
|
||||||
if (params.textbookId)
|
if (params.textbookId)
|
||||||
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
||||||
@@ -285,6 +288,7 @@ export const getLessonPlanById = cache(
|
|||||||
|
|
||||||
// ---- 创建 ----
|
// ---- 创建 ----
|
||||||
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
|
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
|
||||||
|
// V3 升级:当提供 chapterId 时,使用 buildDefaultSkeleton 生成完整 v3 文档(含正文节点)
|
||||||
export async function createLessonPlan(input: {
|
export async function createLessonPlan(input: {
|
||||||
title: string;
|
title: string;
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
@@ -299,14 +303,28 @@ export async function createLessonPlan(input: {
|
|||||||
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
|
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
|
||||||
|
|
||||||
const planId = createId();
|
const planId = createId();
|
||||||
// 如果提供了翻译函数,将模板中的 i18n 键翻译为实际文本
|
|
||||||
|
// V3:如果提供了 chapterId,拉取章节正文并生成默认骨架
|
||||||
|
let content: LessonPlanDocument;
|
||||||
|
if (input.chapterId && input.textbookId) {
|
||||||
|
const chapters = await getChaptersByTextbookId(input.textbookId);
|
||||||
|
const chapter = findChapterById(chapters, input.chapterId);
|
||||||
|
const chapterContent = chapter?.content ?? "";
|
||||||
|
content = buildDefaultSkeleton(
|
||||||
|
input.chapterId,
|
||||||
|
chapterContent,
|
||||||
|
input.translateTitle,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 无章节:使用模板生成(v3 格式,正文节点内容为空)
|
||||||
const blocks = input.translateTitle
|
const blocks = input.translateTitle
|
||||||
? template.blocks.map((b) => ({
|
? template.blocks.map((b) => ({
|
||||||
...b,
|
...b,
|
||||||
title: input.translateTitle!(b.title),
|
title: input.translateTitle!(b.title),
|
||||||
}))
|
}))
|
||||||
: template.blocks;
|
: template.blocks;
|
||||||
const content = buildInitialContent(blocks);
|
content = buildInitialContent(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(lessonPlans).values({
|
await db.insert(lessonPlans).values({
|
||||||
id: planId,
|
id: planId,
|
||||||
@@ -326,13 +344,65 @@ export async function createLessonPlan(input: {
|
|||||||
return { planId };
|
return { planId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 工具:在章节树中查找章节(含子章节)----
|
||||||
|
function findChapterById(
|
||||||
|
chapters: Awaited<ReturnType<typeof getChaptersByTextbookId>>,
|
||||||
|
chapterId: string,
|
||||||
|
): { content?: string | null } | null {
|
||||||
|
for (const ch of chapters) {
|
||||||
|
if (ch.id === chapterId) return ch;
|
||||||
|
if (ch.children && ch.children.length > 0) {
|
||||||
|
const found = findChapterById(ch.children, chapterId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 获取教材列表(供 picker 使用)----
|
||||||
|
export async function getTextbooksForPicker(): Promise<
|
||||||
|
{ id: string; title: string; subject: string; grade: string | null }[]
|
||||||
|
> {
|
||||||
|
const textbooks = await getTextbooks();
|
||||||
|
return textbooks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
subject: t.subject,
|
||||||
|
grade: t.grade,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 获取章节列表(供 picker 使用)----
|
||||||
|
export async function getChaptersForPicker(
|
||||||
|
textbookId: string,
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string | null;
|
||||||
|
order: number | null;
|
||||||
|
content?: string | null;
|
||||||
|
children?: unknown[];
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const chapters = await getChaptersByTextbookId(textbookId);
|
||||||
|
return chapters.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
parentId: c.parentId,
|
||||||
|
order: c.order,
|
||||||
|
content: c.content,
|
||||||
|
children: c.children,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 更新 content(自动保存,不生成版本)----
|
// ---- 更新 content(自动保存,不生成版本)----
|
||||||
export async function updateLessonPlanContent(
|
export async function updateLessonPlanContent(
|
||||||
planId: string,
|
planId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
patch: { title?: string; content: LessonPlanDocument },
|
patch: { title?: string; content: LessonPlanDocument },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db
|
const result = await db
|
||||||
.update(lessonPlans)
|
.update(lessonPlans)
|
||||||
.set({
|
.set({
|
||||||
...(patch.title ? { title: patch.title } : {}),
|
...(patch.title ? { title: patch.title } : {}),
|
||||||
@@ -342,6 +412,11 @@ export async function updateLessonPlanContent(
|
|||||||
.where(
|
.where(
|
||||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// MySQL 返回 [ResultSetHeader, FieldPacket[]],通过 affectedRows 判断是否更新成功
|
||||||
|
if (result[0].affectedRows === 0) {
|
||||||
|
throw new LessonPlanDataError("NOT_FOUND");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 软删除 ----
|
// ---- 软删除 ----
|
||||||
@@ -349,12 +424,17 @@ export async function softDeleteLessonPlan(
|
|||||||
planId: string,
|
planId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db
|
const result = await db
|
||||||
.update(lessonPlans)
|
.update(lessonPlans)
|
||||||
.set({ status: "archived" })
|
.set({ status: "archived" })
|
||||||
.where(
|
.where(
|
||||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// MySQL 返回 [ResultSetHeader, FieldPacket[]],通过 affectedRows 判断是否更新成功
|
||||||
|
if (result[0].affectedRows === 0) {
|
||||||
|
throw new LessonPlanDataError("NOT_FOUND");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 复制 ----
|
// ---- 复制 ----
|
||||||
|
|||||||
@@ -3,12 +3,19 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import type {
|
import type {
|
||||||
|
AnchorEdge,
|
||||||
|
AnchorType,
|
||||||
|
AnyLessonPlanEdge,
|
||||||
Block,
|
Block,
|
||||||
BlockType,
|
BlockType,
|
||||||
|
FlowEdge,
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanEdge,
|
|
||||||
LessonPlanNode,
|
LessonPlanNode,
|
||||||
|
NodeAnchor,
|
||||||
|
TextbookContentNode,
|
||||||
|
TextbookContentNodeData,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { defaultDataForType } from "../lib/document-migration";
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -28,9 +35,25 @@ interface EditorState {
|
|||||||
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
|
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
|
||||||
removeNode: (id: string) => void;
|
removeNode: (id: string) => void;
|
||||||
|
|
||||||
|
// 正文节点操作
|
||||||
|
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
|
||||||
|
getTextbookContentNode: () => TextbookContentNode | undefined;
|
||||||
|
|
||||||
|
// 锚点操作
|
||||||
|
addAnchor: (params: {
|
||||||
|
nodeId: string;
|
||||||
|
type: AnchorType;
|
||||||
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
textPreview?: string;
|
||||||
|
}) => string;
|
||||||
|
removeAnchor: (anchorId: string) => void;
|
||||||
|
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
|
||||||
|
|
||||||
|
// 连线
|
||||||
connect: (source: string, target: string) => void;
|
connect: (source: string, target: string) => void;
|
||||||
disconnect: (edgeId: string) => void;
|
disconnect: (edgeId: string) => void;
|
||||||
setEdges: (edges: LessonPlanEdge[]) => void;
|
setEdges: (edges: AnyLessonPlanEdge[]) => void;
|
||||||
|
|
||||||
selectNode: (id: string | null) => void;
|
selectNode: (id: string | null) => void;
|
||||||
|
|
||||||
@@ -43,18 +66,16 @@ function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
|
|||||||
return nodes.map((n, i) => ({ ...n, order: i }));
|
return nodes.map((n, i) => ({ ...n, order: i }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultData(type: BlockType): Block["data"] {
|
|
||||||
return type === "exercise"
|
|
||||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
|
||||||
: type === "text_study"
|
|
||||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
|
||||||
: { html: "", knowledgePointIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||||
planId: "",
|
planId: "",
|
||||||
title: "",
|
title: "",
|
||||||
doc: { version: 2, nodes: [], edges: [] },
|
doc: {
|
||||||
|
version: 3,
|
||||||
|
textbookContentNodeId: "",
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
anchors: [],
|
||||||
|
},
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
lastSavedAt: null,
|
lastSavedAt: null,
|
||||||
@@ -77,12 +98,17 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
|||||||
|
|
||||||
addNode: (type, position, title) => {
|
addNode: (type, position, title) => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
const nodeCount = get().doc.nodes.length;
|
const state = get();
|
||||||
|
// 教学节点 order 从 0 开始(正文节点 order=-1 不计入)
|
||||||
|
const teachingNodes = state.doc.nodes.filter(
|
||||||
|
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||||
|
);
|
||||||
|
const nodeCount = teachingNodes.length;
|
||||||
const node: LessonPlanNode = {
|
const node: LessonPlanNode = {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
title: title ?? type, // 调用方应传入翻译后的标题,fallback 为 type 键
|
title: title ?? type,
|
||||||
data: defaultData(type),
|
data: defaultDataForType(type),
|
||||||
order: nodeCount,
|
order: nodeCount,
|
||||||
position: position ?? {
|
position: position ?? {
|
||||||
x: 80 + (nodeCount % 4) * 280,
|
x: 80 + (nodeCount % 4) * 280,
|
||||||
@@ -102,37 +128,139 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
|||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
nodes: s.doc.nodes.map((n) =>
|
nodes: s.doc.nodes.map((n) =>
|
||||||
n.id === id ? { ...n, ...patch } : n,
|
n.id === id
|
||||||
|
? n.type === "textbook_content"
|
||||||
|
? { ...n, ...patch } as TextbookContentNode
|
||||||
|
: { ...n, ...patch } as LessonPlanNode
|
||||||
|
: n,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
// 实时拖动:每次调用立即更新位置(不再等待 dragging=false)
|
||||||
updateNodePosition: (id, position) =>
|
updateNodePosition: (id, position) =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
nodes: s.doc.nodes.map((n) =>
|
nodes: s.doc.nodes.map((n) =>
|
||||||
n.id === id ? { ...n, position } : n,
|
n.id === id
|
||||||
|
? n.type === "textbook_content"
|
||||||
|
? { ...n, position } as TextbookContentNode
|
||||||
|
: { ...n, position } as LessonPlanNode
|
||||||
|
: n,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
removeNode: (id) =>
|
removeNode: (id) =>
|
||||||
set((s) => ({
|
set((s) => {
|
||||||
|
const remainingTeachingNodes = reindex(
|
||||||
|
s.doc.nodes.filter(
|
||||||
|
(n): n is LessonPlanNode => n.id !== id && n.type !== "textbook_content",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const textbookNode = s.doc.nodes.find(
|
||||||
|
(n): n is TextbookContentNode => n.type === "textbook_content",
|
||||||
|
);
|
||||||
|
const nodes = textbookNode ? [textbookNode, ...remainingTeachingNodes] : remainingTeachingNodes;
|
||||||
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
nodes: reindex(s.doc.nodes.filter((n) => n.id !== id)),
|
nodes,
|
||||||
edges: s.doc.edges.filter(
|
edges: s.doc.edges.filter(
|
||||||
(e) => e.source !== id && e.target !== id,
|
(e) => e.source !== id && e.target !== id,
|
||||||
),
|
),
|
||||||
|
// 同时移除关联的锚点
|
||||||
|
anchors: s.doc.anchors.filter((a) => a.nodeId !== id),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
selectedNodeId:
|
selectedNodeId:
|
||||||
s.selectedNodeId === id ? null : s.selectedNodeId,
|
s.selectedNodeId === id ? null : s.selectedNodeId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- 正文节点操作 ----
|
||||||
|
updateTextbookContent: (data) =>
|
||||||
|
set((s) => ({
|
||||||
|
doc: {
|
||||||
|
...s.doc,
|
||||||
|
nodes: s.doc.nodes.map((n) =>
|
||||||
|
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
|
||||||
|
? { ...n, data: { ...n.data, ...data } }
|
||||||
|
: n,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
getTextbookContentNode: () => {
|
||||||
|
const state = get();
|
||||||
|
return state.doc.nodes.find(
|
||||||
|
(n): n is TextbookContentNode => n.type === "textbook_content",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- 锚点操作 ----
|
||||||
|
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
|
||||||
|
const anchorId = createId();
|
||||||
|
const state = get();
|
||||||
|
const textbookNodeId = state.doc.textbookContentNodeId;
|
||||||
|
|
||||||
|
const anchor: NodeAnchor = {
|
||||||
|
id: anchorId,
|
||||||
|
nodeId,
|
||||||
|
type,
|
||||||
|
start,
|
||||||
|
...(end !== undefined ? { end } : {}),
|
||||||
|
...(textPreview ? { textPreview } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const edge: AnchorEdge = {
|
||||||
|
id: `ae_${nodeId}_${textbookNodeId}_${anchorId.slice(0, 6)}`,
|
||||||
|
source: nodeId,
|
||||||
|
target: textbookNodeId,
|
||||||
|
type: "anchor",
|
||||||
|
anchorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
set((s) => ({
|
||||||
|
doc: {
|
||||||
|
...s.doc,
|
||||||
|
anchors: [...s.doc.anchors, anchor],
|
||||||
|
edges: [...s.doc.edges, edge],
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return anchorId;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAnchor: (anchorId) =>
|
||||||
|
set((s) => ({
|
||||||
|
doc: {
|
||||||
|
...s.doc,
|
||||||
|
anchors: s.doc.anchors.filter((a) => a.id !== anchorId),
|
||||||
|
edges: s.doc.edges.filter(
|
||||||
|
(e) => !(e.type === "anchor" && e.anchorId === anchorId),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateAnchor: (anchorId, patch) =>
|
||||||
|
set((s) => ({
|
||||||
|
doc: {
|
||||||
|
...s.doc,
|
||||||
|
anchors: s.doc.anchors.map((a) =>
|
||||||
|
a.id === anchorId ? { ...a, ...patch } : a,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
})),
|
||||||
|
|
||||||
|
// ---- 连线 ----
|
||||||
connect: (source, target) =>
|
connect: (source, target) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
// 避免重复连线
|
// 避免重复连线
|
||||||
@@ -142,10 +270,11 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return s;
|
return s;
|
||||||
const edge: LessonPlanEdge = {
|
const edge: FlowEdge = {
|
||||||
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
|
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
|
type: "flow",
|
||||||
};
|
};
|
||||||
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
|
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
|
||||||
}),
|
}),
|
||||||
|
|||||||
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 { createId } from "@paralleldrive/cuid2";
|
||||||
import type {
|
import type {
|
||||||
|
AnchorEdge,
|
||||||
|
AnyLessonPlanEdge,
|
||||||
|
AnyLessonPlanNode,
|
||||||
|
BlockData,
|
||||||
|
BlockType,
|
||||||
|
FlowEdge,
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanDocumentV1,
|
LessonPlanDocumentV1,
|
||||||
|
LessonPlanDocumentV2,
|
||||||
LessonPlanEdge,
|
LessonPlanEdge,
|
||||||
LessonPlanNode,
|
LessonPlanNode,
|
||||||
|
NodeAnchor,
|
||||||
TemplateBlockSkeleton,
|
TemplateBlockSkeleton,
|
||||||
|
TextbookContentNode,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,8 +21,39 @@ import type {
|
|||||||
* 从 data-access.ts 抽取,便于单元测试。
|
* 从 data-access.ts 抽取,便于单元测试。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ---- 默认数据生成器:为每种 BlockType 提供初始 data ----
|
||||||
|
export function defaultDataForType(type: BlockType): BlockData {
|
||||||
|
switch (type) {
|
||||||
|
case "objective":
|
||||||
|
return { objectives: [] };
|
||||||
|
case "key_point":
|
||||||
|
return { keyPoints: [] };
|
||||||
|
case "import":
|
||||||
|
return { method: "question", prompt: "", durationMin: 5 };
|
||||||
|
case "new_teaching":
|
||||||
|
return { teachingPoints: [] };
|
||||||
|
case "summary":
|
||||||
|
return { summaryPoints: [], homeworkPreview: "" };
|
||||||
|
case "homework":
|
||||||
|
return { assignments: [] };
|
||||||
|
case "blackboard":
|
||||||
|
return { layout: "text", content: "", knowledgePointIds: [] };
|
||||||
|
case "reflection":
|
||||||
|
return { reflection: [] };
|
||||||
|
case "exercise":
|
||||||
|
return { items: [], purpose: "class_practice", knowledgePointIds: [] };
|
||||||
|
case "text_study":
|
||||||
|
return { sourceText: "", annotations: [], knowledgePointIds: [] };
|
||||||
|
case "rich_text":
|
||||||
|
case "consolidation":
|
||||||
|
default:
|
||||||
|
// consolidation 暂复用富文本结构(向后兼容旧模板)
|
||||||
|
return { html: "", knowledgePointIds: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
|
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
|
||||||
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
|
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocumentV2 {
|
||||||
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
|
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
|
||||||
...b,
|
...b,
|
||||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||||
@@ -29,8 +69,63 @@ export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
|
|||||||
return { version: 2, nodes, edges };
|
return { version: 2, nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- v2 → v3 迁移:注入正文节点 + 锚点数组 + 边类型 ----
|
||||||
|
export function migrateV2ToV3(
|
||||||
|
doc: LessonPlanDocumentV2,
|
||||||
|
chapterId?: string | null,
|
||||||
|
chapterContent?: string | null,
|
||||||
|
): LessonPlanDocument {
|
||||||
|
const textbookContentNodeId = createId();
|
||||||
|
const textbookNode: TextbookContentNode = {
|
||||||
|
id: textbookContentNodeId,
|
||||||
|
type: "textbook_content",
|
||||||
|
title: "textbook_content",
|
||||||
|
data: {
|
||||||
|
chapterId: chapterId ?? "",
|
||||||
|
content: chapterContent ?? "",
|
||||||
|
zoom: 1,
|
||||||
|
},
|
||||||
|
order: -1,
|
||||||
|
position: { x: 400, y: 200 },
|
||||||
|
draggable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 旧 edges 转为 flow 类型
|
||||||
|
const flowEdges: FlowEdge[] = doc.edges.map((e) => ({
|
||||||
|
...e,
|
||||||
|
type: "flow" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
textbookContentNodeId,
|
||||||
|
nodes: [textbookNode, ...doc.nodes],
|
||||||
|
edges: flowEdges,
|
||||||
|
anchors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型守卫:判断是否为 v3 文档 ----
|
||||||
|
function isV3Document(content: unknown): content is LessonPlanDocument {
|
||||||
|
if (!content || typeof content !== "object") return false;
|
||||||
|
const c = content as {
|
||||||
|
version?: unknown;
|
||||||
|
textbookContentNodeId?: unknown;
|
||||||
|
nodes?: unknown;
|
||||||
|
edges?: unknown;
|
||||||
|
anchors?: unknown;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
c.version === 3 &&
|
||||||
|
typeof c.textbookContentNodeId === "string" &&
|
||||||
|
Array.isArray(c.nodes) &&
|
||||||
|
Array.isArray(c.edges) &&
|
||||||
|
Array.isArray(c.anchors)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 类型守卫:判断是否为 v2 文档 ----
|
// ---- 类型守卫:判断是否为 v2 文档 ----
|
||||||
function isV2Document(content: unknown): content is LessonPlanDocument {
|
function isV2Document(content: unknown): content is LessonPlanDocumentV2 {
|
||||||
if (!content || typeof content !== "object") return false;
|
if (!content || typeof content !== "object") return false;
|
||||||
const c = content as { version?: unknown; nodes?: unknown; edges?: unknown };
|
const c = content as { version?: unknown; nodes?: unknown; edges?: unknown };
|
||||||
return (
|
return (
|
||||||
@@ -47,40 +142,188 @@ function isV1Document(content: unknown): content is LessonPlanDocumentV1 {
|
|||||||
return c.version === 1 && Array.isArray(c.blocks);
|
return c.version === 1 && Array.isArray(c.blocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
|
// ---- 规范化:确保 content 是 v3 格式(兼容 v1/v2 旧数据)----
|
||||||
export function normalizeDocument(
|
export function normalizeDocument(
|
||||||
content: unknown,
|
content: unknown,
|
||||||
|
chapterId?: string | null,
|
||||||
|
chapterContent?: string | null,
|
||||||
): LessonPlanDocument {
|
): LessonPlanDocument {
|
||||||
if (isV2Document(content)) return content;
|
if (isV3Document(content)) return content;
|
||||||
if (isV1Document(content)) return migrateV1ToV2(content);
|
if (isV2Document(content)) {
|
||||||
// 空文档
|
return migrateV2ToV3(content, chapterId, chapterContent);
|
||||||
return { version: 2, nodes: [], edges: [] };
|
}
|
||||||
|
if (isV1Document(content)) {
|
||||||
|
return migrateV2ToV3(migrateV1ToV2(content), chapterId, chapterContent);
|
||||||
|
}
|
||||||
|
// 空文档:创建一个无正文的 v3
|
||||||
|
const textbookContentNodeId = createId();
|
||||||
|
const textbookNode: TextbookContentNode = {
|
||||||
|
id: textbookContentNodeId,
|
||||||
|
type: "textbook_content",
|
||||||
|
title: "textbook_content",
|
||||||
|
data: { chapterId: chapterId ?? "", content: chapterContent ?? "", zoom: 1 },
|
||||||
|
order: -1,
|
||||||
|
position: { x: 400, y: 200 },
|
||||||
|
draggable: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
textbookContentNodeId,
|
||||||
|
nodes: [textbookNode],
|
||||||
|
edges: [],
|
||||||
|
anchors: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
// ---- 模板初始化:根据骨架生成初始 content(v3,无正文节点)----
|
||||||
|
// 注意:此函数不创建正文节点,调用方应使用 buildDefaultSkeleton 创建完整 v3 文档
|
||||||
export function buildInitialContent(
|
export function buildInitialContent(
|
||||||
blocks: TemplateBlockSkeleton[],
|
blocks: TemplateBlockSkeleton[],
|
||||||
): LessonPlanDocument {
|
): LessonPlanDocument {
|
||||||
|
const textbookContentNodeId = createId();
|
||||||
|
const textbookNode: TextbookContentNode = {
|
||||||
|
id: textbookContentNodeId,
|
||||||
|
type: "textbook_content",
|
||||||
|
title: "textbook_content",
|
||||||
|
data: { chapterId: "", content: "", zoom: 1 },
|
||||||
|
order: -1,
|
||||||
|
position: { x: 400, y: 200 },
|
||||||
|
draggable: false,
|
||||||
|
};
|
||||||
|
|
||||||
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
|
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
type: b.type,
|
type: b.type,
|
||||||
title: b.title,
|
title: b.title,
|
||||||
data:
|
data: defaultDataForType(b.type),
|
||||||
b.type === "exercise"
|
|
||||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
|
||||||
: b.type === "text_study"
|
|
||||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
|
||||||
: { html: "", knowledgePointIds: [] },
|
|
||||||
order: i,
|
order: i,
|
||||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||||
}));
|
}));
|
||||||
const edges: LessonPlanEdge[] = [];
|
|
||||||
|
const edges: FlowEdge[] = [];
|
||||||
for (let i = 0; i < nodes.length - 1; i++) {
|
for (let i = 0; i < nodes.length - 1; i++) {
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||||
source: nodes[i].id,
|
source: nodes[i].id,
|
||||||
target: nodes[i + 1].id,
|
target: nodes[i + 1].id,
|
||||||
|
type: "flow",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { version: 2, nodes, edges };
|
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
textbookContentNodeId,
|
||||||
|
nodes: [textbookNode, ...nodes],
|
||||||
|
edges,
|
||||||
|
anchors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 默认骨架:10 节点 + 1 正文节点(创建课案时使用)----
|
||||||
|
export function buildDefaultSkeleton(
|
||||||
|
chapterId: string,
|
||||||
|
chapterContent: string,
|
||||||
|
translateTitle?: (key: string) => string,
|
||||||
|
): LessonPlanDocument {
|
||||||
|
const textbookContentNodeId = createId();
|
||||||
|
const textbookNode: TextbookContentNode = {
|
||||||
|
id: textbookContentNodeId,
|
||||||
|
type: "textbook_content",
|
||||||
|
title: "textbook_content",
|
||||||
|
data: { chapterId, content: chapterContent, zoom: 1 },
|
||||||
|
order: -1,
|
||||||
|
position: { x: 400, y: 200 },
|
||||||
|
draggable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认 10 节点骨架(标题使用 i18n 键 blockType.${type})
|
||||||
|
const skeleton: { type: BlockType; position: { x: number; y: number } }[] = [
|
||||||
|
{ type: "objective", position: { x: 80, y: 80 } },
|
||||||
|
{ type: "key_point", position: { x: 80, y: 200 } },
|
||||||
|
{ type: "import", position: { x: 80, y: 320 } },
|
||||||
|
{ type: "text_study", position: { x: 80, y: 440 } },
|
||||||
|
{ type: "new_teaching", position: { x: 720, y: 80 } },
|
||||||
|
{ type: "exercise", position: { x: 720, y: 200 } },
|
||||||
|
{ type: "summary", position: { x: 720, y: 320 } },
|
||||||
|
{ type: "homework", position: { x: 80, y: 560 } },
|
||||||
|
{ type: "blackboard", position: { x: 720, y: 440 } },
|
||||||
|
{ type: "reflection", position: { x: 720, y: 560 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const nodes: LessonPlanNode[] = skeleton.map((s, i) => ({
|
||||||
|
id: createId(),
|
||||||
|
type: s.type,
|
||||||
|
title: translateTitle
|
||||||
|
? translateTitle(`blockType.${s.type}`)
|
||||||
|
: `blockType.${s.type}`,
|
||||||
|
data: defaultDataForType(s.type),
|
||||||
|
order: i,
|
||||||
|
position: s.position,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 默认流程连线:导入→文本研习→新授→练习→小结
|
||||||
|
const flowPairs: [number, number][] = [
|
||||||
|
[2, 3], // import → text_study
|
||||||
|
[3, 4], // text_study → new_teaching
|
||||||
|
[4, 5], // new_teaching → exercise
|
||||||
|
[5, 6], // exercise → summary
|
||||||
|
];
|
||||||
|
const edges: FlowEdge[] = flowPairs.map(([from, to]) => ({
|
||||||
|
id: `e_${nodes[from].id}_${nodes[to].id}`,
|
||||||
|
source: nodes[from].id,
|
||||||
|
target: nodes[to].id,
|
||||||
|
type: "flow",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
textbookContentNodeId,
|
||||||
|
nodes: [textbookNode, ...nodes],
|
||||||
|
edges,
|
||||||
|
anchors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 工具函数:判断节点是否为正文节点 ----
|
||||||
|
export function isTextbookContentNode(
|
||||||
|
node: AnyLessonPlanNode,
|
||||||
|
): node is TextbookContentNode {
|
||||||
|
return node.type === "textbook_content";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 工具函数:判断边是否为锚点边 ----
|
||||||
|
export function isAnchorEdge(
|
||||||
|
edge: AnyLessonPlanEdge,
|
||||||
|
): edge is AnchorEdge {
|
||||||
|
return edge.type === "anchor";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 工具函数:获取节点的关联锚点 ----
|
||||||
|
export function getAnchorsForNode(
|
||||||
|
anchors: NodeAnchor[],
|
||||||
|
nodeId: string,
|
||||||
|
): NodeAnchor[] {
|
||||||
|
return anchors.filter((a) => a.nodeId === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 工具函数:根据选中节点获取激活的锚点 ID 集合 ----
|
||||||
|
export function getActiveAnchorIds(
|
||||||
|
anchors: NodeAnchor[],
|
||||||
|
selectedNodeId: string | null,
|
||||||
|
): Set<string> {
|
||||||
|
if (!selectedNodeId) return new Set();
|
||||||
|
return new Set(
|
||||||
|
anchors.filter((a) => a.nodeId === selectedNodeId).map((a) => a.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 工具函数:根据锚点 ID 获取对应的边 ----
|
||||||
|
export function getAnchorEdges(
|
||||||
|
edges: AnyLessonPlanEdge[],
|
||||||
|
anchorIds: Set<string>,
|
||||||
|
): AnchorEdge[] {
|
||||||
|
return edges.filter(
|
||||||
|
(e): e is AnchorEdge =>
|
||||||
|
e.type === "anchor" && anchorIds.has(e.anchorId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)。
|
* values 类型对齐 next-intl 的 TranslationValues(string | number | Date)。
|
||||||
*/
|
*/
|
||||||
export interface NodeSummaryT {
|
export interface NodeSummaryT {
|
||||||
(key: "editor.questionCount" | "editor.charCount" | "editor.nodeSummaryEmpty", values?: Record<string, string | number | Date>): string;
|
(
|
||||||
|
key:
|
||||||
|
| "editor.questionCount"
|
||||||
|
| "editor.charCount"
|
||||||
|
| "editor.nodeSummaryEmpty"
|
||||||
|
| "editor.itemCount"
|
||||||
|
| "editor.pointCount"
|
||||||
|
| "editor.assignmentCount"
|
||||||
|
| "editor.durationMin",
|
||||||
|
values?: Record<string, string | number | Date>,
|
||||||
|
): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,17 +26,69 @@ export interface NodeSummaryT {
|
|||||||
*/
|
*/
|
||||||
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
|
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
|
||||||
const data = node.data as {
|
const data = node.data as {
|
||||||
|
// 富文本类
|
||||||
html?: string;
|
html?: string;
|
||||||
|
// 文本研习
|
||||||
sourceText?: string;
|
sourceText?: string;
|
||||||
|
annotations?: unknown[];
|
||||||
|
// 练习
|
||||||
items?: unknown[];
|
items?: unknown[];
|
||||||
|
// 教学目标
|
||||||
|
objectives?: unknown[];
|
||||||
|
// 重难点
|
||||||
|
keyPoints?: unknown[];
|
||||||
|
// 导入
|
||||||
|
durationMin?: number;
|
||||||
|
prompt?: string;
|
||||||
|
// 新授
|
||||||
|
teachingPoints?: unknown[];
|
||||||
|
// 小结
|
||||||
|
summaryPoints?: unknown[];
|
||||||
|
// 作业
|
||||||
|
assignments?: unknown[];
|
||||||
|
// 板书
|
||||||
|
content?: string;
|
||||||
|
// 反思
|
||||||
|
reflection?: unknown[];
|
||||||
|
// 知识点
|
||||||
knowledgePointIds?: string[];
|
knowledgePointIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 按类型优先级提取摘要
|
||||||
if (data.items !== undefined) {
|
if (data.items !== undefined) {
|
||||||
return t("editor.questionCount", { count: data.items.length });
|
return t("editor.questionCount", { count: data.items.length });
|
||||||
}
|
}
|
||||||
|
if (data.objectives !== undefined) {
|
||||||
|
return t("editor.itemCount", { count: data.objectives.length });
|
||||||
|
}
|
||||||
|
if (data.keyPoints !== undefined) {
|
||||||
|
return t("editor.itemCount", { count: data.keyPoints.length });
|
||||||
|
}
|
||||||
|
if (data.teachingPoints !== undefined) {
|
||||||
|
return t("editor.pointCount", { count: data.teachingPoints.length });
|
||||||
|
}
|
||||||
|
if (data.summaryPoints !== undefined) {
|
||||||
|
return t("editor.itemCount", { count: data.summaryPoints.length });
|
||||||
|
}
|
||||||
|
if (data.assignments !== undefined) {
|
||||||
|
return t("editor.assignmentCount", { count: data.assignments.length });
|
||||||
|
}
|
||||||
|
if (data.reflection !== undefined) {
|
||||||
|
return t("editor.itemCount", { count: data.reflection.length });
|
||||||
|
}
|
||||||
|
if (data.durationMin !== undefined) {
|
||||||
|
return t("editor.durationMin", { count: data.durationMin });
|
||||||
|
}
|
||||||
|
if (data.annotations !== undefined && data.sourceText !== undefined) {
|
||||||
|
return t("editor.charCount", { count: data.sourceText.length });
|
||||||
|
}
|
||||||
if (data.sourceText !== undefined && data.sourceText) {
|
if (data.sourceText !== undefined && data.sourceText) {
|
||||||
return t("editor.charCount", { count: data.sourceText.length });
|
return t("editor.charCount", { count: data.sourceText.length });
|
||||||
}
|
}
|
||||||
|
if (data.content !== undefined && data.content) {
|
||||||
|
const text = data.content.replace(/<[^>]+>/g, "").trim();
|
||||||
|
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
|
||||||
|
}
|
||||||
if (data.html) {
|
if (data.html) {
|
||||||
// 去标签后取前 40 字
|
// 去标签后取前 40 字
|
||||||
const text = data.html.replace(/<[^>]+>/g, "").trim();
|
const text = data.html.replace(/<[^>]+>/g, "").trim();
|
||||||
@@ -35,6 +97,17 @@ export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
|
|||||||
return t("editor.nodeSummaryEmpty");
|
return t("editor.nodeSummaryEmpty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯函数:获取正文节点摘要。
|
||||||
|
*/
|
||||||
|
export function getTextbookContentSummary(
|
||||||
|
node: TextbookContentNode,
|
||||||
|
t: NodeSummaryT,
|
||||||
|
): string {
|
||||||
|
if (!node.data.content) return t("editor.nodeSummaryEmpty");
|
||||||
|
return t("editor.charCount", { count: node.data.content.length });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点类型 → 图标颜色(Material Design 色板)。
|
* 节点类型 → 图标颜色(Material Design 色板)。
|
||||||
* 供 lesson-node 和 minimap 复用。
|
* 供 lesson-node 和 minimap 复用。
|
||||||
@@ -52,6 +125,7 @@ export const NODE_COLORS: Record<string, string> = {
|
|||||||
exercise: "#e91e63",
|
exercise: "#e91e63",
|
||||||
rich_text: "#9e9e9e",
|
rich_text: "#9e9e9e",
|
||||||
reflection: "#cddc39",
|
reflection: "#cddc39",
|
||||||
|
textbook_content: "#455a64",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getNodeColor(type: string): string {
|
export function getNodeColor(type: string): string {
|
||||||
|
|||||||
@@ -1,30 +1,113 @@
|
|||||||
import type { Node, Edge } from "@xyflow/react";
|
import type { Node, Edge } from "@xyflow/react";
|
||||||
import type { LessonPlanNode, LessonPlanEdge } from "../types";
|
import type {
|
||||||
|
AnyLessonPlanEdge,
|
||||||
|
AnyLessonPlanNode,
|
||||||
|
LessonPlanNode,
|
||||||
|
NodeAnchor,
|
||||||
|
TextbookContentNode,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
|
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
|
||||||
* 从 node-editor.tsx 抽取,便于单元测试。
|
* 从 node-editor.tsx 抽取,便于单元测试。
|
||||||
|
*
|
||||||
|
* v3 升级:
|
||||||
|
* - 区分教学节点(type="lesson")和正文节点(type="textbook_content")
|
||||||
|
* - 正文节点传入 anchors/selectedNodeId/onAddAnchor 等回调
|
||||||
|
* - 边区分 anchor/flow 类型,应用不同透明度
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function toRfNodes(
|
export interface ToRfNodesContext {
|
||||||
nodes: LessonPlanNode[],
|
anchors: NodeAnchor[];
|
||||||
selectedNodeId: string | null,
|
selectedNodeId: string | null;
|
||||||
): Node[] {
|
onAddRangeAnchor?: (params: {
|
||||||
return nodes.map((n) => ({
|
nodeId: string;
|
||||||
id: n.id,
|
start: number;
|
||||||
type: "lesson",
|
end: number;
|
||||||
position: n.position,
|
textPreview: string;
|
||||||
data: { node: n } as Record<string, unknown>,
|
}) => void;
|
||||||
selected: n.id === selectedNodeId,
|
onAddPointAnchor?: (params: {
|
||||||
}));
|
nodeId: string;
|
||||||
|
start: number;
|
||||||
|
}) => void;
|
||||||
|
onSelectNode?: (id: string | null) => void;
|
||||||
|
onZoomChange?: (zoom: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
|
export function toRfNodes(
|
||||||
return edges.map((e) => ({
|
nodes: AnyLessonPlanNode[],
|
||||||
|
selectedNodeId: string | null,
|
||||||
|
ctx?: ToRfNodesContext,
|
||||||
|
): Node[] {
|
||||||
|
return nodes.map((n) => {
|
||||||
|
// 正文节点
|
||||||
|
if (n.type === "textbook_content") {
|
||||||
|
const tbNode = n as TextbookContentNode;
|
||||||
|
return {
|
||||||
|
id: tbNode.id,
|
||||||
|
type: "textbook_content",
|
||||||
|
position: tbNode.position,
|
||||||
|
data: {
|
||||||
|
node: tbNode,
|
||||||
|
anchors: ctx?.anchors ?? [],
|
||||||
|
selectedNodeId,
|
||||||
|
onAddRangeAnchor: ctx?.onAddRangeAnchor,
|
||||||
|
onAddPointAnchor: ctx?.onAddPointAnchor,
|
||||||
|
onSelectNode: ctx?.onSelectNode,
|
||||||
|
onZoomChange: ctx?.onZoomChange,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
selected: tbNode.id === selectedNodeId,
|
||||||
|
draggable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 教学节点
|
||||||
|
const lessonNode = n as LessonPlanNode;
|
||||||
|
return {
|
||||||
|
id: lessonNode.id,
|
||||||
|
type: "lesson",
|
||||||
|
position: lessonNode.position,
|
||||||
|
data: { node: lessonNode } as Record<string, unknown>,
|
||||||
|
selected: lessonNode.id === selectedNodeId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRfEdges(
|
||||||
|
edges: AnyLessonPlanEdge[],
|
||||||
|
selectedNodeId: string | null,
|
||||||
|
anchors: NodeAnchor[],
|
||||||
|
): Edge[] {
|
||||||
|
return edges.map((e) => {
|
||||||
|
if (e.type === "anchor") {
|
||||||
|
// 锚点边:默认 10% 透明度,选中关联节点时 100%
|
||||||
|
const anchor = anchors.find((a) => a.id === e.anchorId);
|
||||||
|
const isActive = anchor && anchor.nodeId === selectedNodeId;
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
animated: false,
|
||||||
|
className: isActive ? "anchor-edge active" : "anchor-edge",
|
||||||
|
style: {
|
||||||
|
stroke: anchor ? getNodeColorForAnchor(anchor.nodeId) : "#9e9e9e",
|
||||||
|
strokeWidth: 2,
|
||||||
|
opacity: isActive ? 1 : 0.1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流程边
|
||||||
|
return {
|
||||||
...e,
|
...e,
|
||||||
animated: true,
|
animated: true,
|
||||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的颜色查找(避免循环依赖 node-summary)
|
||||||
|
function getNodeColorForAnchor(_nodeId: string): string {
|
||||||
|
// 实际颜色由 CSS 类 .anchor-edge 设置,这里返回默认值
|
||||||
|
return "#1976d2";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,12 +115,28 @@ export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
|
|||||||
*/
|
*/
|
||||||
export function fromRfEdges(
|
export function fromRfEdges(
|
||||||
rfEdges: Edge[],
|
rfEdges: Edge[],
|
||||||
): LessonPlanEdge[] {
|
): AnyLessonPlanEdge[] {
|
||||||
return rfEdges.map((e) => ({
|
return rfEdges.map((e) => {
|
||||||
|
const base = {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
source: e.source,
|
source: e.source,
|
||||||
target: e.target,
|
target: e.target,
|
||||||
sourceHandle: e.sourceHandle ?? null,
|
sourceHandle: e.sourceHandle ?? null,
|
||||||
targetHandle: e.targetHandle ?? null,
|
targetHandle: e.targetHandle ?? null,
|
||||||
}));
|
};
|
||||||
|
|
||||||
|
// 保留原有的 type 信息(通过 className 判断或默认为 flow)
|
||||||
|
if (e.className?.includes("anchor-edge")) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: "anchor" as const,
|
||||||
|
anchorId: e.id, // 简化:用 edge id 作为 anchorId(实际应从 data 读取)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: "flow" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export const createLessonPlanSchema = z.object({
|
|||||||
export const updateLessonPlanContentSchema = z.object({
|
export const updateLessonPlanContentSchema = z.object({
|
||||||
planId: z.string().min(1),
|
planId: z.string().min(1),
|
||||||
title: z.string().min(1).max(255).optional(),
|
title: z.string().min(1).max(255).optional(),
|
||||||
content: z.unknown(), // Block 文档结构由 types 守卫,运行时只校验存在
|
// Block 文档结构由 types 守卫,运行时只校验是对象
|
||||||
|
content: z.record(z.string(), z.unknown()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveVersionSchema = z.object({
|
export const saveVersionSchema = z.object({
|
||||||
@@ -30,5 +31,32 @@ export const saveAsTemplateSchema = z.object({
|
|||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AI 知识点推荐输入校验
|
||||||
|
export const suggestKnowledgePointsSchema = z.object({
|
||||||
|
doc: z.record(z.string(), z.unknown()),
|
||||||
|
textbookId: z.string().optional(),
|
||||||
|
chapterId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 知识点选项查询输入校验
|
||||||
|
export const getKnowledgePointOptionsSchema = z.object({
|
||||||
|
textbookId: z.string().optional(),
|
||||||
|
chapterId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发布作业输入校验
|
||||||
|
const dateStringSchema = z
|
||||||
|
.string()
|
||||||
|
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format");
|
||||||
|
|
||||||
|
export const publishLessonPlanHomeworkSchema = z.object({
|
||||||
|
planId: z.string().min(1),
|
||||||
|
blockId: z.string().min(1),
|
||||||
|
classIds: z.array(z.string().min(1)).min(1, "至少选择一个班级"),
|
||||||
|
availableAt: dateStringSchema.optional(),
|
||||||
|
dueAt: dateStringSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateLessonPlanInput = z.infer<typeof createLessonPlanSchema>;
|
export type CreateLessonPlanInput = z.infer<typeof createLessonPlanSchema>;
|
||||||
export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
|
export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
|
||||||
|
export type PublishLessonPlanHomeworkInput = z.infer<typeof publishLessonPlanHomeworkSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 课案状态
|
// 课案状态
|
||||||
export type LessonPlanStatus = "draft" | "published" | "archived";
|
export type LessonPlanStatus = "draft" | "published" | "archived";
|
||||||
|
|
||||||
// Block 类型枚举
|
// Block 类型枚举(教学节点)
|
||||||
export type BlockType =
|
export type BlockType =
|
||||||
| "objective"
|
| "objective"
|
||||||
| "key_point"
|
| "key_point"
|
||||||
@@ -16,13 +16,21 @@ export type BlockType =
|
|||||||
| "rich_text"
|
| "rich_text"
|
||||||
| "reflection";
|
| "reflection";
|
||||||
|
|
||||||
// 富文本类 block 的 data
|
// 正文节点类型(特殊:不可拖动,画布中央)
|
||||||
|
export type TextbookContentNodeType = "textbook_content";
|
||||||
|
|
||||||
|
// 节点类型联合(用于 React Flow 注册)
|
||||||
|
export type LessonNodeType = BlockType | TextbookContentNodeType;
|
||||||
|
|
||||||
|
// ---- 各 Block 数据接口 ----
|
||||||
|
|
||||||
|
// 富文本类
|
||||||
export interface RichTextBlockData {
|
export interface RichTextBlockData {
|
||||||
html: string;
|
html: string;
|
||||||
knowledgePointIds: string[];
|
knowledgePointIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文本研习 block 的 data
|
// 文本研习
|
||||||
export interface TextStudyAnnotation {
|
export interface TextStudyAnnotation {
|
||||||
id: string;
|
id: string;
|
||||||
anchor: { start: number; end: number };
|
anchor: { start: number; end: number };
|
||||||
@@ -38,22 +46,22 @@ export interface TextStudyBlockData {
|
|||||||
knowledgePointIds: string[];
|
knowledgePointIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 练习 block 的 data
|
// 练习
|
||||||
export type ExercisePurpose = "class_practice" | "after_class_homework";
|
export type ExercisePurpose = "class_practice" | "after_class_homework";
|
||||||
|
|
||||||
export interface InlineQuestionContent {
|
export interface InlineQuestionContent {
|
||||||
content: unknown; // 与 questions.content 对齐
|
content: unknown;
|
||||||
type: string; // 与 questionTypeEnum 对齐
|
type: string;
|
||||||
difficulty: number;
|
difficulty: number;
|
||||||
knowledgePointIds: string[];
|
knowledgePointIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExerciseItem {
|
export interface ExerciseItem {
|
||||||
questionId: string; // bank=真实ID;inline=占位 inline_draft_xxx
|
questionId: string;
|
||||||
source: "bank" | "inline";
|
source: "bank" | "inline";
|
||||||
score: number;
|
score: number;
|
||||||
order: number;
|
order: number;
|
||||||
inlineContent?: InlineQuestionContent; // 仅 inline
|
inlineContent?: InlineQuestionContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExerciseBlockData {
|
export interface ExerciseBlockData {
|
||||||
@@ -65,42 +73,189 @@ export interface ExerciseBlockData {
|
|||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 教学目标(三维目标)
|
||||||
|
export interface ObjectiveItem {
|
||||||
|
dimension: "knowledge" | "process" | "emotion";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectiveBlockData {
|
||||||
|
objectives: ObjectiveItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重难点
|
||||||
|
export interface KeyPointItem {
|
||||||
|
type: "key" | "difficult";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyPointBlockData {
|
||||||
|
keyPoints: KeyPointItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入
|
||||||
|
export interface ImportBlockData {
|
||||||
|
method: "question" | "situation" | "review" | "other";
|
||||||
|
prompt: string;
|
||||||
|
durationMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新授
|
||||||
|
export interface NewTeachingPoint {
|
||||||
|
knowledgePointIds: string[];
|
||||||
|
outline: string;
|
||||||
|
boardNotes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewTeachingBlockData {
|
||||||
|
teachingPoints: NewTeachingPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小结
|
||||||
|
export interface SummaryBlockData {
|
||||||
|
summaryPoints: string[];
|
||||||
|
homeworkPreview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 作业
|
||||||
|
export interface HomeworkAssignment {
|
||||||
|
type: "exercise" | "reading" | "writing";
|
||||||
|
refId?: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeworkBlockData {
|
||||||
|
assignments: HomeworkAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 板书设计
|
||||||
|
export interface BlackboardBlockData {
|
||||||
|
layout: "structure" | "mindmap" | "text";
|
||||||
|
content: string;
|
||||||
|
knowledgePointIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 教学反思
|
||||||
|
export interface ReflectionItem {
|
||||||
|
aspect: "effectiveness" | "problems" | "improvements";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReflectionBlockData {
|
||||||
|
reflection: ReflectionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正文节点数据
|
||||||
|
export interface TextbookContentNodeData {
|
||||||
|
chapterId: string;
|
||||||
|
content: string; // Markdown 正文(缓存)
|
||||||
|
zoom: number; // 缩放比例 0.5-2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block 数据联合类型
|
||||||
|
export type BlockData =
|
||||||
|
| RichTextBlockData
|
||||||
|
| TextStudyBlockData
|
||||||
|
| ExerciseBlockData
|
||||||
|
| ObjectiveBlockData
|
||||||
|
| KeyPointBlockData
|
||||||
|
| ImportBlockData
|
||||||
|
| NewTeachingBlockData
|
||||||
|
| SummaryBlockData
|
||||||
|
| HomeworkBlockData
|
||||||
|
| BlackboardBlockData
|
||||||
|
| ReflectionBlockData;
|
||||||
|
|
||||||
// Block 联合
|
// Block 联合
|
||||||
export interface Block {
|
export interface Block {
|
||||||
id: string;
|
id: string;
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
title: string;
|
title: string;
|
||||||
data: RichTextBlockData | TextStudyBlockData | ExerciseBlockData;
|
data: BlockData;
|
||||||
order: number;
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 节点(Block + 画布坐标)
|
// 教学节点(Block + 画布坐标)
|
||||||
export interface LessonPlanNode extends Block {
|
export interface LessonPlanNode extends Block {
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连线(节点间数据流/流程顺序)
|
// 正文节点(不可拖动)
|
||||||
|
export interface TextbookContentNode {
|
||||||
|
id: string;
|
||||||
|
type: TextbookContentNodeType;
|
||||||
|
title: string;
|
||||||
|
data: TextbookContentNodeData;
|
||||||
|
order: number;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
draggable: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点联合
|
||||||
|
export type AnyLessonPlanNode = LessonPlanNode | TextbookContentNode;
|
||||||
|
|
||||||
|
// ---- 锚点 ----
|
||||||
|
export type AnchorType = "range" | "point";
|
||||||
|
|
||||||
|
export interface NodeAnchor {
|
||||||
|
id: string;
|
||||||
|
nodeId: string; // 关联的教学节点 ID
|
||||||
|
type: AnchorType;
|
||||||
|
start: number; // 正文纯文本偏移量
|
||||||
|
end?: number; // range 锚定的结束偏移
|
||||||
|
textPreview?: string; // range 锚定的文字预览(用于失效重定位)
|
||||||
|
invalid?: boolean; // 正文变更后无法重定位时标记为失效
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 边 ----
|
||||||
|
export type LessonEdgeType = "anchor" | "flow";
|
||||||
|
|
||||||
|
// 基础边
|
||||||
export interface LessonPlanEdge {
|
export interface LessonPlanEdge {
|
||||||
id: string;
|
id: string;
|
||||||
source: string; // 源节点 id
|
source: string;
|
||||||
target: string; // 目标节点 id
|
target: string;
|
||||||
sourceHandle?: string | null;
|
sourceHandle?: string | null;
|
||||||
targetHandle?: string | null;
|
targetHandle?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文档 v1(旧格式,向后兼容读取)
|
// 锚点连线(教学节点 → 正文节点)
|
||||||
|
export interface AnchorEdge extends LessonPlanEdge {
|
||||||
|
type: "anchor";
|
||||||
|
anchorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流程连线(教学节点 → 教学节点)
|
||||||
|
export interface FlowEdge extends LessonPlanEdge {
|
||||||
|
type: "flow";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyLessonPlanEdge = AnchorEdge | FlowEdge;
|
||||||
|
|
||||||
|
// ---- 文档版本 ----
|
||||||
|
|
||||||
|
// v1(旧格式,向后兼容读取)
|
||||||
export interface LessonPlanDocumentV1 {
|
export interface LessonPlanDocumentV1 {
|
||||||
version: 1;
|
version: 1;
|
||||||
blocks: Block[];
|
blocks: Block[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文档 v2(节点图格式)
|
// v2(节点图格式)
|
||||||
export interface LessonPlanDocument {
|
export interface LessonPlanDocumentV2 {
|
||||||
version: 2;
|
version: 2;
|
||||||
nodes: LessonPlanNode[];
|
nodes: LessonPlanNode[];
|
||||||
edges: LessonPlanEdge[];
|
edges: LessonPlanEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v3(课文锚点画布格式)
|
||||||
|
export interface LessonPlanDocument {
|
||||||
|
version: 3;
|
||||||
|
textbookContentNodeId: string;
|
||||||
|
nodes: AnyLessonPlanNode[];
|
||||||
|
edges: AnyLessonPlanEdge[];
|
||||||
|
anchors: NodeAnchor[];
|
||||||
|
}
|
||||||
|
|
||||||
// 课案
|
// 课案
|
||||||
export interface LessonPlan {
|
export interface LessonPlan {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user