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 { publishLessonPlanHomework, PublishServiceError } from "./publish-service";
|
||||
import { publishLessonPlanHomeworkSchema } from "./schema";
|
||||
import { translateFieldErrors } from "./lib/i18n-errors";
|
||||
import type { ActionState } from "./types";
|
||||
|
||||
export async function publishLessonPlanHomeworkAction(input: {
|
||||
@@ -22,24 +23,32 @@ export async function publishLessonPlanHomeworkAction(input: {
|
||||
try {
|
||||
const parsed = publishLessonPlanHomeworkSchema.safeParse(input);
|
||||
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(
|
||||
Permissions.LESSON_PLAN_PUBLISH,
|
||||
);
|
||||
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({
|
||||
planId: parsed.data.planId,
|
||||
blockId: parsed.data.blockId,
|
||||
userId: ctx.userId,
|
||||
classIds: parsed.data.classIds,
|
||||
availableAt: parsed.data.availableAt
|
||||
? safeParseDate(parsed.data.availableAt, "可用时间")
|
||||
? safeParseDate(parsed.data.availableAt, availableAtLabel)
|
||||
: undefined,
|
||||
dueAt: parsed.data.dueAt
|
||||
? safeParseDate(parsed.data.dueAt, "截止时间")
|
||||
? safeParseDate(parsed.data.dueAt, dueAtLabel)
|
||||
: undefined,
|
||||
homeworkTitle,
|
||||
homeworkDescription,
|
||||
});
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
revalidatePath("/teacher/homework");
|
||||
@@ -69,4 +78,5 @@ const PUBLISH_ERROR_KEY_MAP: Record<string, string> = {
|
||||
ALREADY_PUBLISHED: "publish.alreadyPublished",
|
||||
NO_SUBJECT_OR_GRADE: "publish.noSubjectOrGrade",
|
||||
NO_STUDENTS: "publish.noStudents",
|
||||
INVALID_QUESTION_TYPE: "error.invalidQuestionType",
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
createLessonPlan,
|
||||
updateLessonPlanContent,
|
||||
softDeleteLessonPlan,
|
||||
publishLessonPlan,
|
||||
unpublishLessonPlan,
|
||||
duplicateLessonPlan,
|
||||
getTextbooksForPicker,
|
||||
getChaptersForPicker,
|
||||
@@ -34,7 +36,8 @@ import {
|
||||
revertVersionSchema,
|
||||
saveAsTemplateSchema,
|
||||
} 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: {
|
||||
@@ -60,13 +63,12 @@ export async function getLessonPlansAction(params: {
|
||||
// ---- 单课案 ----
|
||||
export async function getLessonPlanByIdAction(
|
||||
planId: string,
|
||||
): Promise<
|
||||
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
|
||||
> {
|
||||
): Promise<ActionState<{ plan: LessonPlan }>> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const plan = await getLessonPlanById(planId, ctx.userId);
|
||||
if (!plan) return { success: false, message: "课案不存在" };
|
||||
if (!plan) return { success: false, message: t("error.notFound") };
|
||||
return { success: true, data: { plan } };
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
@@ -90,7 +92,8 @@ export async function createLessonPlanAction(
|
||||
templateId: formData.get("templateId"),
|
||||
});
|
||||
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({
|
||||
...parsed.data,
|
||||
@@ -131,8 +134,10 @@ export async function updateLessonPlanAction(input: {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = updateLessonPlanContentSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
if (!parsed.success) {
|
||||
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||
return { success: false, errors };
|
||||
}
|
||||
await updateLessonPlanContent(parsed.data.planId, ctx.userId, {
|
||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||
// 从 unknown 转换:Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫
|
||||
@@ -154,8 +159,10 @@ export async function saveLessonPlanVersionAction(input: {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = saveVersionSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
if (!parsed.success) {
|
||||
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||
return { success: false, errors };
|
||||
}
|
||||
const { versionNo } = await createLessonPlanVersion({
|
||||
planId: parsed.data.planId,
|
||||
content: input.content,
|
||||
@@ -192,17 +199,23 @@ export async function revertLessonPlanVersionAction(input: {
|
||||
planId: string;
|
||||
versionNo: number;
|
||||
}): Promise<ActionState<{ newVersionNo: number }>> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = revertVersionSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
if (!parsed.success) {
|
||||
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(
|
||||
parsed.data.planId,
|
||||
parsed.data.versionNo,
|
||||
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`);
|
||||
return { success: true, data: { newVersionNo: result.newVersionNo } };
|
||||
} 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(
|
||||
planId: string,
|
||||
@@ -231,7 +282,12 @@ export async function duplicateLessonPlanAction(
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
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");
|
||||
return { success: true, data: { newPlanId } };
|
||||
} catch (e) {
|
||||
@@ -265,8 +321,10 @@ export async function saveAsTemplateAction(input: {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||
const parsed = saveAsTemplateSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
if (!parsed.success) {
|
||||
const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors);
|
||||
return { success: false, errors };
|
||||
}
|
||||
const { templateId } = await saveAsTemplate({
|
||||
...parsed.data,
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Tag } from "lucide-react";
|
||||
import type { BlackboardBlockData } from "../../types";
|
||||
import { isBlackboardLayout } from "../../lib/type-guards";
|
||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||
|
||||
interface Props {
|
||||
@@ -30,12 +31,12 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
|
||||
</label>
|
||||
<select
|
||||
value={data.layout}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...data,
|
||||
layout: e.target.value as BlackboardBlockData["layout"],
|
||||
})
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (isBlackboardLayout(value)) {
|
||||
onUpdate({ ...data, layout: value });
|
||||
}
|
||||
}}
|
||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
|
||||
>
|
||||
{LAYOUTS.map((l) => (
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
ExerciseBlockData,
|
||||
ExerciseItem,
|
||||
ExercisePurpose,
|
||||
} from "../../types";
|
||||
import { isExercisePurpose } from "../../lib/type-guards";
|
||||
|
||||
interface Props {
|
||||
blockId: string;
|
||||
@@ -59,9 +59,12 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
<select
|
||||
id={`exercise-purpose-${blockId}`}
|
||||
value={data.purpose}
|
||||
onChange={(e) =>
|
||||
update({ purpose: e.target.value as ExercisePurpose })
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (isExercisePurpose(value)) {
|
||||
update({ purpose: value });
|
||||
}
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="class_practice">{t("exercise.purpose.class_practice")}</option>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { HomeworkAssignment, HomeworkBlockData } from "../../types";
|
||||
import { isHomeworkType } from "../../lib/type-guards";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
@@ -45,11 +46,12 @@ export function HomeworkBlock({ data, onUpdate }: Props) {
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<select
|
||||
value={item.type}
|
||||
onChange={(e) =>
|
||||
updateItem(idx, {
|
||||
type: e.target.value as HomeworkAssignment["type"],
|
||||
})
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (isHomeworkType(value)) {
|
||||
updateItem(idx, { type: value });
|
||||
}
|
||||
}}
|
||||
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||
>
|
||||
{TYPES.map((tp) => (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ImportBlockData } from "../../types";
|
||||
import { isImportMethod } from "../../lib/type-guards";
|
||||
|
||||
interface Props {
|
||||
data: ImportBlockData;
|
||||
@@ -24,9 +25,12 @@ export function ImportBlock({ data, onUpdate }: Props) {
|
||||
</label>
|
||||
<select
|
||||
value={data.method}
|
||||
onChange={(e) =>
|
||||
onUpdate({ ...data, method: e.target.value as ImportBlockData["method"] })
|
||||
}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
>
|
||||
{METHODS.map((m) => (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { KeyPointBlockData, KeyPointItem } from "../../types";
|
||||
import { isKeyPointType } from "../../lib/type-guards";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
@@ -45,9 +46,12 @@ export function KeyPointBlock({ data, onUpdate }: Props) {
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<select
|
||||
value={item.type}
|
||||
onChange={(e) =>
|
||||
updateItem(idx, { type: e.target.value as KeyPointItem["type"] })
|
||||
}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
>
|
||||
{TYPES.map((tp) => (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { ObjectiveBlockData, ObjectiveItem } from "../../types";
|
||||
import { isObjectiveDimension } from "../../lib/type-guards";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
@@ -48,11 +49,12 @@ export function ObjectiveBlock({ data, onUpdate }: Props) {
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<select
|
||||
value={item.dimension}
|
||||
onChange={(e) =>
|
||||
updateItem(idx, {
|
||||
dimension: e.target.value as ObjectiveItem["dimension"],
|
||||
})
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (isObjectiveDimension(value)) {
|
||||
updateItem(idx, { dimension: value });
|
||||
}
|
||||
}}
|
||||
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||
>
|
||||
{DIMENSIONS.map((d) => (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { ReflectionBlockData, ReflectionItem } from "../../types";
|
||||
import { isReflectionAspect } from "../../lib/type-guards";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
@@ -45,11 +46,12 @@ export function ReflectionBlock({ data, onUpdate }: Props) {
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<select
|
||||
value={item.aspect}
|
||||
onChange={(e) =>
|
||||
updateItem(idx, {
|
||||
aspect: e.target.value as ReflectionItem["aspect"],
|
||||
})
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (isReflectionAspect(value)) {
|
||||
updateItem(idx, { aspect: value });
|
||||
}
|
||||
}}
|
||||
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
|
||||
>
|
||||
{ASPECTS.map((a) => (
|
||||
|
||||
@@ -4,12 +4,8 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
||||
|
||||
interface KpOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||
import type { KnowledgePointOption } from "../providers/lesson-plan-provider";
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
@@ -27,13 +23,15 @@ export function KnowledgePointPicker({
|
||||
onClose,
|
||||
}: Props) {
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textbookId) {
|
||||
if (!textbookId || !service) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -43,7 +41,7 @@ export function KnowledgePointPicker({
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
return getKnowledgePointOptionsAction({ textbookId, chapterId });
|
||||
return service.getKnowledgePointOptions({ textbookId, chapterId });
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled || !res) return;
|
||||
@@ -64,7 +62,7 @@ export function KnowledgePointPicker({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [textbookId, chapterId, t]);
|
||||
}, [textbookId, chapterId, t, service]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setLocal((prev) =>
|
||||
|
||||
@@ -17,25 +17,62 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { formatDateTime } from "@/shared/lib/utils";
|
||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||
import { useLessonPlanContextSafe, useRoleConfig, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
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 router = useRouter();
|
||||
const roleConfig = useRoleConfig();
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
|
||||
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
||||
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
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() {
|
||||
if (!service) return;
|
||||
try {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
const res = await service.deleteLessonPlan(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||
toast.success(t("status.archived"));
|
||||
@@ -50,10 +87,9 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
if (!service) return;
|
||||
try {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
const res = await service.duplicateLessonPlan(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||
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 (
|
||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||
<Link
|
||||
href={`/teacher/lesson-plans/${plan.id}/edit`}
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-title-md text-title-md hover:text-primary">
|
||||
{plan.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link
|
||||
href={planHref}
|
||||
className="block flex-1 min-w-0"
|
||||
>
|
||||
<h3 className="font-title-md text-title-md hover:text-primary truncate">
|
||||
{plan.title}
|
||||
</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">
|
||||
{plan.textbookTitle ?? t("list.noTextbook")} · {plan.chapterTitle ?? t("list.noChapter")}
|
||||
</div>
|
||||
@@ -89,13 +166,86 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
? formatDateTime(plan.lastSavedAt)
|
||||
: t("list.neverSaved")}
|
||||
</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">
|
||||
{roleConfig.canDuplicate && (
|
||||
{roleConfig.canDuplicate && !isReadOnly && (
|
||||
<Button variant="outline" size="sm" onClick={handleDuplicate}>
|
||||
{t("action.duplicate")}
|
||||
</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>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
|
||||
@@ -7,19 +7,30 @@ import { NodeEditor } from "./node-editor";
|
||||
import { NodeEditPanel } from "./node-edit-panel";
|
||||
import { VersionHistoryDrawer } from "./version-history-drawer";
|
||||
import {
|
||||
updateLessonPlanAction,
|
||||
saveLessonPlanVersionAction,
|
||||
getLessonPlanByIdAction,
|
||||
} from "../actions";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
useLessonPlanContextSafe,
|
||||
useLessonPlanTrackerSafe,
|
||||
} from "../providers/lesson-plan-provider";
|
||||
import type { BlockType } from "../types";
|
||||
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 {
|
||||
planId: string;
|
||||
initialTitle: string;
|
||||
initialDoc: import("../types").LessonPlanDocument;
|
||||
initialStatus?: "draft" | "published" | "archived";
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
textbookTitle?: string;
|
||||
@@ -46,6 +57,7 @@ export function LessonPlanEditor({
|
||||
planId,
|
||||
initialTitle,
|
||||
initialDoc,
|
||||
initialStatus = "draft",
|
||||
textbookId,
|
||||
chapterId,
|
||||
textbookTitle,
|
||||
@@ -55,8 +67,12 @@ export function LessonPlanEditor({
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const editor = useLessonPlanEditor();
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
const [showVersions, setShowVersions] = 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 versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -69,14 +85,16 @@ export function LessonPlanEditor({
|
||||
}, [initKey]);
|
||||
|
||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||
useEffect(() => {
|
||||
if (!editor.isDirty) return;
|
||||
if (!service) return;
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
autoSaveTimer.current = setTimeout(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
try {
|
||||
const res = await updateLessonPlanAction({
|
||||
const res = await service.updateLessonPlan({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
@@ -91,15 +109,16 @@ export function LessonPlanEditor({
|
||||
return () => {
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
};
|
||||
}, [editor.isDirty, editor.doc, planId]);
|
||||
}, [editor.isDirty, editor.doc, planId, service]);
|
||||
|
||||
// 定时自动版本(30min)
|
||||
useEffect(() => {
|
||||
if (!service) return;
|
||||
versionTimer.current = setInterval(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
if (!state.isDirty) return;
|
||||
try {
|
||||
await saveLessonPlanVersionAction({
|
||||
await service.saveLessonPlanVersion({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: t("version.autoLabel"),
|
||||
@@ -111,7 +130,7 @@ export function LessonPlanEditor({
|
||||
return () => {
|
||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||
};
|
||||
}, [planId, t]);
|
||||
}, [planId, t, service]);
|
||||
|
||||
// 离开未保存提示(P3-1)
|
||||
useEffect(() => {
|
||||
@@ -138,10 +157,11 @@ export function LessonPlanEditor({
|
||||
}, [showAddMenu]);
|
||||
|
||||
const handleManualSave = useCallback(async () => {
|
||||
if (!service) return;
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
try {
|
||||
const res = await saveLessonPlanVersionAction({
|
||||
const res = await service.saveLessonPlanVersion({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
});
|
||||
@@ -154,20 +174,63 @@ export function LessonPlanEditor({
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, [tracker]);
|
||||
}, [tracker, service]);
|
||||
|
||||
// 版本回退后刷新内容(修复 P1-1)
|
||||
const handleReverted = useCallback(async () => {
|
||||
if (!service) return;
|
||||
const state = useLessonPlanEditor.getState();
|
||||
try {
|
||||
const res = await getLessonPlanByIdAction(state.planId);
|
||||
const res = await service.getLessonPlanById(state.planId);
|
||||
if (res.success && res.data?.plan) {
|
||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] reload after revert failed", e);
|
||||
}
|
||||
}, []);
|
||||
}, [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 (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -209,6 +272,52 @@ export function LessonPlanEditor({
|
||||
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
||||
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
|
||||
</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>
|
||||
|
||||
{/* 主区域:画布 + 侧边面板 */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +9,10 @@ interface Props {
|
||||
fallback?: ReactNode;
|
||||
/** 错误时的回调,用于上报埋点 */
|
||||
onError?: (error: Error, info: ErrorInfo) => void;
|
||||
/** 错误提示文案(V3 i18n:由包装组件注入)*/
|
||||
errorText?: string;
|
||||
/** 重试按钮文案(V3 i18n:由包装组件注入)*/
|
||||
retryText?: string;
|
||||
}
|
||||
|
||||
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) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
@@ -46,10 +53,10 @@ export class LessonPlanErrorBoundary extends Component<Props, State> {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 gap-3 text-center">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{this.state.error?.message ?? "区块加载失败"}
|
||||
{this.state.error?.message ?? this.props.errorText}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={this.handleRetry}>
|
||||
重试
|
||||
{this.props.retryText}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -57,3 +64,18 @@ export class LessonPlanErrorBoundary extends Component<Props, State> {
|
||||
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 { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
import { getLessonPlansAction } from "../actions";
|
||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
interface Props {
|
||||
initialItems: LessonPlanListItem[];
|
||||
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 [items, setItems] = useState(initialItems);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
// 使用 useCallback 稳定 handleFilter 引用,避免 LessonPlanFilters 的 useEffect 无限循环
|
||||
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||
// 若未在 Provider 内使用,则不执行任何服务端调用(强制要求 Provider 包裹)
|
||||
const handleFilter = useCallback(
|
||||
async (params: {
|
||||
query?: string;
|
||||
@@ -28,17 +36,9 @@ export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||
status?: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
if (!service) return;
|
||||
try {
|
||||
if (service) {
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) {
|
||||
setItems(res.data.items);
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const res = await getLessonPlansAction(params);
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) {
|
||||
setItems(res.data.items);
|
||||
} 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">
|
||||
{items.map((p) => (
|
||||
<LessonPlanCard key={p.id} plan={p} />
|
||||
<LessonPlanCard key={p.id} plan={p} viewMode={viewMode} />
|
||||
))}
|
||||
</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 { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
|
||||
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
|
||||
import { getNodeColor } from "../lib/node-summary";
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
@@ -20,7 +21,7 @@ interface Props {
|
||||
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const tAi = useTranslations("ai");
|
||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
|
||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
|
||||
useLessonPlanEditor();
|
||||
const aiClient = useAiClientOptional();
|
||||
const [showAiPanel, setShowAiPanel] = useState(false);
|
||||
@@ -35,8 +36,16 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// 正文节点不在侧边面板编辑(直接在画布上交互)
|
||||
// P2-1:正文节点显示操作提示 + 锚点列表(而非误导性的"内容为空")
|
||||
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 (
|
||||
<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">
|
||||
@@ -52,15 +61,65 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 text-sm text-on-surface-variant">
|
||||
{t("editor.textbookContentEmpty")}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* 操作提示 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 教学节点:通过类型守卫收窄为 LessonPlanNode
|
||||
const lessonNode = node as import("../types").LessonPlanNode;
|
||||
// 教学节点:textbook_content 分支已上方 return,此处 TypeScript 已收窄为 LessonPlanNode
|
||||
const lessonNode = node;
|
||||
|
||||
// 从节点标题提取主题用于 AI 内容生成
|
||||
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 { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||
import { getNodeColor } from "../lib/node-summary";
|
||||
import type { AnyLessonPlanNode } from "../types";
|
||||
import type { AnyLessonPlanNode, BlockType } from "../types";
|
||||
|
||||
const nodeTypes = {
|
||||
lesson: LessonNode,
|
||||
@@ -42,22 +42,27 @@ export function NodeEditor({}: Props) {
|
||||
selectNode,
|
||||
setEdges,
|
||||
addAnchor,
|
||||
updateTextbookContent,
|
||||
addNode,
|
||||
} = 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(
|
||||
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
|
||||
// 如果 nodeId 是 __selected__,使用当前选中节点
|
||||
// 如果是 __new__,提示用户先创建节点
|
||||
// __selected__ 表示使用当前选中节点
|
||||
const actualNodeId =
|
||||
params.nodeId === "__selected__"
|
||||
? selectedNodeId ?? ""
|
||||
: params.nodeId;
|
||||
if (!actualNodeId || actualNodeId === "__new__") {
|
||||
// 简化:不自动创建新节点,提示用户先选中或创建
|
||||
return;
|
||||
}
|
||||
if (!actualNodeId) return;
|
||||
addAnchor({
|
||||
nodeId: actualNodeId,
|
||||
type: "range",
|
||||
@@ -75,9 +80,7 @@ export function NodeEditor({}: Props) {
|
||||
params.nodeId === "__selected__"
|
||||
? selectedNodeId ?? ""
|
||||
: params.nodeId;
|
||||
if (!actualNodeId || actualNodeId === "__new__") {
|
||||
return;
|
||||
}
|
||||
if (!actualNodeId) return;
|
||||
addAnchor({
|
||||
nodeId: actualNodeId,
|
||||
type: "point",
|
||||
@@ -87,11 +90,25 @@ export function NodeEditor({}: Props) {
|
||||
[addAnchor, selectedNodeId],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(zoom: number) => {
|
||||
updateTextbookContent({ zoom });
|
||||
// P1-1:创建新节点并锚定
|
||||
const handleCreateNewNode = useCallback(
|
||||
(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
|
||||
@@ -100,12 +117,13 @@ export function NodeEditor({}: Props) {
|
||||
toRfNodes(doc.nodes, selectedNodeId, {
|
||||
anchors: doc.anchors,
|
||||
selectedNodeId,
|
||||
anchorableNodes,
|
||||
onAddRangeAnchor: handleAddRangeAnchor,
|
||||
onAddPointAnchor: handleAddPointAnchor,
|
||||
onCreateNewNode: handleCreateNewNode,
|
||||
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(
|
||||
@@ -173,7 +191,13 @@ export function NodeEditor({}: Props) {
|
||||
);
|
||||
|
||||
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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-center text-on-surface-variant">
|
||||
@@ -216,9 +240,21 @@ export function NodeEditor({}: Props) {
|
||||
<MiniMap
|
||||
className="!bg-surface !border-outline-variant"
|
||||
nodeColor={(n) => {
|
||||
const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
|
||||
if (!nodeData) return "#9e9e9e";
|
||||
return getNodeColor(nodeData.type);
|
||||
// V3 修复:从 React Flow 的 Node.data(Record<string, unknown>)安全提取 node 字段
|
||||
// 使用类型守卫替代 as 断言
|
||||
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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NodeProps } from "@xyflow/react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
import type { NodeAnchor, TextbookContentNode as TextbookContentNodeModel } from "../../types";
|
||||
import {
|
||||
@@ -15,29 +11,54 @@ import {
|
||||
parseAnchoredText,
|
||||
toCircledNumber,
|
||||
getNextPointIndex,
|
||||
markdownToPlainText,
|
||||
} from "../../lib/anchor-injector";
|
||||
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 {
|
||||
data: {
|
||||
node: TextbookContentNodeModel;
|
||||
anchors: NodeAnchor[];
|
||||
selectedNodeId: string | null;
|
||||
onAddRangeAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
textPreview: string;
|
||||
}) => void;
|
||||
onAddPointAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
}) => void;
|
||||
onSelectNode?: (id: string | null) => void;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
};
|
||||
selected: boolean;
|
||||
node: TextbookContentNodeModel;
|
||||
anchors: NodeAnchor[];
|
||||
selectedNodeId: string | null;
|
||||
/** 可锚定的教学节点列表(用于锚点节点选择器)*/
|
||||
anchorableNodes?: { id: string; title: string; type: string }[];
|
||||
onAddRangeAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
textPreview: string;
|
||||
}) => void;
|
||||
onAddPointAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
}) => void;
|
||||
/** 创建新节点并锚定 */
|
||||
onCreateNewNode?: (params: {
|
||||
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({
|
||||
@@ -45,21 +66,27 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
selected,
|
||||
}: NodeProps) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const props = (data as unknown as TextbookContentNodeProps["data"]).node
|
||||
? (data as unknown as TextbookContentNodeProps["data"])
|
||||
: null;
|
||||
const props = isTextbookContentNodePropsData(data) ? data : null;
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [showAnchorMenu, setShowAnchorMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
selection: { start: number; end: number; text: string } | null;
|
||||
point: number | null;
|
||||
} | null>(null);
|
||||
// 光标位置指示器(左键点击时显示)
|
||||
const [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 anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]);
|
||||
const selectedNodeId = props?.selectedNodeId ?? null;
|
||||
const anchorableNodes = props?.anchorableNodes ?? [];
|
||||
|
||||
// 注入锚点标记后的 Markdown
|
||||
const injectedContent = useMemo(() => {
|
||||
@@ -91,84 +118,114 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
[anchors],
|
||||
);
|
||||
|
||||
// 处理文本选择
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!node) return;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) {
|
||||
// 点击空白处:尝试计算点击位置偏移
|
||||
return;
|
||||
}
|
||||
// 计算选中文本在纯文本中的偏移
|
||||
const computeSelectionOffset = useCallback(
|
||||
(selectedText: string): { start: number; end: number } | null => {
|
||||
if (!node) return null;
|
||||
const plainText = markdownToPlainText(node.data.content);
|
||||
const start = plainText.indexOf(selectedText);
|
||||
if (start >= 0) {
|
||||
return { start, end: start + selectedText.length };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[node],
|
||||
);
|
||||
|
||||
const text = selection.toString();
|
||||
if (!text) return;
|
||||
// 计算点击位置在纯文本中的偏移,并返回 caret 的视口坐标(用于精确定位光标指示器)
|
||||
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 plainText = node.data.content;
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
// 右键菜单:在右键位置弹出锚点菜单
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 简化:用 selection 的 anchorOffset 和 focusOffset
|
||||
// 注意:这是近似值,对于复杂 DOM 结构可能不准确
|
||||
const startOffset = range.startOffset;
|
||||
const endOffset = range.endOffset;
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection && !selection.isCollapsed ? selection.toString() : "";
|
||||
|
||||
// 如果在同一文本节点
|
||||
if (startContainer === endContainer && startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const containerText = startContainer.textContent ?? "";
|
||||
const containerStart = plainText.indexOf(containerText);
|
||||
if (containerStart >= 0) {
|
||||
const absoluteStart = containerStart + startOffset;
|
||||
const absoluteEnd = containerStart + endOffset;
|
||||
const selectedText = plainText.slice(absoluteStart, absoluteEnd);
|
||||
|
||||
// 显示锚点菜单
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (selectedText) {
|
||||
// 有选中文本:提供区间锚定
|
||||
const offsets = computeSelectionOffset(selectedText);
|
||||
if (!offsets) return;
|
||||
setShowAnchorMenu({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 10,
|
||||
selection: { start: absoluteStart, end: absoluteEnd, text: selectedText },
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
selection: { ...offsets, text: selectedText },
|
||||
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();
|
||||
}, [node]);
|
||||
|
||||
// 处理点击(点锚定)
|
||||
// 左键点击:显示光标位置指示器(用 caret 实际坐标精确定位)
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node) return;
|
||||
// 如果有选中文本,不处理点击
|
||||
// 如果有选中文本,不显示光标(让浏览器处理选择)
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed) return;
|
||||
|
||||
// 计算点击位置在纯文本中的偏移
|
||||
// 简化:使用 caretRangeFromPoint(Chromium)或 caretPositionFromPoint(Firefox)
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
let offset = -1;
|
||||
|
||||
if (document.caretPositionFromPoint) {
|
||||
const pos = document.caretPositionFromPoint(x, y);
|
||||
if (pos) offset = pos.offset;
|
||||
} else if (document.caretRangeFromPoint) {
|
||||
const range = document.caretRangeFromPoint(x, y);
|
||||
if (range) offset = range.startOffset;
|
||||
if (selection && !selection.isCollapsed) {
|
||||
setCursorPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (offset < 0) return;
|
||||
// 计算 caret 位置,优先用 caret 的 rect 坐标,fallback 到鼠标坐标
|
||||
const { offset, rect } = computePointOffset(e.clientX, e.clientY);
|
||||
if (offset < 0) {
|
||||
setCursorPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAnchorMenu({
|
||||
x,
|
||||
y,
|
||||
selection: null,
|
||||
point: offset,
|
||||
setCursorPos({
|
||||
x: rect && rect.width >= 0 ? rect.left : e.clientX,
|
||||
y: rect && rect.width >= 0 ? rect.top : e.clientY,
|
||||
});
|
||||
},
|
||||
[node],
|
||||
[node, computePointOffset],
|
||||
);
|
||||
|
||||
// 关闭锚点菜单
|
||||
@@ -184,18 +241,65 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
return () => document.removeEventListener("mousedown", handleOutside);
|
||||
}, [showAnchorMenu]);
|
||||
|
||||
// 缩放控制
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (!node || !props?.onZoomChange) return;
|
||||
const newZoom = Math.min(2, node.data.zoom + 0.1);
|
||||
props.onZoomChange(newZoom);
|
||||
}, [node, props]);
|
||||
// 光标指示器自动消失
|
||||
useEffect(() => {
|
||||
if (!cursorPos) return;
|
||||
const timer = setTimeout(() => setCursorPos(null), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cursorPos]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (!node || !props?.onZoomChange) return;
|
||||
const newZoom = Math.max(0.5, node.data.zoom - 0.1);
|
||||
props.onZoomChange(newZoom);
|
||||
}, [node, props]);
|
||||
// 阻止 React Flow 在正文内容区和缩放手柄上拦截 pointerdown 事件(切实保障文本选择和缩放可用)
|
||||
// nodrag class 只能阻止拖拽,但 React Flow 可能在更上层 preventDefault 阻止文本选择
|
||||
// 使用原生事件监听器 stopPropagation,让 React Flow 完全收不到 pointerdown
|
||||
useEffect(() => {
|
||||
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) {
|
||||
return (
|
||||
@@ -209,67 +313,45 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
|
||||
return (
|
||||
<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={{
|
||||
borderColor: selected ? "#1976d2" : "#455a64",
|
||||
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
||||
width: 480,
|
||||
width: 520,
|
||||
minWidth: 300,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<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" }}
|
||||
>
|
||||
<span>{t("editor.textbookContent")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="!p-1 !h-6 !w-6 text-white hover:bg-white/20"
|
||||
onClick={handleZoomOut}
|
||||
aria-label={t("editor.zoomOut")}
|
||||
>
|
||||
<ZoomOut className="w-3 h-3" />
|
||||
</Button>
|
||||
<span className="text-xs">{Math.round(node.data.zoom * 100)}%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="!p-1 !h-6 !w-6 text-white hover:bg-white/20"
|
||||
onClick={handleZoomIn}
|
||||
aria-label={t("editor.zoomIn")}
|
||||
>
|
||||
<ZoomIn className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-white/60 text-[10px]">
|
||||
{t("editor.rightClickHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 正文内容 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="px-4 py-3 max-h-[60vh] overflow-y-auto"
|
||||
style={{
|
||||
transform: `scale(${node.data.zoom})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
onMouseUp={handleMouseUp}
|
||||
// nodrag class 让 React Flow 跳过拖拽逻辑,允许在正文上选择文本
|
||||
className="px-4 py-3 flex-1 overflow-y-auto text-sm leading-relaxed text-on-surface select-text nodrag"
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={handleClick}
|
||||
style={{ userSelect: "text", WebkitUserSelect: "text" }}
|
||||
>
|
||||
{node.data.content ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
p: ({ children }) => {
|
||||
// 将段落中的锚点标记渲染为 span
|
||||
return <p>{renderChildrenWithAnchors(children, segments, activeAnchorIds, getAnchorNodeColor, props?.onSelectNode, anchors)}</p>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{injectedContent}
|
||||
</ReactMarkdown>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{renderSegments({
|
||||
segments,
|
||||
activeAnchorIds,
|
||||
getAnchorNodeColor,
|
||||
onSelectNode: props?.onSelectNode,
|
||||
anchors,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-on-surface-variant text-sm py-8 text-center">
|
||||
@@ -278,15 +360,43 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
)}
|
||||
</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
|
||||
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={{
|
||||
left: showAnchorMenu.x,
|
||||
top: showAnchorMenu.y,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
>
|
||||
{showAnchorMenu.selection ? (
|
||||
@@ -296,7 +406,9 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
</div>
|
||||
<AnchorNodeSelector
|
||||
t={t}
|
||||
onSelect={(nodeId) => {
|
||||
anchorableNodes={anchorableNodes}
|
||||
hasSelectedNode={!!selectedNodeId}
|
||||
onPickNode={(nodeId) => {
|
||||
if (props?.onAddRangeAnchor && showAnchorMenu.selection) {
|
||||
props.onAddRangeAnchor({
|
||||
nodeId,
|
||||
@@ -307,6 +419,17 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
}
|
||||
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>
|
||||
) : showAnchorMenu.point !== null ? (
|
||||
@@ -316,7 +439,9 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
</div>
|
||||
<AnchorNodeSelector
|
||||
t={t}
|
||||
onSelect={(nodeId) => {
|
||||
anchorableNodes={anchorableNodes}
|
||||
hasSelectedNode={!!selectedNodeId}
|
||||
onPickNode={(nodeId) => {
|
||||
if (props?.onAddPointAnchor && showAnchorMenu.point !== null) {
|
||||
props.onAddPointAnchor({
|
||||
nodeId,
|
||||
@@ -325,116 +450,22 @@ export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
onCreateNew={() => {
|
||||
if (props?.onCreateNewNode && showAnchorMenu.point !== null) {
|
||||
props.onCreateNewNode({
|
||||
anchorType: "point",
|
||||
start: showAnchorMenu.point,
|
||||
});
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</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 { useTranslations } from "next-intl";
|
||||
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
@@ -23,6 +22,8 @@ export function PublishHomeworkDialog({
|
||||
onPublished,
|
||||
}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||
const [availableAt, setAvailableAt] = useState("");
|
||||
@@ -31,6 +32,7 @@ export function PublishHomeworkDialog({
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handlePublish() {
|
||||
if (!service) return;
|
||||
if (selectedClasses.length === 0) {
|
||||
setError(t("publish.selectClass"));
|
||||
return;
|
||||
@@ -38,7 +40,7 @@ export function PublishHomeworkDialog({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await publishLessonPlanHomeworkAction({
|
||||
const res = await service.publishLessonPlanHomework({
|
||||
planId,
|
||||
blockId,
|
||||
classIds: selectedClasses,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
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 { useDebounce } from "@/shared/hooks/use-debounce"
|
||||
import { X } from "lucide-react"
|
||||
@@ -10,11 +11,16 @@ import { QuestionBankFilters } from "@/shared/components/question/question-bank-
|
||||
import type { ExerciseItem } from "../types"
|
||||
import type { QuestionType } from "@/modules/questions/types"
|
||||
|
||||
interface QuestionRow {
|
||||
id: string
|
||||
type: string
|
||||
difficulty: number
|
||||
content: unknown
|
||||
// 类型守卫:验证字符串是否为有效的 QuestionType(避免 as 断言)
|
||||
function isQuestionType(v: string): v is QuestionType {
|
||||
const validTypes: readonly string[] = [
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"judgment",
|
||||
"text",
|
||||
"composite",
|
||||
]
|
||||
return validTypes.includes(v)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -25,7 +31,9 @@ interface Props {
|
||||
|
||||
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
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 [loading, setLoading] = useState(false)
|
||||
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 [difficultyValue, setDifficultyValue] = useState<string>("all")
|
||||
|
||||
const filters = useMemo<{
|
||||
q?: string
|
||||
type?: QuestionType
|
||||
difficulty?: number
|
||||
}>(() => {
|
||||
const newFilters: {
|
||||
q?: string
|
||||
type?: QuestionType
|
||||
difficulty?: number
|
||||
} = {}
|
||||
const filters = useMemo<QuestionPickerParams>(() => {
|
||||
const newFilters: QuestionPickerParams = {}
|
||||
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)
|
||||
return newFilters
|
||||
}, [searchValue, typeValue, difficultyValue])
|
||||
@@ -55,6 +58,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
const debouncedFilters = useDebounce(filters, 300)
|
||||
|
||||
useEffect(() => {
|
||||
if (!service) return
|
||||
let cancelled = false
|
||||
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
|
||||
Promise.resolve()
|
||||
@@ -62,20 +66,12 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
return getQuestionsAction(debouncedFilters)
|
||||
return service.getQuestions(debouncedFilters)
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled || !res) return
|
||||
if (res.success && res.data) {
|
||||
const data = res.data.data
|
||||
setQuestions(
|
||||
data.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content,
|
||||
})),
|
||||
)
|
||||
setQuestions(res.data.data)
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"))
|
||||
}
|
||||
@@ -91,9 +87,9 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
return () => {
|
||||
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
|
||||
setPicked((prev) => [
|
||||
...prev,
|
||||
|
||||
@@ -3,38 +3,25 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createLessonPlanAction, getTextbooksForPickerAction, getChaptersForPickerAction } from "../actions";
|
||||
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 { SYSTEM_TEMPLATES } from "../constants";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
|
||||
|
||||
interface TextbookOption {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string | null;
|
||||
}
|
||||
|
||||
interface ChapterOption {
|
||||
id: string;
|
||||
title: string;
|
||||
parentId: string | null;
|
||||
order: number | null;
|
||||
content?: string | null;
|
||||
children?: unknown[];
|
||||
}
|
||||
import type { LessonPlanTemplate } from "../types";
|
||||
|
||||
export function TemplatePicker() {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const router = useRouter();
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [textbooks, setTextbooks] = useState<TextbookOption[]>([]);
|
||||
const [textbooks, setTextbooks] = useState<TextbookPickerOption[]>([]);
|
||||
const [textbookId, setTextbookId] = useState<string>("");
|
||||
const [chapters, setChapters] = useState<ChapterOption[]>([]);
|
||||
const [chapters, setChapters] = useState<ChapterPickerOption[]>([]);
|
||||
const [chapterId, setChapterId] = useState<string>(
|
||||
() => searchParams.get("chapterId") ?? "",
|
||||
);
|
||||
@@ -43,14 +30,17 @@ export function TemplatePicker() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
|
||||
// P1-6:个人模板
|
||||
const [personalTemplates, setPersonalTemplates] = useState<LessonPlanTemplate[]>([]);
|
||||
|
||||
// 派生:当前教材的章节是否正在加载
|
||||
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
|
||||
|
||||
// 初始加载教材列表 + URL 参数预选
|
||||
// 初始加载教材列表 + URL 参数预选 + 个人模板(P1-6)
|
||||
useEffect(() => {
|
||||
if (!service) return;
|
||||
let cancelled = false;
|
||||
getTextbooksForPickerAction()
|
||||
service.getTextbooksForPicker()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) {
|
||||
@@ -68,18 +58,31 @@ export function TemplatePicker() {
|
||||
.finally(() => {
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [searchParams]);
|
||||
}, [searchParams, service]);
|
||||
|
||||
// 教材变化时加载章节
|
||||
useEffect(() => {
|
||||
if (!textbookId) {
|
||||
if (!textbookId || !service) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
getChaptersForPickerAction(textbookId)
|
||||
service.getChaptersForPicker(textbookId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) {
|
||||
@@ -93,16 +96,16 @@ export function TemplatePicker() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [textbookId]);
|
||||
}, [textbookId, service]);
|
||||
|
||||
// 扁平化章节列表(用于下拉选择,带缩进前缀)
|
||||
const flattenedChapters = useMemo(() => {
|
||||
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) {
|
||||
result.push({ id: ch.id, title: ch.title, depth });
|
||||
if (ch.children && Array.isArray(ch.children) && ch.children.length > 0) {
|
||||
walk(ch.children as ChapterOption[], depth + 1);
|
||||
walk(ch.children as ChapterPickerOption[], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +133,7 @@ export function TemplatePicker() {
|
||||
const canSubmit = !!selected && !!title && !!textbookId && !!chapterId;
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
if (!service) return;
|
||||
setError(null);
|
||||
if (!textbookId || !chapterId) {
|
||||
setError(t("picker.errorTextbookChapterRequired"));
|
||||
@@ -140,7 +144,7 @@ export function TemplatePicker() {
|
||||
formData.set("textbookId", textbookId);
|
||||
formData.set("chapterId", chapterId);
|
||||
try {
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
const res = await service.createLessonPlan(null, formData);
|
||||
if (res.success && res.data) {
|
||||
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
@@ -247,6 +251,10 @@ export function TemplatePicker() {
|
||||
{/* 步骤 4:模板 */}
|
||||
<div>
|
||||
<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">
|
||||
{SYSTEM_TEMPLATES.map((tpl) => (
|
||||
<button
|
||||
@@ -268,6 +276,43 @@ export function TemplatePicker() {
|
||||
</button>
|
||||
))}
|
||||
</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 && (
|
||||
<p className="text-xs text-on-surface-variant mt-2">
|
||||
{t("picker.skeletonHint")}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
} from "../actions";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -37,6 +33,8 @@ export function VersionHistoryDrawer({
|
||||
onReverted,
|
||||
}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -47,8 +45,9 @@ export function VersionHistoryDrawer({
|
||||
// 用微任务延迟避免同步 setState 触发级联渲染
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
if (!service) return;
|
||||
setLoading(true);
|
||||
getLessonPlanVersionsAction(planId)
|
||||
service.getLessonPlanVersions(planId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) setVersions(res.data.versions);
|
||||
@@ -63,11 +62,12 @@ export function VersionHistoryDrawer({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, planId]);
|
||||
}, [open, planId, service]);
|
||||
|
||||
async function handleRevert(versionNo: number) {
|
||||
if (!service) return;
|
||||
try {
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
const res = await service.revertLessonPlanVersion({ planId, versionNo });
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type {
|
||||
BlackboardBlockData,
|
||||
BlockData,
|
||||
BlockType,
|
||||
ExerciseBlockData,
|
||||
HomeworkBlockData,
|
||||
ImportBlockData,
|
||||
KeyPointBlockData,
|
||||
NewTeachingBlockData,
|
||||
ObjectiveBlockData,
|
||||
ReflectionBlockData,
|
||||
RichTextBlockData,
|
||||
SummaryBlockData,
|
||||
TextStudyBlockData,
|
||||
} 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 { ExerciseBlock } from "../components/blocks/exercise-block";
|
||||
import { TextStudyBlock } from "../components/blocks/text-study-block";
|
||||
@@ -75,92 +77,117 @@ export function isRichTextBlock(type: BlockType): boolean {
|
||||
* 根据 type 从注册表查找并渲染对应 Block,所有组件引用均为模块顶层静态声明,
|
||||
* 满足 react-hooks/static-components 规则。
|
||||
* 新增 Block 类型时,在此 switch 中添加对应 case 即可。
|
||||
*
|
||||
* V3 修复:使用类型守卫替代 `as` 断言,安全收窄 BlockData 联合类型。
|
||||
* 类型守卫失败时返回 null(理论上不会发生,因为 type 与 data 由调用方保证一致)。
|
||||
*/
|
||||
export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null {
|
||||
const { type, ...rest } = props;
|
||||
switch (type) {
|
||||
case "objective":
|
||||
case "objective": {
|
||||
if (!isObjectiveBlockData(rest.data)) return null;
|
||||
return (
|
||||
<ObjectiveBlock
|
||||
data={rest.data as ObjectiveBlockData}
|
||||
data={rest.data}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "key_point":
|
||||
}
|
||||
case "key_point": {
|
||||
if (!isKeyPointBlockData(rest.data)) return null;
|
||||
return (
|
||||
<KeyPointBlock
|
||||
data={rest.data as KeyPointBlockData}
|
||||
data={rest.data}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "import":
|
||||
}
|
||||
case "import": {
|
||||
if (!isImportBlockData(rest.data)) return null;
|
||||
return (
|
||||
<ImportBlock
|
||||
data={rest.data as ImportBlockData}
|
||||
data={rest.data}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "new_teaching":
|
||||
}
|
||||
case "new_teaching": {
|
||||
if (!isNewTeachingBlockData(rest.data)) return null;
|
||||
return (
|
||||
<NewTeachingBlock
|
||||
data={rest.data as NewTeachingBlockData}
|
||||
data={rest.data}
|
||||
textbookId={rest.textbookId}
|
||||
chapterId={rest.chapterId}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "summary":
|
||||
}
|
||||
case "summary": {
|
||||
if (!isSummaryBlockData(rest.data)) return null;
|
||||
return (
|
||||
<SummaryBlock
|
||||
data={rest.data as SummaryBlockData}
|
||||
data={rest.data}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "homework":
|
||||
}
|
||||
case "homework": {
|
||||
if (!isHomeworkBlockData(rest.data)) return null;
|
||||
return (
|
||||
<HomeworkBlock
|
||||
data={rest.data as HomeworkBlockData}
|
||||
data={rest.data}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "blackboard":
|
||||
}
|
||||
case "blackboard": {
|
||||
if (!isBlackboardBlockData(rest.data)) return null;
|
||||
return (
|
||||
<BlackboardBlock
|
||||
data={rest.data as BlackboardBlockData}
|
||||
data={rest.data}
|
||||
textbookId={rest.textbookId}
|
||||
chapterId={rest.chapterId}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "reflection":
|
||||
}
|
||||
case "reflection": {
|
||||
if (!isReflectionBlockData(rest.data)) return null;
|
||||
return (
|
||||
<ReflectionBlock
|
||||
data={rest.data as ReflectionBlockData}
|
||||
data={rest.data}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
case "exercise":
|
||||
}
|
||||
case "exercise": {
|
||||
if (!isExerciseBlockData(rest.data)) return null;
|
||||
return (
|
||||
<ExerciseBlock
|
||||
blockId={rest.blockId}
|
||||
data={rest.data as ExerciseBlockData}
|
||||
data={rest.data}
|
||||
classes={rest.classes ?? []}
|
||||
textbookId={rest.textbookId}
|
||||
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 "consolidation":
|
||||
case "consolidation": {
|
||||
if (!isRichTextBlockData(rest.data)) return null;
|
||||
return (
|
||||
<RichTextBlock
|
||||
data={rest.data as RichTextBlockData}
|
||||
data={rest.data}
|
||||
textbookId={rest.textbookId}
|
||||
chapterId={rest.chapterId}
|
||||
onUpdate={(d) => rest.onUpdate(d)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ export async function getLessonPlansByKnowledgePoint(
|
||||
subjectName: null,
|
||||
gradeName: null,
|
||||
creatorName: null,
|
||||
versionCount: 1,
|
||||
versions: [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -87,5 +89,7 @@ export async function getLessonPlansByQuestion(
|
||||
subjectName: null,
|
||||
gradeName: null,
|
||||
creatorName: null,
|
||||
versionCount: 1,
|
||||
versions: [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ export async function revertToVersion(
|
||||
planId: string,
|
||||
versionNo: number,
|
||||
userId: string,
|
||||
// V3 修复:由 actions 层传入 i18n 翻译的回退标签,避免 data-access 硬编码中文
|
||||
revertLabel: string,
|
||||
): Promise<{ newVersionNo: number } | null> {
|
||||
const content = await getVersionContent(planId, versionNo, userId);
|
||||
if (!content) return null;
|
||||
@@ -138,7 +140,7 @@ export async function revertToVersion(
|
||||
id: createId(),
|
||||
planId,
|
||||
versionNo: newNo,
|
||||
label: `回退到 v${versionNo}`,
|
||||
label: revertLabel,
|
||||
content,
|
||||
isAuto: false,
|
||||
creatorId: userId,
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
LessonPlanListItem,
|
||||
LessonPlanTemplate,
|
||||
LessonPlanStatus,
|
||||
LessonPlanVersionSummary,
|
||||
TemplateType,
|
||||
TemplateScope,
|
||||
} from "./types";
|
||||
@@ -126,6 +127,8 @@ function mapRowToListItem(row: {
|
||||
subjectName: row.subjectName,
|
||||
gradeName: row.gradeName,
|
||||
creatorName: row.creatorName,
|
||||
versionCount: 1,
|
||||
versions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,14 +195,12 @@ function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||
];
|
||||
}
|
||||
case "class_members": {
|
||||
// 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧)
|
||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||
return [publishedFilter];
|
||||
// 学生:仅查看 published 课案
|
||||
return [sql<boolean>`(${lessonPlans.status} = 'published')`];
|
||||
}
|
||||
case "children": {
|
||||
// 家长:仅查看 published 课案(同学生)
|
||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||
return [publishedFilter];
|
||||
// 家长:仅查看 published 课案
|
||||
return [sql<boolean>`(${lessonPlans.status} = 'published')`];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,7 +267,45 @@ export const getLessonPlans = cache(
|
||||
.orderBy(desc(lessonPlans.updatedAt));
|
||||
|
||||
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(
|
||||
planId: string,
|
||||
userId: string,
|
||||
duplicateSuffix: string = " - Copy",
|
||||
): Promise<{ newPlanId: string }> {
|
||||
const src = await getLessonPlanById(planId, userId);
|
||||
if (!src) throw new LessonPlanDataError("NOT_FOUND");
|
||||
@@ -448,7 +527,7 @@ export async function duplicateLessonPlan(
|
||||
const newId = createId();
|
||||
await db.insert(lessonPlans).values({
|
||||
id: newId,
|
||||
title: `${src.title} - 副本`,
|
||||
title: `${src.title}${duplicateSuffix}`,
|
||||
textbookId: src.textbookId,
|
||||
chapterId: src.chapterId,
|
||||
subjectId: src.subjectId,
|
||||
@@ -463,6 +542,29 @@ export async function duplicateLessonPlan(
|
||||
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(
|
||||
templateId: string,
|
||||
|
||||
@@ -31,7 +31,8 @@ interface EditorState {
|
||||
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
||||
|
||||
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;
|
||||
removeNode: (id: string) => void;
|
||||
|
||||
@@ -127,11 +128,14 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
// V3 修复:patch 已排除 type 字段,但 TypeScript 仍会因 spread 拓宽 data 类型
|
||||
// (BlockData 联合不包含 TextbookContentNodeData)而报错,此处 as 为必要断言。
|
||||
// 实际安全:调用方不会对 textbook_content 节点通过 updateNode 传入 data。
|
||||
nodes: s.doc.nodes.map((n) =>
|
||||
n.id === id
|
||||
? n.type === "textbook_content"
|
||||
? { ...n, ...patch } as TextbookContentNode
|
||||
: { ...n, ...patch } as LessonPlanNode
|
||||
? ({ ...n, ...patch } as TextbookContentNode)
|
||||
: ({ ...n, ...patch } as LessonPlanNode)
|
||||
: n,
|
||||
),
|
||||
},
|
||||
@@ -143,11 +147,12 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
// 同 updateNode:spread 后 TypeScript 拓宽类型,需 as 断言收窄
|
||||
nodes: s.doc.nodes.map((n) =>
|
||||
n.id === id
|
||||
? n.type === "textbook_content"
|
||||
? { ...n, position } as TextbookContentNode
|
||||
: { ...n, position } as LessonPlanNode
|
||||
? ({ ...n, position } as TextbookContentNode)
|
||||
: ({ ...n, position } as LessonPlanNode)
|
||||
: n,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -226,28 +226,29 @@ export function buildDefaultSkeleton(
|
||||
translateTitle?: (key: string) => string,
|
||||
): LessonPlanDocument {
|
||||
const textbookContentNodeId = createId();
|
||||
// P2-5:正文节点居中,左右列增大间距避免重叠
|
||||
const textbookNode: TextbookContentNode = {
|
||||
id: textbookContentNodeId,
|
||||
type: "textbook_content",
|
||||
title: "textbook_content",
|
||||
data: { chapterId, content: chapterContent, zoom: 1 },
|
||||
order: -1,
|
||||
position: { x: 400, y: 200 },
|
||||
position: { x: 500, y: 250 },
|
||||
draggable: false,
|
||||
};
|
||||
|
||||
// 默认 10 节点骨架(标题使用 i18n 键 blockType.${type})
|
||||
// P2-5:左列 x=80,右列 x=900,避免与正文节点(宽 480)重叠
|
||||
const skeleton: { type: BlockType; position: { x: number; y: number } }[] = [
|
||||
{ type: "objective", position: { x: 80, y: 80 } },
|
||||
{ type: "key_point", position: { x: 80, y: 200 } },
|
||||
{ type: "import", position: { x: 80, y: 320 } },
|
||||
{ type: "text_study", position: { x: 80, y: 440 } },
|
||||
{ type: "new_teaching", position: { x: 720, y: 80 } },
|
||||
{ type: "exercise", position: { x: 720, y: 200 } },
|
||||
{ type: "summary", position: { x: 720, y: 320 } },
|
||||
{ type: "new_teaching", position: { x: 900, y: 80 } },
|
||||
{ type: "exercise", position: { x: 900, y: 200 } },
|
||||
{ type: "summary", position: { x: 900, y: 320 } },
|
||||
{ type: "homework", position: { x: 80, y: 560 } },
|
||||
{ type: "blackboard", position: { x: 720, y: 440 } },
|
||||
{ type: "reflection", position: { x: 720, y: 560 } },
|
||||
{ type: "blackboard", position: { x: 900, y: 440 } },
|
||||
{ type: "reflection", position: { x: 900, y: 560 } },
|
||||
];
|
||||
|
||||
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 {
|
||||
AnyLessonPlanEdge,
|
||||
AnyLessonPlanNode,
|
||||
LessonPlanNode,
|
||||
NodeAnchor,
|
||||
TextbookContentNode,
|
||||
} from "../types";
|
||||
import { getNodeColor } from "./node-summary";
|
||||
|
||||
/**
|
||||
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
|
||||
@@ -20,6 +19,8 @@ import type {
|
||||
export interface ToRfNodesContext {
|
||||
anchors: NodeAnchor[];
|
||||
selectedNodeId: string | null;
|
||||
/** 可锚定的教学节点列表(P1-1:用于节点选择器)*/
|
||||
anchorableNodes?: { id: string; title: string; type: string }[];
|
||||
onAddRangeAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
@@ -30,8 +31,14 @@ export interface ToRfNodesContext {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
}) => void;
|
||||
/** 创建新节点并锚定(P1-1)*/
|
||||
onCreateNewNode?: (params: {
|
||||
anchorType: "range" | "point";
|
||||
start: number;
|
||||
end?: number;
|
||||
textPreview?: string;
|
||||
}) => void;
|
||||
onSelectNode?: (id: string | null) => void;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
}
|
||||
|
||||
export function toRfNodes(
|
||||
@@ -39,10 +46,25 @@ export function toRfNodes(
|
||||
selectedNodeId: string | null,
|
||||
ctx?: ToRfNodesContext,
|
||||
): 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) => {
|
||||
// 正文节点
|
||||
// 正文节点:n.type === "textbook_content" 已收窄为 TextbookContentNode
|
||||
if (n.type === "textbook_content") {
|
||||
const tbNode = n as TextbookContentNode;
|
||||
const tbNode = n;
|
||||
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(tbNode.id);
|
||||
return {
|
||||
id: tbNode.id,
|
||||
type: "textbook_content",
|
||||
@@ -51,24 +73,28 @@ export function toRfNodes(
|
||||
node: tbNode,
|
||||
anchors: ctx?.anchors ?? [],
|
||||
selectedNodeId,
|
||||
anchorableNodes: ctx?.anchorableNodes ?? [],
|
||||
onAddRangeAnchor: ctx?.onAddRangeAnchor,
|
||||
onAddPointAnchor: ctx?.onAddPointAnchor,
|
||||
onCreateNewNode: ctx?.onCreateNewNode,
|
||||
onSelectNode: ctx?.onSelectNode,
|
||||
onZoomChange: ctx?.onZoomChange,
|
||||
} as Record<string, unknown>,
|
||||
selected: tbNode.id === selectedNodeId,
|
||||
draggable: false,
|
||||
style: isDimmed ? { opacity: 0.3 } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 教学节点
|
||||
const lessonNode = n as LessonPlanNode;
|
||||
// 教学节点:textbook_content 分支已上方 return,此处 n 已收窄为 LessonPlanNode
|
||||
const lessonNode = n;
|
||||
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(lessonNode.id);
|
||||
return {
|
||||
id: lessonNode.id,
|
||||
type: "lesson",
|
||||
position: lessonNode.position,
|
||||
data: { node: lessonNode } as Record<string, unknown>,
|
||||
selected: lessonNode.id === selectedNodeId,
|
||||
style: isDimmed ? { opacity: 0.3 } : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -80,36 +106,39 @@ export function toRfEdges(
|
||||
): Edge[] {
|
||||
return edges.map((e) => {
|
||||
if (e.type === "anchor") {
|
||||
// 锚点边:默认 10% 透明度,选中关联节点时 100%
|
||||
// 锚点边:默认 40% 透明度,选中关联节点时 100%
|
||||
const anchor = anchors.find((a) => a.id === e.anchorId);
|
||||
const isActive = anchor && anchor.nodeId === selectedNodeId;
|
||||
// P1-4 修复:使用锚点关联节点的颜色,而非硬编码蓝色
|
||||
const strokeColor = anchor ? getNodeColor(anchor.nodeId) : "#9e9e9e";
|
||||
return {
|
||||
...e,
|
||||
animated: false,
|
||||
animated: isActive,
|
||||
className: isActive ? "anchor-edge active" : "anchor-edge",
|
||||
// P1-3 修复:将 anchorId 存入 data,fromRfEdges 从 data 读取
|
||||
data: { anchorId: e.anchorId },
|
||||
style: {
|
||||
stroke: anchor ? getNodeColorForAnchor(anchor.nodeId) : "#9e9e9e",
|
||||
strokeWidth: 2,
|
||||
opacity: isActive ? 1 : 0.1,
|
||||
stroke: strokeColor,
|
||||
strokeWidth: isActive ? 3 : 2,
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 流程边
|
||||
const isDimmed = selectedNodeId !== null && e.source !== selectedNodeId && e.target !== selectedNodeId;
|
||||
return {
|
||||
...e,
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
animated: !isDimmed,
|
||||
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 格式。
|
||||
*/
|
||||
@@ -125,12 +154,13 @@ export function fromRfEdges(
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
};
|
||||
|
||||
// 保留原有的 type 信息(通过 className 判断或默认为 flow)
|
||||
if (e.className?.includes("anchor-edge")) {
|
||||
// P1-3 修复:优先从 data.anchorId 读取,回退到 className 判断
|
||||
const dataAnchorId = (e.data as { anchorId?: string } | undefined)?.anchorId;
|
||||
if (dataAnchorId || e.className?.includes("anchor-edge")) {
|
||||
return {
|
||||
...base,
|
||||
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";
|
||||
|
||||
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 注入。
|
||||
* 组件不直接 import actions,只通过此接口调用。
|
||||
*/
|
||||
@@ -18,6 +76,27 @@ export interface LessonPlanDataService {
|
||||
status?: 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<{
|
||||
success: boolean;
|
||||
@@ -36,6 +115,67 @@ export interface LessonPlanDataService {
|
||||
|
||||
/** 删除/归档课案 */
|
||||
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 {
|
||||
const ctx = useContext(LessonPlanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useLessonPlanContext 必须在 LessonPlanProvider 内使用");
|
||||
// V3 修复:开发者错误消息改为英文(非用户可见)
|
||||
throw new Error("useLessonPlanContext must be used within a LessonPlanProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { persistExamDraft, addExamQuestions } from "@/modules/exams/data-access"
|
||||
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
|
||||
import { getStudentIdsByClassIds } from "@/modules/classes/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 {
|
||||
planId: string;
|
||||
@@ -19,6 +20,10 @@ interface PublishInput {
|
||||
classIds: string[];
|
||||
availableAt?: Date;
|
||||
dueAt?: Date;
|
||||
/** 作业标题(由 actions 层 i18n 翻译后传入)*/
|
||||
homeworkTitle: string;
|
||||
/** 作业描述(由 actions 层 i18n 翻译后传入)*/
|
||||
homeworkDescription: string;
|
||||
}
|
||||
|
||||
interface PublishResult {
|
||||
@@ -27,12 +32,6 @@ interface PublishResult {
|
||||
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 错误:使用错误码替代硬编码中文,
|
||||
* 由 actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息。
|
||||
@@ -44,7 +43,8 @@ export type PublishErrorCode =
|
||||
| "NO_QUESTIONS"
|
||||
| "ALREADY_PUBLISHED"
|
||||
| "NO_SUBJECT_OR_GRADE"
|
||||
| "NO_STUDENTS";
|
||||
| "NO_STUDENTS"
|
||||
| "INVALID_QUESTION_TYPE";
|
||||
|
||||
export class PublishServiceError extends Error {
|
||||
constructor(public readonly code: PublishErrorCode) {
|
||||
@@ -64,7 +64,10 @@ export async function publishLessonPlanHomework(
|
||||
.limit(1);
|
||||
if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND");
|
||||
const row = rows[0];
|
||||
// 类型守卫:从 Drizzle 推导类型收窄为 LessonPlan 所需字段
|
||||
// 使用类型守卫收窄(替代 as 断言)
|
||||
const status: LessonPlanStatus = isLessonPlanStatus(row.status)
|
||||
? row.status
|
||||
: "draft";
|
||||
const plan: LessonPlan = {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
@@ -76,7 +79,7 @@ export async function publishLessonPlanHomework(
|
||||
templateId: row.templateId,
|
||||
templateName: row.templateName,
|
||||
content: normalizeDocument(row.content),
|
||||
status: isLessonPlanStatus(row.status) ? row.status : "draft",
|
||||
status,
|
||||
creatorId: row.creatorId,
|
||||
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -85,13 +88,15 @@ export async function publishLessonPlanHomework(
|
||||
if (plan.creatorId !== input.userId)
|
||||
throw new PublishServiceError("NO_PERMISSION");
|
||||
|
||||
// 2. 定位 exercise block
|
||||
// 2. 定位 exercise block(使用类型守卫替代 as 断言)
|
||||
const block = plan.content.nodes.find((b) => b.id === input.blockId);
|
||||
if (!block || block.type !== "exercise")
|
||||
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
||||
const data = block.data as ExerciseBlockData;
|
||||
if (data.items.length === 0) throw new PublishServiceError("NO_QUESTIONS");
|
||||
if (data.publishedAssignmentId)
|
||||
if (!isExerciseBlockData(block.data))
|
||||
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
||||
if (block.data.items.length === 0)
|
||||
throw new PublishServiceError("NO_QUESTIONS");
|
||||
if (block.data.publishedAssignmentId)
|
||||
throw new PublishServiceError("ALREADY_PUBLISHED");
|
||||
|
||||
// 3. inline 题目入库,替换占位 ID
|
||||
@@ -99,22 +104,22 @@ export async function publishLessonPlanHomework(
|
||||
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
|
||||
if (!newBlock || newBlock.type !== "exercise")
|
||||
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++) {
|
||||
const item = newData.items[i];
|
||||
if (item.source === "inline" && item.inlineContent) {
|
||||
// 类型守卫:确保 inline 题目类型合法
|
||||
const validTypes = ["single_choice", "multiple_choice", "text", "judgment", "composite"] as const;
|
||||
const qt = item.inlineContent.type;
|
||||
if (!validTypes.includes(qt as typeof validTypes[number])) {
|
||||
throw new Error(`无效的题目类型: ${qt}`);
|
||||
// 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误)
|
||||
if (!isValidQuestionType(qt)) {
|
||||
throw new PublishServiceError("INVALID_QUESTION_TYPE");
|
||||
}
|
||||
const questionType = qt as typeof validTypes[number];
|
||||
const questionId = await createQuestionWithRelations(
|
||||
{
|
||||
content: item.inlineContent.content,
|
||||
type: questionType,
|
||||
type: qt,
|
||||
difficulty: item.inlineContent.difficulty,
|
||||
knowledgePointIds: item.inlineContent.knowledgePointIds,
|
||||
},
|
||||
@@ -128,19 +133,19 @@ export async function publishLessonPlanHomework(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 打包 exam 草稿
|
||||
// 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入)
|
||||
const examId = createId();
|
||||
if (!plan.subjectId || !plan.gradeId) {
|
||||
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
|
||||
}
|
||||
await persistExamDraft({
|
||||
examId,
|
||||
title: `${plan.title} - 作业`,
|
||||
title: input.homeworkTitle,
|
||||
creatorId: input.userId,
|
||||
subjectId: plan.subjectId,
|
||||
gradeId: plan.gradeId,
|
||||
scheduledAt: undefined,
|
||||
description: `来自课案:${plan.title}`,
|
||||
description: input.homeworkDescription,
|
||||
});
|
||||
// 插入 examQuestions(通过 exams data-access 跨模块接口)
|
||||
await addExamQuestions(
|
||||
@@ -161,8 +166,8 @@ export async function publishLessonPlanHomework(
|
||||
await createHomeworkAssignment({
|
||||
assignmentId,
|
||||
sourceExamId: examId,
|
||||
title: `${plan.title} - 作业`,
|
||||
description: `来自课案:${plan.title}`,
|
||||
title: input.homeworkTitle,
|
||||
description: input.homeworkDescription,
|
||||
structure: null,
|
||||
status: "published",
|
||||
creatorId: input.userId,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// V3 修复:Zod 错误消息使用 i18n 键,由 actions 层通过 translateFieldErrors() 翻译
|
||||
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(),
|
||||
chapterId: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
gradeId: z.string().optional(),
|
||||
templateId: z.string().min(1, "请选择模板"),
|
||||
templateId: z.string().min(1, "error.templateRequired"),
|
||||
});
|
||||
|
||||
export const updateLessonPlanContentSchema = z.object({
|
||||
@@ -47,12 +48,12 @@ export const getKnowledgePointOptionsSchema = z.object({
|
||||
// 发布作业输入校验
|
||||
const dateStringSchema = z
|
||||
.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({
|
||||
planId: 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(),
|
||||
dueAt: dateStringSchema.optional(),
|
||||
});
|
||||
|
||||
@@ -2,17 +2,33 @@
|
||||
|
||||
import {
|
||||
getLessonPlansAction,
|
||||
getLessonPlanByIdAction,
|
||||
updateLessonPlanAction,
|
||||
saveLessonPlanVersionAction,
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
duplicateLessonPlanAction,
|
||||
deleteLessonPlanAction,
|
||||
publishLessonPlanAction,
|
||||
unpublishLessonPlanAction,
|
||||
createLessonPlanAction,
|
||||
getTextbooksForPickerAction,
|
||||
getChaptersForPickerAction,
|
||||
getLessonPlanTemplatesAction,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
* 默认数据服务实现:包装现有 Server Actions。
|
||||
* 通过 LessonPlanProvider 注入,组件不直接 import actions。
|
||||
* 测试时可替换为 mock 实现。
|
||||
*
|
||||
* V3 扩展:新增 picker/dialog 组件所需方法(createLessonPlan / getTextbooksForPicker /
|
||||
* getChaptersForPicker / getLessonPlanTemplates / getKnowledgePointOptions /
|
||||
* publishLessonPlanHomework / getQuestions)。
|
||||
*/
|
||||
export function createDefaultDataService(): LessonPlanDataService {
|
||||
return {
|
||||
@@ -24,6 +40,27 @@ export function createDefaultDataService(): LessonPlanDataService {
|
||||
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) {
|
||||
const res = await getLessonPlanVersionsAction(planId);
|
||||
if (res.success && res.data) {
|
||||
@@ -46,5 +83,83 @@ export function createDefaultDataService(): LessonPlanDataService {
|
||||
const res = await deleteLessonPlanAction(planId);
|
||||
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;
|
||||
}
|
||||
|
||||
// 版本摘要(用于卡片上的版本选择器)
|
||||
export interface LessonPlanVersionSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: LessonPlanStatus;
|
||||
updatedAt: string;
|
||||
lastSavedAt: string | null;
|
||||
}
|
||||
|
||||
// 列表项(带教材/章节名)
|
||||
export interface LessonPlanListItem extends LessonPlan {
|
||||
textbookTitle: string | null;
|
||||
@@ -321,6 +330,10 @@ export interface LessonPlanListItem extends LessonPlan {
|
||||
subjectName: string | null;
|
||||
gradeName: string | null;
|
||||
creatorName: string | null;
|
||||
/** 同一备课的版本数(≥1,=1 表示无多版本)*/
|
||||
versionCount: number;
|
||||
/** 同一备课的所有版本摘要(按 updatedAt 降序)*/
|
||||
versions: LessonPlanVersionSummary[];
|
||||
}
|
||||
|
||||
// ActionState(与项目现有约定一致)
|
||||
|
||||
Reference in New Issue
Block a user