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

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