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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
417
src/modules/lesson-preparation/components/calendar-view.tsx
Normal file
417
src/modules/lesson-preparation/components/calendar-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user