feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff

- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
This commit is contained in:
SpecialX
2026-07-03 10:25:21 +08:00
parent a16f09d3c3
commit 20023e13fd
75 changed files with 5131 additions and 1186 deletions

View File

@@ -1,182 +0,0 @@
"use client";
/**
* @deprecated 已被 NodeEditor 替代,保留此文件用于向后兼容。
* 列表式渲染器,使用新的 nodes API。
*/
import {
DndContext,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
GripVertical,
Trash2,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
import { RichTextBlock } from "./blocks/rich-text-block";
import { ExerciseBlock } from "./blocks/exercise-block";
import { TextStudyBlock } from "./blocks/text-study-block";
import { ReflectionBlock } from "./blocks/reflection-block";
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData, ReflectionBlockData } from "../types";
interface BlockRendererProps {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}
function SortableBlock({
node,
index,
total,
textbookId,
chapterId,
classes,
}: {
node: LessonPlanNode;
index: number;
total: number;
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: node.id });
const { updateNode, removeNode } = useLessonPlanEditor();
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type);
return (
<div
ref={setNodeRef}
style={style}
className="border border-outline-variant rounded-lg bg-surface-container-lowest"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-outline-variant bg-surface-container-low">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-outline hover:text-on-surface"
>
<GripVertical className="w-4 h-4" />
</button>
<input
value={node.title}
onChange={(e) => updateNode(node.id, { title: e.target.value })}
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
/>
<button
onClick={() => updateNode(node.id, { order: index - 1 })}
disabled={index === 0}
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={() => updateNode(node.id, { order: index + 1 })}
disabled={index === total - 1}
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
>
<ChevronDown className="w-4 h-4" />
</button>
<button
onClick={() => removeNode(node.id)}
className="p-1 text-error hover:text-error/80"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="p-2">
{isRichText ? (
<RichTextBlock
data={node.data as RichTextBlockData}
textbookId={textbookId}
chapterId={chapterId}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : node.type === "exercise" ? (
<ExerciseBlock
blockId={node.id}
data={node.data as ExerciseBlockData}
classes={classes ?? []}
/>
) : node.type === "text_study" ? (
<TextStudyBlock
blockId={node.id}
data={node.data as TextStudyBlockData}
/>
) : node.type === "reflection" ? (
<ReflectionBlock
data={node.data as ReflectionBlockData}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : (
<div className="text-on-surface-variant text-sm p-4">
block
</div>
)}
</div>
</div>
);
}
export function BlockRenderer({
textbookId,
chapterId,
classes,
}: BlockRendererProps) {
const { doc, updateNode } = useLessonPlanEditor();
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
// 拖拽排序仅更新 order 字段,实际位置由节点图管理
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// 交换 order 并写回 store修复 onDragEnd 未回写 store 的 BUG
const tmpOrder = doc.nodes[oldIndex].order;
updateNode(doc.nodes[oldIndex].id, { order: doc.nodes[newIndex].order });
updateNode(doc.nodes[newIndex].id, { order: tmpOrder });
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={doc.nodes.map((b) => b.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{doc.nodes
.filter((b): b is LessonPlanNode => b.type !== "textbook_content")
.map((b, i) => (
<SortableBlock
key={b.id}
node={b}
index={i}
total={doc.nodes.length}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -6,6 +6,7 @@ import { Tag } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { isBlackboardLayout } from "../../lib/type-guards";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
interface Props {
data: BlackboardBlockData;
@@ -72,13 +73,15 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
</button>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -1,12 +1,14 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { QuestionBankPicker } from "../question-bank-picker";
import { InlineQuestionEditor } from "../inline-question-editor";
import { PublishHomeworkDialog } from "../publish-homework-dialog";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import type {
@@ -120,12 +122,13 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
<span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">
{t("status.publishedAsHomework")}
</span>
<a
{/* V4 P1-5 修复:原生 <a> 替换为 next/link 的 <Link> */}
<Link
href="/teacher/homework"
className="text-primary underline"
>
{t("action.viewHomework")}
</a>
</Link>
</div>
) : (
data.purpose === "after_class_homework" &&
@@ -140,31 +143,37 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
)}
</div>
{showBank && (
<QuestionBankPicker
existingIds={data.items.map((i) => i.questionId)}
onPick={addItems}
onClose={() => setShowBank(false)}
/>
<LessonPlanErrorBoundary>
<QuestionBankPicker
existingIds={data.items.map((i) => i.questionId)}
onPick={addItems}
onClose={() => setShowBank(false)}
/>
</LessonPlanErrorBoundary>
)}
{showInline && (
<InlineQuestionEditor
textbookId={textbookId}
chapterId={chapterId}
onAdd={(item) => {
addItems([item]);
setShowInline(false);
}}
onClose={() => setShowInline(false)}
/>
<LessonPlanErrorBoundary>
<InlineQuestionEditor
textbookId={textbookId}
chapterId={chapterId}
onAdd={(item) => {
addItems([item]);
setShowInline(false);
}}
onClose={() => setShowInline(false)}
/>
</LessonPlanErrorBoundary>
)}
{showPublish && (
<PublishHomeworkDialog
planId={planId}
blockId={blockId}
classes={classes}
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>
<LessonPlanErrorBoundary>
<PublishHomeworkDialog
planId={planId}
blockId={blockId}
classes={classes}
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -43,7 +43,7 @@ export function HomeworkBlock({ data, onUpdate }: Props) {
{t("homework.hint")}
</div>
{data.assignments.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`homework-${idx}`} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) => {

View File

@@ -43,7 +43,7 @@ export function KeyPointBlock({ data, onUpdate }: Props) {
{t("keyPoint.hint")}
</div>
{data.keyPoints.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`key-point-${idx}`} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) => {

View File

@@ -6,6 +6,7 @@ import { Plus, Trash2, Tag } from "lucide-react";
import type { NewTeachingBlockData, NewTeachingPoint } from "../../types";
import { Button } from "@/shared/components/ui/button";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
interface Props {
data: NewTeachingBlockData;
@@ -48,7 +49,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
{t("newTeaching.hint")}
</div>
{data.teachingPoints.map((point, idx) => (
<div key={idx} className="border border-outline-variant rounded p-2 space-y-2">
<div key={`teaching-${idx}`} className="border border-outline-variant rounded p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
{t("newTeaching.pointIndex", { index: idx + 1 })}
@@ -106,13 +107,15 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
{t("newTeaching.addPoint")}
</Button>
{pickerFor !== null && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.teachingPoints[pickerFor]?.knowledgePointIds ?? []}
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
onClose={() => setPickerFor(null)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.teachingPoints[pickerFor]?.knowledgePointIds ?? []}
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
onClose={() => setPickerFor(null)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -46,7 +46,7 @@ export function ObjectiveBlock({ data, onUpdate }: Props) {
{t("objective.hint")}
</div>
{data.objectives.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`objective-${idx}`} className="flex items-start gap-2">
<select
value={item.dimension}
onChange={(e) => {

View File

@@ -43,7 +43,7 @@ export function ReflectionBlock({ data, onUpdate }: Props) {
{t("reflection.hint")}
</div>
{data.reflection.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`reflection-${idx}`} className="flex items-start gap-2">
<select
value={item.aspect}
onChange={(e) => {

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { RichTextBlockData } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { Tag } from "lucide-react";
interface Props {
@@ -70,13 +71,15 @@ export function RichTextBlock({
</button>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -38,7 +38,7 @@ export function SummaryBlock({ data, onUpdate }: Props) {
{t("summary.hint")}
</div>
{data.summaryPoints.map((point, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`summary-${idx}`} className="flex items-start gap-2">
<span className="text-xs text-on-surface-variant mt-1">{idx + 1}.</span>
<input
type="text"

View File

@@ -0,0 +1,417 @@
"use client";
import type { JSX } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { cn } from "@/shared/lib/utils";
import { getCalendarEventsAction } from "../actions-calendar";
import type { LessonPlanCalendarEvent } from "../data-access-calendar";
type ViewMode = "week" | "month";
interface Props {
initialTeacherId: string;
}
const WEEK_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
/** 计算所在周的第一天(周日为起点) */
function getWeekStart(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - d.getDay());
return d;
}
/** 计算所在月的第一天 */
function getMonthStart(date: Date): Date {
const d = new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
return d;
}
/** 计算所在月的最后一天 */
/** 生成日历网格6 行 × 7 列 = 42 天,覆盖整月) */
function buildMonthGrid(monthDate: Date): Date[] {
const start = getMonthStart(monthDate);
const gridStart = getWeekStart(start);
const days: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart);
d.setDate(gridStart.getDate() + i);
days.push(d);
}
return days;
}
/** 生成周历网格7 天) */
function buildWeekGrid(weekDate: Date): Date[] {
const start = getWeekStart(weekDate);
const days: Date[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push(d);
}
return days;
}
function formatDateKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** 事件颜色映射 */
function getEventColor(eventType: LessonPlanCalendarEvent["eventType"]): string {
switch (eventType) {
case "created":
return "bg-blue-100 text-blue-700 border-blue-200";
case "updated":
return "bg-slate-100 text-slate-700 border-slate-200";
case "version_saved":
return "bg-purple-100 text-purple-700 border-purple-200";
case "submitted":
return "bg-amber-100 text-amber-700 border-amber-200";
case "published":
return "bg-emerald-100 text-emerald-700 border-emerald-200";
case "reviewed":
return "bg-indigo-100 text-indigo-700 border-indigo-200";
default:
return "bg-slate-100 text-slate-700 border-slate-200";
}
}
export function CalendarView({ initialTeacherId }: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
const [viewMode, setViewMode] = useState<ViewMode>("week");
const [cursor, setCursor] = useState<Date>(() => new Date());
const [events, setEvents] = useState<LessonPlanCalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
// 计算当前视图的起止日期
const { startDate, endDate } = useMemo(() => {
if (viewMode === "week") {
const start = getWeekStart(cursor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
return { startDate: start, endDate: end };
}
const start = getMonthStart(cursor);
// 月视图扩展为 6 周网格
const gridStart = getWeekStart(start);
const gridEnd = new Date(gridStart);
gridEnd.setDate(gridStart.getDate() + 41);
gridEnd.setHours(23, 59, 59, 999);
return { startDate: gridStart, endDate: gridEnd };
}, [viewMode, cursor]);
// 加载数据
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res = await getCalendarEventsAction({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
if (cancelled) return;
if (res.success && res.data) {
setEvents(res.data.events);
} else {
setError(res.message ?? t("calendar.loadFailed"));
setEvents([]);
}
} catch (e) {
if (cancelled) return;
console.error("[CalendarView] load failed", e);
setError(t("calendar.loadFailed"));
setEvents([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [startDate, endDate, t]);
// 按日期分组事件
const grouped = useMemo(() => {
const map = new Map<string, LessonPlanCalendarEvent[]>();
for (const e of events) {
const key = formatDateKey(e.occurredAt);
const list = map.get(key) ?? [];
list.push(e);
map.set(key, list);
}
return map;
}, [events]);
const days = useMemo(() => {
return viewMode === "week" ? buildWeekGrid(cursor) : buildMonthGrid(cursor);
}, [viewMode, cursor]);
const handlePrev = useCallback(() => {
setCursor((prev) => {
const d = new Date(prev);
if (viewMode === "week") {
d.setDate(d.getDate() - 7);
} else {
d.setMonth(d.getMonth() - 1);
}
return d;
});
}, [viewMode]);
const handleNext = useCallback(() => {
setCursor((prev) => {
const d = new Date(prev);
if (viewMode === "week") {
d.setDate(d.getDate() + 7);
} else {
d.setMonth(d.getMonth() + 1);
}
return d;
});
}, [viewMode]);
const handleToday = useCallback(() => {
setCursor(new Date());
}, []);
const title = useMemo(() => {
const y = cursor.getFullYear();
const m = cursor.getMonth() + 1;
if (viewMode === "month") {
return `${y}-${String(m).padStart(2, "0")}`;
}
const ws = getWeekStart(cursor);
const we = new Date(ws);
we.setDate(ws.getDate() + 6);
return `${ws.getFullYear()}-${String(ws.getMonth() + 1).padStart(2, "0")}-${String(ws.getDate()).padStart(2, "0")} ~ ${we.getFullYear()}-${String(we.getMonth() + 1).padStart(2, "0")}-${String(we.getDate()).padStart(2, "0")}`;
}, [cursor, viewMode]);
// 隐藏未使用变量 lint 警告initialTeacherId 由 Server Component 注入以便未来扩展)
void initialTeacherId;
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={handlePrev} aria-label={t("calendar.prev")}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleNext} aria-label={t("calendar.next")}>
<ChevronRight className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleToday}>
{t("calendar.today")}
</Button>
<span className="ml-2 text-sm font-medium">{title}</span>
</div>
<div className="flex items-center gap-1 border rounded-md p-0.5">
<Button
variant={viewMode === "week" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("week")}
>
{t("calendar.weekView")}
</Button>
<Button
variant={viewMode === "month" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("month")}
>
{t("calendar.monthView")}
</Button>
</div>
</div>
{/* 错误提示 */}
{error !== null && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* 周历视图 */}
{viewMode === "week" ? (
<WeekGrid
days={days}
grouped={grouped}
today={today}
loading={loading}
t={t}
/>
) : (
<MonthGrid
days={days}
cursor={cursor}
grouped={grouped}
today={today}
loading={loading}
t={t}
/>
)}
</div>
);
}
interface GridProps {
days: Date[];
grouped: Map<string, LessonPlanCalendarEvent[]>;
today: Date;
loading: boolean;
t: ReturnType<typeof useTranslations>;
}
function WeekGrid({ days, grouped, today, loading, t }: GridProps): JSX.Element {
return (
<div className="grid grid-cols-7 gap-2">
{days.map((d) => {
const key = formatDateKey(d);
const dayEvents = grouped.get(key) ?? [];
const isToday = isSameDay(d, today);
return (
<div key={key} className="flex flex-col">
<div
className={cn(
"text-center text-xs font-medium pb-1 border-b",
isToday ? "text-primary border-primary" : "text-muted-foreground border-border",
)}
>
{t(`calendar.weekDays.${WEEK_DAY_KEYS[d.getDay()]}`)}
<span className="ml-1">{d.getDate()}</span>
</div>
<div className="flex-1 min-h-[200px] space-y-1 pt-1">
{loading ? (
<Skeleton className="h-8 w-full" />
) : dayEvents.length === 0 ? (
<p className="text-xs text-muted-foreground/60 italic mt-2 text-center">
{t("calendar.noEvents")}
</p>
) : (
dayEvents.slice(0, 6).map((e) => <EventChip key={e.id} event={e} t={t} />)
)}
{dayEvents.length > 6 && (
<p className="text-xs text-muted-foreground">+{dayEvents.length - 6}</p>
)}
</div>
</div>
);
})}
</div>
);
}
function MonthGrid({ days, cursor, grouped, today, loading, t }: GridProps & { cursor: Date }): JSX.Element {
return (
<div className="border rounded-md overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-7 bg-muted/40">
{WEEK_DAY_KEYS.map((dk) => (
<div key={dk} className="text-center text-xs font-medium py-2 border-r last:border-r-0">
{t(`calendar.weekDays.${dk}`)}
</div>
))}
</div>
{/* 日期格子 */}
<div className="grid grid-cols-7">
{days.map((d, i) => {
const key = formatDateKey(d);
const dayEvents = grouped.get(key) ?? [];
const isCurrentMonth = d.getMonth() === cursor.getMonth();
const isToday = isSameDay(d, today);
return (
<div
key={key}
className={cn(
"min-h-[120px] border-r border-b p-1",
(i + 1) % 7 === 0 && "border-r-0",
i >= days.length - 7 && "border-b-0",
!isCurrentMonth && "bg-muted/20",
)}
>
<div
className={cn(
"text-xs font-medium mb-1 inline-flex items-center justify-center w-6 h-6 rounded-full",
isToday ? "bg-primary text-primary-foreground" : "text-muted-foreground",
!isCurrentMonth && "opacity-40",
)}
>
{d.getDate()}
</div>
<div className="space-y-1">
{loading ? (
<Skeleton className="h-4 w-full" />
) : (
dayEvents.slice(0, 3).map((e) => <EventChip key={e.id} event={e} t={t} compact />)
)}
{dayEvents.length > 3 && (
<p className="text-xs text-muted-foreground">+{dayEvents.length - 3}</p>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
interface EventChipProps {
event: LessonPlanCalendarEvent;
t: ReturnType<typeof useTranslations>;
compact?: boolean;
}
function EventChip({ event, t, compact }: EventChipProps): JSX.Element {
const color = getEventColor(event.eventType);
const href = `/teacher/lesson-plans/${event.planId}/edit`;
const label = t(`calendar.eventType.${event.eventType}`);
const meta =
event.eventType === "version_saved" && event.versionNo !== undefined
? t("calendar.eventMeta", { versionNo: event.versionNo })
: null;
return (
<Link
href={href}
className={cn(
"block rounded border px-1.5 py-0.5 text-xs transition-colors hover:opacity-80",
color,
compact && "truncate",
)}
title={`${label}${event.title}${meta ?? ""}`}
>
<span className="font-medium">{label}</span>
<span className="mx-1 opacity-60">·</span>
<span className="truncate">{event.title}</span>
{meta !== null && <span className="ml-1 opacity-70">{meta}</span>}
</Link>
);
}

View File

@@ -0,0 +1,120 @@
import type { JSX } from "react";
import { useMemo } from "react";
import { cn } from "@/shared/lib/utils";
import type { GradeOption, SubjectOption } from "@/modules/school/data-access";
import type { StandardsCoverageCell } from "@/modules/lesson-preparation/data-access-analytics";
interface Props {
grades: GradeOption[];
subjects: SubjectOption[];
heatmap: StandardsCoverageCell[];
}
/** 根据覆盖率返回背景色 */
function getCoverageColor(percent: number): string {
if (percent === 0) return "bg-muted/40 text-muted-foreground";
if (percent < 25) return "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300";
if (percent < 50) return "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300";
if (percent < 75) return "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300";
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300";
}
export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Element {
// 构建查找表subjectId|gradeId -> cell
const cellMap = useMemo(() => {
const map = new Map<string, StandardsCoverageCell>();
for (const cell of heatmap) {
const key = `${cell.subjectId ?? ""}|${cell.gradeId ?? ""}`;
map.set(key, cell);
}
return map;
}, [heatmap]);
if (grades.length === 0 || subjects.length === 0) {
return (
<div className="rounded-lg border p-8 text-center text-muted-foreground">
</div>
);
}
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="border border-border bg-muted/50 p-2 text-left font-medium sticky left-0 z-10 min-w-[100px]">
</th>
{grades.map((g) => (
<th
key={g.id}
className="border border-border bg-muted/50 p-2 text-center font-medium min-w-[80px]"
>
{g.name}
</th>
))}
</tr>
</thead>
<tbody>
{subjects.map((s) => (
<tr key={s.id}>
<td className="border border-border p-2 font-medium sticky left-0 z-10 bg-card min-w-[100px]">
{s.name}
</td>
{grades.map((g) => {
const key = `${s.id}|${g.id}`;
const cell = cellMap.get(key);
const total = cell?.totalPlans ?? 0;
const linked = cell?.standardsLinkedPlans ?? 0;
const percent = cell?.coveragePercent ?? 0;
return (
<td
key={key}
className={cn(
"border border-border p-2 text-center transition-colors hover:opacity-80 cursor-default",
getCoverageColor(percent),
)}
title={`学科:${s.name}\年级:${g.name}\n课案总数${total}\n已关联课标${linked}\n覆盖率${percent}%`}
>
{total === 0 ? (
<span className="text-xs opacity-60"></span>
) : (
<div className="flex flex-col items-center">
<span className="text-lg font-bold">{percent}%</span>
<span className="text-xs opacity-70">
{linked}/{total}
</span>
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* 图例 */}
<div className="flex flex-wrap items-center gap-4 text-xs">
<span className="text-muted-foreground"></span>
<LegendItem color="bg-muted/40" label="无数据" />
<LegendItem color="bg-red-100 dark:bg-red-950/50" label="0-25%" />
<LegendItem color="bg-amber-100 dark:bg-amber-950/50" label="25-50%" />
<LegendItem color="bg-blue-100 dark:bg-blue-950/50" label="50-75%" />
<LegendItem color="bg-emerald-100 dark:bg-emerald-950/50" label="75-100%" />
</div>
</div>
);
}
function LegendItem({ color, label }: { color: string; label: string }): JSX.Element {
return (
<span className="flex items-center gap-1">
<span className={cn("inline-block w-4 h-4 rounded border border-border", color)} />
{label}
</span>
);
}

View File

@@ -5,9 +5,12 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { createId } from "@paralleldrive/cuid2";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { X, Tag } from "lucide-react";
import { KnowledgePointPicker } from "./knowledge-point-picker";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import type { ExerciseItem, InlineQuestionContent } from "../types";
import { VALID_QUESTION_TYPES } from "../lib/type-guards";
interface Props {
onAdd: (item: ExerciseItem) => void;
@@ -16,11 +19,21 @@ interface Props {
chapterId?: string;
}
// V4 P1-10 修复:复用 lib/type-guards 的 VALID_QUESTION_TYPES
// 过滤掉 inline 编辑器不支持的类型multiple_choice / composite 需要复杂 UI
const INLINE_QUESTION_TYPES = VALID_QUESTION_TYPES.filter(
(t): t is "single_choice" | "text" | "judgment" =>
t === "single_choice" || t === "text" || t === "judgment",
);
type InlineQuestionType = (typeof INLINE_QUESTION_TYPES)[number];
function isInlineQuestionType(v: string): v is InlineQuestionType {
return (INLINE_QUESTION_TYPES as readonly string[]).includes(v);
}
export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }: Props) {
const t = useTranslations("lessonPreparation");
const [type, setType] = useState<
"single_choice" | "text" | "judgment"
>("single_choice");
const [type, setType] = useState<InlineQuestionType>("single_choice");
const [difficulty, setDifficulty] = useState(3);
const [text, setText] = useState("");
const [options, setOptions] = useState<string[]>(["", ""]);
@@ -28,12 +41,6 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
const [kpIds, setKpIds] = useState<string[]>([]);
const [showKpPicker, setShowKpPicker] = useState(false);
// 类型守卫:安全地将 string 收窄为联合类型
const QUESTION_TYPES = ["single_choice", "text", "judgment"] as const;
function isQuestionType(v: string): v is "single_choice" | "text" | "judgment" {
return QUESTION_TYPES.includes(v as typeof QUESTION_TYPES[number]);
}
function handleAdd() {
if (!text.trim()) {
toast.error(t("questionBank.stemRequired"));
@@ -70,158 +77,173 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col" role="dialog" aria-modal="true" aria-label={t("questionBank.inlineTitle")}>
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<label htmlFor="inline-question-type" className="text-sm font-medium">
{t("questionBank.typeLabel")}
</label>
<select
id="inline-question-type"
value={type}
onChange={(e) => {
if (isQuestionType(e.target.value)) {
setType(e.target.value);
}
}}
className="w-full border rounded px-2 py-1 mt-1"
>
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
<option value="text">{t("questionBank.type.text")}</option>
<option value="judgment">{t("questionBank.type.judgment")}</option>
</select>
<div
className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t("questionBank.inlineTitle")}
>
{/* P1 修复:包裹 FocusTrap 实现焦点陷阱,支持键盘 Tab 循环与关闭后焦点恢复。
className="contents" 使 FocusTrap 容器不生成盒子,子元素直接参与父级 flex 布局。 */}
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div>
<label htmlFor="inline-question-stem" className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
<textarea
id="inline-question-stem"
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
/>
</div>
{type === "single_choice" && (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<label className="text-sm font-medium">
{t("questionBank.optionsLabel")}
<label htmlFor="inline-question-type" className="text-sm font-medium">
{t("questionBank.typeLabel")}
</label>
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-2 mt-1">
<input
type="radio"
checked={correctIdx === i}
onChange={() => setCorrectIdx(i)}
/>
<input
value={opt}
onChange={(e) =>
setOptions(
options.map((o, j) =>
j === i ? e.target.value : o,
),
)
}
className="flex-1 border rounded px-2 py-1"
/>
{options.length > 2 && (
<button
onClick={() =>
setOptions(options.filter((_, j) => j !== i))
}
>
{t("action.delete")}
</button>
)}
</div>
))}
{options.length < 6 && (
<button
onClick={() => setOptions([...options, ""])}
className="text-sm text-primary mt-1"
>
{t("questionBank.addOption")}
</button>
)}
<select
id="inline-question-type"
value={type}
onChange={(e) => {
if (isInlineQuestionType(e.target.value)) {
setType(e.target.value);
}
}}
className="w-full border rounded px-2 py-1 mt-1"
>
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
<option value="text">{t("questionBank.type.text")}</option>
<option value="judgment">{t("questionBank.type.judgment")}</option>
</select>
</div>
)}
{type === "judgment" && (
<div>
<label className="text-sm font-medium">{t("questionBank.correctAnswer")}</label>
<div className="flex gap-3 mt-1">
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 0}
onChange={() => setCorrectIdx(0)}
/>
{t("questionBank.correct")}
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 1}
onChange={() => setCorrectIdx(1)}
/>
{t("questionBank.incorrect")}
<label htmlFor="inline-question-stem" className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
<textarea
id="inline-question-stem"
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
/>
</div>
{type === "single_choice" && (
<div>
<label className="text-sm font-medium">
{t("questionBank.optionsLabel")}
</label>
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-2 mt-1">
<input
type="radio"
checked={correctIdx === i}
onChange={() => setCorrectIdx(i)}
aria-label={t("questionBank.correctAnswer")}
/>
<input
value={opt}
onChange={(e) =>
setOptions(
options.map((o, j) =>
j === i ? e.target.value : o,
),
)
}
className="flex-1 border rounded px-2 py-1"
aria-label={t("questionBank.optionLabel", { index: i + 1 })}
/>
{options.length > 2 && (
<button
onClick={() =>
setOptions(options.filter((_, j) => j !== i))
}
aria-label={t("action.delete")}
>
{t("action.delete")}
</button>
)}
</div>
))}
{options.length < 6 && (
<button
onClick={() => setOptions([...options, ""])}
className="text-sm text-primary mt-1"
>
{t("questionBank.addOption")}
</button>
)}
</div>
)}
{type === "judgment" && (
<div>
<label className="text-sm font-medium">{t("questionBank.correctAnswer")}</label>
<div className="flex gap-3 mt-1">
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 0}
onChange={() => setCorrectIdx(0)}
/>
{t("questionBank.correct")}
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 1}
onChange={() => setCorrectIdx(1)}
/>
{t("questionBank.incorrect")}
</label>
</div>
</div>
)}
<div>
<label htmlFor="inline-question-difficulty" className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
<select
id="inline-question-difficulty"
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-1"
>
{[1, 2, 3, 4, 5].map((d) => (
<option key={d} value={d}>
{t("questionBank.difficulty", { level: d })}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">{t("questionBank.knowledgePointLabel")}</label>
<div className="flex items-center gap-2 mt-1">
{kpIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.selected", { count: kpIds.length })}
</span>
)}
<button
type="button"
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
aria-label={t("knowledgePoint.select")}
>
<Tag className="w-3 h-3" aria-hidden="true" />
{t("knowledgePoint.select")}
</button>
</div>
</div>
)}
<div>
<label htmlFor="inline-question-difficulty" className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
<select
id="inline-question-difficulty"
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-1"
>
{[1, 2, 3, 4, 5].map((d) => (
<option key={d} value={d}>
{t("questionBank.difficulty", { level: d })}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">{t("questionBank.knowledgePointLabel")}</label>
<div className="flex items-center gap-2 mt-1">
{kpIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.selected", { count: kpIds.length })}
</span>
)}
<button
type="button"
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
{t("knowledgePoint.select")}
</button>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handleAdd}>{t("questionBank.addBtn")}</Button>
</div>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handleAdd}>{t("questionBank.addBtn")}</Button>
</div>
</FocusTrap>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={kpIds}
onChange={setKpIds}
onClose={() => setShowKpPicker(false)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={kpIds}
onChange={setKpIds}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { KnowledgePointSkeleton } from "./lesson-plan-skeleton";
import { X } from "lucide-react";
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
import type { KnowledgePointOption } from "../providers/lesson-plan-provider";
@@ -30,35 +32,38 @@ export function KnowledgePointPicker({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// P1-1 修复ESC 键关闭对话框
useEffect(() => {
if (!textbookId || !service) {
return;
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
useEffect(() => {
if (!textbookId || !service) return;
let cancelled = false;
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
Promise.resolve()
.then(() => {
// V4 P2-3 修复:使用 async IIFE + ignore flag 替代 Promise.resolve().then()
(async () => {
setLoading(true);
setError(null);
try {
const res = await service.getKnowledgePointOptions({ textbookId, chapterId });
if (cancelled) return;
setLoading(true);
setError(null);
return service.getKnowledgePointOptions({ textbookId, chapterId });
})
.then((res) => {
if (cancelled || !res) return;
if (res.success && res.data) {
setOptions(res.data.options);
} else {
setError(res.message ?? t("error.loadFailed"));
}
})
.catch((e) => {
} catch (e) {
if (cancelled) return;
console.error("[KnowledgePointPicker] load options failed", e);
setError(t("error.loadFailed"));
})
.finally(() => {
} finally {
if (!cancelled) setLoading(false);
});
}
})();
return () => {
cancelled = true;
};
@@ -78,6 +83,7 @@ export function KnowledgePointPicker({
aria-label={t("knowledgePoint.title")}
className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
@@ -86,9 +92,7 @@ export function KnowledgePointPicker({
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-on-surface-variant text-sm">
{t("knowledgePoint.loading")}
</p>
<KnowledgePointSkeleton />
) : error ? (
<p className="text-error text-sm">{error}</p>
) : options.length === 0 ? (
@@ -127,6 +131,7 @@ export function KnowledgePointPicker({
{t("action.confirm")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -69,7 +70,8 @@ export function LessonPlanCard({
// 只读视图(非 teacher不显示编辑操作
const isReadOnly = viewMode !== "teacher";
async function handleArchive() {
// P2 修复:使用 useCallback 包裹异步处理函数,避免每次渲染重新创建
const handleArchive = useCallback(async () => {
if (!service) return;
try {
const res = await service.deleteLessonPlan(plan.id);
@@ -84,9 +86,9 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] archive failed", e);
toast.error(t("error.delete"));
}
}
}, [service, plan.id, tracker, t, router]);
async function handleDuplicate() {
const handleDuplicate = useCallback(async () => {
if (!service) return;
try {
const res = await service.duplicateLessonPlan(plan.id);
@@ -100,9 +102,9 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] duplicate failed", e);
toast.error(t("error.duplicate"));
}
}
}, [service, plan.id, tracker, t, router]);
async function handlePublish() {
const handlePublish = useCallback(async () => {
if (!service) return;
try {
const res = await service.publishLessonPlan(plan.id);
@@ -117,9 +119,9 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] publish failed", e);
toast.error(t("error.save"));
}
}
}, [service, plan.id, tracker, t, router]);
async function handleUnpublish() {
const handleUnpublish = useCallback(async () => {
if (!service) return;
try {
const res = await service.unpublishLessonPlan(plan.id);
@@ -134,7 +136,7 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] unpublish failed", e);
toast.error(t("error.save"));
}
}
}, [service, plan.id, tracker, t, router]);
return (
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">

View File

@@ -4,13 +4,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
import { NodeEditPanel } from "./node-edit-panel";
import { NodeEditPanel, type AiContentGeneratorSlot } from "./node-edit-panel";
import { VersionHistoryDrawer } from "./version-history-drawer";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import {
useLessonPlanContextSafe,
useLessonPlanTrackerSafe,
} from "../providers/lesson-plan-provider";
import type { BlockType } from "../types";
import type { BlockType, LessonPlanStatus } from "../types";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
@@ -30,12 +31,14 @@ interface Props {
planId: string;
initialTitle: string;
initialDoc: import("../types").LessonPlanDocument;
initialStatus?: "draft" | "published" | "archived";
initialStatus?: LessonPlanStatus;
textbookId?: string;
chapterId?: string;
textbookTitle?: string;
chapterTitle?: string;
classes?: { id: string; name: string }[];
/** AI 内容生成器(可选,通过 props 注入避免模块耦合P0-11 修复)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
const BLOCK_TYPES_TO_ADD: BlockType[] = [
@@ -63,6 +66,7 @@ export function LessonPlanEditor({
textbookTitle,
chapterTitle,
classes,
aiContentGenerator,
}: Props) {
const t = useTranslations("lessonPreparation");
const editor = useLessonPlanEditor();
@@ -71,7 +75,7 @@ export function LessonPlanEditor({
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
const [planStatus, setPlanStatus] = useState<"draft" | "published" | "archived">(initialStatus);
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -324,7 +328,9 @@ export function LessonPlanEditor({
<div className="flex-1 flex overflow-hidden">
{/* 节点画布 */}
<div className="flex-1 relative">
<NodeEditor />
<LessonPlanErrorBoundary>
<NodeEditor />
</LessonPlanErrorBoundary>
{/* 添加节点浮动按钮 */}
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
<Button
@@ -359,17 +365,20 @@ export function LessonPlanEditor({
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
aiContentGenerator={aiContentGenerator}
/>
</div>
)}
</div>
<VersionHistoryDrawer
open={showVersions}
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
/>
<LessonPlanErrorBoundary>
<VersionHistoryDrawer
open={showVersions}
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
/>
</LessonPlanErrorBoundary>
</div>
);
}

View File

@@ -11,9 +11,11 @@ interface Props {
status?: string;
}) => void;
subjects: { id: string; name: string }[];
// P2 修复:加载状态,禁用输入避免重复请求
isLoading?: boolean;
}
export function LessonPlanFilters({ onFilter, subjects }: Props) {
export function LessonPlanFilters({ onFilter, subjects, isLoading = false }: Props) {
const t = useTranslations("lessonPreparation");
const [query, setQuery] = useState("");
const [subjectId, setSubjectId] = useState<string>("");
@@ -45,7 +47,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
placeholder={t("filters.searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
/>
<label htmlFor="lesson-plan-subject" className="sr-only">
{t("filters.allSubjects")}
@@ -54,7 +58,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
id="lesson-plan-subject"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
>
<option value="">{t("filters.allSubjects")}</option>
{subjects.map((s) => (
@@ -70,7 +76,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
id="lesson-plan-status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
>
<option value="">{t("filters.allStatus")}</option>
<option value="draft">{t("status.draft")}</option>

View File

@@ -24,6 +24,8 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
const t = useTranslations("lessonPreparation");
const [items, setItems] = useState(initialItems);
const [error, setError] = useState<string | null>(null);
// P2 修复:增加筛选加载状态,避免用户重复点击
const [isLoading, setIsLoading] = useState(false);
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
@@ -37,6 +39,7 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
}) => {
setError(null);
if (!service) return;
setIsLoading(true);
try {
const res = await service.getLessonPlans(params);
if (res.success && res.data) {
@@ -47,6 +50,8 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
} catch (e) {
console.error("[LessonPlanList] filter failed", e);
setError(t("error.loadFailed"));
} finally {
setIsLoading(false);
}
},
[service, t],
@@ -54,13 +59,17 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
return (
<div className="space-y-4">
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} isLoading={isLoading} />
{error && (
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
{error}
</p>
)}
{items.length === 0 ? (
{isLoading && items.length === 0 ? (
<p className="text-on-surface-variant text-center py-12">
{t("list.loading")}
</p>
) : items.length === 0 ? (
<p className="text-on-surface-variant text-center py-12">
{t("list.empty")}
</p>

View File

@@ -9,7 +9,6 @@ import {
Controls,
MiniMap,
type Node,
type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LessonNode } from "./nodes/lesson-node";
@@ -43,8 +42,8 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
const rfEdges = useMemo(
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? []),
[doc.edges, doc.anchors, selectedNodeId],
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? [], doc.nodes),
[doc.edges, doc.anchors, selectedNodeId, doc.nodes],
);
// 为正文节点准备 data锚点、选中节点、选择回调
@@ -88,7 +87,7 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
<ReactFlow
nodes={nodesWithData}
edges={rfEdges as Edge[]}
edges={rfEdges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
@@ -97,6 +96,8 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
zoomOnScroll={true}
zoomOnPinch={true}
panOnScroll={false}
zoomOnDoubleClick={false}
selectionOnDrag={false}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}

View File

@@ -1,29 +1,41 @@
"use client";
import { useState } from "react";
import { useState, type ComponentType } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, ChevronDown, ChevronUp } from "lucide-react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { BlockRenderer } from "../config/block-registry";
import { BlockRenderer, BLOCK_REGISTRY } from "../config/block-registry";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
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";
/**
* P0-11 修复AI 内容生成器 slot 类型。
* 通过 props 注入 AI 组件,避免直接 import @/modules/ai。
* 调用方teacher edit 页面)提供具体实现。
*/
export interface AiContentGeneratorSlotProps {
topic: string;
textbookId?: string;
chapterId?: string;
}
export type AiContentGeneratorSlot = ComponentType<AiContentGeneratorSlotProps>;
interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerator }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
useLessonPlanEditor();
const aiClient = useAiClientOptional();
const [showAiPanel, setShowAiPanel] = useState(false);
const node = doc.nodes.find((n) => n.id === selectedNodeId);
@@ -160,8 +172,8 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
<UnknownBlockHint type={lessonNode.type} t={t} />
</LessonPlanErrorBoundary>
{/* AI 内容生成区(可折叠) */}
{aiClient ? (
{/* AI 内容生成区(可折叠)— P0-11 修复:通过 props 注入,不直接 import @/modules/ai */}
{aiContentGenerator ? (
<div className="mt-4 border-t border-outline-variant pt-3">
<Button
variant="ghost"
@@ -182,11 +194,16 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
</Button>
{showAiPanel ? (
<div className="mt-2">
<AiLessonContentGenerator
topic={aiTopic}
textbookId={textbookId}
chapterId={chapterId}
/>
{(() => {
const AiGenerator = aiContentGenerator;
return (
<AiGenerator
topic={aiTopic}
textbookId={textbookId}
chapterId={chapterId}
/>
);
})()}
</div>
) : null}
</div>
@@ -220,12 +237,8 @@ function UnknownBlockHint({
type: string;
t: ReturnType<typeof useTranslations>;
}) {
// 已知类型不显示提示
const knownTypes = [
"objective", "key_point", "import", "new_teaching", "consolidation",
"summary", "homework", "blackboard", "rich_text", "exercise",
"text_study", "reflection",
];
// V4 P1-9 修复knownTypes 从 BLOCK_REGISTRY 派生,消除重复定义
const knownTypes: readonly string[] = Object.keys(BLOCK_REGISTRY);
if (knownTypes.includes(type)) {
return null;
}

View File

@@ -127,8 +127,8 @@ export function NodeEditor({}: Props) {
);
const rfEdges: Edge[] = useMemo(
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors),
[doc.edges, selectedNodeId, doc.anchors],
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors, doc.nodes),
[doc.edges, selectedNodeId, doc.anchors, doc.nodes],
);
const onNodesChange = useCallback(
@@ -221,6 +221,10 @@ export function NodeEditor({}: Props) {
nodesDraggable
edgesFocusable
elementsSelectable
panOnDrag
zoomOnPinch
zoomOnDoubleClick={false}
selectionOnDrag={false}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
defaultEdgeOptions={{

View File

@@ -6,12 +6,31 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { LessonPlanNode } from "../../types";
import { getNodeColor, getNodeSummary, type NodeSummaryT } from "../../lib/node-summary";
// P1 修复:类型守卫替代 as 断言,安全从 React Flow NodeProps.data 收窄
function isLessonNodeData(data: unknown): data is { node: LessonPlanNode } {
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"
);
}
export const LessonNode = memo(function LessonNode({
data,
selected,
}: NodeProps) {
const t = useTranslations("lessonPreparation");
const nodeData = (data as { node: LessonPlanNode }).node;
// P1 修复:使用类型守卫安全收窄,若数据形状不符则渲染空状态避免运行时崩溃
if (!isLessonNodeData(data)) {
return (
<div className="rounded-lg border-2 border-outline-variant bg-surface p-3 text-xs text-on-surface-variant">
{t("editor.unknownBlockType")}
</div>
);
}
const nodeData = data.node;
const color = getNodeColor(nodeData.type);
// 适配 next-intl 的 t 到 NodeSummaryT 接口
const summaryT: NodeSummaryT = (key, values) => t(key, values);

View File

@@ -1,4 +1,4 @@
import type { ReactNode, CSSProperties } from "react";
import type { ReactNode, CSSProperties, KeyboardEvent } from "react";
import type { NodeAnchor } from "../../types";
import {
@@ -14,6 +14,22 @@ interface RenderSegmentsParams {
anchors?: NodeAnchor[];
}
/**
* 键盘激活回调Enter / Space 触发与点击同等的选中逻辑V4 P0-3 修复 a11y
*/
function handleAnchorKeyDown(
e: KeyboardEvent<HTMLSpanElement>,
anchor: NodeAnchor | undefined,
onSelectNode: ((id: string | null) => void) | undefined,
): void {
if (!anchor || !onSelectNode) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelectNode(anchor.nodeId);
}
}
/**
* 渲染锚点段落数组(简化版:直接遍历 segments不使用 ReactMarkdown
* 解决问题 7避免每个段落重复渲染整个文档内容
@@ -33,9 +49,14 @@ export function renderSegments({
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);
// V4 P0-3 修复:锚点 span 添加 role/tabIndex/onKeyDown/aria-label支持键盘导航与读屏
return (
<span
key={idx}
role={anchor ? "button" : undefined}
tabIndex={anchor ? 0 : undefined}
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
aria-pressed={anchor ? isActive : undefined}
className={`range-anchor ${isActive ? "active" : ""}`}
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
style={
@@ -50,6 +71,7 @@ export function renderSegments({
onSelectNode(anchor.nodeId);
}
}}
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
>
{seg.content}
</span>
@@ -62,9 +84,14 @@ export function renderSegments({
const pointIndex = anchor
? (anchors?.filter((a) => a.type === "point").indexOf(anchor) ?? -1) + 1
: 1;
// V4 P0-3 修复point anchor 同样补齐 a11y 属性
return (
<span
key={idx}
role={anchor ? "button" : undefined}
tabIndex={anchor ? 0 : undefined}
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
aria-pressed={anchor ? isActive : undefined}
className={`point-anchor ${isActive ? "active" : ""}`}
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
style={
@@ -79,6 +106,7 @@ export function renderSegments({
onSelectNode(anchor.nodeId);
}
}}
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
>
{toCircledNumber(pointIndex ?? 1)}
</span>

View File

@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { X } from "lucide-react";
interface Props {
@@ -31,6 +32,15 @@ export function PublishHomeworkDialog({
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// P1-1 修复ESC 键关闭对话框
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
async function handlePublish() {
if (!service) return;
if (selectedClasses.length === 0) {
@@ -74,6 +84,7 @@ export function PublishHomeworkDialog({
aria-label={t("publish.title")}
className="bg-surface rounded-lg shadow-xl w-96"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("publish.title")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
@@ -137,6 +148,7 @@ export function PublishHomeworkDialog({
{loading ? t("publish.publishing") : t("publish.publish")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);

View File

@@ -2,9 +2,11 @@
import { useEffect, useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider"
import { useQuestionService } from "../providers/lesson-plan-provider"
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
import { Button } from "@/shared/components/ui/button"
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
import { useDebounce } from "@/shared/hooks/use-debounce"
import { X } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
@@ -31,13 +33,22 @@ interface Props {
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const t = useTranslations("lessonPreparation")
const ctx = useLessonPlanContextSafe()
const service = ctx?.service ?? null
// V4 P0-4 修复:通过 QuestionService 接口获取题目,不直接依赖 questions 模块
const questionService = useQuestionService()
const [questions, setQuestions] = useState<QuestionPickerItem[]>([])
const [picked, setPicked] = useState<ExerciseItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// P1-1 修复ESC 键关闭对话框
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose()
}
document.addEventListener("keydown", handleEsc)
return () => document.removeEventListener("keydown", handleEsc)
}, [onClose])
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
const [searchValue, setSearchValue] = useState("")
const [typeValue, setTypeValue] = useState<string>("all")
@@ -58,36 +69,32 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const debouncedFilters = useDebounce(filters, 300)
useEffect(() => {
if (!service) return
if (!questionService) return
let cancelled = false
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
Promise.resolve()
.then(() => {
// V4 P2-3 修复:使用 async IIFE + ignore flag 替代 Promise.resolve().then()
;(async () => {
setLoading(true)
setError(null)
try {
const res = await questionService.getQuestions(debouncedFilters)
if (cancelled) return
setLoading(true)
setError(null)
return service.getQuestions(debouncedFilters)
})
.then((res) => {
if (cancelled || !res) return
if (res.success && res.data) {
setQuestions(res.data.data)
} else {
setError(res.message ?? t("error.loadFailed"))
}
})
.catch((e) => {
} catch (e) {
if (cancelled) return
console.error("[QuestionBankPicker] load questions failed", e)
setError(t("error.loadFailed"))
})
.finally(() => {
} finally {
if (!cancelled) setLoading(false)
})
}
})()
return () => {
cancelled = true
}
}, [debouncedFilters, t, service])
}, [debouncedFilters, t, questionService])
function add(q: QuestionPickerItem) {
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
@@ -119,6 +126,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
aria-label={t("questionBank.title")}
className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("questionBank.title")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
@@ -138,9 +146,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-on-surface-variant text-sm text-center py-8">
{t("questionBank.loading")}
</p>
<QuestionBankSkeleton />
) : error ? (
<p className="text-error text-sm text-center py-8">{error}</p>
) : questions.length === 0 ? (
@@ -177,6 +183,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
{t("questionBank.insert")}
</Button>
</div>
</FocusTrap>
</div>
</div>
)

View File

@@ -7,6 +7,7 @@ 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 { cn } from "@/shared/lib/utils";
import { SYSTEM_TEMPLATES } from "../constants";
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
import type { LessonPlanTemplate } from "../types";
@@ -105,7 +106,8 @@ export function TemplatePicker() {
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 ChapterPickerOption[], depth + 1);
// P1 修复ChapterPickerOption.children 已改为递归类型,无需 as 断言
walk(ch.children, depth + 1);
}
}
}
@@ -261,11 +263,13 @@ export function TemplatePicker() {
type="button"
key={tpl.id}
onClick={() => setSelected(tpl.id)}
className={`text-left p-4 border-2 rounded-lg transition-colors ${
// V4 P1-7 修复:使用 cn() 替代模板字符串拼接 className
className={cn(
"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"
}`}
: "border-outline-variant hover:border-primary/50",
)}
>
<div className="font-title-md">{t(`template.names.${tpl.id}`)}</div>
<div className="text-sm text-on-surface-variant mt-1">
@@ -292,11 +296,13 @@ export function TemplatePicker() {
type="button"
key={tpl.id}
onClick={() => setSelected(tpl.id)}
className={`text-left p-4 border-2 rounded-lg transition-colors ${
// V4 P1-7 修复:使用 cn() 替代模板字符串拼接 className
className={cn(
"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"
}`}
: "border-outline-variant hover:border-primary/50",
)}
>
<div className="font-title-md flex items-center gap-2">
<span className="truncate">{tpl.name}</span>

View File

@@ -0,0 +1,106 @@
/**
* M11 版本 diff 预览组件
*
* 在 version-history-drawer 中弹出,对比选中版本与当前文档的差异。
* 调用 lib/document-diff.ts 的纯函数计算 diff渲染为彩色段落。
*/
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import {
computeDocumentDiff,
summarizeDiff,
} from "../lib/document-diff";
import type { LessonPlanDocument } from "../types";
import type { LessonPlanVersion } from "../types";
interface Props {
open: boolean;
onClose: () => void;
selectedVersion: LessonPlanVersion | null;
currentDoc: LessonPlanDocument;
}
export function VersionDiffViewer({
open,
onClose,
selectedVersion,
currentDoc,
}: Props) {
const t = useTranslations("lessonPreparation");
const diffSegments = useMemo(() => {
if (!selectedVersion) return [];
const versionContent = selectedVersion.content as unknown as LessonPlanDocument;
return computeDocumentDiff(versionContent, currentDoc);
}, [selectedVersion, currentDoc]);
const summary = useMemo(() => summarizeDiff(diffSegments), [diffSegments]);
if (!open || !selectedVersion) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40">
<div
role="dialog"
aria-modal="true"
aria-label={t("version.diff.title", { versionNo: selectedVersion.versionNo })}
className="bg-surface rounded-lg shadow-xl w-[900px] max-h-[85vh] flex flex-col"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
<div>
<h3 className="font-title-md">
{t("version.diff.title", { versionNo: selectedVersion.versionNo })}
</h3>
<p className="text-xs text-on-surface-variant mt-1">
{t("version.diff.summary", {
added: summary.added,
removed: summary.removed,
unchanged: summary.unchanged,
})}
</p>
</div>
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-sm">
{diffSegments.length === 0 ? (
<p className="text-on-surface-variant text-center py-8">
{t("version.diff.noChanges")}
</p>
) : (
<pre className="whitespace-pre-wrap">
{diffSegments.map((seg, idx) => {
const prefix = seg.type === "added" ? "+ " : seg.type === "removed" ? "- " : " ";
const colorClass =
seg.type === "added"
? "bg-success-container/30 text-success"
: seg.type === "removed"
? "bg-error-container/30 text-error"
: "text-on-surface-variant";
return (
<div key={idx} className={`px-2 py-0.5 ${colorClass}`}>
{prefix}
{seg.content}
</div>
);
})}
</pre>
)}
</div>
<div className="p-4 border-t border-outline-variant flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t("action.close")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);
}

View File

@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { VersionListSkeleton } from "./lesson-plan-skeleton";
import {
AlertDialog,
AlertDialogAction,
@@ -39,26 +41,33 @@ export function VersionHistoryDrawer({
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
const [loading, setLoading] = useState(false);
// P1-1 修复ESC 键关闭抽屉open 时才监听)
useEffect(() => {
if (!open) return;
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [open, onClose]);
useEffect(() => {
if (!open || !service) return;
let cancelled = false;
// 用微任务延迟避免同步 setState 触发级联渲染
queueMicrotask(() => {
if (cancelled) return;
if (!service) return;
// V4 P2-3 修复:使用 async IIFE + ignore flag 替代 queueMicrotask
(async () => {
setLoading(true);
service.getLessonPlanVersions(planId)
.then((res) => {
if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions);
})
.catch((e) => {
console.error("[VersionHistoryDrawer] load versions failed", e);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
});
try {
const res = await service.getLessonPlanVersions(planId);
if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions);
} catch (e) {
if (cancelled) return;
console.error("[VersionHistoryDrawer] load versions failed", e);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
@@ -87,9 +96,10 @@ export function VersionHistoryDrawer({
<div className="fixed inset-0 z-50 flex">
<div className="flex-1 bg-black/30" onClick={onClose} />
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
<FocusTrap className="contents">
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
{loading ? (
<p>{t("version.loading")}</p>
<VersionListSkeleton />
) : versions.length === 0 ? (
<p className="text-on-surface-variant">{t("version.empty")}</p>
) : (
@@ -138,6 +148,7 @@ export function VersionHistoryDrawer({
))}
</div>
)}
</FocusTrap>
</div>
</div>
);