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:
SpecialX
2026-06-24 12:02:42 +08:00
parent a48e7d0e27
commit 6bc113eaff
39 changed files with 2129 additions and 571 deletions

View File

@@ -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",
};

View File

@@ -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,

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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) => (

View File

@@ -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) => (

View File

@@ -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) => (

View File

@@ -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) => (

View File

@@ -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) =>

View File

@@ -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">

View File

@@ -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>
{/* 主区域:画布 + 侧边面板 */}

View File

@@ -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}
/>
);
}

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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}>
&ldquo;{anchor.textPreview}&rdquo;
</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");

View File

@@ -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.dataRecord<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>

View File

@@ -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>
);
}

View File

@@ -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;
// 计算点击位置在纯文本中的偏移
// 简化:使用 caretRangeFromPointChromium或 caretPositionFromPointFirefox
const x = e.clientX;
const y = e.clientY;
let offset = -1;
if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(x, y);
if (pos) offset = pos.offset;
} else if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(x, y);
if (range) offset = range.startOffset;
if (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>
);
});
}

View File

@@ -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>
);
});
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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")}

View File

@@ -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 }));

View File

@@ -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;
}

View File

@@ -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: [],
}));
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
// 同 updateNodespread 后 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,
),
},

View File

@@ -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) => ({

View 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 };
}

View File

@@ -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 存入 datafromRfEdges 从 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,
};
}

View 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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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(),
});

View File

@@ -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 };
},
};
}

View File

@@ -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与项目现有约定一致