feat(lesson-preparation): add readonly view, anchor node selector, and type guards
- Add lesson-plan-readonly-view for viewing published plans - Add anchor-node-selector and textbook-segments for canvas anchor positioning - Add i18n-errors and type-guards lib utilities - Add lesson-plan-provider-setup for provider initialization - Update actions, data-access (knowledge, versions, main), publish-service - Update blocks (blackboard, exercise, homework, import, key-point, objective, reflection) - Update editor, node-editor, node-edit-panel, pickers, and providers
This commit is contained in:
@@ -9,6 +9,7 @@ import { Permissions } from "@/shared/types/permissions";
|
|||||||
import { handleActionError, safeParseDate } from "@/shared/lib/action-utils";
|
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 { publishLessonPlanHomeworkSchema } from "./schema";
|
||||||
|
import { translateFieldErrors } from "./lib/i18n-errors";
|
||||||
import type { ActionState } from "./types";
|
import type { ActionState } from "./types";
|
||||||
|
|
||||||
export async function publishLessonPlanHomeworkAction(input: {
|
export async function publishLessonPlanHomeworkAction(input: {
|
||||||
@@ -22,24 +23,32 @@ export async function publishLessonPlanHomeworkAction(input: {
|
|||||||
try {
|
try {
|
||||||
const parsed = publishLessonPlanHomeworkSchema.safeParse(input);
|
const parsed = publishLessonPlanHomeworkSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||||
|
return { success: false, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
// V3 修复:作业标题/描述由 actions 层 i18n 翻译后传入,避免 service 层硬编码中文
|
||||||
|
const homeworkTitle = t("publish.homeworkTitle", { title: parsed.data.planId });
|
||||||
|
const homeworkDescription = t("publish.homeworkDescription");
|
||||||
|
const availableAtLabel = t("publish.availableAtLabel");
|
||||||
|
const dueAtLabel = t("publish.dueAtLabel");
|
||||||
const result = await publishLessonPlanHomework({
|
const result = await publishLessonPlanHomework({
|
||||||
planId: parsed.data.planId,
|
planId: parsed.data.planId,
|
||||||
blockId: parsed.data.blockId,
|
blockId: parsed.data.blockId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
classIds: parsed.data.classIds,
|
classIds: parsed.data.classIds,
|
||||||
availableAt: parsed.data.availableAt
|
availableAt: parsed.data.availableAt
|
||||||
? safeParseDate(parsed.data.availableAt, "可用时间")
|
? safeParseDate(parsed.data.availableAt, availableAtLabel)
|
||||||
: undefined,
|
: undefined,
|
||||||
dueAt: parsed.data.dueAt
|
dueAt: parsed.data.dueAt
|
||||||
? safeParseDate(parsed.data.dueAt, "截止时间")
|
? safeParseDate(parsed.data.dueAt, dueAtLabel)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
homeworkTitle,
|
||||||
|
homeworkDescription,
|
||||||
});
|
});
|
||||||
revalidatePath("/teacher/lesson-plans");
|
revalidatePath("/teacher/lesson-plans");
|
||||||
revalidatePath("/teacher/homework");
|
revalidatePath("/teacher/homework");
|
||||||
@@ -69,4 +78,5 @@ const PUBLISH_ERROR_KEY_MAP: Record<string, string> = {
|
|||||||
ALREADY_PUBLISHED: "publish.alreadyPublished",
|
ALREADY_PUBLISHED: "publish.alreadyPublished",
|
||||||
NO_SUBJECT_OR_GRADE: "publish.noSubjectOrGrade",
|
NO_SUBJECT_OR_GRADE: "publish.noSubjectOrGrade",
|
||||||
NO_STUDENTS: "publish.noStudents",
|
NO_STUDENTS: "publish.noStudents",
|
||||||
|
INVALID_QUESTION_TYPE: "error.invalidQuestionType",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
createLessonPlan,
|
createLessonPlan,
|
||||||
updateLessonPlanContent,
|
updateLessonPlanContent,
|
||||||
softDeleteLessonPlan,
|
softDeleteLessonPlan,
|
||||||
|
publishLessonPlan,
|
||||||
|
unpublishLessonPlan,
|
||||||
duplicateLessonPlan,
|
duplicateLessonPlan,
|
||||||
getTextbooksForPicker,
|
getTextbooksForPicker,
|
||||||
getChaptersForPicker,
|
getChaptersForPicker,
|
||||||
@@ -34,7 +36,8 @@ import {
|
|||||||
revertVersionSchema,
|
revertVersionSchema,
|
||||||
saveAsTemplateSchema,
|
saveAsTemplateSchema,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import type { ActionState, LessonPlanDocument } from "./types";
|
import { translateFieldErrors } from "./lib/i18n-errors";
|
||||||
|
import type { ActionState, LessonPlan, LessonPlanDocument } from "./types";
|
||||||
|
|
||||||
// ---- 课案列表 ----
|
// ---- 课案列表 ----
|
||||||
export async function getLessonPlansAction(params: {
|
export async function getLessonPlansAction(params: {
|
||||||
@@ -60,13 +63,12 @@ export async function getLessonPlansAction(params: {
|
|||||||
// ---- 单课案 ----
|
// ---- 单课案 ----
|
||||||
export async function getLessonPlanByIdAction(
|
export async function getLessonPlanByIdAction(
|
||||||
planId: string,
|
planId: string,
|
||||||
): Promise<
|
): Promise<ActionState<{ plan: LessonPlan }>> {
|
||||||
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: "课案不存在" };
|
if (!plan) return { success: false, message: t("error.notFound") };
|
||||||
return { success: true, data: { plan } };
|
return { success: true, data: { plan } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
@@ -90,7 +92,8 @@ export async function createLessonPlanAction(
|
|||||||
templateId: formData.get("templateId"),
|
templateId: formData.get("templateId"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||||
|
return { success: false, errors };
|
||||||
}
|
}
|
||||||
const { planId } = await createLessonPlan({
|
const { planId } = await createLessonPlan({
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
@@ -131,8 +134,10 @@ export async function updateLessonPlanAction(input: {
|
|||||||
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);
|
||||||
if (!parsed.success)
|
if (!parsed.success) {
|
||||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||||
|
return { success: false, errors };
|
||||||
|
}
|
||||||
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 } : {}),
|
||||||
// 从 unknown 转换:Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫
|
// 从 unknown 转换:Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫
|
||||||
@@ -154,8 +159,10 @@ export async function saveLessonPlanVersionAction(input: {
|
|||||||
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);
|
||||||
if (!parsed.success)
|
if (!parsed.success) {
|
||||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||||
|
return { success: false, errors };
|
||||||
|
}
|
||||||
const { versionNo } = await createLessonPlanVersion({
|
const { versionNo } = await createLessonPlanVersion({
|
||||||
planId: parsed.data.planId,
|
planId: parsed.data.planId,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
@@ -192,17 +199,23 @@ 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);
|
||||||
if (!parsed.success)
|
if (!parsed.success) {
|
||||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||||
|
return { success: false, errors };
|
||||||
|
}
|
||||||
|
// V3 修复:传入 i18n 翻译的回退标签,避免 data-access 硬编码中文
|
||||||
|
const revertLabel = t("version.revertLabel", { versionNo: parsed.data.versionNo });
|
||||||
const result = await revertToVersion(
|
const result = await revertToVersion(
|
||||||
parsed.data.planId,
|
parsed.data.planId,
|
||||||
parsed.data.versionNo,
|
parsed.data.versionNo,
|
||||||
ctx.userId,
|
ctx.userId,
|
||||||
|
revertLabel,
|
||||||
);
|
);
|
||||||
if (!result) return { success: false, message: "版本不存在" };
|
if (!result) return { success: false, message: t("error.versionNotFound") };
|
||||||
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) {
|
||||||
@@ -224,6 +237,44 @@ export async function deleteLessonPlanAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 发布课案(P0-1 修复)----
|
||||||
|
// 发布后学生和家长可查看此课案
|
||||||
|
export async function publishLessonPlanAction(
|
||||||
|
planId: string,
|
||||||
|
): Promise<ActionState> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_PUBLISH);
|
||||||
|
await publishLessonPlan(planId, ctx.userId);
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
revalidatePath(`/teacher/lesson-plans/${planId}/edit`);
|
||||||
|
return { success: true, message: t("action.publishPlanSuccess") };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||||
|
return { success: false, message: t("error.notFound") };
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 撤回发布 ----
|
||||||
|
// 撤回后学生和家长将无法查看此课案
|
||||||
|
export async function unpublishLessonPlanAction(
|
||||||
|
planId: string,
|
||||||
|
): Promise<ActionState> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_PUBLISH);
|
||||||
|
await unpublishLessonPlan(planId, ctx.userId);
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
revalidatePath(`/teacher/lesson-plans/${planId}/edit`);
|
||||||
|
return { success: true, message: t("action.unpublishPlanSuccess") };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||||
|
return { success: false, message: t("error.notFound") };
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 复制 ----
|
// ---- 复制 ----
|
||||||
export async function duplicateLessonPlanAction(
|
export async function duplicateLessonPlanAction(
|
||||||
planId: string,
|
planId: string,
|
||||||
@@ -231,7 +282,12 @@ export async function duplicateLessonPlanAction(
|
|||||||
const t = await getTranslations("lessonPreparation");
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||||
const { newPlanId } = await duplicateLessonPlan(planId, ctx.userId);
|
// V3 修复:传入 i18n 翻译的副本后缀,避免 data-access 硬编码中文
|
||||||
|
const { newPlanId } = await duplicateLessonPlan(
|
||||||
|
planId,
|
||||||
|
ctx.userId,
|
||||||
|
t("error.duplicateSuffix"),
|
||||||
|
);
|
||||||
revalidatePath("/teacher/lesson-plans");
|
revalidatePath("/teacher/lesson-plans");
|
||||||
return { success: true, data: { newPlanId } };
|
return { success: true, data: { newPlanId } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -265,8 +321,10 @@ export async function saveAsTemplateAction(input: {
|
|||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||||
const parsed = saveAsTemplateSchema.safeParse(input);
|
const parsed = saveAsTemplateSchema.safeParse(input);
|
||||||
if (!parsed.success)
|
if (!parsed.success) {
|
||||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||||
|
return { success: false, errors };
|
||||||
|
}
|
||||||
const { templateId } = await saveAsTemplate({
|
const { templateId } = await saveAsTemplate({
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Tag } from "lucide-react";
|
import { Tag } from "lucide-react";
|
||||||
import type { BlackboardBlockData } from "../../types";
|
import type { BlackboardBlockData } from "../../types";
|
||||||
|
import { isBlackboardLayout } from "../../lib/type-guards";
|
||||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,12 +31,12 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={data.layout}
|
value={data.layout}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
onUpdate({
|
const value = e.target.value;
|
||||||
...data,
|
if (isBlackboardLayout(value)) {
|
||||||
layout: e.target.value as BlackboardBlockData["layout"],
|
onUpdate({ ...data, layout: value });
|
||||||
})
|
}
|
||||||
}
|
}}
|
||||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
|
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
|
||||||
>
|
>
|
||||||
{LAYOUTS.map((l) => (
|
{LAYOUTS.map((l) => (
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import type {
|
import type {
|
||||||
ExerciseBlockData,
|
ExerciseBlockData,
|
||||||
ExerciseItem,
|
ExerciseItem,
|
||||||
ExercisePurpose,
|
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
|
import { isExercisePurpose } from "../../lib/type-guards";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blockId: string;
|
blockId: string;
|
||||||
@@ -59,9 +59,12 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
<select
|
<select
|
||||||
id={`exercise-purpose-${blockId}`}
|
id={`exercise-purpose-${blockId}`}
|
||||||
value={data.purpose}
|
value={data.purpose}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
update({ purpose: e.target.value as ExercisePurpose })
|
const value = e.target.value;
|
||||||
}
|
if (isExercisePurpose(value)) {
|
||||||
|
update({ purpose: value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="border rounded px-2 py-1 text-sm"
|
className="border rounded px-2 py-1 text-sm"
|
||||||
>
|
>
|
||||||
<option value="class_practice">{t("exercise.purpose.class_practice")}</option>
|
<option value="class_practice">{t("exercise.purpose.class_practice")}</option>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import type { HomeworkAssignment, HomeworkBlockData } from "../../types";
|
import type { HomeworkAssignment, HomeworkBlockData } from "../../types";
|
||||||
|
import { isHomeworkType } from "../../lib/type-guards";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,11 +46,12 @@ export function HomeworkBlock({ data, onUpdate }: Props) {
|
|||||||
<div key={idx} className="flex items-start gap-2">
|
<div key={idx} className="flex items-start gap-2">
|
||||||
<select
|
<select
|
||||||
value={item.type}
|
value={item.type}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updateItem(idx, {
|
const value = e.target.value;
|
||||||
type: e.target.value as HomeworkAssignment["type"],
|
if (isHomeworkType(value)) {
|
||||||
})
|
updateItem(idx, { type: value });
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||||
>
|
>
|
||||||
{TYPES.map((tp) => (
|
{TYPES.map((tp) => (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { ImportBlockData } from "../../types";
|
import type { ImportBlockData } from "../../types";
|
||||||
|
import { isImportMethod } from "../../lib/type-guards";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ImportBlockData;
|
data: ImportBlockData;
|
||||||
@@ -24,9 +25,12 @@ export function ImportBlock({ data, onUpdate }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={data.method}
|
value={data.method}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
onUpdate({ ...data, method: e.target.value as ImportBlockData["method"] })
|
const value = e.target.value;
|
||||||
}
|
if (isImportMethod(value)) {
|
||||||
|
onUpdate({ ...data, method: value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
|
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
|
||||||
>
|
>
|
||||||
{METHODS.map((m) => (
|
{METHODS.map((m) => (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import type { KeyPointBlockData, KeyPointItem } from "../../types";
|
import type { KeyPointBlockData, KeyPointItem } from "../../types";
|
||||||
|
import { isKeyPointType } from "../../lib/type-guards";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,9 +46,12 @@ export function KeyPointBlock({ data, onUpdate }: Props) {
|
|||||||
<div key={idx} className="flex items-start gap-2">
|
<div key={idx} className="flex items-start gap-2">
|
||||||
<select
|
<select
|
||||||
value={item.type}
|
value={item.type}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updateItem(idx, { type: e.target.value as KeyPointItem["type"] })
|
const value = e.target.value;
|
||||||
}
|
if (isKeyPointType(value)) {
|
||||||
|
updateItem(idx, { type: value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||||
>
|
>
|
||||||
{TYPES.map((tp) => (
|
{TYPES.map((tp) => (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import type { ObjectiveBlockData, ObjectiveItem } from "../../types";
|
import type { ObjectiveBlockData, ObjectiveItem } from "../../types";
|
||||||
|
import { isObjectiveDimension } from "../../lib/type-guards";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -48,11 +49,12 @@ export function ObjectiveBlock({ data, onUpdate }: Props) {
|
|||||||
<div key={idx} className="flex items-start gap-2">
|
<div key={idx} className="flex items-start gap-2">
|
||||||
<select
|
<select
|
||||||
value={item.dimension}
|
value={item.dimension}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updateItem(idx, {
|
const value = e.target.value;
|
||||||
dimension: e.target.value as ObjectiveItem["dimension"],
|
if (isObjectiveDimension(value)) {
|
||||||
})
|
updateItem(idx, { dimension: value });
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||||
>
|
>
|
||||||
{DIMENSIONS.map((d) => (
|
{DIMENSIONS.map((d) => (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import type { ReflectionBlockData, ReflectionItem } from "../../types";
|
import type { ReflectionBlockData, ReflectionItem } from "../../types";
|
||||||
|
import { isReflectionAspect } from "../../lib/type-guards";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,11 +46,12 @@ export function ReflectionBlock({ data, onUpdate }: Props) {
|
|||||||
<div key={idx} className="flex items-start gap-2">
|
<div key={idx} className="flex items-start gap-2">
|
||||||
<select
|
<select
|
||||||
value={item.aspect}
|
value={item.aspect}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updateItem(idx, {
|
const value = e.target.value;
|
||||||
aspect: e.target.value as ReflectionItem["aspect"],
|
if (isReflectionAspect(value)) {
|
||||||
})
|
updateItem(idx, { aspect: value });
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||||
>
|
>
|
||||||
{ASPECTS.map((a) => (
|
{ASPECTS.map((a) => (
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||||
|
import type { KnowledgePointOption } from "../providers/lesson-plan-provider";
|
||||||
interface KpOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
@@ -27,13 +23,15 @@ export function KnowledgePointPicker({
|
|||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const [options, setOptions] = useState<KpOption[]>([]);
|
const ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
|
const [options, setOptions] = useState<KnowledgePointOption[]>([]);
|
||||||
const [local, setLocal] = useState<string[]>(selectedIds);
|
const [local, setLocal] = useState<string[]>(selectedIds);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textbookId) {
|
if (!textbookId || !service) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -43,7 +41,7 @@ export function KnowledgePointPicker({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
return getKnowledgePointOptionsAction({ textbookId, chapterId });
|
return service.getKnowledgePointOptions({ textbookId, chapterId });
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled || !res) return;
|
if (cancelled || !res) return;
|
||||||
@@ -64,7 +62,7 @@ export function KnowledgePointPicker({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [textbookId, chapterId, t]);
|
}, [textbookId, chapterId, t, service]);
|
||||||
|
|
||||||
function toggle(id: string) {
|
function toggle(id: string) {
|
||||||
setLocal((prev) =>
|
setLocal((prev) =>
|
||||||
|
|||||||
@@ -17,25 +17,62 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/shared/components/ui/alert-dialog";
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { formatDateTime } from "@/shared/lib/utils";
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
|
||||||
import { useLessonPlanContextSafe, useRoleConfig, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
import { useLessonPlanContextSafe, useRoleConfig, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
import type { LessonPlanListItem } from "../types";
|
import type { LessonPlanListItem } from "../types";
|
||||||
|
|
||||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
export function LessonPlanCard({
|
||||||
|
plan,
|
||||||
|
viewMode = "teacher",
|
||||||
|
}: {
|
||||||
|
plan: LessonPlanListItem;
|
||||||
|
viewMode?: "teacher" | "student" | "parent" | "admin" | "gradeHead";
|
||||||
|
}) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const roleConfig = useRoleConfig();
|
const roleConfig = useRoleConfig();
|
||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
|
|
||||||
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||||
const ctx = useLessonPlanContextSafe();
|
const ctx = useLessonPlanContextSafe();
|
||||||
const service = ctx?.service ?? null;
|
const service = ctx?.service ?? null;
|
||||||
|
|
||||||
|
// 根据视图模式决定跳转链接
|
||||||
|
const planHref =
|
||||||
|
viewMode === "teacher"
|
||||||
|
? `/teacher/lesson-plans/${plan.id}/edit`
|
||||||
|
: viewMode === "student"
|
||||||
|
? `/student/lesson-plans/${plan.id}/view`
|
||||||
|
: viewMode === "parent"
|
||||||
|
? `/parent/lesson-plans/${plan.id}/view`
|
||||||
|
: viewMode === "admin"
|
||||||
|
? `/admin/lesson-plans/${plan.id}/view`
|
||||||
|
: `/grade-head/lesson-plans/${plan.id}/view`;
|
||||||
|
|
||||||
|
// 根据视图模式生成指定版本的跳转链接
|
||||||
|
const getVersionHref = (versionId: string): string => {
|
||||||
|
const base =
|
||||||
|
viewMode === "teacher"
|
||||||
|
? `/teacher/lesson-plans/${versionId}/edit`
|
||||||
|
: viewMode === "student"
|
||||||
|
? `/student/lesson-plans/${versionId}/view`
|
||||||
|
: viewMode === "parent"
|
||||||
|
? `/parent/lesson-plans/${versionId}/view`
|
||||||
|
: viewMode === "admin"
|
||||||
|
? `/admin/lesson-plans/${versionId}/view`
|
||||||
|
: `/grade-head/lesson-plans/${versionId}/view`;
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 是否有多版本
|
||||||
|
const hasMultipleVersions = plan.versionCount > 1;
|
||||||
|
|
||||||
|
// 只读视图(非 teacher)不显示编辑操作
|
||||||
|
const isReadOnly = viewMode !== "teacher";
|
||||||
|
|
||||||
async function handleArchive() {
|
async function handleArchive() {
|
||||||
|
if (!service) return;
|
||||||
try {
|
try {
|
||||||
const res = service
|
const res = await service.deleteLessonPlan(plan.id);
|
||||||
? await service.deleteLessonPlan(plan.id)
|
|
||||||
: await deleteLessonPlanAction(plan.id);
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||||
toast.success(t("status.archived"));
|
toast.success(t("status.archived"));
|
||||||
@@ -50,10 +87,9 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDuplicate() {
|
async function handleDuplicate() {
|
||||||
|
if (!service) return;
|
||||||
try {
|
try {
|
||||||
const res = service
|
const res = await service.duplicateLessonPlan(plan.id);
|
||||||
? await service.duplicateLessonPlan(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();
|
||||||
@@ -66,16 +102,57 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePublish() {
|
||||||
|
if (!service) return;
|
||||||
|
try {
|
||||||
|
const res = await service.publishLessonPlan(plan.id);
|
||||||
|
if (res.success) {
|
||||||
|
tracker.track("lesson_plan.publish", { planId: plan.id });
|
||||||
|
toast.success(res.message ?? t("action.publishPlanSuccess"));
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanCard] publish failed", e);
|
||||||
|
toast.error(t("error.save"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnpublish() {
|
||||||
|
if (!service) return;
|
||||||
|
try {
|
||||||
|
const res = await service.unpublishLessonPlan(plan.id);
|
||||||
|
if (res.success) {
|
||||||
|
tracker.track("lesson_plan.unpublish", { planId: plan.id });
|
||||||
|
toast.success(res.message ?? t("action.unpublishPlanSuccess"));
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanCard] unpublish failed", e);
|
||||||
|
toast.error(t("error.save"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||||
<Link
|
<div className="flex items-start justify-between gap-2">
|
||||||
href={`/teacher/lesson-plans/${plan.id}/edit`}
|
<Link
|
||||||
className="block"
|
href={planHref}
|
||||||
>
|
className="block flex-1 min-w-0"
|
||||||
<h3 className="font-title-md text-title-md hover:text-primary">
|
>
|
||||||
{plan.title}
|
<h3 className="font-title-md text-title-md hover:text-primary truncate">
|
||||||
</h3>
|
{plan.title}
|
||||||
</Link>
|
</h3>
|
||||||
|
</Link>
|
||||||
|
{hasMultipleVersions && (
|
||||||
|
<span className="shrink-0 inline-flex items-center rounded-full bg-primary-container px-2 py-0.5 text-xs font-medium text-on-primary-container">
|
||||||
|
{t("list.versionCount", { count: plan.versionCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-on-surface-variant mt-1">
|
<div className="text-sm text-on-surface-variant mt-1">
|
||||||
{plan.textbookTitle ?? t("list.noTextbook")} · {plan.chapterTitle ?? t("list.noChapter")}
|
{plan.textbookTitle ?? t("list.noTextbook")} · {plan.chapterTitle ?? t("list.noChapter")}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,13 +166,86 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
? formatDateTime(plan.lastSavedAt)
|
? formatDateTime(plan.lastSavedAt)
|
||||||
: t("list.neverSaved")}
|
: t("list.neverSaved")}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 版本选择器:仅多版本时显示 */}
|
||||||
|
{hasMultipleVersions && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<label htmlFor={`version-select-${plan.id}`} className="text-xs text-on-surface-variant">
|
||||||
|
{t("list.versionSelectorLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`version-select-${plan.id}`}
|
||||||
|
className="flex-1 text-xs border border-outline-variant rounded bg-surface px-2 py-1 text-on-surface focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
value={plan.id}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (selectedId && selectedId !== plan.id) {
|
||||||
|
router.push(getVersionHref(selectedId));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.versions.map((v, idx) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{`v${plan.versions.length - idx} · ${t(`status.${v.status}`)} · ${formatDateTime(v.updatedAt)}`}
|
||||||
|
{idx === 0 ? ` (${t("list.versionCurrent")})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
{roleConfig.canDuplicate && (
|
{roleConfig.canDuplicate && !isReadOnly && (
|
||||||
<Button variant="outline" size="sm" onClick={handleDuplicate}>
|
<Button variant="outline" size="sm" onClick={handleDuplicate}>
|
||||||
{t("action.duplicate")}
|
{t("action.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{roleConfig.canArchive && (
|
{/* 发布/撤回发布按钮(仅教师视图)*/}
|
||||||
|
{!isReadOnly && plan.status === "draft" && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="default" size="sm">
|
||||||
|
{t("action.publishPlan")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("action.publishPlan")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("action.publishPlanConfirm")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handlePublish}>
|
||||||
|
{t("action.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
{!isReadOnly && plan.status === "published" && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{t("action.unpublishPlan")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("action.unpublishPlan")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("action.unpublishPlanConfirm")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleUnpublish}>
|
||||||
|
{t("action.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
{roleConfig.canArchive && !isReadOnly && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
|
|||||||
@@ -7,19 +7,30 @@ import { NodeEditor } from "./node-editor";
|
|||||||
import { NodeEditPanel } from "./node-edit-panel";
|
import { NodeEditPanel } from "./node-edit-panel";
|
||||||
import { VersionHistoryDrawer } from "./version-history-drawer";
|
import { VersionHistoryDrawer } from "./version-history-drawer";
|
||||||
import {
|
import {
|
||||||
updateLessonPlanAction,
|
useLessonPlanContextSafe,
|
||||||
saveLessonPlanVersionAction,
|
useLessonPlanTrackerSafe,
|
||||||
getLessonPlanByIdAction,
|
} from "../providers/lesson-plan-provider";
|
||||||
} from "../actions";
|
|
||||||
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, Book, FileText } from "lucide-react";
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
|
import { Plus, Save, History, Book, FileText, Send, Undo2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
planId: string;
|
planId: string;
|
||||||
initialTitle: string;
|
initialTitle: string;
|
||||||
initialDoc: import("../types").LessonPlanDocument;
|
initialDoc: import("../types").LessonPlanDocument;
|
||||||
|
initialStatus?: "draft" | "published" | "archived";
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
chapterId?: string;
|
chapterId?: string;
|
||||||
textbookTitle?: string;
|
textbookTitle?: string;
|
||||||
@@ -46,6 +57,7 @@ export function LessonPlanEditor({
|
|||||||
planId,
|
planId,
|
||||||
initialTitle,
|
initialTitle,
|
||||||
initialDoc,
|
initialDoc,
|
||||||
|
initialStatus = "draft",
|
||||||
textbookId,
|
textbookId,
|
||||||
chapterId,
|
chapterId,
|
||||||
textbookTitle,
|
textbookTitle,
|
||||||
@@ -55,8 +67,12 @@ export function LessonPlanEditor({
|
|||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const editor = useLessonPlanEditor();
|
const editor = useLessonPlanEditor();
|
||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
|
const ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
const [showVersions, setShowVersions] = useState(false);
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||||
|
const [planStatus, setPlanStatus] = useState<"draft" | "published" | "archived">(initialStatus);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -69,14 +85,16 @@ export function LessonPlanEditor({
|
|||||||
}, [initKey]);
|
}, [initKey]);
|
||||||
|
|
||||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||||
|
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor.isDirty) return;
|
if (!editor.isDirty) return;
|
||||||
|
if (!service) return;
|
||||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||||
autoSaveTimer.current = setTimeout(async () => {
|
autoSaveTimer.current = setTimeout(async () => {
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
state.setSaving(true);
|
state.setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await updateLessonPlanAction({
|
const res = await service.updateLessonPlan({
|
||||||
planId: state.planId,
|
planId: state.planId,
|
||||||
title: state.title,
|
title: state.title,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
@@ -91,15 +109,16 @@ export function LessonPlanEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||||
};
|
};
|
||||||
}, [editor.isDirty, editor.doc, planId]);
|
}, [editor.isDirty, editor.doc, planId, service]);
|
||||||
|
|
||||||
// 定时自动版本(30min)
|
// 定时自动版本(30min)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!service) return;
|
||||||
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 {
|
try {
|
||||||
await saveLessonPlanVersionAction({
|
await service.saveLessonPlanVersion({
|
||||||
planId: state.planId,
|
planId: state.planId,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
label: t("version.autoLabel"),
|
label: t("version.autoLabel"),
|
||||||
@@ -111,7 +130,7 @@ export function LessonPlanEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||||
};
|
};
|
||||||
}, [planId, t]);
|
}, [planId, t, service]);
|
||||||
|
|
||||||
// 离开未保存提示(P3-1)
|
// 离开未保存提示(P3-1)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -138,10 +157,11 @@ export function LessonPlanEditor({
|
|||||||
}, [showAddMenu]);
|
}, [showAddMenu]);
|
||||||
|
|
||||||
const handleManualSave = useCallback(async () => {
|
const handleManualSave = useCallback(async () => {
|
||||||
|
if (!service) return;
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
state.setSaving(true);
|
state.setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await saveLessonPlanVersionAction({
|
const res = await service.saveLessonPlanVersion({
|
||||||
planId: state.planId,
|
planId: state.planId,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
});
|
});
|
||||||
@@ -154,20 +174,63 @@ export function LessonPlanEditor({
|
|||||||
} finally {
|
} finally {
|
||||||
state.setSaving(false);
|
state.setSaving(false);
|
||||||
}
|
}
|
||||||
}, [tracker]);
|
}, [tracker, service]);
|
||||||
|
|
||||||
// 版本回退后刷新内容(修复 P1-1)
|
// 版本回退后刷新内容(修复 P1-1)
|
||||||
const handleReverted = useCallback(async () => {
|
const handleReverted = useCallback(async () => {
|
||||||
|
if (!service) return;
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
try {
|
try {
|
||||||
const res = await getLessonPlanByIdAction(state.planId);
|
const res = await service.getLessonPlanById(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) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanEditor] reload after revert failed", e);
|
console.error("[LessonPlanEditor] reload after revert failed", e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [service]);
|
||||||
|
|
||||||
|
// 发布课案(P0-1 修复)
|
||||||
|
const handlePublish = useCallback(async () => {
|
||||||
|
if (!service) return;
|
||||||
|
setPublishing(true);
|
||||||
|
try {
|
||||||
|
const res = await service.publishLessonPlan(planId);
|
||||||
|
if (res.success) {
|
||||||
|
setPlanStatus("published");
|
||||||
|
tracker.track("lesson_plan.publish", { planId });
|
||||||
|
toast.success(res.message ?? t("action.publishPlanSuccess"));
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanEditor] publish failed", e);
|
||||||
|
toast.error(t("error.save"));
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
}, [planId, tracker, t, service]);
|
||||||
|
|
||||||
|
// 撤回发布
|
||||||
|
const handleUnpublish = useCallback(async () => {
|
||||||
|
if (!service) return;
|
||||||
|
setPublishing(true);
|
||||||
|
try {
|
||||||
|
const res = await service.unpublishLessonPlan(planId);
|
||||||
|
if (res.success) {
|
||||||
|
setPlanStatus("draft");
|
||||||
|
tracker.track("lesson_plan.unpublish", { planId });
|
||||||
|
toast.success(res.message ?? t("action.unpublishPlanSuccess"));
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanEditor] unpublish failed", e);
|
||||||
|
toast.error(t("error.save"));
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
}, [planId, tracker, t, service]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -209,6 +272,52 @@ export function LessonPlanEditor({
|
|||||||
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
||||||
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
|
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* 发布/撤回发布按钮(P0-1 修复)*/}
|
||||||
|
{planStatus === "published" ? (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={publishing}>
|
||||||
|
<Undo2 className="w-4 h-4 mr-1" /> {t("action.unpublishPlan")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("action.unpublishPlan")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("action.unpublishPlanConfirm")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleUnpublish}>
|
||||||
|
{t("action.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
) : (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="sm" disabled={publishing}>
|
||||||
|
<Send className="w-4 h-4 mr-1" /> {t("action.publishPlan")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("action.publishPlan")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("action.publishPlanConfirm")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handlePublish}>
|
||||||
|
{t("action.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主区域:画布 + 侧边面板 */}
|
{/* 主区域:画布 + 侧边面板 */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,6 +9,10 @@ interface Props {
|
|||||||
fallback?: ReactNode;
|
fallback?: ReactNode;
|
||||||
/** 错误时的回调,用于上报埋点 */
|
/** 错误时的回调,用于上报埋点 */
|
||||||
onError?: (error: Error, info: ErrorInfo) => void;
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
/** 错误提示文案(V3 i18n:由包装组件注入)*/
|
||||||
|
errorText?: string;
|
||||||
|
/** 重试按钮文案(V3 i18n:由包装组件注入)*/
|
||||||
|
retryText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -16,11 +21,13 @@ interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 备课模块错误边界。
|
* 备课模块错误边界(内部类组件)。
|
||||||
* 包裹独立数据区块(版本抽屉/题库选择器/知识点选择器/发布对话框),
|
* 包裹独立数据区块(版本抽屉/题库选择器/知识点选择器/发布对话框),
|
||||||
* 单个区块异常不影响整页。
|
* 单个区块异常不影响整页。
|
||||||
|
*
|
||||||
|
* V3 修复:i18n 文案由外层 LessonPlanErrorBoundary 包装组件通过 useTranslations 注入。
|
||||||
*/
|
*/
|
||||||
export class LessonPlanErrorBoundary extends Component<Props, State> {
|
class LessonPlanErrorBoundaryBase extends Component<Props, State> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false, error: null };
|
this.state = { hasError: false, error: null };
|
||||||
@@ -46,10 +53,10 @@ export class LessonPlanErrorBoundary extends Component<Props, State> {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center p-8 gap-3 text-center">
|
<div className="flex flex-col items-center justify-center p-8 gap-3 text-center">
|
||||||
<p className="text-sm text-on-surface-variant">
|
<p className="text-sm text-on-surface-variant">
|
||||||
{this.state.error?.message ?? "区块加载失败"}
|
{this.state.error?.message ?? this.props.errorText}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={this.handleRetry}>
|
<Button variant="outline" size="sm" onClick={this.handleRetry}>
|
||||||
重试
|
{this.props.retryText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -57,3 +64,18 @@ export class LessonPlanErrorBoundary extends Component<Props, State> {
|
|||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备课模块错误边界(V3 i18n 包装组件)。
|
||||||
|
* 使用 useTranslations 注入错误文案,对外保持原有 API 不变。
|
||||||
|
*/
|
||||||
|
export function LessonPlanErrorBoundary(props: Props): ReactNode {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
return (
|
||||||
|
<LessonPlanErrorBoundaryBase
|
||||||
|
errorText={t("error.loadFailed")}
|
||||||
|
retryText={t("error.retry")}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,23 +4,31 @@ 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";
|
||||||
import { getLessonPlansAction } from "../actions";
|
|
||||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||||
import type { LessonPlanListItem } from "../types";
|
import type { LessonPlanListItem } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialItems: LessonPlanListItem[];
|
initialItems: LessonPlanListItem[];
|
||||||
subjects: { id: string; name: string }[];
|
subjects: { id: string; name: string }[];
|
||||||
|
/**
|
||||||
|
* 视图模式:决定卡片的跳转链接和可用操作。
|
||||||
|
* - teacher(默认):跳转到编辑页
|
||||||
|
* - student / parent:跳转到只读查看页
|
||||||
|
* - admin:跳转到管理员查看页
|
||||||
|
* - gradeHead:跳转到教研组长查看页
|
||||||
|
*/
|
||||||
|
viewMode?: "teacher" | "student" | "parent" | "admin" | "gradeHead";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }: 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 [error, setError] = useState<string | null>(null);
|
||||||
const ctx = useLessonPlanContextSafe();
|
const ctx = useLessonPlanContextSafe();
|
||||||
const service = ctx?.service ?? null;
|
const service = ctx?.service ?? null;
|
||||||
|
|
||||||
// 使用 useCallback 稳定 handleFilter 引用,避免 LessonPlanFilters 的 useEffect 无限循环
|
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||||
|
// 若未在 Provider 内使用,则不执行任何服务端调用(强制要求 Provider 包裹)
|
||||||
const handleFilter = useCallback(
|
const handleFilter = useCallback(
|
||||||
async (params: {
|
async (params: {
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -28,17 +36,9 @@ export function LessonPlanList({ initialItems, subjects }: Props) {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}) => {
|
}) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!service) return;
|
||||||
try {
|
try {
|
||||||
if (service) {
|
const res = await service.getLessonPlans(params);
|
||||||
const res = await service.getLessonPlans(params);
|
|
||||||
if (res.success && res.data) {
|
|
||||||
setItems(res.data.items);
|
|
||||||
} else {
|
|
||||||
setError(res.message ?? t("error.loadFailed"));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await getLessonPlansAction(params);
|
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setItems(res.data.items);
|
setItems(res.data.items);
|
||||||
} else {
|
} else {
|
||||||
@@ -67,7 +67,7 @@ export function LessonPlanList({ initialItems, subjects }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{items.map((p) => (
|
{items.map((p) => (
|
||||||
<LessonPlanCard key={p.id} plan={p} />
|
<LessonPlanCard key={p.id} plan={p} viewMode={viewMode} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { LessonNode } from "./nodes/lesson-node";
|
||||||
|
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
||||||
|
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||||
|
import { getNodeColor } from "../lib/node-summary";
|
||||||
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
lesson: LessonNode,
|
||||||
|
textbook_content: TextbookContentNodeComponent,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
textbookTitle?: string;
|
||||||
|
chapterTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读课案画布视图(学生/家长/管理员/教研组长使用)。
|
||||||
|
*
|
||||||
|
* 复用 React Flow 渲染,但禁用所有编辑交互:
|
||||||
|
* - 节点不可拖动、不可连线
|
||||||
|
* - 可缩放、可平移(便于查看)
|
||||||
|
* - 可点击节点查看详情(通过 onSelectNode 回调)
|
||||||
|
*/
|
||||||
|
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
|
||||||
|
const rfEdges = useMemo(
|
||||||
|
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? []),
|
||||||
|
[doc.edges, doc.anchors, selectedNodeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 为正文节点准备 data(锚点、选中节点、选择回调)
|
||||||
|
const nodesWithData: Node[] = useMemo(() => {
|
||||||
|
return rfNodes.map((n) => {
|
||||||
|
if (n.type === "textbook_content") {
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
data: {
|
||||||
|
...n.data,
|
||||||
|
node: doc.nodes.find((nn) => nn.id === n.id),
|
||||||
|
anchors: doc.anchors ?? [],
|
||||||
|
selectedNodeId,
|
||||||
|
onSelectNode: setSelectedNodeId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full relative">
|
||||||
|
{/* 顶部信息条 */}
|
||||||
|
{(textbookTitle || chapterTitle) && (
|
||||||
|
<div className="absolute top-2 left-2 z-10 bg-surface/95 backdrop-blur border border-outline-variant rounded-md px-3 py-1.5 text-xs text-on-surface-variant flex items-center gap-2 shadow-sm">
|
||||||
|
{textbookTitle && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">{t("editor.textbookLabel")}</span>
|
||||||
|
{textbookTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{chapterTitle && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">{t("editor.chapterLabel")}</span>
|
||||||
|
{chapterTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodesWithData}
|
||||||
|
edges={rfEdges as Edge[]}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable={true}
|
||||||
|
panOnDrag={true}
|
||||||
|
zoomOnScroll={true}
|
||||||
|
zoomOnPinch={true}
|
||||||
|
panOnScroll={false}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
<MiniMap
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
nodeColor={(n) => {
|
||||||
|
// V3 修复:使用 getNodeColor 替代硬编码颜色,与编辑器保持一致
|
||||||
|
const data = n.data;
|
||||||
|
if (!data || typeof data !== "object") return getNodeColor(n.type ?? "");
|
||||||
|
const nodeData = data.node;
|
||||||
|
if (
|
||||||
|
nodeData &&
|
||||||
|
typeof nodeData === "object" &&
|
||||||
|
nodeData !== null &&
|
||||||
|
"type" in nodeData &&
|
||||||
|
typeof nodeData.type === "string"
|
||||||
|
) {
|
||||||
|
return getNodeColor(nodeData.type);
|
||||||
|
}
|
||||||
|
return getNodeColor(n.type ?? "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Button } from "@/shared/components/ui/button";
|
|||||||
import { Trash2, X } from "lucide-react";
|
import { Trash2, X } from "lucide-react";
|
||||||
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
|
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
|
||||||
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
|
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
|
||||||
|
import { getNodeColor } from "../lib/node-summary";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
@@ -20,7 +21,7 @@ interface Props {
|
|||||||
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const tAi = useTranslations("ai");
|
const tAi = useTranslations("ai");
|
||||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
|
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
|
||||||
useLessonPlanEditor();
|
useLessonPlanEditor();
|
||||||
const aiClient = useAiClientOptional();
|
const aiClient = useAiClientOptional();
|
||||||
const [showAiPanel, setShowAiPanel] = useState(false);
|
const [showAiPanel, setShowAiPanel] = useState(false);
|
||||||
@@ -35,8 +36,16 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 正文节点不在侧边面板编辑(直接在画布上交互)
|
// P2-1:正文节点显示操作提示 + 锚点列表(而非误导性的"内容为空")
|
||||||
if (node.type === "textbook_content") {
|
if (node.type === "textbook_content") {
|
||||||
|
// 收集所有锚点,并关联到对应的教学节点
|
||||||
|
const anchorsWithNode = doc.anchors
|
||||||
|
.map((a) => {
|
||||||
|
const linkedNode = doc.nodes.find((n) => n.id === a.nodeId);
|
||||||
|
return { anchor: a, nodeTitle: linkedNode?.title ?? "?", nodeType: linkedNode?.type ?? "rich_text" };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.anchor.start - b.anchor.start);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
|
||||||
@@ -52,15 +61,65 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
|||||||
<X className="w-4 h-4" aria-hidden="true" />
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4 text-sm text-on-surface-variant">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{t("editor.textbookContentEmpty")}
|
{/* 操作提示 */}
|
||||||
|
<div className="rounded-md border border-outline-variant bg-surface-container-low p-3 text-sm text-on-surface-variant">
|
||||||
|
{t("editor.textbookOperateHint")}
|
||||||
|
</div>
|
||||||
|
{/* 锚点列表 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-on-surface-variant mb-2">
|
||||||
|
{t("editor.anchorListTitle")}({anchorsWithNode.length})
|
||||||
|
</div>
|
||||||
|
{anchorsWithNode.length === 0 ? (
|
||||||
|
<p className="text-sm text-on-surface-variant italic">
|
||||||
|
{t("editor.anchorListEmpty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{anchorsWithNode.map(({ anchor, nodeTitle, nodeType }) => (
|
||||||
|
<li
|
||||||
|
key={anchor.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded border border-outline-variant bg-surface-container-lowest text-sm"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: getNodeColor(nodeType) }}
|
||||||
|
/>
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary text-secondary-foreground flex-shrink-0">
|
||||||
|
{anchor.type === "range"
|
||||||
|
? t("editor.anchorRangeLabel")
|
||||||
|
: t("editor.anchorPointLabel")}
|
||||||
|
</span>
|
||||||
|
<span className="truncate flex-1" title={nodeTitle}>
|
||||||
|
{nodeTitle}
|
||||||
|
</span>
|
||||||
|
{anchor.textPreview && (
|
||||||
|
<span className="truncate text-xs text-on-surface-variant max-w-[120px]" title={anchor.textPreview}>
|
||||||
|
“{anchor.textPreview}”
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="!p-1 !h-6 !w-6 text-on-surface-variant hover:text-error"
|
||||||
|
onClick={() => removeAnchor(anchor.id)}
|
||||||
|
aria-label={t("action.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 教学节点:通过类型守卫收窄为 LessonPlanNode
|
// 教学节点:textbook_content 分支已上方 return,此处 TypeScript 已收窄为 LessonPlanNode
|
||||||
const lessonNode = node as import("../types").LessonPlanNode;
|
const lessonNode = node;
|
||||||
|
|
||||||
// 从节点标题提取主题用于 AI 内容生成
|
// 从节点标题提取主题用于 AI 内容生成
|
||||||
const aiTopic = lessonNode.title || t("editor.textbookContent");
|
const aiTopic = lessonNode.title || t("editor.textbookContent");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { LessonNode } from "./nodes/lesson-node";
|
|||||||
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-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 { AnyLessonPlanNode } from "../types";
|
import type { AnyLessonPlanNode, BlockType } from "../types";
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
lesson: LessonNode,
|
lesson: LessonNode,
|
||||||
@@ -42,22 +42,27 @@ export function NodeEditor({}: Props) {
|
|||||||
selectNode,
|
selectNode,
|
||||||
setEdges,
|
setEdges,
|
||||||
addAnchor,
|
addAnchor,
|
||||||
updateTextbookContent,
|
addNode,
|
||||||
} = useLessonPlanEditor();
|
} = useLessonPlanEditor();
|
||||||
|
|
||||||
|
// P1-1:构建可锚定的教学节点列表(排除正文节点)
|
||||||
|
const anchorableNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
doc.nodes
|
||||||
|
.filter((n): n is Extract<AnyLessonPlanNode, { type: BlockType }> => n.type !== "textbook_content")
|
||||||
|
.map((n) => ({ id: n.id, title: n.title, type: n.type })),
|
||||||
|
[doc.nodes],
|
||||||
|
);
|
||||||
|
|
||||||
// 锚点添加回调(正文节点使用)
|
// 锚点添加回调(正文节点使用)
|
||||||
const handleAddRangeAnchor = useCallback(
|
const handleAddRangeAnchor = useCallback(
|
||||||
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
|
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
|
||||||
// 如果 nodeId 是 __selected__,使用当前选中节点
|
// __selected__ 表示使用当前选中节点
|
||||||
// 如果是 __new__,提示用户先创建节点
|
|
||||||
const actualNodeId =
|
const actualNodeId =
|
||||||
params.nodeId === "__selected__"
|
params.nodeId === "__selected__"
|
||||||
? selectedNodeId ?? ""
|
? selectedNodeId ?? ""
|
||||||
: params.nodeId;
|
: params.nodeId;
|
||||||
if (!actualNodeId || actualNodeId === "__new__") {
|
if (!actualNodeId) return;
|
||||||
// 简化:不自动创建新节点,提示用户先选中或创建
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addAnchor({
|
addAnchor({
|
||||||
nodeId: actualNodeId,
|
nodeId: actualNodeId,
|
||||||
type: "range",
|
type: "range",
|
||||||
@@ -75,9 +80,7 @@ export function NodeEditor({}: Props) {
|
|||||||
params.nodeId === "__selected__"
|
params.nodeId === "__selected__"
|
||||||
? selectedNodeId ?? ""
|
? selectedNodeId ?? ""
|
||||||
: params.nodeId;
|
: params.nodeId;
|
||||||
if (!actualNodeId || actualNodeId === "__new__") {
|
if (!actualNodeId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
addAnchor({
|
addAnchor({
|
||||||
nodeId: actualNodeId,
|
nodeId: actualNodeId,
|
||||||
type: "point",
|
type: "point",
|
||||||
@@ -87,11 +90,25 @@ export function NodeEditor({}: Props) {
|
|||||||
[addAnchor, selectedNodeId],
|
[addAnchor, selectedNodeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleZoomChange = useCallback(
|
// P1-1:创建新节点并锚定
|
||||||
(zoom: number) => {
|
const handleCreateNewNode = useCallback(
|
||||||
updateTextbookContent({ zoom });
|
(params: {
|
||||||
|
anchorType: "range" | "point";
|
||||||
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
textPreview?: string;
|
||||||
|
}) => {
|
||||||
|
// 默认创建 rich_text 节点(最通用的类型),用户可后续切换
|
||||||
|
const newNodeId = addNode("rich_text", undefined, t("blockType.rich_text"));
|
||||||
|
addAnchor({
|
||||||
|
nodeId: newNodeId,
|
||||||
|
type: params.anchorType,
|
||||||
|
start: params.start,
|
||||||
|
...(params.end !== undefined ? { end: params.end } : {}),
|
||||||
|
...(params.textPreview ? { textPreview: params.textPreview } : {}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[updateTextbookContent],
|
[addNode, addAnchor, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 使用纯函数映射 nodes/edges
|
// 使用纯函数映射 nodes/edges
|
||||||
@@ -100,12 +117,13 @@ export function NodeEditor({}: Props) {
|
|||||||
toRfNodes(doc.nodes, selectedNodeId, {
|
toRfNodes(doc.nodes, selectedNodeId, {
|
||||||
anchors: doc.anchors,
|
anchors: doc.anchors,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
|
anchorableNodes,
|
||||||
onAddRangeAnchor: handleAddRangeAnchor,
|
onAddRangeAnchor: handleAddRangeAnchor,
|
||||||
onAddPointAnchor: handleAddPointAnchor,
|
onAddPointAnchor: handleAddPointAnchor,
|
||||||
|
onCreateNewNode: handleCreateNewNode,
|
||||||
onSelectNode: selectNode,
|
onSelectNode: selectNode,
|
||||||
onZoomChange: handleZoomChange,
|
|
||||||
}),
|
}),
|
||||||
[doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange],
|
[doc.nodes, doc.anchors, selectedNodeId, anchorableNodes, handleAddRangeAnchor, handleAddPointAnchor, handleCreateNewNode, selectNode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rfEdges: Edge[] = useMemo(
|
const rfEdges: Edge[] = useMemo(
|
||||||
@@ -173,7 +191,13 @@ export function NodeEditor({}: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative" role="application" aria-label={t("editor.canvasLabel")}>
|
<div
|
||||||
|
className="w-full h-full relative"
|
||||||
|
role="application"
|
||||||
|
aria-label={t("editor.canvasLabel")}
|
||||||
|
// 禁用整个画布的浏览器默认右键菜单
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
{doc.nodes.length === 0 && (
|
{doc.nodes.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||||
<div className="text-center text-on-surface-variant">
|
<div className="text-center text-on-surface-variant">
|
||||||
@@ -216,9 +240,21 @@ 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?: AnyLessonPlanNode }).node;
|
// V3 修复:从 React Flow 的 Node.data(Record<string, unknown>)安全提取 node 字段
|
||||||
if (!nodeData) return "#9e9e9e";
|
// 使用类型守卫替代 as 断言
|
||||||
return getNodeColor(nodeData.type);
|
const data = n.data;
|
||||||
|
if (!data || typeof data !== "object") return "#9e9e9e";
|
||||||
|
const nodeData = data.node;
|
||||||
|
if (
|
||||||
|
nodeData &&
|
||||||
|
typeof nodeData === "object" &&
|
||||||
|
nodeData !== null &&
|
||||||
|
"type" in nodeData &&
|
||||||
|
typeof nodeData.type === "string"
|
||||||
|
) {
|
||||||
|
return getNodeColor(nodeData.type);
|
||||||
|
}
|
||||||
|
return "#9e9e9e";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { getNodeColor } from "../../lib/node-summary";
|
||||||
|
|
||||||
|
interface AnchorNodeSelectorProps {
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
anchorableNodes: { id: string; title: string; type: string }[];
|
||||||
|
hasSelectedNode: boolean;
|
||||||
|
onPickNode: (nodeId: string) => void;
|
||||||
|
onCreateNew: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锚点节点选择器(P1-1 完善)
|
||||||
|
* - 渲染可锚定的教学节点列表(点击即关联到该节点)
|
||||||
|
* - 提供"关联到当前选中节点"快捷项(仅当有选中节点时)
|
||||||
|
* - 提供"创建新节点并关联"选项(触发 onCreateNew 回调)
|
||||||
|
*/
|
||||||
|
export function AnchorNodeSelector({
|
||||||
|
t,
|
||||||
|
anchorableNodes,
|
||||||
|
hasSelectedNode,
|
||||||
|
onPickNode,
|
||||||
|
onCreateNew,
|
||||||
|
}: AnchorNodeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{hasSelectedNode && (
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded font-medium"
|
||||||
|
onClick={() => onPickNode("__selected__")}
|
||||||
|
>
|
||||||
|
{t("editor.anchorToSelectedNode")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{anchorableNodes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-on-surface-variant/70 px-2 pt-1">
|
||||||
|
{t("editor.selectNodeForAnchor")}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-y-auto">
|
||||||
|
{anchorableNodes.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded flex items-center gap-2"
|
||||||
|
onClick={() => onPickNode(n.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: getNodeColor(n.type) }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{n.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-outline-variant mt-1 pt-1">
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded text-primary"
|
||||||
|
onClick={onCreateNew}
|
||||||
|
>
|
||||||
|
+ {t("editor.createNewNode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react";
|
import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { NodeProps } from "@xyflow/react";
|
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 type { NodeAnchor, TextbookContentNode as TextbookContentNodeModel } from "../../types";
|
||||||
import {
|
import {
|
||||||
@@ -15,29 +11,54 @@ import {
|
|||||||
parseAnchoredText,
|
parseAnchoredText,
|
||||||
toCircledNumber,
|
toCircledNumber,
|
||||||
getNextPointIndex,
|
getNextPointIndex,
|
||||||
|
markdownToPlainText,
|
||||||
} from "../../lib/anchor-injector";
|
} from "../../lib/anchor-injector";
|
||||||
import { getNodeColor } from "../../lib/node-summary";
|
import { getNodeColor } from "../../lib/node-summary";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { AnchorNodeSelector } from "./anchor-node-selector";
|
||||||
|
import { renderSegments } from "./textbook-segments";
|
||||||
|
|
||||||
interface TextbookContentNodeProps {
|
interface TextbookContentNodeProps {
|
||||||
data: {
|
node: TextbookContentNodeModel;
|
||||||
node: TextbookContentNodeModel;
|
anchors: NodeAnchor[];
|
||||||
anchors: NodeAnchor[];
|
selectedNodeId: string | null;
|
||||||
selectedNodeId: string | null;
|
/** 可锚定的教学节点列表(用于锚点节点选择器)*/
|
||||||
onAddRangeAnchor?: (params: {
|
anchorableNodes?: { id: string; title: string; type: string }[];
|
||||||
nodeId: string;
|
onAddRangeAnchor?: (params: {
|
||||||
start: number;
|
nodeId: string;
|
||||||
end: number;
|
start: number;
|
||||||
textPreview: string;
|
end: number;
|
||||||
}) => void;
|
textPreview: string;
|
||||||
onAddPointAnchor?: (params: {
|
}) => void;
|
||||||
nodeId: string;
|
onAddPointAnchor?: (params: {
|
||||||
start: number;
|
nodeId: string;
|
||||||
}) => void;
|
start: number;
|
||||||
onSelectNode?: (id: string | null) => void;
|
}) => void;
|
||||||
onZoomChange?: (zoom: number) => void;
|
/** 创建新节点并锚定 */
|
||||||
};
|
onCreateNewNode?: (params: {
|
||||||
selected: boolean;
|
anchorType: "range" | "point";
|
||||||
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
textPreview?: string;
|
||||||
|
}) => void;
|
||||||
|
onSelectNode?: (id: string | null) => void;
|
||||||
|
onResize?: (width: number, height: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型守卫:安全收窄 React Flow NodeProps.data 为 TextbookContentNodeProps
|
||||||
|
* 替代 `as unknown as` 断言,通过结构检查确保数据形状正确
|
||||||
|
*/
|
||||||
|
function isTextbookContentNodePropsData(
|
||||||
|
data: unknown,
|
||||||
|
): data is TextbookContentNodeProps {
|
||||||
|
if (typeof data !== "object" || data === null) return false;
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof obj.node === "object" &&
|
||||||
|
obj.node !== null &&
|
||||||
|
typeof (obj.node as Record<string, unknown>).type === "string" &&
|
||||||
|
Array.isArray(obj.anchors)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextbookContentNode = memo(function TextbookContentNode({
|
export const TextbookContentNode = memo(function TextbookContentNode({
|
||||||
@@ -45,21 +66,27 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
selected,
|
selected,
|
||||||
}: NodeProps) {
|
}: NodeProps) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const props = (data as unknown as TextbookContentNodeProps["data"]).node
|
const props = isTextbookContentNodePropsData(data) ? data : null;
|
||||||
? (data as unknown as TextbookContentNodeProps["data"])
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||||
const [showAnchorMenu, setShowAnchorMenu] = useState<{
|
const [showAnchorMenu, setShowAnchorMenu] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
selection: { start: number; end: number; text: string } | null;
|
selection: { start: number; end: number; text: string } | null;
|
||||||
point: number | null;
|
point: number | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
// 光标位置指示器(左键点击时显示)
|
||||||
|
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
// 拖拽缩放状态
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
const resizeStart = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||||
|
|
||||||
const node = props?.node;
|
const node = props?.node;
|
||||||
const anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]);
|
const anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]);
|
||||||
const selectedNodeId = props?.selectedNodeId ?? null;
|
const selectedNodeId = props?.selectedNodeId ?? null;
|
||||||
|
const anchorableNodes = props?.anchorableNodes ?? [];
|
||||||
|
|
||||||
// 注入锚点标记后的 Markdown
|
// 注入锚点标记后的 Markdown
|
||||||
const injectedContent = useMemo(() => {
|
const injectedContent = useMemo(() => {
|
||||||
@@ -91,84 +118,114 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
[anchors],
|
[anchors],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理文本选择
|
// 计算选中文本在纯文本中的偏移
|
||||||
const handleMouseUp = useCallback(() => {
|
const computeSelectionOffset = useCallback(
|
||||||
if (!node) return;
|
(selectedText: string): { start: number; end: number } | null => {
|
||||||
const selection = window.getSelection();
|
if (!node) return null;
|
||||||
if (!selection || selection.isCollapsed) {
|
const plainText = markdownToPlainText(node.data.content);
|
||||||
// 点击空白处:尝试计算点击位置偏移
|
const start = plainText.indexOf(selectedText);
|
||||||
return;
|
if (start >= 0) {
|
||||||
}
|
return { start, end: start + selectedText.length };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[node],
|
||||||
|
);
|
||||||
|
|
||||||
const text = selection.toString();
|
// 计算点击位置在纯文本中的偏移,并返回 caret 的视口坐标(用于精确定位光标指示器)
|
||||||
if (!text) return;
|
const computePointOffset = useCallback(
|
||||||
|
(clientX: number, clientY: number): { offset: number; rect: DOMRect | null } => {
|
||||||
|
let offset = -1;
|
||||||
|
let rect: DOMRect | null = null;
|
||||||
|
// 优先用 caretPositionFromPoint(标准 API)
|
||||||
|
if (document.caretPositionFromPoint) {
|
||||||
|
const pos = document.caretPositionFromPoint(clientX, clientY);
|
||||||
|
if (pos) {
|
||||||
|
offset = pos.offset;
|
||||||
|
try {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(pos.offsetNode, pos.offset);
|
||||||
|
range.setEnd(pos.offsetNode, pos.offset);
|
||||||
|
// collapsed range 用 getClientRects 获取 caret 位置
|
||||||
|
const rects = range.getClientRects();
|
||||||
|
rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();
|
||||||
|
} catch {
|
||||||
|
rect = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (document.caretRangeFromPoint) {
|
||||||
|
// 回退:WebKit 专用 API
|
||||||
|
const range = document.caretRangeFromPoint(clientX, clientY);
|
||||||
|
if (range) {
|
||||||
|
offset = range.startOffset;
|
||||||
|
const rects = range.getClientRects();
|
||||||
|
rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { offset, rect };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 计算纯文本偏移量
|
// 右键菜单:在右键位置弹出锚点菜单
|
||||||
const range = selection.getRangeAt(0);
|
const handleContextMenu = useCallback(
|
||||||
const plainText = node.data.content;
|
(e: React.MouseEvent) => {
|
||||||
const startContainer = range.startContainer;
|
if (!node) return;
|
||||||
const endContainer = range.endContainer;
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
// 简化:用 selection 的 anchorOffset 和 focusOffset
|
const selection = window.getSelection();
|
||||||
// 注意:这是近似值,对于复杂 DOM 结构可能不准确
|
const selectedText = selection && !selection.isCollapsed ? selection.toString() : "";
|
||||||
const startOffset = range.startOffset;
|
|
||||||
const endOffset = range.endOffset;
|
|
||||||
|
|
||||||
// 如果在同一文本节点
|
if (selectedText) {
|
||||||
if (startContainer === endContainer && startContainer.nodeType === Node.TEXT_NODE) {
|
// 有选中文本:提供区间锚定
|
||||||
const containerText = startContainer.textContent ?? "";
|
const offsets = computeSelectionOffset(selectedText);
|
||||||
const containerStart = plainText.indexOf(containerText);
|
if (!offsets) return;
|
||||||
if (containerStart >= 0) {
|
|
||||||
const absoluteStart = containerStart + startOffset;
|
|
||||||
const absoluteEnd = containerStart + endOffset;
|
|
||||||
const selectedText = plainText.slice(absoluteStart, absoluteEnd);
|
|
||||||
|
|
||||||
// 显示锚点菜单
|
|
||||||
const rect = range.getBoundingClientRect();
|
|
||||||
setShowAnchorMenu({
|
setShowAnchorMenu({
|
||||||
x: rect.left + rect.width / 2,
|
x: e.clientX,
|
||||||
y: rect.top - 10,
|
y: e.clientY,
|
||||||
selection: { start: absoluteStart, end: absoluteEnd, text: selectedText },
|
selection: { ...offsets, text: selectedText },
|
||||||
point: null,
|
point: null,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// 无选中文本:提供点锚定
|
||||||
|
const { offset } = computePointOffset(e.clientX, e.clientY);
|
||||||
|
if (offset < 0) return;
|
||||||
|
setShowAnchorMenu({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
selection: null,
|
||||||
|
point: offset,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[node, computeSelectionOffset, computePointOffset],
|
||||||
|
);
|
||||||
|
|
||||||
selection.removeAllRanges();
|
// 左键点击:显示光标位置指示器(用 caret 实际坐标精确定位)
|
||||||
}, [node]);
|
|
||||||
|
|
||||||
// 处理点击(点锚定)
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
// 如果有选中文本,不处理点击
|
// 如果有选中文本,不显示光标(让浏览器处理选择)
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (selection && !selection.isCollapsed) return;
|
if (selection && !selection.isCollapsed) {
|
||||||
|
setCursorPos(null);
|
||||||
// 计算点击位置在纯文本中的偏移
|
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;
|
// 计算 caret 位置,优先用 caret 的 rect 坐标,fallback 到鼠标坐标
|
||||||
|
const { offset, rect } = computePointOffset(e.clientX, e.clientY);
|
||||||
|
if (offset < 0) {
|
||||||
|
setCursorPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setShowAnchorMenu({
|
setCursorPos({
|
||||||
x,
|
x: rect && rect.width >= 0 ? rect.left : e.clientX,
|
||||||
y,
|
y: rect && rect.width >= 0 ? rect.top : e.clientY,
|
||||||
selection: null,
|
|
||||||
point: offset,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[node],
|
[node, computePointOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 关闭锚点菜单
|
// 关闭锚点菜单
|
||||||
@@ -184,18 +241,65 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
return () => document.removeEventListener("mousedown", handleOutside);
|
return () => document.removeEventListener("mousedown", handleOutside);
|
||||||
}, [showAnchorMenu]);
|
}, [showAnchorMenu]);
|
||||||
|
|
||||||
// 缩放控制
|
// 光标指示器自动消失
|
||||||
const handleZoomIn = useCallback(() => {
|
useEffect(() => {
|
||||||
if (!node || !props?.onZoomChange) return;
|
if (!cursorPos) return;
|
||||||
const newZoom = Math.min(2, node.data.zoom + 0.1);
|
const timer = setTimeout(() => setCursorPos(null), 2000);
|
||||||
props.onZoomChange(newZoom);
|
return () => clearTimeout(timer);
|
||||||
}, [node, props]);
|
}, [cursorPos]);
|
||||||
|
|
||||||
const handleZoomOut = useCallback(() => {
|
// 阻止 React Flow 在正文内容区和缩放手柄上拦截 pointerdown 事件(切实保障文本选择和缩放可用)
|
||||||
if (!node || !props?.onZoomChange) return;
|
// nodrag class 只能阻止拖拽,但 React Flow 可能在更上层 preventDefault 阻止文本选择
|
||||||
const newZoom = Math.max(0.5, node.data.zoom - 0.1);
|
// 使用原生事件监听器 stopPropagation,让 React Flow 完全收不到 pointerdown
|
||||||
props.onZoomChange(newZoom);
|
useEffect(() => {
|
||||||
}, [node, props]);
|
const stopPointer = (e: PointerEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
const contentEl = contentRef.current;
|
||||||
|
const resizeEl = resizeHandleRef.current;
|
||||||
|
contentEl?.addEventListener("pointerdown", stopPointer);
|
||||||
|
resizeEl?.addEventListener("pointerdown", stopPointer);
|
||||||
|
return () => {
|
||||||
|
contentEl?.removeEventListener("pointerdown", stopPointer);
|
||||||
|
resizeEl?.removeEventListener("pointerdown", stopPointer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 拖拽缩放
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
resizeStart.current = { x: e.clientX, y: e.clientY, w: rect.width, h: rect.height };
|
||||||
|
setResizing(true);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizing || !resizeStart.current || !containerRef.current) return;
|
||||||
|
function handleMove(e: MouseEvent) {
|
||||||
|
if (!resizeStart.current || !containerRef.current) return;
|
||||||
|
const dx = e.clientX - resizeStart.current.x;
|
||||||
|
const dy = e.clientY - resizeStart.current.y;
|
||||||
|
const newW = Math.max(300, resizeStart.current.w + dx);
|
||||||
|
const newH = Math.max(200, resizeStart.current.h + dy);
|
||||||
|
containerRef.current.style.width = `${newW}px`;
|
||||||
|
containerRef.current.style.height = `${newH}px`;
|
||||||
|
}
|
||||||
|
function handleUp() {
|
||||||
|
setResizing(false);
|
||||||
|
resizeStart.current = null;
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", handleMove);
|
||||||
|
document.addEventListener("mouseup", handleUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMove);
|
||||||
|
document.removeEventListener("mouseup", handleUp);
|
||||||
|
};
|
||||||
|
}, [resizing]);
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
@@ -209,67 +313,45 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border-2 bg-surface shadow-lg"
|
ref={containerRef}
|
||||||
|
className="rounded-lg border-2 bg-surface shadow-lg flex flex-col relative"
|
||||||
style={{
|
style={{
|
||||||
borderColor: selected ? "#1976d2" : "#455a64",
|
borderColor: selected ? "#1976d2" : "#455a64",
|
||||||
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
||||||
width: 480,
|
width: 520,
|
||||||
|
minWidth: 300,
|
||||||
|
minHeight: 200,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center justify-between"
|
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center justify-between flex-shrink-0"
|
||||||
style={{ backgroundColor: "#455a64" }}
|
style={{ backgroundColor: "#455a64" }}
|
||||||
>
|
>
|
||||||
<span>{t("editor.textbookContent")}</span>
|
<span>{t("editor.textbookContent")}</span>
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-white/60 text-[10px]">
|
||||||
<Button
|
{t("editor.rightClickHint")}
|
||||||
variant="ghost"
|
</span>
|
||||||
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>
|
||||||
|
|
||||||
{/* 正文内容 */}
|
{/* 正文内容 */}
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="px-4 py-3 max-h-[60vh] overflow-y-auto"
|
// nodrag class 让 React Flow 跳过拖拽逻辑,允许在正文上选择文本
|
||||||
style={{
|
className="px-4 py-3 flex-1 overflow-y-auto text-sm leading-relaxed text-on-surface select-text nodrag"
|
||||||
transform: `scale(${node.data.zoom})`,
|
onContextMenu={handleContextMenu}
|
||||||
transformOrigin: "top left",
|
|
||||||
}}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
style={{ userSelect: "text", WebkitUserSelect: "text" }}
|
||||||
>
|
>
|
||||||
{node.data.content ? (
|
{node.data.content ? (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
<div className="whitespace-pre-wrap break-words">
|
||||||
<ReactMarkdown
|
{renderSegments({
|
||||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
segments,
|
||||||
rehypePlugins={[rehypeSanitize]}
|
activeAnchorIds,
|
||||||
components={{
|
getAnchorNodeColor,
|
||||||
p: ({ children }) => {
|
onSelectNode: props?.onSelectNode,
|
||||||
// 将段落中的锚点标记渲染为 span
|
anchors,
|
||||||
return <p>{renderChildrenWithAnchors(children, segments, activeAnchorIds, getAnchorNodeColor, props?.onSelectNode, anchors)}</p>;
|
})}
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{injectedContent}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-on-surface-variant text-sm py-8 text-center">
|
<div className="text-on-surface-variant text-sm py-8 text-center">
|
||||||
@@ -278,15 +360,43 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 锚点浮动菜单 */}
|
{/* 拖拽缩放手柄 */}
|
||||||
{showAnchorMenu && (
|
<div
|
||||||
|
ref={resizeHandleRef}
|
||||||
|
// nodrag class 让 React Flow 跳过拖拽逻辑,允许缩放手柄独立工作
|
||||||
|
className="absolute bottom-0 right-0 w-5 h-5 cursor-nwse-resize bg-surface-container-high border-l-2 border-t-2 border-outline-variant rounded-tl-md flex items-center justify-center hover:bg-surface-container-highest z-10 nodrag"
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
title={t("editor.dragToResize")}
|
||||||
|
>
|
||||||
|
<svg width="8" height="8" viewBox="0 0 8 8" className="text-on-surface-variant">
|
||||||
|
<path d="M0 8 L8 0 M3 8 L8 3 M6 8 L8 6" stroke="currentColor" strokeWidth="1" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 光标位置指示器(通过 portal 渲染到 body,避免 React Flow transform 容器影响 fixed 定位)*/}
|
||||||
|
{cursorPos && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed pointer-events-none z-40"
|
||||||
|
style={{
|
||||||
|
left: cursorPos.x,
|
||||||
|
top: cursorPos.y,
|
||||||
|
width: 2,
|
||||||
|
height: 16,
|
||||||
|
backgroundColor: "#1976d2",
|
||||||
|
animation: "cursor-blink 1s infinite",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 锚点浮动菜单(右键触发,通过 portal 渲染到 body 保证 fixed 定位相对视口)*/}
|
||||||
|
{showAnchorMenu && typeof document !== "undefined" && createPortal(
|
||||||
<div
|
<div
|
||||||
data-anchor-menu
|
data-anchor-menu
|
||||||
className="fixed z-50 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 min-w-[200px]"
|
className="fixed z-50 bg-surface border border-outline-variant rounded-lg shadow-xl p-2 min-w-[220px] max-h-[60vh] overflow-y-auto"
|
||||||
style={{
|
style={{
|
||||||
left: showAnchorMenu.x,
|
left: showAnchorMenu.x,
|
||||||
top: showAnchorMenu.y,
|
top: showAnchorMenu.y,
|
||||||
transform: "translate(-50%, -100%)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showAnchorMenu.selection ? (
|
{showAnchorMenu.selection ? (
|
||||||
@@ -296,7 +406,9 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
</div>
|
</div>
|
||||||
<AnchorNodeSelector
|
<AnchorNodeSelector
|
||||||
t={t}
|
t={t}
|
||||||
onSelect={(nodeId) => {
|
anchorableNodes={anchorableNodes}
|
||||||
|
hasSelectedNode={!!selectedNodeId}
|
||||||
|
onPickNode={(nodeId) => {
|
||||||
if (props?.onAddRangeAnchor && showAnchorMenu.selection) {
|
if (props?.onAddRangeAnchor && showAnchorMenu.selection) {
|
||||||
props.onAddRangeAnchor({
|
props.onAddRangeAnchor({
|
||||||
nodeId,
|
nodeId,
|
||||||
@@ -307,6 +419,17 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
}
|
}
|
||||||
setShowAnchorMenu(null);
|
setShowAnchorMenu(null);
|
||||||
}}
|
}}
|
||||||
|
onCreateNew={() => {
|
||||||
|
if (props?.onCreateNewNode && showAnchorMenu.selection) {
|
||||||
|
props.onCreateNewNode({
|
||||||
|
anchorType: "range",
|
||||||
|
start: showAnchorMenu.selection.start,
|
||||||
|
end: showAnchorMenu.selection.end,
|
||||||
|
textPreview: showAnchorMenu.selection.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowAnchorMenu(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : showAnchorMenu.point !== null ? (
|
) : showAnchorMenu.point !== null ? (
|
||||||
@@ -316,7 +439,9 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
</div>
|
</div>
|
||||||
<AnchorNodeSelector
|
<AnchorNodeSelector
|
||||||
t={t}
|
t={t}
|
||||||
onSelect={(nodeId) => {
|
anchorableNodes={anchorableNodes}
|
||||||
|
hasSelectedNode={!!selectedNodeId}
|
||||||
|
onPickNode={(nodeId) => {
|
||||||
if (props?.onAddPointAnchor && showAnchorMenu.point !== null) {
|
if (props?.onAddPointAnchor && showAnchorMenu.point !== null) {
|
||||||
props.onAddPointAnchor({
|
props.onAddPointAnchor({
|
||||||
nodeId,
|
nodeId,
|
||||||
@@ -325,116 +450,22 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
|||||||
}
|
}
|
||||||
setShowAnchorMenu(null);
|
setShowAnchorMenu(null);
|
||||||
}}
|
}}
|
||||||
|
onCreateNew={() => {
|
||||||
|
if (props?.onCreateNewNode && showAnchorMenu.point !== null) {
|
||||||
|
props.onCreateNewNode({
|
||||||
|
anchorType: "point",
|
||||||
|
start: showAnchorMenu.point,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowAnchorMenu(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { ReactNode, CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { NodeAnchor } from "../../types";
|
||||||
|
import {
|
||||||
|
parseAnchoredText,
|
||||||
|
toCircledNumber,
|
||||||
|
} from "../../lib/anchor-injector";
|
||||||
|
|
||||||
|
interface RenderSegmentsParams {
|
||||||
|
segments: ReturnType<typeof parseAnchoredText>;
|
||||||
|
activeAnchorIds: Set<string>;
|
||||||
|
getAnchorNodeColor: (anchorId: string) => string;
|
||||||
|
onSelectNode?: (id: string | null) => void;
|
||||||
|
anchors?: NodeAnchor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染锚点段落数组(简化版:直接遍历 segments,不使用 ReactMarkdown)
|
||||||
|
* 解决问题 7:避免每个段落重复渲染整个文档内容
|
||||||
|
*/
|
||||||
|
export function renderSegments({
|
||||||
|
segments,
|
||||||
|
activeAnchorIds,
|
||||||
|
getAnchorNodeColor,
|
||||||
|
onSelectNode,
|
||||||
|
anchors,
|
||||||
|
}: RenderSegmentsParams): ReactNode {
|
||||||
|
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" : ""}`}
|
||||||
|
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
backgroundColor: color,
|
||||||
|
"--node-color": color,
|
||||||
|
} as 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" : ""}`}
|
||||||
|
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
backgroundColor: color,
|
||||||
|
"--node-color": color,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (anchor && onSelectNode) {
|
||||||
|
onSelectNode(anchor.nodeId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toCircledNumber(pointIndex ?? 1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
@@ -23,6 +22,8 @@ export function PublishHomeworkDialog({
|
|||||||
onPublished,
|
onPublished,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||||
const [availableAt, setAvailableAt] = useState("");
|
const [availableAt, setAvailableAt] = useState("");
|
||||||
@@ -31,6 +32,7 @@ export function PublishHomeworkDialog({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
|
if (!service) return;
|
||||||
if (selectedClasses.length === 0) {
|
if (selectedClasses.length === 0) {
|
||||||
setError(t("publish.selectClass"));
|
setError(t("publish.selectClass"));
|
||||||
return;
|
return;
|
||||||
@@ -38,7 +40,7 @@ export function PublishHomeworkDialog({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await publishLessonPlanHomeworkAction({
|
const res = await service.publishLessonPlanHomework({
|
||||||
planId,
|
planId,
|
||||||
blockId,
|
blockId,
|
||||||
classIds: selectedClasses,
|
classIds: selectedClasses,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider"
|
||||||
|
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { useDebounce } from "@/shared/hooks/use-debounce"
|
import { useDebounce } from "@/shared/hooks/use-debounce"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
@@ -10,11 +11,16 @@ import { QuestionBankFilters } from "@/shared/components/question/question-bank-
|
|||||||
import type { ExerciseItem } from "../types"
|
import type { ExerciseItem } from "../types"
|
||||||
import type { QuestionType } from "@/modules/questions/types"
|
import type { QuestionType } from "@/modules/questions/types"
|
||||||
|
|
||||||
interface QuestionRow {
|
// 类型守卫:验证字符串是否为有效的 QuestionType(避免 as 断言)
|
||||||
id: string
|
function isQuestionType(v: string): v is QuestionType {
|
||||||
type: string
|
const validTypes: readonly string[] = [
|
||||||
difficulty: number
|
"single_choice",
|
||||||
content: unknown
|
"multiple_choice",
|
||||||
|
"judgment",
|
||||||
|
"text",
|
||||||
|
"composite",
|
||||||
|
]
|
||||||
|
return validTypes.includes(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,7 +31,9 @@ interface Props {
|
|||||||
|
|
||||||
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||||
const t = useTranslations("lessonPreparation")
|
const t = useTranslations("lessonPreparation")
|
||||||
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
const ctx = useLessonPlanContextSafe()
|
||||||
|
const service = ctx?.service ?? null
|
||||||
|
const [questions, setQuestions] = useState<QuestionPickerItem[]>([])
|
||||||
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -35,18 +43,13 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
const [typeValue, setTypeValue] = useState<string>("all")
|
const [typeValue, setTypeValue] = useState<string>("all")
|
||||||
const [difficultyValue, setDifficultyValue] = useState<string>("all")
|
const [difficultyValue, setDifficultyValue] = useState<string>("all")
|
||||||
|
|
||||||
const filters = useMemo<{
|
const filters = useMemo<QuestionPickerParams>(() => {
|
||||||
q?: string
|
const newFilters: QuestionPickerParams = {}
|
||||||
type?: QuestionType
|
|
||||||
difficulty?: number
|
|
||||||
}>(() => {
|
|
||||||
const newFilters: {
|
|
||||||
q?: string
|
|
||||||
type?: QuestionType
|
|
||||||
difficulty?: number
|
|
||||||
} = {}
|
|
||||||
if (searchValue) newFilters.q = searchValue
|
if (searchValue) newFilters.q = searchValue
|
||||||
if (typeValue !== "all") newFilters.type = typeValue as QuestionType
|
// 类型守卫:仅当值为有效 QuestionType 时才赋值(避免 as 断言)
|
||||||
|
if (typeValue !== "all" && isQuestionType(typeValue)) {
|
||||||
|
newFilters.type = typeValue
|
||||||
|
}
|
||||||
if (difficultyValue !== "all") newFilters.difficulty = Number(difficultyValue)
|
if (difficultyValue !== "all") newFilters.difficulty = Number(difficultyValue)
|
||||||
return newFilters
|
return newFilters
|
||||||
}, [searchValue, typeValue, difficultyValue])
|
}, [searchValue, typeValue, difficultyValue])
|
||||||
@@ -55,6 +58,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
const debouncedFilters = useDebounce(filters, 300)
|
const debouncedFilters = useDebounce(filters, 300)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!service) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
|
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
@@ -62,20 +66,12 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
return getQuestionsAction(debouncedFilters)
|
return service.getQuestions(debouncedFilters)
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled || !res) return
|
if (cancelled || !res) return
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
const data = res.data.data
|
setQuestions(res.data.data)
|
||||||
setQuestions(
|
|
||||||
data.map((q) => ({
|
|
||||||
id: q.id,
|
|
||||||
type: q.type,
|
|
||||||
difficulty: q.difficulty,
|
|
||||||
content: q.content,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
setError(res.message ?? t("error.loadFailed"))
|
setError(res.message ?? t("error.loadFailed"))
|
||||||
}
|
}
|
||||||
@@ -91,9 +87,9 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [debouncedFilters, t])
|
}, [debouncedFilters, t, service])
|
||||||
|
|
||||||
function add(q: QuestionRow) {
|
function add(q: QuestionPickerItem) {
|
||||||
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
|
||||||
setPicked((prev) => [
|
setPicked((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -3,38 +3,25 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { createLessonPlanAction, getTextbooksForPickerAction, getChaptersForPickerAction } from "../actions";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
|
import type { TextbookPickerOption, ChapterPickerOption } from "../providers/lesson-plan-provider";
|
||||||
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 { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
|
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
|
||||||
|
import type { LessonPlanTemplate } from "../types";
|
||||||
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 ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [textbooks, setTextbooks] = useState<TextbookOption[]>([]);
|
const [textbooks, setTextbooks] = useState<TextbookPickerOption[]>([]);
|
||||||
const [textbookId, setTextbookId] = useState<string>("");
|
const [textbookId, setTextbookId] = useState<string>("");
|
||||||
const [chapters, setChapters] = useState<ChapterOption[]>([]);
|
const [chapters, setChapters] = useState<ChapterPickerOption[]>([]);
|
||||||
const [chapterId, setChapterId] = useState<string>(
|
const [chapterId, setChapterId] = useState<string>(
|
||||||
() => searchParams.get("chapterId") ?? "",
|
() => searchParams.get("chapterId") ?? "",
|
||||||
);
|
);
|
||||||
@@ -43,14 +30,17 @@ export function TemplatePicker() {
|
|||||||
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 [loadingTextbooks, setLoadingTextbooks] = useState(true);
|
||||||
|
// P1-6:个人模板
|
||||||
|
const [personalTemplates, setPersonalTemplates] = useState<LessonPlanTemplate[]>([]);
|
||||||
|
|
||||||
// 派生:当前教材的章节是否正在加载
|
// 派生:当前教材的章节是否正在加载
|
||||||
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
|
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
|
||||||
|
|
||||||
// 初始加载教材列表 + URL 参数预选
|
// 初始加载教材列表 + URL 参数预选 + 个人模板(P1-6)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!service) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getTextbooksForPickerAction()
|
service.getTextbooksForPicker()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
@@ -68,18 +58,31 @@ export function TemplatePicker() {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoadingTextbooks(false);
|
if (!cancelled) setLoadingTextbooks(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// P1-6:加载个人模板
|
||||||
|
service.getLessonPlanTemplates()
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const personal = res.data.templates.filter((tpl) => tpl.type === "personal");
|
||||||
|
setPersonalTemplates(personal);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("[TemplatePicker] load personal templates failed", e);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [searchParams]);
|
}, [searchParams, service]);
|
||||||
|
|
||||||
// 教材变化时加载章节
|
// 教材变化时加载章节
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textbookId) {
|
if (!textbookId || !service) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getChaptersForPickerAction(textbookId)
|
service.getChaptersForPicker(textbookId)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
@@ -93,16 +96,16 @@ export function TemplatePicker() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [textbookId]);
|
}, [textbookId, service]);
|
||||||
|
|
||||||
// 扁平化章节列表(用于下拉选择,带缩进前缀)
|
// 扁平化章节列表(用于下拉选择,带缩进前缀)
|
||||||
const flattenedChapters = useMemo(() => {
|
const flattenedChapters = useMemo(() => {
|
||||||
const result: { id: string; title: string; depth: number }[] = [];
|
const result: { id: string; title: string; depth: number }[] = [];
|
||||||
function walk(list: ChapterOption[], depth: number) {
|
function walk(list: ChapterPickerOption[], depth: number) {
|
||||||
for (const ch of list) {
|
for (const ch of list) {
|
||||||
result.push({ id: ch.id, title: ch.title, depth });
|
result.push({ id: ch.id, title: ch.title, depth });
|
||||||
if (ch.children && Array.isArray(ch.children) && ch.children.length > 0) {
|
if (ch.children && Array.isArray(ch.children) && ch.children.length > 0) {
|
||||||
walk(ch.children as ChapterOption[], depth + 1);
|
walk(ch.children as ChapterPickerOption[], depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,7 @@ export function TemplatePicker() {
|
|||||||
const canSubmit = !!selected && !!title && !!textbookId && !!chapterId;
|
const canSubmit = !!selected && !!title && !!textbookId && !!chapterId;
|
||||||
|
|
||||||
async function handleSubmit(formData: FormData) {
|
async function handleSubmit(formData: FormData) {
|
||||||
|
if (!service) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!textbookId || !chapterId) {
|
if (!textbookId || !chapterId) {
|
||||||
setError(t("picker.errorTextbookChapterRequired"));
|
setError(t("picker.errorTextbookChapterRequired"));
|
||||||
@@ -140,7 +144,7 @@ export function TemplatePicker() {
|
|||||||
formData.set("textbookId", textbookId);
|
formData.set("textbookId", textbookId);
|
||||||
formData.set("chapterId", chapterId);
|
formData.set("chapterId", chapterId);
|
||||||
try {
|
try {
|
||||||
const res = await createLessonPlanAction(null, formData);
|
const res = await service.createLessonPlan(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 });
|
||||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||||
@@ -247,6 +251,10 @@ export function TemplatePicker() {
|
|||||||
{/* 步骤 4:模板 */}
|
{/* 步骤 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="text-xs font-medium text-on-surface-variant mb-2 mt-1">
|
||||||
|
{t("template.systemSection")}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{SYSTEM_TEMPLATES.map((tpl) => (
|
{SYSTEM_TEMPLATES.map((tpl) => (
|
||||||
<button
|
<button
|
||||||
@@ -268,6 +276,43 @@ export function TemplatePicker() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 个人模板(P1-6)*/}
|
||||||
|
<div className="text-xs font-medium text-on-surface-variant mb-2 mt-4">
|
||||||
|
{t("template.personalSection")}
|
||||||
|
</div>
|
||||||
|
{personalTemplates.length === 0 ? (
|
||||||
|
<p className="text-sm text-on-surface-variant italic">
|
||||||
|
{t("template.noPersonalTemplates")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{personalTemplates.map((tpl) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tpl.id}
|
||||||
|
onClick={() => setSelected(tpl.id)}
|
||||||
|
className={`text-left p-4 border-2 rounded-lg transition-colors ${
|
||||||
|
selected === tpl.id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-outline-variant hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-title-md flex items-center gap-2">
|
||||||
|
<span className="truncate">{tpl.name}</span>
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary text-secondary-foreground flex-shrink-0">
|
||||||
|
{t("template.personalBadge")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-on-surface-variant mt-1">
|
||||||
|
{tpl.blocks.length === 0
|
||||||
|
? t("template.blankHint")
|
||||||
|
: t("template.blockCount", { count: tpl.blocks.length })}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedTextbook && selectedChapter && (
|
{selectedTextbook && selectedChapter && (
|
||||||
<p className="text-xs text-on-surface-variant mt-2">
|
<p className="text-xs text-on-surface-variant mt-2">
|
||||||
{t("picker.skeletonHint")}
|
{t("picker.skeletonHint")}
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
getLessonPlanVersionsAction,
|
|
||||||
revertLessonPlanVersionAction,
|
|
||||||
} from "../actions";
|
|
||||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -37,6 +33,8 @@ export function VersionHistoryDrawer({
|
|||||||
onReverted,
|
onReverted,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -47,8 +45,9 @@ export function VersionHistoryDrawer({
|
|||||||
// 用微任务延迟避免同步 setState 触发级联渲染
|
// 用微任务延迟避免同步 setState 触发级联渲染
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (!service) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getLessonPlanVersionsAction(planId)
|
service.getLessonPlanVersions(planId)
|
||||||
.then((res) => {
|
.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);
|
||||||
@@ -63,11 +62,12 @@ export function VersionHistoryDrawer({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, planId]);
|
}, [open, planId, service]);
|
||||||
|
|
||||||
async function handleRevert(versionNo: number) {
|
async function handleRevert(versionNo: number) {
|
||||||
|
if (!service) return;
|
||||||
try {
|
try {
|
||||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
const res = await service.revertLessonPlanVersion({ planId, versionNo });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||||
toast.success(t("version.revertSuccess", { versionNo }));
|
toast.success(t("version.revertSuccess", { versionNo }));
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import type {
|
import type {
|
||||||
BlackboardBlockData,
|
|
||||||
BlockData,
|
BlockData,
|
||||||
BlockType,
|
BlockType,
|
||||||
ExerciseBlockData,
|
|
||||||
HomeworkBlockData,
|
|
||||||
ImportBlockData,
|
|
||||||
KeyPointBlockData,
|
|
||||||
NewTeachingBlockData,
|
|
||||||
ObjectiveBlockData,
|
|
||||||
ReflectionBlockData,
|
|
||||||
RichTextBlockData,
|
|
||||||
SummaryBlockData,
|
|
||||||
TextStudyBlockData,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import {
|
||||||
|
isBlackboardBlockData,
|
||||||
|
isExerciseBlockData,
|
||||||
|
isHomeworkBlockData,
|
||||||
|
isImportBlockData,
|
||||||
|
isKeyPointBlockData,
|
||||||
|
isNewTeachingBlockData,
|
||||||
|
isObjectiveBlockData,
|
||||||
|
isReflectionBlockData,
|
||||||
|
isRichTextBlockData,
|
||||||
|
isSummaryBlockData,
|
||||||
|
isTextStudyBlockData,
|
||||||
|
} from "../lib/type-guards";
|
||||||
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";
|
||||||
@@ -75,92 +77,117 @@ export function isRichTextBlock(type: BlockType): boolean {
|
|||||||
* 根据 type 从注册表查找并渲染对应 Block,所有组件引用均为模块顶层静态声明,
|
* 根据 type 从注册表查找并渲染对应 Block,所有组件引用均为模块顶层静态声明,
|
||||||
* 满足 react-hooks/static-components 规则。
|
* 满足 react-hooks/static-components 规则。
|
||||||
* 新增 Block 类型时,在此 switch 中添加对应 case 即可。
|
* 新增 Block 类型时,在此 switch 中添加对应 case 即可。
|
||||||
|
*
|
||||||
|
* V3 修复:使用类型守卫替代 `as` 断言,安全收窄 BlockData 联合类型。
|
||||||
|
* 类型守卫失败时返回 null(理论上不会发生,因为 type 与 data 由调用方保证一致)。
|
||||||
*/
|
*/
|
||||||
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":
|
case "objective": {
|
||||||
|
if (!isObjectiveBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<ObjectiveBlock
|
<ObjectiveBlock
|
||||||
data={rest.data as ObjectiveBlockData}
|
data={rest.data}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "key_point":
|
}
|
||||||
|
case "key_point": {
|
||||||
|
if (!isKeyPointBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<KeyPointBlock
|
<KeyPointBlock
|
||||||
data={rest.data as KeyPointBlockData}
|
data={rest.data}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "import":
|
}
|
||||||
|
case "import": {
|
||||||
|
if (!isImportBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<ImportBlock
|
<ImportBlock
|
||||||
data={rest.data as ImportBlockData}
|
data={rest.data}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "new_teaching":
|
}
|
||||||
|
case "new_teaching": {
|
||||||
|
if (!isNewTeachingBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<NewTeachingBlock
|
<NewTeachingBlock
|
||||||
data={rest.data as NewTeachingBlockData}
|
data={rest.data}
|
||||||
textbookId={rest.textbookId}
|
textbookId={rest.textbookId}
|
||||||
chapterId={rest.chapterId}
|
chapterId={rest.chapterId}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "summary":
|
}
|
||||||
|
case "summary": {
|
||||||
|
if (!isSummaryBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<SummaryBlock
|
<SummaryBlock
|
||||||
data={rest.data as SummaryBlockData}
|
data={rest.data}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "homework":
|
}
|
||||||
|
case "homework": {
|
||||||
|
if (!isHomeworkBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<HomeworkBlock
|
<HomeworkBlock
|
||||||
data={rest.data as HomeworkBlockData}
|
data={rest.data}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "blackboard":
|
}
|
||||||
|
case "blackboard": {
|
||||||
|
if (!isBlackboardBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<BlackboardBlock
|
<BlackboardBlock
|
||||||
data={rest.data as BlackboardBlockData}
|
data={rest.data}
|
||||||
textbookId={rest.textbookId}
|
textbookId={rest.textbookId}
|
||||||
chapterId={rest.chapterId}
|
chapterId={rest.chapterId}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "reflection":
|
}
|
||||||
|
case "reflection": {
|
||||||
|
if (!isReflectionBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<ReflectionBlock
|
<ReflectionBlock
|
||||||
data={rest.data as ReflectionBlockData}
|
data={rest.data}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "exercise":
|
}
|
||||||
|
case "exercise": {
|
||||||
|
if (!isExerciseBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<ExerciseBlock
|
<ExerciseBlock
|
||||||
blockId={rest.blockId}
|
blockId={rest.blockId}
|
||||||
data={rest.data as ExerciseBlockData}
|
data={rest.data}
|
||||||
classes={rest.classes ?? []}
|
classes={rest.classes ?? []}
|
||||||
textbookId={rest.textbookId}
|
textbookId={rest.textbookId}
|
||||||
chapterId={rest.chapterId}
|
chapterId={rest.chapterId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "text_study":
|
}
|
||||||
return <TextStudyBlock blockId={rest.blockId} data={rest.data as TextStudyBlockData} />;
|
case "text_study": {
|
||||||
|
if (!isTextStudyBlockData(rest.data)) return null;
|
||||||
|
return <TextStudyBlock blockId={rest.blockId} data={rest.data} />;
|
||||||
|
}
|
||||||
case "rich_text":
|
case "rich_text":
|
||||||
case "consolidation":
|
case "consolidation": {
|
||||||
|
if (!isRichTextBlockData(rest.data)) return null;
|
||||||
return (
|
return (
|
||||||
<RichTextBlock
|
<RichTextBlock
|
||||||
data={rest.data as RichTextBlockData}
|
data={rest.data}
|
||||||
textbookId={rest.textbookId}
|
textbookId={rest.textbookId}
|
||||||
chapterId={rest.chapterId}
|
chapterId={rest.chapterId}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export async function getLessonPlansByKnowledgePoint(
|
|||||||
subjectName: null,
|
subjectName: null,
|
||||||
gradeName: null,
|
gradeName: null,
|
||||||
creatorName: null,
|
creatorName: null,
|
||||||
|
versionCount: 1,
|
||||||
|
versions: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,5 +89,7 @@ export async function getLessonPlansByQuestion(
|
|||||||
subjectName: null,
|
subjectName: null,
|
||||||
gradeName: null,
|
gradeName: null,
|
||||||
creatorName: null,
|
creatorName: null,
|
||||||
|
versionCount: 1,
|
||||||
|
versions: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export async function revertToVersion(
|
|||||||
planId: string,
|
planId: string,
|
||||||
versionNo: number,
|
versionNo: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
// V3 修复:由 actions 层传入 i18n 翻译的回退标签,避免 data-access 硬编码中文
|
||||||
|
revertLabel: string,
|
||||||
): Promise<{ newVersionNo: number } | null> {
|
): Promise<{ newVersionNo: number } | null> {
|
||||||
const content = await getVersionContent(planId, versionNo, userId);
|
const content = await getVersionContent(planId, versionNo, userId);
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
@@ -138,7 +140,7 @@ export async function revertToVersion(
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
planId,
|
planId,
|
||||||
versionNo: newNo,
|
versionNo: newNo,
|
||||||
label: `回退到 v${versionNo}`,
|
label: revertLabel,
|
||||||
content,
|
content,
|
||||||
isAuto: false,
|
isAuto: false,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
LessonPlanListItem,
|
LessonPlanListItem,
|
||||||
LessonPlanTemplate,
|
LessonPlanTemplate,
|
||||||
LessonPlanStatus,
|
LessonPlanStatus,
|
||||||
|
LessonPlanVersionSummary,
|
||||||
TemplateType,
|
TemplateType,
|
||||||
TemplateScope,
|
TemplateScope,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -126,6 +127,8 @@ function mapRowToListItem(row: {
|
|||||||
subjectName: row.subjectName,
|
subjectName: row.subjectName,
|
||||||
gradeName: row.gradeName,
|
gradeName: row.gradeName,
|
||||||
creatorName: row.creatorName,
|
creatorName: row.creatorName,
|
||||||
|
versionCount: 1,
|
||||||
|
versions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,14 +195,12 @@ function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
case "class_members": {
|
case "class_members": {
|
||||||
// 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧)
|
// 学生:仅查看 published 课案
|
||||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
return [sql<boolean>`(${lessonPlans.status} = 'published')`];
|
||||||
return [publishedFilter];
|
|
||||||
}
|
}
|
||||||
case "children": {
|
case "children": {
|
||||||
// 家长:仅查看 published 课案(同学生)
|
// 家长:仅查看 published 课案
|
||||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
return [sql<boolean>`(${lessonPlans.status} = 'published')`];
|
||||||
return [publishedFilter];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,7 +267,45 @@ export const getLessonPlans = cache(
|
|||||||
.orderBy(desc(lessonPlans.updatedAt));
|
.orderBy(desc(lessonPlans.updatedAt));
|
||||||
|
|
||||||
const items = rows.map(mapRowToListItem);
|
const items = rows.map(mapRowToListItem);
|
||||||
return items;
|
|
||||||
|
// 版本聚合:按 textbookId + chapterId + creatorId 分组(同一教师对同一章节的多个课案视为版本)
|
||||||
|
// 无 textbookId/chapterId 的课案各自独立成组
|
||||||
|
const groups = new Map<string, LessonPlanListItem[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key =
|
||||||
|
item.textbookId && item.chapterId
|
||||||
|
? `${item.textbookId}|${item.chapterId}|${item.creatorId}`
|
||||||
|
: `__single__${item.id}`;
|
||||||
|
const arr = groups.get(key);
|
||||||
|
if (arr) {
|
||||||
|
arr.push(item);
|
||||||
|
} else {
|
||||||
|
groups.set(key, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每组取第一个(updatedAt 最新,因已按 updatedAt desc 排序)作为代表,附加版本信息
|
||||||
|
const grouped: LessonPlanListItem[] = [];
|
||||||
|
for (const groupItems of groups.values()) {
|
||||||
|
const representative = groupItems[0];
|
||||||
|
if (!representative) continue;
|
||||||
|
const versions: LessonPlanVersionSummary[] = groupItems
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
title: v.title,
|
||||||
|
status: v.status,
|
||||||
|
updatedAt: v.updatedAt,
|
||||||
|
lastSavedAt: v.lastSavedAt,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
||||||
|
grouped.push({
|
||||||
|
...representative,
|
||||||
|
versionCount: groupItems.length,
|
||||||
|
versions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -437,10 +476,50 @@ export async function softDeleteLessonPlan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 发布 / 撤回发布 ----
|
||||||
|
// P0-1 修复:课案发布机制,发布后学生/家长可查看
|
||||||
|
export async function publishLessonPlan(
|
||||||
|
planId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await db
|
||||||
|
.update(lessonPlans)
|
||||||
|
.set({ status: "published", lastSavedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result[0].affectedRows === 0) {
|
||||||
|
throw new LessonPlanDataError("NOT_FOUND");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpublishLessonPlan(
|
||||||
|
planId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await db
|
||||||
|
.update(lessonPlans)
|
||||||
|
.set({ status: "draft", lastSavedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lessonPlans.id, planId),
|
||||||
|
eq(lessonPlans.creatorId, userId),
|
||||||
|
eq(lessonPlans.status, "published"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result[0].affectedRows === 0) {
|
||||||
|
throw new LessonPlanDataError("NOT_FOUND");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 复制 ----
|
// ---- 复制 ----
|
||||||
|
// V3 修复:duplicateSuffix 由 actions 层 i18n 翻译后传入,避免 data-access 硬编码中文
|
||||||
export async function duplicateLessonPlan(
|
export async function duplicateLessonPlan(
|
||||||
planId: string,
|
planId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
duplicateSuffix: string = " - Copy",
|
||||||
): Promise<{ newPlanId: string }> {
|
): Promise<{ newPlanId: string }> {
|
||||||
const src = await getLessonPlanById(planId, userId);
|
const src = await getLessonPlanById(planId, userId);
|
||||||
if (!src) throw new LessonPlanDataError("NOT_FOUND");
|
if (!src) throw new LessonPlanDataError("NOT_FOUND");
|
||||||
@@ -448,7 +527,7 @@ export async function duplicateLessonPlan(
|
|||||||
const newId = createId();
|
const newId = createId();
|
||||||
await db.insert(lessonPlans).values({
|
await db.insert(lessonPlans).values({
|
||||||
id: newId,
|
id: newId,
|
||||||
title: `${src.title} - 副本`,
|
title: `${src.title}${duplicateSuffix}`,
|
||||||
textbookId: src.textbookId,
|
textbookId: src.textbookId,
|
||||||
chapterId: src.chapterId,
|
chapterId: src.chapterId,
|
||||||
subjectId: src.subjectId,
|
subjectId: src.subjectId,
|
||||||
@@ -463,6 +542,29 @@ export async function duplicateLessonPlan(
|
|||||||
return { newPlanId: newId };
|
return { newPlanId: newId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 统计:各状态课案数量(供管理员看板使用,避免 app 层直查 DB)----
|
||||||
|
export interface LessonPlanStats {
|
||||||
|
total: number;
|
||||||
|
published: number;
|
||||||
|
draft: number;
|
||||||
|
archived: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLessonPlanStats(): Promise<LessonPlanStats> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ status: lessonPlans.status, count: sql<number>`count(*)` })
|
||||||
|
.from(lessonPlans)
|
||||||
|
.where(sql`${lessonPlans.status} != 'archived'`)
|
||||||
|
.groupBy(lessonPlans.status);
|
||||||
|
const map = new Map(rows.map((r) => [r.status, Number(r.count)]));
|
||||||
|
return {
|
||||||
|
total: Array.from(map.values()).reduce((a, b) => a + b, 0),
|
||||||
|
published: map.get("published") ?? 0,
|
||||||
|
draft: map.get("draft") ?? 0,
|
||||||
|
archived: 0, // 已排除 archived
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 模板查询(内部)----
|
// ---- 模板查询(内部)----
|
||||||
export async function getTemplateById(
|
export async function getTemplateById(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ interface EditorState {
|
|||||||
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
||||||
|
|
||||||
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
|
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
|
||||||
updateNode: (id: string, patch: Partial<Block>) => void;
|
// V3 修复:patch 排除 type 字段,防止改变节点类型,同时消除 as 断言
|
||||||
|
updateNode: (id: string, patch: Omit<Partial<Block>, "type">) => void;
|
||||||
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;
|
||||||
|
|
||||||
@@ -127,11 +128,14 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
|||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
|
// V3 修复:patch 已排除 type 字段,但 TypeScript 仍会因 spread 拓宽 data 类型
|
||||||
|
// (BlockData 联合不包含 TextbookContentNodeData)而报错,此处 as 为必要断言。
|
||||||
|
// 实际安全:调用方不会对 textbook_content 节点通过 updateNode 传入 data。
|
||||||
nodes: s.doc.nodes.map((n) =>
|
nodes: s.doc.nodes.map((n) =>
|
||||||
n.id === id
|
n.id === id
|
||||||
? n.type === "textbook_content"
|
? n.type === "textbook_content"
|
||||||
? { ...n, ...patch } as TextbookContentNode
|
? ({ ...n, ...patch } as TextbookContentNode)
|
||||||
: { ...n, ...patch } as LessonPlanNode
|
: ({ ...n, ...patch } as LessonPlanNode)
|
||||||
: n,
|
: n,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -143,11 +147,12 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
|||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
|
// 同 updateNode:spread 后 TypeScript 拓宽类型,需 as 断言收窄
|
||||||
nodes: s.doc.nodes.map((n) =>
|
nodes: s.doc.nodes.map((n) =>
|
||||||
n.id === id
|
n.id === id
|
||||||
? n.type === "textbook_content"
|
? n.type === "textbook_content"
|
||||||
? { ...n, position } as TextbookContentNode
|
? ({ ...n, position } as TextbookContentNode)
|
||||||
: { ...n, position } as LessonPlanNode
|
: ({ ...n, position } as LessonPlanNode)
|
||||||
: n,
|
: n,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -226,28 +226,29 @@ export function buildDefaultSkeleton(
|
|||||||
translateTitle?: (key: string) => string,
|
translateTitle?: (key: string) => string,
|
||||||
): LessonPlanDocument {
|
): LessonPlanDocument {
|
||||||
const textbookContentNodeId = createId();
|
const textbookContentNodeId = createId();
|
||||||
|
// P2-5:正文节点居中,左右列增大间距避免重叠
|
||||||
const textbookNode: TextbookContentNode = {
|
const textbookNode: TextbookContentNode = {
|
||||||
id: textbookContentNodeId,
|
id: textbookContentNodeId,
|
||||||
type: "textbook_content",
|
type: "textbook_content",
|
||||||
title: "textbook_content",
|
title: "textbook_content",
|
||||||
data: { chapterId, content: chapterContent, zoom: 1 },
|
data: { chapterId, content: chapterContent, zoom: 1 },
|
||||||
order: -1,
|
order: -1,
|
||||||
position: { x: 400, y: 200 },
|
position: { x: 500, y: 250 },
|
||||||
draggable: false,
|
draggable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 默认 10 节点骨架(标题使用 i18n 键 blockType.${type})
|
// P2-5:左列 x=80,右列 x=900,避免与正文节点(宽 480)重叠
|
||||||
const skeleton: { type: BlockType; position: { x: number; y: number } }[] = [
|
const skeleton: { type: BlockType; position: { x: number; y: number } }[] = [
|
||||||
{ type: "objective", position: { x: 80, y: 80 } },
|
{ type: "objective", position: { x: 80, y: 80 } },
|
||||||
{ type: "key_point", position: { x: 80, y: 200 } },
|
{ type: "key_point", position: { x: 80, y: 200 } },
|
||||||
{ type: "import", position: { x: 80, y: 320 } },
|
{ type: "import", position: { x: 80, y: 320 } },
|
||||||
{ type: "text_study", position: { x: 80, y: 440 } },
|
{ type: "text_study", position: { x: 80, y: 440 } },
|
||||||
{ type: "new_teaching", position: { x: 720, y: 80 } },
|
{ type: "new_teaching", position: { x: 900, y: 80 } },
|
||||||
{ type: "exercise", position: { x: 720, y: 200 } },
|
{ type: "exercise", position: { x: 900, y: 200 } },
|
||||||
{ type: "summary", position: { x: 720, y: 320 } },
|
{ type: "summary", position: { x: 900, y: 320 } },
|
||||||
{ type: "homework", position: { x: 80, y: 560 } },
|
{ type: "homework", position: { x: 80, y: 560 } },
|
||||||
{ type: "blackboard", position: { x: 720, y: 440 } },
|
{ type: "blackboard", position: { x: 900, y: 440 } },
|
||||||
{ type: "reflection", position: { x: 720, y: 560 } },
|
{ type: "reflection", position: { x: 900, y: 560 } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const nodes: LessonPlanNode[] = skeleton.map((s, i) => ({
|
const nodes: LessonPlanNode[] = skeleton.map((s, i) => ({
|
||||||
|
|||||||
50
src/modules/lesson-preparation/lib/i18n-errors.ts
Normal file
50
src/modules/lesson-preparation/lib/i18n-errors.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Zod 校验失败的 fieldErrors 中的 i18n 键翻译为实际消息。
|
||||||
|
*
|
||||||
|
* schema.ts 中错误消息存储为 i18n 键(如 "error.titleRequired"),
|
||||||
|
* 此函数在 actions 层调用,将键翻译为当前语言的文本。
|
||||||
|
*
|
||||||
|
* 返回类型为 Record<string, string[]>(不含 undefined),
|
||||||
|
* 因为只有非 undefined 的字段才会被加入结果。
|
||||||
|
*/
|
||||||
|
export async function translateFieldErrors(
|
||||||
|
errors: Record<string, string[] | undefined>,
|
||||||
|
): Promise<Record<string, string[]>> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
for (const [field, messages] of Object.entries(errors)) {
|
||||||
|
if (!messages) continue;
|
||||||
|
result[field] = messages.map((msg) => {
|
||||||
|
// 仅翻译以 "error." 开头的 i18n 键,其他保持原样
|
||||||
|
if (msg.startsWith("error.")) {
|
||||||
|
return t(msg as Parameters<typeof t>[0]);
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全解析 Zod 结果并返回 ActionState 错误格式(带 i18n 翻译)。
|
||||||
|
* 若校验失败,返回翻译后的 fieldErrors;若成功,返回 parsed.data。
|
||||||
|
*/
|
||||||
|
export async function safeParseWithI18n<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; errors: Record<string, string[]> }
|
||||||
|
> {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.flatten().fieldErrors;
|
||||||
|
const translated = await translateFieldErrors(errors);
|
||||||
|
return { success: false, errors: translated };
|
||||||
|
}
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@ import type { Node, Edge } from "@xyflow/react";
|
|||||||
import type {
|
import type {
|
||||||
AnyLessonPlanEdge,
|
AnyLessonPlanEdge,
|
||||||
AnyLessonPlanNode,
|
AnyLessonPlanNode,
|
||||||
LessonPlanNode,
|
|
||||||
NodeAnchor,
|
NodeAnchor,
|
||||||
TextbookContentNode,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { getNodeColor } from "./node-summary";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
|
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
|
||||||
@@ -20,6 +19,8 @@ import type {
|
|||||||
export interface ToRfNodesContext {
|
export interface ToRfNodesContext {
|
||||||
anchors: NodeAnchor[];
|
anchors: NodeAnchor[];
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
|
/** 可锚定的教学节点列表(P1-1:用于节点选择器)*/
|
||||||
|
anchorableNodes?: { id: string; title: string; type: string }[];
|
||||||
onAddRangeAnchor?: (params: {
|
onAddRangeAnchor?: (params: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
start: number;
|
start: number;
|
||||||
@@ -30,8 +31,14 @@ export interface ToRfNodesContext {
|
|||||||
nodeId: string;
|
nodeId: string;
|
||||||
start: number;
|
start: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
/** 创建新节点并锚定(P1-1)*/
|
||||||
|
onCreateNewNode?: (params: {
|
||||||
|
anchorType: "range" | "point";
|
||||||
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
textPreview?: string;
|
||||||
|
}) => void;
|
||||||
onSelectNode?: (id: string | null) => void;
|
onSelectNode?: (id: string | null) => void;
|
||||||
onZoomChange?: (zoom: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toRfNodes(
|
export function toRfNodes(
|
||||||
@@ -39,10 +46,25 @@ export function toRfNodes(
|
|||||||
selectedNodeId: string | null,
|
selectedNodeId: string | null,
|
||||||
ctx?: ToRfNodesContext,
|
ctx?: ToRfNodesContext,
|
||||||
): Node[] {
|
): Node[] {
|
||||||
|
// 当有选中节点时,收集所有与选中节点相关的节点 ID(通过锚点关联)
|
||||||
|
const relatedNodeIds = new Set<string>();
|
||||||
|
if (selectedNodeId && ctx?.anchors) {
|
||||||
|
for (const a of ctx.anchors) {
|
||||||
|
if (a.nodeId === selectedNodeId) {
|
||||||
|
relatedNodeIds.add(a.nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 正文节点始终相关(因为锚点在正文上)
|
||||||
|
const textbookNode = nodes.find((n) => n.type === "textbook_content");
|
||||||
|
if (textbookNode) relatedNodeIds.add(textbookNode.id);
|
||||||
|
relatedNodeIds.add(selectedNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
return nodes.map((n) => {
|
return nodes.map((n) => {
|
||||||
// 正文节点
|
// 正文节点:n.type === "textbook_content" 已收窄为 TextbookContentNode
|
||||||
if (n.type === "textbook_content") {
|
if (n.type === "textbook_content") {
|
||||||
const tbNode = n as TextbookContentNode;
|
const tbNode = n;
|
||||||
|
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(tbNode.id);
|
||||||
return {
|
return {
|
||||||
id: tbNode.id,
|
id: tbNode.id,
|
||||||
type: "textbook_content",
|
type: "textbook_content",
|
||||||
@@ -51,24 +73,28 @@ export function toRfNodes(
|
|||||||
node: tbNode,
|
node: tbNode,
|
||||||
anchors: ctx?.anchors ?? [],
|
anchors: ctx?.anchors ?? [],
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
|
anchorableNodes: ctx?.anchorableNodes ?? [],
|
||||||
onAddRangeAnchor: ctx?.onAddRangeAnchor,
|
onAddRangeAnchor: ctx?.onAddRangeAnchor,
|
||||||
onAddPointAnchor: ctx?.onAddPointAnchor,
|
onAddPointAnchor: ctx?.onAddPointAnchor,
|
||||||
|
onCreateNewNode: ctx?.onCreateNewNode,
|
||||||
onSelectNode: ctx?.onSelectNode,
|
onSelectNode: ctx?.onSelectNode,
|
||||||
onZoomChange: ctx?.onZoomChange,
|
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
selected: tbNode.id === selectedNodeId,
|
selected: tbNode.id === selectedNodeId,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
|
style: isDimmed ? { opacity: 0.3 } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 教学节点
|
// 教学节点:textbook_content 分支已上方 return,此处 n 已收窄为 LessonPlanNode
|
||||||
const lessonNode = n as LessonPlanNode;
|
const lessonNode = n;
|
||||||
|
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(lessonNode.id);
|
||||||
return {
|
return {
|
||||||
id: lessonNode.id,
|
id: lessonNode.id,
|
||||||
type: "lesson",
|
type: "lesson",
|
||||||
position: lessonNode.position,
|
position: lessonNode.position,
|
||||||
data: { node: lessonNode } as Record<string, unknown>,
|
data: { node: lessonNode } as Record<string, unknown>,
|
||||||
selected: lessonNode.id === selectedNodeId,
|
selected: lessonNode.id === selectedNodeId,
|
||||||
|
style: isDimmed ? { opacity: 0.3 } : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -80,36 +106,39 @@ export function toRfEdges(
|
|||||||
): Edge[] {
|
): Edge[] {
|
||||||
return edges.map((e) => {
|
return edges.map((e) => {
|
||||||
if (e.type === "anchor") {
|
if (e.type === "anchor") {
|
||||||
// 锚点边:默认 10% 透明度,选中关联节点时 100%
|
// 锚点边:默认 40% 透明度,选中关联节点时 100%
|
||||||
const anchor = anchors.find((a) => a.id === e.anchorId);
|
const anchor = anchors.find((a) => a.id === e.anchorId);
|
||||||
const isActive = anchor && anchor.nodeId === selectedNodeId;
|
const isActive = anchor && anchor.nodeId === selectedNodeId;
|
||||||
|
// P1-4 修复:使用锚点关联节点的颜色,而非硬编码蓝色
|
||||||
|
const strokeColor = anchor ? getNodeColor(anchor.nodeId) : "#9e9e9e";
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
animated: false,
|
animated: isActive,
|
||||||
className: isActive ? "anchor-edge active" : "anchor-edge",
|
className: isActive ? "anchor-edge active" : "anchor-edge",
|
||||||
|
// P1-3 修复:将 anchorId 存入 data,fromRfEdges 从 data 读取
|
||||||
|
data: { anchorId: e.anchorId },
|
||||||
style: {
|
style: {
|
||||||
stroke: anchor ? getNodeColorForAnchor(anchor.nodeId) : "#9e9e9e",
|
stroke: strokeColor,
|
||||||
strokeWidth: 2,
|
strokeWidth: isActive ? 3 : 2,
|
||||||
opacity: isActive ? 1 : 0.1,
|
opacity: isActive ? 1 : 0.4,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流程边
|
// 流程边
|
||||||
|
const isDimmed = selectedNodeId !== null && e.source !== selectedNodeId && e.target !== selectedNodeId;
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
animated: true,
|
animated: !isDimmed,
|
||||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
style: {
|
||||||
|
stroke: "#1976d2",
|
||||||
|
strokeWidth: 2,
|
||||||
|
opacity: isDimmed ? 0.2 : 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单的颜色查找(避免循环依赖 node-summary)
|
|
||||||
function getNodeColorForAnchor(_nodeId: string): string {
|
|
||||||
// 实际颜色由 CSS 类 .anchor-edge 设置,这里返回默认值
|
|
||||||
return "#1976d2";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 React Flow edges 转回课案 edges 格式。
|
* 将 React Flow edges 转回课案 edges 格式。
|
||||||
*/
|
*/
|
||||||
@@ -125,12 +154,13 @@ export function fromRfEdges(
|
|||||||
targetHandle: e.targetHandle ?? null,
|
targetHandle: e.targetHandle ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保留原有的 type 信息(通过 className 判断或默认为 flow)
|
// P1-3 修复:优先从 data.anchorId 读取,回退到 className 判断
|
||||||
if (e.className?.includes("anchor-edge")) {
|
const dataAnchorId = (e.data as { anchorId?: string } | undefined)?.anchorId;
|
||||||
|
if (dataAnchorId || e.className?.includes("anchor-edge")) {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
type: "anchor" as const,
|
type: "anchor" as const,
|
||||||
anchorId: e.id, // 简化:用 edge id 作为 anchorId(实际应从 data 读取)
|
anchorId: dataAnchorId ?? e.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
213
src/modules/lesson-preparation/lib/type-guards.ts
Normal file
213
src/modules/lesson-preparation/lib/type-guards.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// 备课模块集中类型守卫:替代 `as` 断言,安全收窄 unknown 联合类型
|
||||||
|
import type {
|
||||||
|
BlackboardBlockData,
|
||||||
|
BlockData,
|
||||||
|
BlockType,
|
||||||
|
ExerciseBlockData,
|
||||||
|
ExercisePurpose,
|
||||||
|
HomeworkAssignment,
|
||||||
|
HomeworkBlockData,
|
||||||
|
ImportBlockData,
|
||||||
|
KeyPointBlockData,
|
||||||
|
KeyPointItem,
|
||||||
|
LessonPlanNode,
|
||||||
|
LessonPlanStatus,
|
||||||
|
NewTeachingBlockData,
|
||||||
|
ObjectiveBlockData,
|
||||||
|
ObjectiveItem,
|
||||||
|
ReflectionBlockData,
|
||||||
|
ReflectionItem,
|
||||||
|
RichTextBlockData,
|
||||||
|
SummaryBlockData,
|
||||||
|
TemplateScope,
|
||||||
|
TemplateType,
|
||||||
|
TextStudyBlockData,
|
||||||
|
TextbookContentNode,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
// ---- 基础类型守卫 ----
|
||||||
|
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
|
||||||
|
export function isLessonPlanStatus(v: string): v is LessonPlanStatus {
|
||||||
|
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_TYPES = ["system", "personal"] as const;
|
||||||
|
export function isTemplateType(v: string): v is TemplateType {
|
||||||
|
return (TEMPLATE_TYPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_SCOPES = [
|
||||||
|
"regular",
|
||||||
|
"review",
|
||||||
|
"experiment",
|
||||||
|
"inquiry",
|
||||||
|
"blank",
|
||||||
|
"custom",
|
||||||
|
] as const;
|
||||||
|
export function isTemplateScope(v: string): v is TemplateScope {
|
||||||
|
return (TEMPLATE_SCOPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Block 数据类型守卫 ----
|
||||||
|
// 各守卫通过检查该 Block 数据接口的"特征字段"来收窄联合类型 BlockData。
|
||||||
|
// 特征字段选取接口中独有且必填的字段,避免与其他接口混淆。
|
||||||
|
|
||||||
|
function isObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === "object" && v !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRichTextBlockData(data: BlockData): data is RichTextBlockData {
|
||||||
|
return isObject(data) && typeof data.html === "string" && Array.isArray(data.knowledgePointIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextStudyBlockData(data: BlockData): data is TextStudyBlockData {
|
||||||
|
return (
|
||||||
|
isObject(data) &&
|
||||||
|
typeof data.sourceText === "string" &&
|
||||||
|
Array.isArray(data.annotations) &&
|
||||||
|
Array.isArray(data.knowledgePointIds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExerciseBlockData(data: BlockData): data is ExerciseBlockData {
|
||||||
|
return (
|
||||||
|
isObject(data) &&
|
||||||
|
Array.isArray(data.items) &&
|
||||||
|
(data.purpose === "class_practice" || data.purpose === "after_class_homework")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObjectiveBlockData(data: BlockData): data is ObjectiveBlockData {
|
||||||
|
return isObject(data) && Array.isArray(data.objectives);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKeyPointBlockData(data: BlockData): data is KeyPointBlockData {
|
||||||
|
return isObject(data) && Array.isArray(data.keyPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImportBlockData(data: BlockData): data is ImportBlockData {
|
||||||
|
return (
|
||||||
|
isObject(data) &&
|
||||||
|
typeof data.prompt === "string" &&
|
||||||
|
typeof data.durationMin === "number" &&
|
||||||
|
typeof data.method === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewTeachingBlockData(data: BlockData): data is NewTeachingBlockData {
|
||||||
|
return isObject(data) && Array.isArray(data.teachingPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSummaryBlockData(data: BlockData): data is SummaryBlockData {
|
||||||
|
return isObject(data) && Array.isArray(data.summaryPoints) && typeof data.homeworkPreview === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHomeworkBlockData(data: BlockData): data is HomeworkBlockData {
|
||||||
|
return isObject(data) && Array.isArray(data.assignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockData {
|
||||||
|
return (
|
||||||
|
isObject(data) &&
|
||||||
|
typeof data.layout === "string" &&
|
||||||
|
typeof data.content === "string" &&
|
||||||
|
Array.isArray(data.knowledgePointIds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReflectionBlockData(data: BlockData): data is ReflectionBlockData {
|
||||||
|
return isObject(data) && Array.isArray(data.reflection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Block 字段值类型守卫(用于 select onChange 等场景,替代 `as` 断言)----
|
||||||
|
|
||||||
|
const BLACKBOARD_LAYOUTS = ["structure", "mindmap", "text"] as const;
|
||||||
|
export function isBlackboardLayout(
|
||||||
|
v: string,
|
||||||
|
): v is BlackboardBlockData["layout"] {
|
||||||
|
return (BLACKBOARD_LAYOUTS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_METHODS = ["question", "situation", "review", "other"] as const;
|
||||||
|
export function isImportMethod(v: string): v is ImportBlockData["method"] {
|
||||||
|
return (IMPORT_METHODS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXERCISE_PURPOSES = ["class_practice", "after_class_homework"] as const;
|
||||||
|
export function isExercisePurpose(v: string): v is ExercisePurpose {
|
||||||
|
return (EXERCISE_PURPOSES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OBJECTIVE_DIMENSIONS = ["knowledge", "process", "emotion"] as const;
|
||||||
|
export function isObjectiveDimension(
|
||||||
|
v: string,
|
||||||
|
): v is ObjectiveItem["dimension"] {
|
||||||
|
return (OBJECTIVE_DIMENSIONS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_POINT_TYPES = ["key", "difficult"] as const;
|
||||||
|
export function isKeyPointType(v: string): v is KeyPointItem["type"] {
|
||||||
|
return (KEY_POINT_TYPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOMEWORK_TYPES = ["exercise", "reading", "writing"] as const;
|
||||||
|
export function isHomeworkType(v: string): v is HomeworkAssignment["type"] {
|
||||||
|
return (HOMEWORK_TYPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFLECTION_ASPECTS = [
|
||||||
|
"effectiveness",
|
||||||
|
"problems",
|
||||||
|
"improvements",
|
||||||
|
] as const;
|
||||||
|
export function isReflectionAspect(
|
||||||
|
v: string,
|
||||||
|
): v is ReflectionItem["aspect"] {
|
||||||
|
return (REFLECTION_ASPECTS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 节点类型守卫 ----
|
||||||
|
export function isTextbookContentNode(
|
||||||
|
node: { type: string },
|
||||||
|
): node is TextbookContentNode {
|
||||||
|
return node.type === "textbook_content";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLessonPlanNode(
|
||||||
|
node: { type: string },
|
||||||
|
): node is LessonPlanNode {
|
||||||
|
return node.type !== "textbook_content";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 题目类型守卫 ----
|
||||||
|
const VALID_QUESTION_TYPES = [
|
||||||
|
"single_choice",
|
||||||
|
"multiple_choice",
|
||||||
|
"text",
|
||||||
|
"judgment",
|
||||||
|
"composite",
|
||||||
|
] as const;
|
||||||
|
export type ValidQuestionType = (typeof VALID_QUESTION_TYPES)[number];
|
||||||
|
|
||||||
|
export function isValidQuestionType(v: string): v is ValidQuestionType {
|
||||||
|
return (VALID_QUESTION_TYPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BlockType 守卫 ----
|
||||||
|
const VALID_BLOCK_TYPES: BlockType[] = [
|
||||||
|
"objective",
|
||||||
|
"key_point",
|
||||||
|
"import",
|
||||||
|
"new_teaching",
|
||||||
|
"consolidation",
|
||||||
|
"summary",
|
||||||
|
"homework",
|
||||||
|
"blackboard",
|
||||||
|
"text_study",
|
||||||
|
"exercise",
|
||||||
|
"rich_text",
|
||||||
|
"reflection",
|
||||||
|
];
|
||||||
|
export function isBlockType(v: string): v is BlockType {
|
||||||
|
return (VALID_BLOCK_TYPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
LessonPlanProvider,
|
||||||
|
TEACHER_ROLE_CONFIG,
|
||||||
|
type LessonPlanRoleConfig,
|
||||||
|
} from "./lesson-plan-provider";
|
||||||
|
import { createDefaultDataService } from "../services/default-data-service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备课模块 Provider 设置组件(V3 新增)。
|
||||||
|
* 在页面层包裹此组件,自动注入默认数据服务和角色配置。
|
||||||
|
* 组件通过 useLessonPlanContextSafe() 获取 service,不直接 import actions。
|
||||||
|
*/
|
||||||
|
export function LessonPlanProviderSetup({
|
||||||
|
children,
|
||||||
|
roleConfig = TEACHER_ROLE_CONFIG,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
roleConfig?: LessonPlanRoleConfig;
|
||||||
|
}) {
|
||||||
|
const service = useMemo(() => createDefaultDataService(), []);
|
||||||
|
return (
|
||||||
|
<LessonPlanProvider service={service} roleConfig={roleConfig}>
|
||||||
|
{children}
|
||||||
|
</LessonPlanProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,68 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
import type { LessonPlanListItem, LessonPlanVersion } from "../types";
|
import type { QuestionType } from "@/modules/questions/types";
|
||||||
|
import type {
|
||||||
|
ActionState,
|
||||||
|
LessonPlan,
|
||||||
|
LessonPlanDocument,
|
||||||
|
LessonPlanListItem,
|
||||||
|
LessonPlanTemplate,
|
||||||
|
LessonPlanVersion,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
// ---- V3 扩展:picker/dialog 组件所需的数据类型 ----
|
||||||
|
|
||||||
|
/** 教材选项(template-picker 使用)*/
|
||||||
|
export interface TextbookPickerOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
grade: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 章节选项(template-picker 使用)*/
|
||||||
|
export interface ChapterPickerOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string | null;
|
||||||
|
order: number | null;
|
||||||
|
content?: string | null;
|
||||||
|
children?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 知识点选项(knowledge-point-picker 使用)*/
|
||||||
|
export interface KnowledgePointOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布作业输入(publish-homework-dialog 使用)*/
|
||||||
|
export interface PublishHomeworkInput {
|
||||||
|
planId: string;
|
||||||
|
blockId: string;
|
||||||
|
classIds: string[];
|
||||||
|
availableAt?: string;
|
||||||
|
dueAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 题库筛选参数(question-bank-picker 使用)*/
|
||||||
|
export interface QuestionPickerParams {
|
||||||
|
q?: string;
|
||||||
|
type?: QuestionType;
|
||||||
|
difficulty?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 题库题目项(question-bank-picker 使用)*/
|
||||||
|
export interface QuestionPickerItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
difficulty: number;
|
||||||
|
content: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 备课模块数据服务接口(P1-7)。
|
* 备课模块数据服务接口(P1-7 / V3 扩展)。
|
||||||
* 抽象数据依赖,各角色/测试可提供不同实现,通过 LessonPlanProvider 注入。
|
* 抽象数据依赖,各角色/测试可提供不同实现,通过 LessonPlanProvider 注入。
|
||||||
* 组件不直接 import actions,只通过此接口调用。
|
* 组件不直接 import actions,只通过此接口调用。
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +76,27 @@ export interface LessonPlanDataService {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}): Promise<{ success: boolean; data?: { items: LessonPlanListItem[] }; message?: string }>;
|
}): Promise<{ success: boolean; data?: { items: LessonPlanListItem[] }; message?: string }>;
|
||||||
|
|
||||||
|
/** 获取单个课案(V3 新增)*/
|
||||||
|
getLessonPlanById(planId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { plan: LessonPlan };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 更新课案内容(自动保存,V3 新增)*/
|
||||||
|
updateLessonPlan(input: {
|
||||||
|
planId: string;
|
||||||
|
title?: string;
|
||||||
|
content: LessonPlanDocument;
|
||||||
|
}): Promise<{ success: boolean; message?: string }>;
|
||||||
|
|
||||||
|
/** 保存版本(V3 新增)*/
|
||||||
|
saveLessonPlanVersion(input: {
|
||||||
|
planId: string;
|
||||||
|
content: LessonPlanDocument;
|
||||||
|
label?: string;
|
||||||
|
}): Promise<{ success: boolean; data?: { versionNo: number }; message?: string }>;
|
||||||
|
|
||||||
/** 获取课案版本列表 */
|
/** 获取课案版本列表 */
|
||||||
getLessonPlanVersions(planId: string): Promise<{
|
getLessonPlanVersions(planId: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -36,6 +115,67 @@ export interface LessonPlanDataService {
|
|||||||
|
|
||||||
/** 删除/归档课案 */
|
/** 删除/归档课案 */
|
||||||
deleteLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
deleteLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
||||||
|
|
||||||
|
/** 发布课案(V3 新增)*/
|
||||||
|
publishLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
||||||
|
|
||||||
|
/** 撤回发布(V3 新增)*/
|
||||||
|
unpublishLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
||||||
|
|
||||||
|
// ---- V3 新增:picker/dialog 组件所需方法 ----
|
||||||
|
|
||||||
|
/** 创建课案(template-picker 使用)*/
|
||||||
|
createLessonPlan(
|
||||||
|
prevState: ActionState | null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ success: boolean; data?: { planId: string }; message?: string; errors?: Record<string, string[]> }>;
|
||||||
|
|
||||||
|
/** 获取教材列表(template-picker 使用)*/
|
||||||
|
getTextbooksForPicker(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { textbooks: TextbookPickerOption[] };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 获取章节列表(template-picker 使用)*/
|
||||||
|
getChaptersForPicker(textbookId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { chapters: ChapterPickerOption[] };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 获取模板列表(template-picker 使用)*/
|
||||||
|
getLessonPlanTemplates(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { templates: LessonPlanTemplate[] };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 获取知识点选项(knowledge-point-picker 使用)*/
|
||||||
|
getKnowledgePointOptions(input: {
|
||||||
|
textbookId?: string;
|
||||||
|
chapterId?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { options: KnowledgePointOption[] };
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 发布作业(publish-homework-dialog 使用)*/
|
||||||
|
publishLessonPlanHomework(input: PublishHomeworkInput): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { examId: string; assignmentId: string };
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 获取题库题目(question-bank-picker 使用,跨模块)*/
|
||||||
|
getQuestions(params: QuestionPickerParams): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { data: QuestionPickerItem[] };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,7 +307,8 @@ export function useLessonPlanContextSafe(): LessonPlanContextValue | null {
|
|||||||
export function useLessonPlanContext(): LessonPlanContextValue {
|
export function useLessonPlanContext(): LessonPlanContextValue {
|
||||||
const ctx = useContext(LessonPlanContext);
|
const ctx = useContext(LessonPlanContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error("useLessonPlanContext 必须在 LessonPlanProvider 内使用");
|
// V3 修复:开发者错误消息改为英文(非用户可见)
|
||||||
|
throw new Error("useLessonPlanContext must be used within a LessonPlanProvider");
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { persistExamDraft, addExamQuestions } from "@/modules/exams/data-access"
|
|||||||
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
|
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
|
||||||
import { getStudentIdsByClassIds } from "@/modules/classes/data-access";
|
import { getStudentIdsByClassIds } from "@/modules/classes/data-access";
|
||||||
import { normalizeDocument } from "./data-access";
|
import { normalizeDocument } from "./data-access";
|
||||||
import type { LessonPlanDocument, ExerciseBlockData, LessonPlan, LessonPlanStatus } from "./types";
|
import { isExerciseBlockData, isLessonPlanStatus, isValidQuestionType } from "./lib/type-guards";
|
||||||
|
import type { LessonPlanDocument, LessonPlan, LessonPlanStatus } from "./types";
|
||||||
|
|
||||||
interface PublishInput {
|
interface PublishInput {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -19,6 +20,10 @@ interface PublishInput {
|
|||||||
classIds: string[];
|
classIds: string[];
|
||||||
availableAt?: Date;
|
availableAt?: Date;
|
||||||
dueAt?: Date;
|
dueAt?: Date;
|
||||||
|
/** 作业标题(由 actions 层 i18n 翻译后传入)*/
|
||||||
|
homeworkTitle: string;
|
||||||
|
/** 作业描述(由 actions 层 i18n 翻译后传入)*/
|
||||||
|
homeworkDescription: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublishResult {
|
interface PublishResult {
|
||||||
@@ -27,12 +32,6 @@ interface PublishResult {
|
|||||||
updatedContent: LessonPlanDocument;
|
updatedContent: LessonPlanDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 类型守卫:安全地将 string 收窄为 LessonPlanStatus
|
|
||||||
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
|
|
||||||
function isLessonPlanStatus(v: string): v is LessonPlanStatus {
|
|
||||||
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* publish-service 错误:使用错误码替代硬编码中文,
|
* publish-service 错误:使用错误码替代硬编码中文,
|
||||||
* 由 actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息。
|
* 由 actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息。
|
||||||
@@ -44,7 +43,8 @@ export type PublishErrorCode =
|
|||||||
| "NO_QUESTIONS"
|
| "NO_QUESTIONS"
|
||||||
| "ALREADY_PUBLISHED"
|
| "ALREADY_PUBLISHED"
|
||||||
| "NO_SUBJECT_OR_GRADE"
|
| "NO_SUBJECT_OR_GRADE"
|
||||||
| "NO_STUDENTS";
|
| "NO_STUDENTS"
|
||||||
|
| "INVALID_QUESTION_TYPE";
|
||||||
|
|
||||||
export class PublishServiceError extends Error {
|
export class PublishServiceError extends Error {
|
||||||
constructor(public readonly code: PublishErrorCode) {
|
constructor(public readonly code: PublishErrorCode) {
|
||||||
@@ -64,7 +64,10 @@ export async function publishLessonPlanHomework(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND");
|
if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND");
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
// 类型守卫:从 Drizzle 推导类型收窄为 LessonPlan 所需字段
|
// 使用类型守卫收窄(替代 as 断言)
|
||||||
|
const status: LessonPlanStatus = isLessonPlanStatus(row.status)
|
||||||
|
? row.status
|
||||||
|
: "draft";
|
||||||
const plan: LessonPlan = {
|
const plan: LessonPlan = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
@@ -76,7 +79,7 @@ export async function publishLessonPlanHomework(
|
|||||||
templateId: row.templateId,
|
templateId: row.templateId,
|
||||||
templateName: row.templateName,
|
templateName: row.templateName,
|
||||||
content: normalizeDocument(row.content),
|
content: normalizeDocument(row.content),
|
||||||
status: isLessonPlanStatus(row.status) ? row.status : "draft",
|
status,
|
||||||
creatorId: row.creatorId,
|
creatorId: row.creatorId,
|
||||||
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
|
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
@@ -85,13 +88,15 @@ export async function publishLessonPlanHomework(
|
|||||||
if (plan.creatorId !== input.userId)
|
if (plan.creatorId !== input.userId)
|
||||||
throw new PublishServiceError("NO_PERMISSION");
|
throw new PublishServiceError("NO_PERMISSION");
|
||||||
|
|
||||||
// 2. 定位 exercise block
|
// 2. 定位 exercise block(使用类型守卫替代 as 断言)
|
||||||
const block = plan.content.nodes.find((b) => b.id === input.blockId);
|
const block = plan.content.nodes.find((b) => b.id === input.blockId);
|
||||||
if (!block || block.type !== "exercise")
|
if (!block || block.type !== "exercise")
|
||||||
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
||||||
const data = block.data as ExerciseBlockData;
|
if (!isExerciseBlockData(block.data))
|
||||||
if (data.items.length === 0) throw new PublishServiceError("NO_QUESTIONS");
|
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
||||||
if (data.publishedAssignmentId)
|
if (block.data.items.length === 0)
|
||||||
|
throw new PublishServiceError("NO_QUESTIONS");
|
||||||
|
if (block.data.publishedAssignmentId)
|
||||||
throw new PublishServiceError("ALREADY_PUBLISHED");
|
throw new PublishServiceError("ALREADY_PUBLISHED");
|
||||||
|
|
||||||
// 3. inline 题目入库,替换占位 ID
|
// 3. inline 题目入库,替换占位 ID
|
||||||
@@ -99,22 +104,22 @@ export async function publishLessonPlanHomework(
|
|||||||
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
|
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
|
||||||
if (!newBlock || newBlock.type !== "exercise")
|
if (!newBlock || newBlock.type !== "exercise")
|
||||||
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
||||||
const newData = newBlock.data as ExerciseBlockData;
|
if (!isExerciseBlockData(newBlock.data))
|
||||||
|
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
||||||
|
const newData = newBlock.data;
|
||||||
|
|
||||||
for (let i = 0; i < newData.items.length; i++) {
|
for (let i = 0; i < newData.items.length; i++) {
|
||||||
const item = newData.items[i];
|
const item = newData.items[i];
|
||||||
if (item.source === "inline" && item.inlineContent) {
|
if (item.source === "inline" && item.inlineContent) {
|
||||||
// 类型守卫:确保 inline 题目类型合法
|
|
||||||
const validTypes = ["single_choice", "multiple_choice", "text", "judgment", "composite"] as const;
|
|
||||||
const qt = item.inlineContent.type;
|
const qt = item.inlineContent.type;
|
||||||
if (!validTypes.includes(qt as typeof validTypes[number])) {
|
// 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误)
|
||||||
throw new Error(`无效的题目类型: ${qt}`);
|
if (!isValidQuestionType(qt)) {
|
||||||
|
throw new PublishServiceError("INVALID_QUESTION_TYPE");
|
||||||
}
|
}
|
||||||
const questionType = qt as typeof validTypes[number];
|
|
||||||
const questionId = await createQuestionWithRelations(
|
const questionId = await createQuestionWithRelations(
|
||||||
{
|
{
|
||||||
content: item.inlineContent.content,
|
content: item.inlineContent.content,
|
||||||
type: questionType,
|
type: qt,
|
||||||
difficulty: item.inlineContent.difficulty,
|
difficulty: item.inlineContent.difficulty,
|
||||||
knowledgePointIds: item.inlineContent.knowledgePointIds,
|
knowledgePointIds: item.inlineContent.knowledgePointIds,
|
||||||
},
|
},
|
||||||
@@ -128,19 +133,19 @@ export async function publishLessonPlanHomework(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 打包 exam 草稿
|
// 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入)
|
||||||
const examId = createId();
|
const examId = createId();
|
||||||
if (!plan.subjectId || !plan.gradeId) {
|
if (!plan.subjectId || !plan.gradeId) {
|
||||||
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
|
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
|
||||||
}
|
}
|
||||||
await persistExamDraft({
|
await persistExamDraft({
|
||||||
examId,
|
examId,
|
||||||
title: `${plan.title} - 作业`,
|
title: input.homeworkTitle,
|
||||||
creatorId: input.userId,
|
creatorId: input.userId,
|
||||||
subjectId: plan.subjectId,
|
subjectId: plan.subjectId,
|
||||||
gradeId: plan.gradeId,
|
gradeId: plan.gradeId,
|
||||||
scheduledAt: undefined,
|
scheduledAt: undefined,
|
||||||
description: `来自课案:${plan.title}`,
|
description: input.homeworkDescription,
|
||||||
});
|
});
|
||||||
// 插入 examQuestions(通过 exams data-access 跨模块接口)
|
// 插入 examQuestions(通过 exams data-access 跨模块接口)
|
||||||
await addExamQuestions(
|
await addExamQuestions(
|
||||||
@@ -161,8 +166,8 @@ export async function publishLessonPlanHomework(
|
|||||||
await createHomeworkAssignment({
|
await createHomeworkAssignment({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
sourceExamId: examId,
|
sourceExamId: examId,
|
||||||
title: `${plan.title} - 作业`,
|
title: input.homeworkTitle,
|
||||||
description: `来自课案:${plan.title}`,
|
description: input.homeworkDescription,
|
||||||
structure: null,
|
structure: null,
|
||||||
status: "published",
|
status: "published",
|
||||||
creatorId: input.userId,
|
creatorId: input.userId,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// V3 修复:Zod 错误消息使用 i18n 键,由 actions 层通过 translateFieldErrors() 翻译
|
||||||
export const createLessonPlanSchema = z.object({
|
export const createLessonPlanSchema = z.object({
|
||||||
title: z.string().min(1, "请输入课案标题").max(255),
|
title: z.string().min(1, "error.titleRequired").max(255, "error.titleTooLong"),
|
||||||
textbookId: z.string().optional(),
|
textbookId: z.string().optional(),
|
||||||
chapterId: z.string().optional(),
|
chapterId: z.string().optional(),
|
||||||
subjectId: z.string().optional(),
|
subjectId: z.string().optional(),
|
||||||
gradeId: z.string().optional(),
|
gradeId: z.string().optional(),
|
||||||
templateId: z.string().min(1, "请选择模板"),
|
templateId: z.string().min(1, "error.templateRequired"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateLessonPlanContentSchema = z.object({
|
export const updateLessonPlanContentSchema = z.object({
|
||||||
@@ -47,12 +48,12 @@ export const getKnowledgePointOptionsSchema = z.object({
|
|||||||
// 发布作业输入校验
|
// 发布作业输入校验
|
||||||
const dateStringSchema = z
|
const dateStringSchema = z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format");
|
.refine((v) => !Number.isNaN(new Date(v).getTime()), "error.invalidDate");
|
||||||
|
|
||||||
export const publishLessonPlanHomeworkSchema = z.object({
|
export const publishLessonPlanHomeworkSchema = z.object({
|
||||||
planId: z.string().min(1),
|
planId: z.string().min(1),
|
||||||
blockId: z.string().min(1),
|
blockId: z.string().min(1),
|
||||||
classIds: z.array(z.string().min(1)).min(1, "至少选择一个班级"),
|
classIds: z.array(z.string().min(1)).min(1, "error.classRequired"),
|
||||||
availableAt: dateStringSchema.optional(),
|
availableAt: dateStringSchema.optional(),
|
||||||
dueAt: dateStringSchema.optional(),
|
dueAt: dateStringSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,33 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getLessonPlansAction,
|
getLessonPlansAction,
|
||||||
|
getLessonPlanByIdAction,
|
||||||
|
updateLessonPlanAction,
|
||||||
|
saveLessonPlanVersionAction,
|
||||||
getLessonPlanVersionsAction,
|
getLessonPlanVersionsAction,
|
||||||
revertLessonPlanVersionAction,
|
revertLessonPlanVersionAction,
|
||||||
duplicateLessonPlanAction,
|
duplicateLessonPlanAction,
|
||||||
deleteLessonPlanAction,
|
deleteLessonPlanAction,
|
||||||
|
publishLessonPlanAction,
|
||||||
|
unpublishLessonPlanAction,
|
||||||
|
createLessonPlanAction,
|
||||||
|
getTextbooksForPickerAction,
|
||||||
|
getChaptersForPickerAction,
|
||||||
|
getLessonPlanTemplatesAction,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
|
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
||||||
|
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
||||||
|
import { getQuestionsAction } from "@/modules/questions/actions";
|
||||||
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
|
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认数据服务实现:包装现有 Server Actions。
|
* 默认数据服务实现:包装现有 Server Actions。
|
||||||
* 通过 LessonPlanProvider 注入,组件不直接 import actions。
|
* 通过 LessonPlanProvider 注入,组件不直接 import actions。
|
||||||
* 测试时可替换为 mock 实现。
|
* 测试时可替换为 mock 实现。
|
||||||
|
*
|
||||||
|
* V3 扩展:新增 picker/dialog 组件所需方法(createLessonPlan / getTextbooksForPicker /
|
||||||
|
* getChaptersForPicker / getLessonPlanTemplates / getKnowledgePointOptions /
|
||||||
|
* publishLessonPlanHomework / getQuestions)。
|
||||||
*/
|
*/
|
||||||
export function createDefaultDataService(): LessonPlanDataService {
|
export function createDefaultDataService(): LessonPlanDataService {
|
||||||
return {
|
return {
|
||||||
@@ -24,6 +40,27 @@ export function createDefaultDataService(): LessonPlanDataService {
|
|||||||
return { success: false, message: res.message };
|
return { success: false, message: res.message };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getLessonPlanById(planId) {
|
||||||
|
const res = await getLessonPlanByIdAction(planId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { plan: res.data.plan } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateLessonPlan(input) {
|
||||||
|
const res = await updateLessonPlanAction(input);
|
||||||
|
return { success: res.success, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveLessonPlanVersion(input) {
|
||||||
|
const res = await saveLessonPlanVersionAction(input);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { versionNo: res.data.versionNo } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
async getLessonPlanVersions(planId) {
|
async getLessonPlanVersions(planId) {
|
||||||
const res = await getLessonPlanVersionsAction(planId);
|
const res = await getLessonPlanVersionsAction(planId);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
@@ -46,5 +83,83 @@ export function createDefaultDataService(): LessonPlanDataService {
|
|||||||
const res = await deleteLessonPlanAction(planId);
|
const res = await deleteLessonPlanAction(planId);
|
||||||
return { success: res.success, message: res.message };
|
return { success: res.success, message: res.message };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async publishLessonPlan(planId) {
|
||||||
|
const res = await publishLessonPlanAction(planId);
|
||||||
|
return { success: res.success, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async unpublishLessonPlan(planId) {
|
||||||
|
const res = await unpublishLessonPlanAction(planId);
|
||||||
|
return { success: res.success, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- V3 新增:picker/dialog 组件所需方法 ----
|
||||||
|
|
||||||
|
async createLessonPlan(prevState, formData) {
|
||||||
|
const res = await createLessonPlanAction(prevState, formData);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { planId: res.data.planId } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message, errors: res.errors };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTextbooksForPicker() {
|
||||||
|
const res = await getTextbooksForPickerAction();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { textbooks: res.data.textbooks } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getChaptersForPicker(textbookId) {
|
||||||
|
const res = await getChaptersForPickerAction(textbookId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { chapters: res.data.chapters } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLessonPlanTemplates() {
|
||||||
|
const res = await getLessonPlanTemplatesAction();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { templates: res.data.templates } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getKnowledgePointOptions(input) {
|
||||||
|
const res = await getKnowledgePointOptionsAction(input);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { options: res.data.options } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message, errors: res.errors };
|
||||||
|
},
|
||||||
|
|
||||||
|
async publishLessonPlanHomework(input) {
|
||||||
|
const res = await publishLessonPlanHomeworkAction(input);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { examId: res.data.examId, assignmentId: res.data.assignmentId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message, errors: res.errors };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getQuestions(params) {
|
||||||
|
const res = await getQuestionsAction(params);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
// 从 questions 模块的返回结构中提取 picker 所需字段
|
||||||
|
const items = res.data.data.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
type: q.type,
|
||||||
|
difficulty: q.difficulty,
|
||||||
|
content: q.content,
|
||||||
|
}));
|
||||||
|
return { success: true, data: { data: items } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,15 @@ export interface LessonPlanTemplate {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 版本摘要(用于卡片上的版本选择器)
|
||||||
|
export interface LessonPlanVersionSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: LessonPlanStatus;
|
||||||
|
updatedAt: string;
|
||||||
|
lastSavedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// 列表项(带教材/章节名)
|
// 列表项(带教材/章节名)
|
||||||
export interface LessonPlanListItem extends LessonPlan {
|
export interface LessonPlanListItem extends LessonPlan {
|
||||||
textbookTitle: string | null;
|
textbookTitle: string | null;
|
||||||
@@ -321,6 +330,10 @@ export interface LessonPlanListItem extends LessonPlan {
|
|||||||
subjectName: string | null;
|
subjectName: string | null;
|
||||||
gradeName: string | null;
|
gradeName: string | null;
|
||||||
creatorName: string | null;
|
creatorName: string | null;
|
||||||
|
/** 同一备课的版本数(≥1,=1 表示无多版本)*/
|
||||||
|
versionCount: number;
|
||||||
|
/** 同一备课的所有版本摘要(按 updatedAt 降序)*/
|
||||||
|
versions: LessonPlanVersionSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionState(与项目现有约定一致)
|
// ActionState(与项目现有约定一致)
|
||||||
|
|||||||
Reference in New Issue
Block a user