feat(lesson-preparation): major update with AI features, schedules, and new components

- Add actions-schedules.ts and data-access-schedules.ts for schedule management

- Add AI differentiation, AI feedback, consistency check dialogs

- Add attachment-picker, curriculum-heatmap, print-view, version-diff-view

- Add lesson-plan-mobile-view and schedule-dialog components

- Add lib: ai-differentiation, ai-feedback, auto-layout, consistency-check,

  curriculum-coverage, export, version-diff

- Add history-slice hook for version history

- Update existing components, hooks, providers, services, types

- Add teacher lesson-plans heatmap and library pages
This commit is contained in:
SpecialX
2026-07-04 10:22:10 +08:00
parent 41fe8d8903
commit 25dca843be
45 changed files with 5295 additions and 210 deletions

View File

@@ -0,0 +1,379 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import {
X,
Layers,
Loader2,
AlertCircle,
Users,
CheckCircle2,
XCircle,
BookCheck,
ClipboardList,
GraduationCap,
} from "lucide-react";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { Button } from "@/shared/components/ui/button";
import {
generateDifferentiationSuggestionsAction,
checkCurriculumAlignmentAction,
generateExplainableAssessmentAction,
getKnowledgePointsForAlignmentAction,
} from "../actions-ai";
import type { LessonPlanDocument, DifferentiationLevel } from "../types";
import type {
DifferentiationSuggestion,
CurriculumCheckItem,
ExplainableAssessment,
} from "../lib/ai-differentiation";
interface Props {
doc: LessonPlanDocument;
/** 课案所属教材 ID用于 A4 课标核对按需加载知识点) */
textbookId?: string;
onClose: () => void;
}
type TabKey = "differentiation" | "curriculum" | "assessment";
const TABS: TabKey[] = ["differentiation", "curriculum", "assessment"];
/**
* V5-21 A3/A4/A5AI 差异化对话框。
*
* 三个 Tab 分别承载:
* - A3 差异化教学建议(基础/提高/拓展)
* - A4 课标实时核对(按 textbookId 按需加载知识点)
* - A5 可解释评估(练习节点的评分依据)
*/
export function AiDifferentiationDialog({ doc, textbookId, onClose }: Props) {
const t = useTranslations("lessonPreparation");
const [activeTab, setActiveTab] = useState<TabKey>("differentiation");
// ESC 关闭
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
role="dialog"
aria-modal="true"
aria-label={t("aiDifferentiation.title")}
className="bg-surface rounded-lg shadow-xl w-[680px] max-h-[85vh] 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 flex items-center gap-2">
<Layers className="w-4 h-4 text-primary" aria-hidden="true" />
{t("aiDifferentiation.title")}
</h3>
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
{/* Tab 切换 */}
<div className="flex border-b border-outline-variant" role="tablist">
{TABS.map((tab) => (
<button
key={tab}
role="tab"
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
{t(`aiDifferentiation.tabs.${tab}`)}
</button>
))}
</div>
<div className="p-4 overflow-y-auto flex-1">
{activeTab === "differentiation" && (
<DifferentiationTab doc={doc} t={t} />
)}
{activeTab === "curriculum" && (
<CurriculumTab doc={doc} textbookId={textbookId} t={t} />
)}
{activeTab === "assessment" && <AssessmentTab doc={doc} t={t} />}
</div>
<div className="p-3 border-t border-outline-variant flex justify-end">
<Button variant="outline" size="sm" onClick={onClose}>
{t("editor.consistencyClose")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);
}
/** A3差异化教学建议 Tab */
function DifferentiationTab({
doc,
t,
}: {
doc: LessonPlanDocument;
t: ReturnType<typeof useTranslations>;
}) {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<DifferentiationSuggestion[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res = await generateDifferentiationSuggestionsAction(doc);
if (cancelled) return;
if (res.success && res.data) {
setItems(res.data.items);
} else {
setError(res.message ?? t("aiDifferentiation.loadFailed"));
}
} catch {
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [doc, t]);
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
if (error) return <ErrorBlock message={error} />;
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
// 按层次排序basic → intermediate → advanced
const order: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
const sorted = [...items].sort(
(a, b) => order.indexOf(a.level) - order.indexOf(b.level),
);
return (
<div className="space-y-3">
{sorted.map((item) => (
<section
key={item.level}
className="rounded border border-outline-variant p-3 bg-surface"
>
<h4 className="text-sm font-medium flex items-center gap-1.5 mb-2">
<Users className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
{t(`aiDifferentiation.level.${item.level}`)}
<span className="text-xs text-on-surface-variant"> {item.targetStudents}</span>
</h4>
<ul className="space-y-1.5 text-sm text-on-surface-variant">
{item.suggestions.map((s, idx) => (
<li key={idx} className="flex items-start gap-1.5">
<span className="text-primary mt-1">·</span>
<span>{s}</span>
</li>
))}
</ul>
</section>
))}
</div>
);
}
/** A4课标实时核对 Tab按 textbookId 按需加载知识点) */
function CurriculumTab({
doc,
textbookId,
t,
}: {
doc: LessonPlanDocument;
textbookId?: string;
t: ReturnType<typeof useTranslations>;
}) {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<CurriculumCheckItem[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
// 无教材 ID 时无法获取知识点
if (!textbookId) {
setLoading(false);
setError(t("aiDifferentiation.noKnowledgePoints"));
return;
}
setLoading(true);
setError(null);
try {
// 先按需加载知识点列表
const kpRes = await getKnowledgePointsForAlignmentAction(textbookId);
if (cancelled) return;
if (!kpRes.success || !kpRes.data || kpRes.data.items.length === 0) {
setError(t("aiDifferentiation.noKnowledgePoints"));
return;
}
// 再调用课标核对
const res = await checkCurriculumAlignmentAction(doc, kpRes.data.items);
if (cancelled) return;
if (res.success && res.data) {
setItems(res.data.items);
} else {
setError(res.message ?? t("aiDifferentiation.loadFailed"));
}
} catch {
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [doc, textbookId, t]);
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
if (error) return <ErrorBlock message={error} />;
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
const covered = items.filter((i) => i.covered).length;
const missed = items.length - covered;
return (
<div className="space-y-3">
<div className="flex items-center gap-3 text-sm">
<span className="inline-flex items-center gap-1 text-primary">
<CheckCircle2 className="w-3.5 h-3.5" aria-hidden="true" />
{t("aiDifferentiation.covered", { count: covered })}
</span>
<span className="inline-flex items-center gap-1 text-tertiary">
<XCircle className="w-3.5 h-3.5" aria-hidden="true" />
{t("aiDifferentiation.missed", { count: missed })}
</span>
</div>
<ul className="space-y-2">
{items.map((item, idx) => (
<li
key={idx}
className="rounded border border-outline-variant p-2.5 bg-surface text-sm"
>
<div className="flex items-center gap-1.5 font-medium">
{item.covered ? (
<CheckCircle2 className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
) : (
<XCircle className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />
)}
<span>{item.requirement}</span>
</div>
{item.explanation && (
<p className="text-xs text-on-surface-variant mt-1 ml-5">{item.explanation}</p>
)}
</li>
))}
</ul>
</div>
);
}
/** A5可解释评估 Tab */
function AssessmentTab({
doc,
t,
}: {
doc: LessonPlanDocument;
t: ReturnType<typeof useTranslations>;
}) {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<ExplainableAssessment[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res = await generateExplainableAssessmentAction(doc);
if (cancelled) return;
if (res.success && res.data) {
setItems(res.data.items);
} else {
setError(res.message ?? t("aiDifferentiation.loadFailed"));
}
} catch {
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [doc, t]);
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
if (error) return <ErrorBlock message={error} />;
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
return (
<ul className="space-y-2">
{items.map((item, idx) => (
<li
key={idx}
className="rounded border border-outline-variant p-2.5 bg-surface text-sm"
>
<div className="font-medium flex items-center gap-1.5">
<BookCheck className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
<span>{item.conclusion}</span>
</div>
{item.rationale && (
<p className="text-xs text-on-surface-variant mt-1 ml-5 flex items-start gap-1">
<ClipboardList className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
<span>{item.rationale}</span>
</p>
)}
{item.suggestion && (
<p className="text-xs text-on-surface-variant mt-1 ml-5 flex items-start gap-1">
<GraduationCap className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
<span>{item.suggestion}</span>
</p>
)}
</li>
))}
</ul>
);
}
function LoadingBlock({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Loader2 className="w-6 h-6 animate-spin text-primary" aria-hidden="true" />
<p className="text-sm text-on-surface-variant">{label}</p>
</div>
);
}
function ErrorBlock({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 p-3 rounded border border-error/40 bg-error-container/30 text-sm">
<AlertCircle className="w-4 h-4 text-error" aria-hidden="true" />
{message}
</div>
);
}
function EmptyBlock({ label }: { label: string }) {
return <div className="text-sm text-on-surface-variant text-center py-6">{label}</div>;
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Sparkles, Loader2, AlertCircle, Lightbulb, CheckCircle2, Target, Users } from "lucide-react";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { Button } from "@/shared/components/ui/button";
import { generateLessonPlanFeedbackAction } from "../actions-ai";
import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback";
import type { LessonPlanDocument } from "../types";
interface Props {
doc: LessonPlanDocument;
onClose: () => void;
}
/** V5-17 A1/A2AI 反馈闭环对话框(含解释性展示) */
export function AiFeedbackDialog({ doc, onClose }: Props) {
const t = useTranslations("lessonPreparation");
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<AiFeedbackResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res = await generateLessonPlanFeedbackAction(doc);
if (cancelled) return;
if (res.success && res.data) {
setResult(res.data);
} else {
setError(res.message ?? t("feedback.loadFailed"));
}
} catch {
if (!cancelled) setError(t("feedback.loadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [doc, t]);
// ESC 关闭
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
role="dialog"
aria-modal="true"
aria-label={t("feedback.title")}
className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[85vh] 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 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" aria-hidden="true" />
{t("feedback.title")}
</h3>
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-4 overflow-y-auto flex-1">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Loader2 className="w-6 h-6 animate-spin text-primary" aria-hidden="true" />
<p className="text-sm text-on-surface-variant">{t("feedback.loading")}</p>
</div>
) : error ? (
<div className="flex items-center gap-2 p-3 rounded border border-error/40 bg-error-container/30 text-sm">
<AlertCircle className="w-4 h-4 text-error" aria-hidden="true" />
{error}
</div>
) : result ? (
<FeedbackContent result={result} t={t} />
) : null}
</div>
<div className="p-3 border-t border-outline-variant flex justify-end">
<Button variant="outline" size="sm" onClick={onClose}>
{t("editor.consistencyClose")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);
}
function FeedbackContent({
result,
t,
}: {
result: AiFeedbackResult;
t: ReturnType<typeof useTranslations>;
}) {
// 按维度分组
const grouped: Record<AiFeedbackItem["category"], AiFeedbackItem[]> = {
strengths: [],
improvements: [],
alignment: [],
differentiation: [],
};
for (const item of result.items) {
grouped[item.category].push(item);
}
return (
<div className="space-y-4">
{/* 摘要 + 评分 */}
<div className="flex items-center justify-between rounded border border-outline-variant p-3 bg-surface-container-low">
<div className="flex-1">
<p className="text-sm font-medium">{t("feedback.summary")}</p>
<p className="text-xs text-on-surface-variant mt-1">{result.summary}</p>
</div>
<div className="text-right ml-3">
<div className="text-2xl font-bold text-primary">{result.overallScore}</div>
<div className="text-xs text-on-surface-variant">{t("feedback.score")}</div>
</div>
</div>
{/* 各维度反馈 */}
{(Object.keys(grouped) as AiFeedbackItem["category"][]).map((cat) => {
const items = grouped[cat];
if (items.length === 0) return null;
return (
<section key={cat}>
<h4 className="text-sm font-medium flex items-center gap-1.5 mb-2">
<CategoryIcon category={cat} />
{t(`feedback.category.${cat}`)}
<span className="text-xs text-on-surface-variant">({items.length})</span>
</h4>
<ul className="space-y-2">
{items.map((item, idx) => (
<li
key={idx}
className="rounded border border-outline-variant p-2.5 bg-surface"
>
<div className="text-sm font-medium">{item.title}</div>
{/* A2解释性展示 — reason 字段说明 AI 判断依据 */}
{item.reason && (
<div className="text-xs text-on-surface-variant mt-1 flex items-start gap-1">
<Lightbulb className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
<span>{item.reason}</span>
</div>
)}
</li>
))}
</ul>
</section>
);
})}
{result.items.length === 0 && (
<div className="text-sm text-on-surface-variant text-center py-6">
{t("feedback.empty")}
</div>
)}
</div>
);
}
function CategoryIcon({ category }: { category: AiFeedbackItem["category"] }) {
const icon = {
strengths: <CheckCircle2 className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
improvements: <AlertCircle className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />,
alignment: <Target className="w-3.5 h-3.5 text-secondary" aria-hidden="true" />,
differentiation: <Users className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
}[category];
return icon;
}

View File

@@ -0,0 +1,281 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Upload, FileText, Image as ImageIcon, Music, Video, Trash2 } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { useFileUpload } from "@/modules/files/hooks/use-file-upload";
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
import type { LessonPlanAttachmentOption } from "../providers/lesson-plan-provider";
import type { RichTextAttachment } from "../types";
import { toast } from "sonner";
interface Props {
planId: string;
blockId?: string;
/** 当前节点已嵌入的附件(用于高亮已选) */
selectedIds?: string[];
/** 选择附件后的回调(嵌入到富文本) */
onSelect: (attachment: RichTextAttachment) => void;
onClose: () => void;
}
/** V5-5根据 MIME 类型推断渲染种类(供调用方使用) */
export function inferAttachmentKind(
mimeType: string | undefined,
): RichTextAttachment["kind"] {
if (!mimeType) return "file";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("video/")) return "video";
return "file";
}
function kindIcon(kind: RichTextAttachment["kind"]) {
switch (kind) {
case "image":
return <ImageIcon className="w-4 h-4" aria-hidden="true" />;
case "audio":
return <Music className="w-4 h-4" aria-hidden="true" />;
case "video":
return <Video className="w-4 h-4" aria-hidden="true" />;
default:
return <FileText className="w-4 h-4" aria-hidden="true" />;
}
}
/**
* V5-5素材库 Picker
*
* 功能:
* 1. 上传新文件(复用 use-file-upload hook调用 /api/upload
* 2. 上传成功后调用 service.createLessonPlanAttachment 落库到 lessonPlanAttachments 表
* 3. 列出当前课案已上传的所有附件service.getLessonPlanAttachments
* 4. 点击附件 → onSelect 回调,由调用方决定如何嵌入
*
* 不直接 import actions通过 LessonPlanContext 注入的 service 调用。
*/
export function AttachmentPicker({
planId,
blockId,
selectedIds = [],
onSelect,
onClose,
}: Props) {
const t = useTranslations("lessonPreparation");
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
const [items, setItems] = useState<LessonPlanAttachmentOption[]>([]);
const [loading, setLoading] = useState(true);
// V5-5复用项目已有 use-file-upload hook
const { inputRef, handleFiles, tasks } = useFileUpload({
targetType: "lesson_plan",
targetId: planId,
multiple: true,
onUploaded: async (result) => {
if (!service) return;
// 上传成功后创建附件记录
const res = await service.createLessonPlanAttachment({
planId,
blockId,
fileId: result.id,
displayName: result.originalName,
attachmentType: "material",
});
if (res.success) {
toast.success(t("attachment.createSuccess"));
// 刷新附件列表
void loadAttachments();
} else {
toast.error(res.message ?? t("error.save"));
}
},
});
// V5-5加载附件列表
const loadAttachments = useCallback(async () => {
if (!service) return;
setLoading(true);
try {
const res = await service.getLessonPlanAttachments(planId);
if (res.success && res.data) {
setItems(res.data.items);
}
} catch (e) {
console.error("[AttachmentPicker] load failed", e);
toast.error(t("attachment.empty"));
} finally {
setLoading(false);
}
}, [service, planId, t]);
useEffect(() => {
void loadAttachments();
}, [loadAttachments]);
// V5-5ESC 关闭
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
// V5-5删除附件
async function handleDelete(attachmentId: string) {
if (!service) return;
try {
const res = await service.deleteLessonPlanAttachment(attachmentId);
if (res.success) {
toast.success(t("attachment.deleteSuccess"));
void loadAttachments();
} else {
toast.error(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[AttachmentPicker] delete failed", e);
toast.error(t("error.save"));
}
}
// V5-5选择附件嵌入富文本
function handleSelect(item: LessonPlanAttachmentOption) {
const attachment: RichTextAttachment = {
attachmentId: item.id,
fileId: item.fileId,
displayName: item.displayName,
kind: inferAttachmentKind(item.mimeType),
url: item.url ?? `/api/files/${item.fileId}`,
mimeType: item.mimeType,
};
onSelect(attachment);
onClose();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
role="dialog"
aria-modal="true"
aria-label={t("attachment.title")}
className="bg-surface rounded-lg shadow-xl w-[640px] 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("attachment.title")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-4 overflow-y-auto flex-1 space-y-4">
{/* 上传区域 */}
<div className="border-2 border-dashed border-outline-variant rounded p-4 text-center">
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
<Button
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-1" /> {t("attachment.add")}
</Button>
{/* 上传任务进度 */}
{tasks.length > 0 && (
<div className="mt-3 space-y-1 text-xs text-left">
{tasks.map((task) => (
<div
key={`${task.file.name}-${task.file.size}`}
className="flex items-center gap-2"
>
<span className="flex-1 truncate">{task.file.name}</span>
<span
className={
task.status === "error"
? "text-error"
: task.status === "success"
? "text-primary"
: "text-on-surface-variant"
}
>
{task.status === "error"
? t("attachment.uploadFailed")
: task.status === "success"
? t("attachment.uploadSuccess")
: `${task.progress}%`}
</span>
</div>
))}
</div>
)}
</div>
{/* 已上传附件列表 */}
<div>
<label className="text-sm font-medium block mb-2">
{t("attachment.libraryLabel")}
</label>
{loading ? (
<p className="text-sm text-on-surface-variant text-center py-4">
{t("version.loading")}
</p>
) : items.length === 0 ? (
<p className="text-sm text-on-surface-variant text-center py-4">
{t("attachment.empty")}
</p>
) : (
<ul className="space-y-1">
{items.map((item) => {
const isSelected = selectedIds.includes(item.id);
return (
<li
key={item.id}
className={`flex items-center gap-2 border rounded p-2 ${
isSelected ? "border-primary bg-primary/5" : "border-outline-variant"
}`}
>
<span className="text-on-surface-variant">
{kindIcon(inferAttachmentKind(item.mimeType))}
</span>
<button
className="flex-1 text-left text-sm hover:underline"
onClick={() => handleSelect(item)}
>
{item.displayName}
</button>
<span className="text-xs text-on-surface-variant">
{t(`attachment.type.${item.attachmentType}`)}
</span>
<button
className="text-error hover:bg-error/10 p-1 rounded"
onClick={() => void handleDelete(item.id)}
aria-label={t("attachment.delete")}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
<div className="p-4 border-t flex justify-end">
<Button variant="outline" onClick={onClose}>
{t("action.close")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Tag } from "lucide-react";
import { Tag, Eye, Pencil } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { isBlackboardLayout } from "../../lib/type-guards";
import { KnowledgePointPicker } from "../knowledge-point-picker";
@@ -17,47 +17,98 @@ interface Props {
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
/**
* V5-14 F4板书可视化工具。
*
* 在原有纯文本编辑基础上增加轻量级可视化预览(不引入新库):
* - structure结构式按行解析缩进渲染为带连接线的层级树
* - mindmap思维导图第一行为中心其余为分支节点
* - text文字式等宽字体直接展示
*
* 编辑/预览模式切换,避免双栏占满侧边面板。
*/
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [showKpPicker, setShowKpPicker] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("blackboard.hint")}
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.layoutLabel")}
</label>
<select
value={data.layout}
onChange={(e) => {
const value = e.target.value;
if (isBlackboardLayout(value)) {
onUpdate({ ...data, layout: value });
}
}}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
>
{LAYOUTS.map((l) => (
<option key={l} value={l}>
{t(`blackboard.layout.${l}`)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.contentLabel")}
</label>
<textarea
value={data.content}
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[120px] font-mono"
placeholder={t("blackboard.contentPlaceholder")}
/>
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="text-xs font-medium block mb-1">
{t("blackboard.layoutLabel")}
</label>
<select
value={data.layout}
onChange={(e) => {
const value = e.target.value;
if (isBlackboardLayout(value)) {
onUpdate({ ...data, layout: value });
}
}}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
>
{LAYOUTS.map((l) => (
<option key={l} value={l}>
{t(`blackboard.layout.${l}`)}
</option>
))}
</select>
</div>
{/* 编辑/预览切换 */}
<div className="flex border border-outline-variant rounded overflow-hidden self-end">
<button
type="button"
onClick={() => setPreviewMode(false)}
aria-pressed={!previewMode}
className={`px-2 py-1 text-xs inline-flex items-center gap-1 ${
!previewMode ? "bg-primary text-on-primary" : "bg-surface text-on-surface-variant"
}`}
title={t("blackboard.editMode")}
>
<Pencil className="w-3 h-3" aria-hidden="true" />
{t("blackboard.editMode")}
</button>
<button
type="button"
onClick={() => setPreviewMode(true)}
aria-pressed={previewMode}
className={`px-2 py-1 text-xs inline-flex items-center gap-1 ${
previewMode ? "bg-primary text-on-primary" : "bg-surface text-on-surface-variant"
}`}
title={t("blackboard.previewMode")}
>
<Eye className="w-3 h-3" aria-hidden="true" />
{t("blackboard.previewMode")}
</button>
</div>
</div>
{/* 内容区:编辑模式 or 预览模式 */}
{previewMode ? (
<BlackboardPreview content={data.content} layout={data.layout} t={t} />
) : (
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.contentLabel")}
</label>
<textarea
value={data.content}
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[120px] font-mono"
placeholder={t("blackboard.contentPlaceholder")}
/>
<p className="text-xs text-on-surface-variant mt-1">
{t("blackboard.editHint")}
</p>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{data.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
@@ -86,3 +137,116 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
</div>
);
}
/**
* 板书可视化预览。纯 CSS + 文本解析,不引入新库。
*
* - structure按行解析前导空格/Tab 缩进,渲染为带竖线的层级树
* - mindmap第一行为中心节点其余为放射分支
* - text等宽字体直接展示
*/
function BlackboardPreview({
content,
layout,
t,
}: {
content: string;
layout: BlackboardBlockData["layout"];
t: ReturnType<typeof useTranslations>;
}) {
const parsed = useMemo(() => parseContent(content, layout), [content, layout]);
if (!content.trim()) {
return (
<div className="text-sm text-on-surface-variant text-center py-6 border border-dashed border-outline-variant rounded">
{t("blackboard.previewEmpty")}
</div>
);
}
if (layout === "text") {
return (
<pre
className="text-sm font-mono whitespace-pre-wrap border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]"
>
{content}
</pre>
);
}
if (layout === "mindmap") {
const center = parsed[0]?.text ?? "";
const branches = parsed.slice(1);
return (
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
{/* 中心节点 */}
<div className="flex justify-center mb-3">
<span className="px-3 py-1 rounded-full bg-primary text-on-primary text-sm font-medium text-center max-w-[80%]">
{center || t("blackboard.untitled")}
</span>
</div>
{/* 分支节点 */}
{branches.length > 0 && (
<ul className="space-y-1.5">
{branches.map((node, idx) => (
<li
key={idx}
className="flex items-center gap-2 text-sm"
style={{ paddingLeft: `${node.level * 12}px` }}
>
<span className="text-primary" aria-hidden="true"></span>
<span className="px-2 py-0.5 rounded bg-surface border border-outline-variant">
{node.text}
</span>
</li>
))}
</ul>
)}
</div>
);
}
// structure层级树
return (
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
<ul className="space-y-1">
{parsed.map((node, idx) => (
<li
key={idx}
className="flex items-start gap-1 text-sm"
style={{ paddingLeft: `${node.level * 14}px` }}
>
<span className="text-on-surface-variant mt-0.5" aria-hidden="true">
{node.level === 0 ? "●" : "├"}
</span>
<span className={node.level === 0 ? "font-medium" : ""}>
{node.text}
</span>
</li>
))}
</ul>
</div>
);
}
/** 解析文本为节点列表(按缩进识别层级) */
interface ParsedNode {
level: number;
text: string;
}
function parseContent(
content: string,
_layout: BlackboardBlockData["layout"],
): ParsedNode[] {
const lines = content.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.trim());
return lines.map((line) => {
// 前导空格(每 2 空格或 1 Tab 算一级)
const leadingMatch = line.match(/^[\t ]*/);
const leading = leadingMatch ? leadingMatch[0] : "";
const tabs = (leading.match(/\t/g) ?? []).length;
const spaces = (leading.match(/ /g) ?? []).length;
const level = tabs + spaces;
return { level, text: line.trim() };
});
}

View File

@@ -170,6 +170,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
planId={planId}
blockId={blockId}
classes={classes}
items={data.items}
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>

View File

@@ -3,18 +3,24 @@
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { RichTextBlockData } from "../../types";
import type { RichTextBlockData, RichTextAttachment } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { AttachmentPicker } from "../attachment-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { Tag } from "lucide-react";
import { Tag, Paperclip, Trash2 } from "lucide-react";
interface Props {
data: RichTextBlockData;
hint?: string;
textbookId?: string;
chapterId?: string;
/** V5-5当前课案 ID用于附件库 picker */
planId?: string;
/** V5-5当前 block ID用于附件关联 */
blockId?: string;
onUpdate: (data: RichTextBlockData) => void;
}
@@ -23,6 +29,8 @@ export function RichTextBlock({
hint,
textbookId,
chapterId,
planId,
blockId,
onUpdate,
}: Props) {
const t = useTranslations("lessonPreparation");
@@ -30,6 +38,14 @@ export function RichTextBlock({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: hint ?? t("richText.placeholder") }),
// V5-5集成 Tiptap Image 扩展,支持图片直接嵌入富文本
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: {
class: "rich-text-image max-w-full h-auto rounded",
},
}),
],
content: data.html,
immediatelyRender: false,
@@ -52,6 +68,33 @@ export function RichTextBlock({
}, [data.html, editor]);
const [showKpPicker, setShowKpPicker] = useState(false);
const [showAttachmentPicker, setShowAttachmentPicker] = useState(false); // V5-5
// V5-5从素材库选择附件后插入到富文本图片用 Image 命令,其余追加到 attachments 列表)
function handleSelectAttachment(attachment: RichTextAttachment) {
const currentAttachments = data.attachments ?? [];
if (attachment.kind === "image" && editor) {
// 图片直接插入富文本
editor.commands.setImage({ src: attachment.url, alt: attachment.displayName });
}
// 所有附件(含图片)都记录到 attachments 列表,便于后续管理与素材库复用
onUpdate({
...data,
attachments: [...currentAttachments, attachment],
});
}
// V5-5移除已嵌入的附件
function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = data.attachments ?? [];
onUpdate({
...data,
attachments: currentAttachments.filter((a) => a.attachmentId !== attachmentId),
});
}
// V5-5渲染附件区块
const attachments = data.attachments ?? [];
return (
<div>
@@ -69,7 +112,79 @@ export function RichTextBlock({
<Tag className="w-3 h-3" />
{t("knowledgePoint.annotate")}
</button>
{/* V5-5素材库入口 */}
{planId && (
<button
onClick={() => setShowAttachmentPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Paperclip className="w-3 h-3" />
{t("attachment.insert")}
</button>
)}
</div>
{/* V5-5已嵌入附件列表非图片附件在此展示图片已在富文本中渲染 */}
{attachments.length > 0 && (
<div className="mt-2 px-3 space-y-1">
<div className="text-xs text-on-surface-variant">
{t("attachment.embeddedCount", { count: attachments.length })}
</div>
{attachments
.filter((a) => a.kind !== "image")
.map((a) => (
<div
key={a.attachmentId}
className="flex items-center gap-2 text-xs border border-outline-variant rounded p-1"
>
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 hover:underline truncate"
>
{a.displayName}
</a>
<span className="text-on-surface-variant">{a.kind}</span>
<button
onClick={() => handleRemoveAttachment(a.attachmentId)}
className="text-error hover:bg-error/10 p-0.5 rounded"
aria-label={t("attachment.remove")}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
</div>
))}
{/* V5-5图片附件单独展示缩略图即使已在富文本中内联此处也展示便于管理 */}
{attachments.some((a) => a.kind === "image") && (
<div className="flex flex-wrap gap-2">
{attachments
.filter((a) => a.kind === "image")
.map((a) => (
<div
key={a.attachmentId}
className="relative group border border-outline-variant rounded"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={a.url}
alt={a.displayName}
className="w-16 h-16 object-cover rounded"
/>
<button
onClick={() => handleRemoveAttachment(a.attachmentId)}
className="absolute top-0 right-0 bg-error text-on-error rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={t("attachment.remove")}
>
<Trash2 className="w-2.5 h-2.5" aria-hidden="true" />
</button>
</div>
))}
</div>
)}
</div>
)}
{showKpPicker && (
<LessonPlanErrorBoundary>
<KnowledgePointPicker
@@ -81,6 +196,18 @@ export function RichTextBlock({
/>
</LessonPlanErrorBoundary>
)}
{/* V5-5素材库 picker */}
{showAttachmentPicker && planId && (
<LessonPlanErrorBoundary>
<AttachmentPicker
planId={planId}
blockId={blockId}
selectedIds={attachments.map((a) => a.attachmentId)}
onSelect={handleSelectAttachment}
onClose={() => setShowAttachmentPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { X, AlertTriangle, CheckCircle2 } from "lucide-react";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import type { LessonPlanDocument } from "../types";
import { checkConsistency, hasConsistencyWarnings, type ConsistencyIssue } from "../lib/consistency-check";
import { cn } from "@/shared/lib/utils";
interface Props {
doc: LessonPlanDocument;
onClose: () => void;
}
/**
* V5-19 T3教学评一致性校验对话框。
*
* 以纯函数 `checkConsistency` 计算结果,仅做 UI 展示。
* 不修改文档,不阻断保存。
*/
export function ConsistencyCheckDialog({ doc, onClose }: Props) {
const t = useTranslations("lessonPreparation");
const result = useMemo(() => checkConsistency(doc), [doc]);
const hasWarnings = hasConsistencyWarnings(result);
// ESC 关闭
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
role="dialog"
aria-modal="true"
aria-label={t("consistency.title")}
className="bg-surface rounded-lg shadow-xl w-[520px] max-h-[80vh] 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 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-primary" aria-hidden="true" />
{t("consistency.title")}
</h3>
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-4 overflow-y-auto space-y-4">
{/* 顶部统计 */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded border border-outline-variant p-2">
<div className="text-xs text-on-surface-variant">
{t("editor.consistencyObjectiveCount", { count: result.objectiveCount })}
</div>
</div>
<div className="rounded border border-outline-variant p-2">
<div className="text-xs text-on-surface-variant">
{t("editor.consistencyExerciseCount", { count: result.exerciseCount })}
</div>
</div>
<div className="rounded border border-outline-variant p-2">
<div className="text-xs text-on-surface-variant">
{t("consistency.coverage", {
covered: result.coveredObjectiveCount,
total: result.objectiveCount,
})}
</div>
</div>
</div>
{/* 一致性分数 */}
<div className="flex items-center justify-between rounded border border-outline-variant p-3">
<span className="text-sm font-medium">{t("consistency.score", { score: result.score })}</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded font-medium",
result.score >= 80
? "bg-primary-container text-on-primary-container"
: result.score >= 50
? "bg-secondary-container text-on-secondary-container"
: "bg-error-container text-on-error-container",
)}
>
{result.score}
</span>
</div>
{/* 问题列表 */}
<div>
<div className="text-xs font-medium text-on-surface-variant mb-2">
{hasWarnings ? t("consistency.title") : t("consistency.noIssues")}
</div>
{hasWarnings ? (
<ul className="space-y-1.5">
{result.issues.map((issue, idx) => (
<IssueItem key={`${issue.code}-${idx}`} issue={issue} t={t} />
))}
</ul>
) : (
<div className="flex items-center gap-2 text-sm text-on-surface-variant p-3 rounded border border-outline-variant bg-surface-container-low">
<CheckCircle2 className="w-4 h-4 text-primary" aria-hidden="true" />
{t("consistency.noIssues")}
</div>
)}
</div>
</div>
<div className="p-3 border-t border-outline-variant flex justify-end">
<button
onClick={onClose}
className="text-sm px-3 py-1.5 rounded border border-outline-variant hover:bg-surface-container-low"
>
{t("editor.consistencyClose")}
</button>
</div>
</FocusTrap>
</div>
</div>
);
}
function IssueItem({
issue,
t,
}: {
issue: ConsistencyIssue;
t: ReturnType<typeof useTranslations>;
}) {
const isWarning = issue.severity === "warning";
const message = t(`consistency.code.${issue.code}`, {
title: issue.params?.title ?? issue.nodeTitle ?? "",
});
return (
<li
className={cn(
"flex items-start gap-2 px-2 py-1.5 rounded border text-sm",
isWarning
? "border-error/40 bg-error-container/30"
: "border-outline-variant bg-surface-container-low",
)}
>
<AlertTriangle
className={cn("w-3.5 h-3.5 mt-0.5 flex-shrink-0", isWarning ? "text-error" : "text-on-surface-variant")}
aria-hidden="true"
/>
<span className="flex-1">{message}</span>
</li>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { AlertTriangle } from "lucide-react";
import {
computeCurriculumCoverage,
getHeatLevel,
type PlanKpLink,
} from "../lib/curriculum-coverage";
import type { KnowledgePoint } from "@/modules/textbooks/types";
import { cn } from "@/shared/lib/utils";
interface Props {
allKps: KnowledgePoint[];
planLinks: PlanKpLink[];
/** 章节 ID → 章节名映射 */
chapterNames: Record<string, string>;
}
/** V5-20 T4课标覆盖度热力图组件 */
export function CurriculumHeatmap({ allKps, planLinks, chapterNames }: Props) {
const t = useTranslations("lessonPreparation");
const result = useMemo(
() => computeCurriculumCoverage(allKps, planLinks),
[allKps, planLinks],
);
return (
<div className="space-y-4">
{/* 顶部统计条 */}
<div className="grid grid-cols-3 gap-3">
<StatCard
label={t("heatmap.totalKps")}
value={result.totalKps}
/>
<StatCard
label={t("heatmap.coveredKps")}
value={result.coveredKps}
/>
<StatCard
label={t("heatmap.coverageRate")}
value={`${result.overallCoverageRate}%`}
highlight
/>
</div>
{/* 教学盲点警告 */}
{result.blindSpots.length > 0 && (
<div className="flex items-start gap-2 p-3 rounded border border-error/40 bg-error-container/30">
<AlertTriangle className="w-4 h-4 text-error mt-0.5 flex-shrink-0" aria-hidden="true" />
<div className="text-sm">
<div className="font-medium text-error">
{t("heatmap.blindSpotTitle", { count: result.blindSpots.length })}
</div>
<div className="text-xs text-on-surface-variant mt-1">
{result.blindSpots.slice(0, 5).map((b) => b.kpName).join("、")}
{result.blindSpots.length > 5 && t("heatmap.andMore")}
</div>
</div>
</div>
)}
{/* 按章节展开的热力图 */}
<div className="space-y-3">
{result.chapters.map((ch) => {
const level = getHeatLevel(ch.coverageRate);
const chapterName = chapterNames[ch.chapterId] ?? t("heatmap.unknownChapter");
return (
<section key={ch.chapterId} className="border border-outline-variant rounded-lg overflow-hidden">
{/* 章节头部带热力色块 */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<span
className={cn("inline-block w-3 h-3 rounded-sm", heatColorClass(level))}
aria-hidden="true"
/>
<span className="font-medium text-sm">{chapterName}</span>
</div>
<span className="text-xs text-on-surface-variant">
{t("heatmap.chapterCoverage", {
covered: ch.coveredKps,
total: ch.totalKps,
rate: ch.coverageRate,
})}
</span>
</div>
{/* 知识点列表 */}
<ul className="divide-y divide-outline-variant">
{ch.kps.map((kp) => {
const kpLevel = getHeatLevel(kp.planCount === 0 ? 0 : Math.min(100, kp.planCount * 33));
return (
<li
key={kp.kpId}
className="flex items-center justify-between px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span
className={cn("inline-block w-2 h-2 rounded-full flex-shrink-0", heatColorClass(kpLevel))}
aria-hidden="true"
/>
<span className="truncate">{kp.kpName}</span>
</div>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded flex-shrink-0",
kp.isBlindSpot
? "bg-error-container/40 text-error"
: "bg-surface-container-highest text-on-surface-variant",
)}
>
{kp.isBlindSpot
? t("heatmap.notCovered")
: t("heatmap.planCount", { count: kp.planCount })}
</span>
</li>
);
})}
</ul>
</section>
);
})}
</div>
</div>
);
}
function StatCard({
label,
value,
highlight,
}: {
label: string;
value: string | number;
highlight?: boolean;
}) {
return (
<div
className={cn(
"rounded border p-3 text-center",
highlight
? "border-primary/40 bg-primary-container/20"
: "border-outline-variant bg-surface",
)}
>
<div className="text-xl font-bold">{value}</div>
<div className="text-xs text-on-surface-variant mt-0.5">{label}</div>
</div>
);
}
/** 热力等级 → Tailwind 颜色类 */
function heatColorClass(level: 0 | 1 | 2 | 3 | 4): string {
return [
"bg-error/60",
"bg-tertiary/40",
"bg-tertiary/70",
"bg-primary/60",
"bg-primary",
][level];
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
@@ -24,8 +24,14 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
import { Plus, Save, History, Book, FileText, Send, Undo2 } from "lucide-react";
import { Plus, Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
import { toast } from "sonner";
import { PrintView } from "./print-view";
import { ScheduleDialog } from "./schedule-dialog";
import { ConsistencyCheckDialog } from "./consistency-check-dialog";
import { AiFeedbackDialog } from "./ai-feedback-dialog";
import { AiDifferentiationDialog } from "./ai-differentiation-dialog";
import type { LessonPlan, LessonPlanDocument } from "../types";
interface Props {
planId: string;
@@ -75,6 +81,11 @@ export function LessonPlanEditor({
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
const [showPrint, setShowPrint] = useState(false); // V5-4打印视图
const [showSchedule, setShowSchedule] = useState(false); // V5-7安排课时对话框
const [showConsistency, setShowConsistency] = useState(false); // V5-19一致性校验对话框
const [showAiFeedback, setShowAiFeedback] = useState(false); // V5-17AI 反馈对话框
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21AI 差异化对话框
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -90,9 +101,11 @@ export function LessonPlanEditor({
// 自动保存debounce 3s- 用 getState() 获取最新值(修复 P1-4
// V3 修复:完全通过 service 调用,不直接 import actions
// V5-1 修复:保存失败显示 toast + 设置 saveError断网时不触发保存
useEffect(() => {
if (!editor.isDirty) return;
if (!service) return;
if (!editor.isOnline) return; // V5-1断网期间不触发保存请求
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
@@ -103,9 +116,18 @@ export function LessonPlanEditor({
title: state.title,
content: state.doc,
});
if (res.success) state.markSaved();
if (res.success) {
// V5-1从失败恢复到成功时提示
if (state.saveError) toast.success(t("status.recovered"));
state.markSaved();
} else {
state.setSaveError(true);
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
}
} catch (e) {
console.error("[LessonPlanEditor] auto-save failed", e);
state.setSaveError(true);
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
} finally {
state.setSaving(false);
}
@@ -113,7 +135,98 @@ export function LessonPlanEditor({
return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
};
}, [editor.isDirty, editor.doc, planId, service]);
}, [editor.isDirty, editor.doc, planId, service, editor.isOnline, t]);
// V5-1监听网络在线/离线状态
useEffect(() => {
function handleOnline() {
useLessonPlanEditor.getState().setOnline(true);
toast.success(t("status.backOnline"));
}
function handleOffline() {
useLessonPlanEditor.getState().setOnline(false);
toast.error(t("status.offline"), { description: t("status.offlineHint") });
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, [t]);
// V5-1手动重试保存saveError 时显示按钮)
const handleRetrySave = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
state.setSaving(true);
try {
const res = await service.updateLessonPlan({
planId: state.planId,
title: state.title,
content: state.doc,
});
if (res.success) {
toast.success(t("status.recovered"));
state.markSaved();
} else {
toast.error(t("status.saveFailed"));
}
} catch (e) {
console.error("[LessonPlanEditor] retry save failed", e);
toast.error(t("status.saveFailed"));
} finally {
state.setSaving(false);
}
}, [service, t]);
// V5-2撤销/重做快捷键Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z 或 Cmd/Ctrl+Y
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const isMod = e.metaKey || e.ctrlKey;
if (!isMod) return;
const key = e.key.toLowerCase();
if (key === "z" && !e.shiftKey) {
e.preventDefault();
const state = useLessonPlanEditor.getState();
if (state.canUndo()) state.undo();
} else if ((key === "z" && e.shiftKey) || key === "y") {
e.preventDefault();
const state = useLessonPlanEditor.getState();
if (state.canRedo()) state.redo();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// V5-2撤销/重做按钮处理(追踪 canUndo/canRedo 以驱动 disabled 状态)
const canUndo = editor.canUndo();
const canRedo = editor.canRedo();
const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []);
const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []);
// V5-4构造打印用的 LessonPlan 对象(编辑器仅持有 planId/title/doc其余字段填默认值
const printablePlan: LessonPlan = useMemo(() => {
const doc: LessonPlanDocument = editor.doc;
return {
id: planId,
title: editor.title,
textbookId: textbookId ?? null,
chapterId: chapterId ?? null,
coursePlanItemId: null,
subjectId: null,
gradeId: null,
templateId: null,
templateName: null,
content: doc,
status: planStatus,
creatorId: "",
lastSavedAt: editor.lastSavedAt ? new Date(editor.lastSavedAt).toISOString() : null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}, [planId, editor.title, editor.doc, editor.lastSavedAt, textbookId, chapterId, planStatus]);
// 定时自动版本30min
useEffect(() => {
@@ -266,6 +379,25 @@ export function LessonPlanEditor({
? t("status.unsaved")
: t("status.saved")}
</span>
{/* V5-1保存失败/离线提示与重试按钮 */}
{editor.saveError && (
<Button
variant="outline"
size="sm"
onClick={handleRetrySave}
disabled={editor.isSaving}
className="text-error border-error"
>
<RotateCw className="w-3 h-3 mr-1" />
{t("status.retrySave")}
</Button>
)}
{!editor.isOnline && (
<span className="text-xs text-error inline-flex items-center gap-1">
<WifiOff className="w-3 h-3" />
{t("status.offlineBadge")}
</span>
)}
<Button
variant="outline"
size="sm"
@@ -273,9 +405,77 @@ export function LessonPlanEditor({
>
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
</Button>
{/* V5-2撤销/重做按钮 */}
<Button
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
aria-label={t("action.undo")}
title={t("action.undoShortcut")}
>
<Undo className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
aria-label={t("action.redo")}
title={t("action.redoShortcut")}
>
<Redo className="w-4 h-4" />
</Button>
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
</Button>
{/* V5-4导出/打印按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowPrint(true)}
aria-label={t("export.title")}
>
<Printer className="w-4 h-4 mr-1" /> {t("export.button")}
</Button>
{/* V5-7安排课时按钮 */}
{classes && classes.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowSchedule(true)}
aria-label={t("action.scheduleLesson")}
>
<CalendarClock className="w-4 h-4 mr-1" /> {t("action.scheduleLesson")}
</Button>
)}
{/* V5-19 T3一致性校验按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowConsistency(true)}
aria-label={t("editor.consistencyOpen")}
>
<ClipboardCheck className="w-4 h-4 mr-1" /> {t("editor.consistencyOpen")}
</Button>
{/* V5-17 A1/A2AI 反馈按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowAiFeedback(true)}
aria-label={t("feedback.open")}
>
<Sparkles className="w-4 h-4 mr-1" /> {t("feedback.open")}
</Button>
{/* V5-21 A3/A4/A5AI 差异化与课标核对按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowAiDifferentiation(true)}
aria-label={t("aiDifferentiation.open")}
>
<Layers className="w-4 h-4 mr-1" /> {t("aiDifferentiation.open")}
</Button>
{/* 发布/撤回发布按钮P0-1 修复)*/}
{planStatus === "published" ? (
<AlertDialog>
@@ -362,6 +562,7 @@ export function LessonPlanEditor({
{editor.selectedNodeId && (
<div className="w-[420px] flex-shrink-0">
<NodeEditPanel
planId={planId}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
@@ -377,8 +578,63 @@ export function LessonPlanEditor({
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
currentDoc={editor.doc}
/>
</LessonPlanErrorBoundary>
{/* V5-4导出/打印视图 */}
{showPrint && (
<LessonPlanErrorBoundary>
<PrintView
plan={printablePlan}
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
onClose={() => setShowPrint(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-7安排课时对话框 */}
{showSchedule && classes && classes.length > 0 && (
<LessonPlanErrorBoundary>
<ScheduleDialog
planId={planId}
classes={classes}
onClose={() => setShowSchedule(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-19 T3一致性校验对话框 */}
{showConsistency && (
<LessonPlanErrorBoundary>
<ConsistencyCheckDialog
doc={editor.doc}
onClose={() => setShowConsistency(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-17 A1/A2AI 反馈对话框 */}
{showAiFeedback && (
<LessonPlanErrorBoundary>
<AiFeedbackDialog
doc={editor.doc}
onClose={() => setShowAiFeedback(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-21 A3/A4/A5AI 差异化与课标核对对话框 */}
{showAiDifferentiation && (
<LessonPlanErrorBoundary>
<AiDifferentiationDialog
doc={editor.doc}
textbookId={textbookId}
onClose={() => setShowAiDifferentiation(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Book } from "lucide-react";
import type {
LessonPlanDocument,
LessonPlanNode,
TeachingStage,
} from "../types";
import { TEACHING_STAGE_KEYS } from "../types";
import { getNodeColor, getNodeSummary } from "../lib/node-summary";
import { cn } from "@/shared/lib/utils";
interface Props {
doc: LessonPlanDocument;
textbookTitle?: string;
chapterTitle?: string;
}
/**
* V5-13 P3移动端只读视图。
*
* 在小屏设备上以线性卡片列表替代 React Flow 画布,
* 按教学阶段V5-15分组展示节点提升可读性。
* 仅展示,无编辑交互。
*/
export function LessonPlanMobileView({ doc, textbookTitle, chapterTitle }: Props) {
const t = useTranslations("lessonPreparation");
// 教学节点按 stage 分组(未归类的归入"其他"
const grouped = useMemo(() => {
const teachingNodes = doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
const groups: Record<string, LessonPlanNode[]> = {};
const unstaged: LessonPlanNode[] = [];
for (const n of teachingNodes) {
if (n.stage) {
const key = n.stage as TeachingStage;
if (!groups[key]) groups[key] = [];
groups[key].push(n);
} else {
unstaged.push(n);
}
}
return { groups, unstaged };
}, [doc.nodes]);
return (
<div className="flex flex-col h-full overflow-y-auto bg-surface-container-low">
{/* 顶部信息条 */}
{(textbookTitle || chapterTitle) && (
<div className="sticky top-0 z-10 bg-surface/95 backdrop-blur border-b border-outline-variant px-4 py-2 text-sm">
{textbookTitle && (
<div className="flex items-center gap-1 text-on-surface-variant">
<Book className="w-3.5 h-3.5" aria-hidden="true" />
<span className="font-medium">{textbookTitle}</span>
</div>
)}
{chapterTitle && (
<div className="text-on-surface-variant text-xs mt-0.5">{chapterTitle}</div>
)}
</div>
)}
<div className="flex-1 px-3 py-3 space-y-4">
{/* 按阶段分组渲染 */}
{TEACHING_STAGE_KEYS.map((stage) => {
const nodes = grouped.groups[stage];
if (!nodes || nodes.length === 0) return null;
return (
<section key={stage} className="space-y-2">
<h3 className="text-xs font-medium text-on-surface-variant px-1">
{t(`editor.stage.${stage}`)}
</h3>
{nodes.map((n) => (
<MobileNodeCard key={n.id} node={n} t={t} />
))}
</section>
);
})}
{/* 未归类节点 */}
{grouped.unstaged.length > 0 && (
<section className="space-y-2">
{TEACHING_STAGE_KEYS.some((s) => grouped.groups[s]?.length > 0) && (
<h3 className="text-xs font-medium text-on-surface-variant px-1">
{t("editor.stageNone")}
</h3>
)}
{grouped.unstaged.map((n) => (
<MobileNodeCard key={n.id} node={n} t={t} />
))}
</section>
)}
</div>
</div>
);
}
function MobileNodeCard({
node,
t,
}: {
node: LessonPlanNode;
t: ReturnType<typeof useTranslations>;
}) {
const color = getNodeColor(node.type);
const summary = getNodeSummary(node, (key, values) => t(key, values));
const diff = node.differentiation;
return (
<article className="rounded-lg border border-outline-variant bg-surface p-3 shadow-sm">
<div className="flex items-start gap-2">
<span
className="inline-block w-2.5 h-2.5 rounded-full mt-1.5 flex-shrink-0"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="font-medium text-sm text-on-surface truncate flex-1">
{node.title || node.type}
</h4>
{diff && (
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0",
diff === "basic"
? "bg-primary-container text-on-primary-container"
: diff === "intermediate"
? "bg-secondary-container text-on-secondary-container"
: "bg-tertiary-container text-on-tertiary-container",
)}
>
{t(`editor.differentiation.${diff}`)}
</span>
)}
</div>
{summary && (
<p className="text-xs text-on-surface-variant mt-1 line-clamp-3">
{summary}
</p>
)}
</div>
</div>
</article>
);
}

View File

@@ -15,6 +15,8 @@ import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
import { getNodeColor } from "../lib/node-summary";
import { useMediaQuery } from "@/shared/hooks/use-media-query";
import { LessonPlanMobileView } from "./lesson-plan-mobile-view";
import type { LessonPlanDocument } from "../types";
const nodeTypes = {
@@ -39,6 +41,8 @@ interface Props {
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
const t = useTranslations("lessonPreparation");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
// V5-13 P3小屏设备使用线性移动视图替代画布
const isMobile = useMediaQuery("(max-width: 768px)");
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
const rfEdges = useMemo(
@@ -65,6 +69,13 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
});
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
// V5-13 P3移动端渲染线性视图
if (isMobile) {
return (
<LessonPlanMobileView doc={doc} textbookTitle={textbookTitle} chapterTitle={chapterTitle} />
);
}
return (
<div className="h-full w-full relative">
{/* 顶部信息条 */}

View File

@@ -9,6 +9,8 @@ import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
import { getNodeColor } from "../lib/node-summary";
import type { TeachingStage, DifferentiationLevel } from "../types";
import { TEACHING_STAGE_KEYS, DIFFERENTIATION_LEVEL_KEYS } from "../types";
/**
* P0-11 修复AI 内容生成器 slot 类型。
@@ -27,11 +29,13 @@ interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
/** V5-5当前课案 ID透传给 RichTextBlock 用于素材库 picker */
planId?: string;
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerator }: Props) {
export function NodeEditPanel({ textbookId, chapterId, classes, planId, aiContentGenerator }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
@@ -156,6 +160,55 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
</Button>
</div>
{/* V5-15 T1 + V5-18 W6节点属性条教学阶段 + 差异化标记) */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface-container-low">
{/* 教学阶段 */}
<label className="text-xs text-on-surface-variant flex items-center gap-1">
{t("editor.stageLabel")}
</label>
<select
value={lessonNode.stage ?? ""}
onChange={(e) => {
const v = e.target.value;
updateNode(lessonNode.id, {
stage: v === "" ? undefined : (v as TeachingStage),
});
}}
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
aria-label={t("editor.stageLabel")}
>
<option value="">{t("editor.stageNone")}</option>
{TEACHING_STAGE_KEYS.map((s) => (
<option key={s} value={s}>
{t(`editor.stage.${s}`)}
</option>
))}
</select>
{/* 差异化标记 */}
<label className="text-xs text-on-surface-variant flex items-center gap-1 ml-2">
{t("editor.differentiationLabel")}
</label>
<select
value={lessonNode.differentiation ?? ""}
onChange={(e) => {
const v = e.target.value;
updateNode(lessonNode.id, {
differentiation: v === "" ? undefined : (v as DifferentiationLevel),
});
}}
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
aria-label={t("editor.differentiationLabel")}
>
<option value="">{t("editor.differentiationNone")}</option>
{DIFFERENTIATION_LEVEL_KEYS.map((d) => (
<option key={d} value={d}>
{t(`editor.differentiation.${d}`)}
</option>
))}
</select>
</div>
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
<div className="flex-1 overflow-y-auto p-3">
<LessonPlanErrorBoundary>
@@ -166,6 +219,7 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
planId={planId}
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
/>
{/* BlockRenderer 返回 null 时显示未知类型提示 */}

View File

@@ -14,8 +14,11 @@ import {
type Connection,
applyEdgeChanges,
BackgroundVariant,
Panel,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LayoutGrid } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
@@ -43,8 +46,14 @@ export function NodeEditor({}: Props) {
setEdges,
addAnchor,
addNode,
autoLayout,
} = useLessonPlanEditor();
// V5-8自动布局按钮
const handleAutoLayout = useCallback(() => {
autoLayout("TB");
}, [autoLayout]);
// P1-1构建可锚定的教学节点列表排除正文节点
const anchorableNodes = useMemo(
() =>
@@ -233,6 +242,10 @@ export function NodeEditor({}: Props) {
}}
proOptions={{ hideAttribution: true }}
className="bg-surface-container-low"
onlyRenderVisibleElements
minZoom={0.2}
maxZoom={2.5}
elevateNodesOnSelect={false}
>
<Background
variant={BackgroundVariant.Dots}
@@ -241,6 +254,20 @@ export function NodeEditor({}: Props) {
color="#ccc"
/>
<Controls className="!bg-surface !border-outline-variant" />
{/* V5-8自动布局按钮 */}
<Panel position="top-right" className="!m-2">
<Button
variant="outline"
size="sm"
onClick={handleAutoLayout}
disabled={doc.nodes.length === 0}
title={t("editor.autoLayoutHint")}
aria-label={t("editor.autoLayout")}
>
<LayoutGrid className="w-4 h-4 mr-1" />
{t("editor.autoLayout")}
</Button>
</Panel>
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {

View File

@@ -0,0 +1,174 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
import { X, Printer } from "lucide-react";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { flattenLessonPlanForPrint, type ExportVariant, type PrintableLessonPlan } from "../lib/export";
import type { LessonPlan } from "../types";
interface Props {
plan: LessonPlan;
textbookTitle?: string;
chapterTitle?: string;
teacherName?: string;
className?: string;
onClose: () => void;
}
/**
* V5-4打印视图组件
*
* 渲染扁平化后的 PrintableLessonPlan提供"详细版/简洁版"切换。
* 点击"打印"按钮调用 window.print(),通过浏览器原生能力保存为 PDF。
* 使用 print: CSS 媒体查询隐藏工具栏,仅打印教学环节内容。
*/
export function PrintView({
plan,
textbookTitle,
chapterTitle,
teacherName,
className,
onClose,
}: Props) {
const t = useTranslations("lessonPreparation");
const [variant, setVariant] = useState<ExportVariant>("detailed");
// V5-4esc 关闭
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
const printable = useMemo<PrintableLessonPlan>(
() =>
flattenLessonPlanForPrint(
plan,
{ textbookTitle, chapterTitle, teacherName, className },
variant,
),
[plan, textbookTitle, chapterTitle, teacherName, className, variant],
);
function handlePrint() {
window.print();
}
return (
<div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center print:static print:bg-white print:p-0">
<div
role="dialog"
aria-modal="true"
aria-label={t("export.title")}
className="bg-surface rounded-lg shadow-xl w-[800px] max-h-[90vh] flex flex-col print:static print:w-full print:max-h-none print:rounded-none print:shadow-none"
>
<FocusTrap className="contents">
{/* 工具栏print 时隐藏 */}
<div className="flex justify-between items-center p-4 border-b print:hidden">
<div className="flex items-center gap-3">
<h3 className="font-title-md">{t("export.title")}</h3>
<div className="flex items-center gap-1 text-sm">
<button
className={`px-2 py-0.5 rounded ${variant === "detailed" ? "bg-primary text-on-primary" : "bg-surface-container-high"}`}
onClick={() => setVariant("detailed")}
aria-pressed={variant === "detailed"}
>
{t("export.variantDetailed")}
</button>
<button
className={`px-2 py-0.5 rounded ${variant === "concise" ? "bg-primary text-on-primary" : "bg-surface-container-high"}`}
onClick={() => setVariant("concise")}
aria-pressed={variant === "concise"}
>
{t("export.variantConcise")}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handlePrint}>
<Printer className="w-4 h-4 mr-1" /> {t("export.print")}
</Button>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
{/* 打印内容主体 */}
<div className="p-8 overflow-y-auto flex-1 print:overflow-visible">
{/* 页眉 */}
<div className="border-b-2 border-on-surface pb-2 mb-4">
<h1 className="text-2xl font-bold text-center">
{printable.meta.planTitle}
</h1>
<div className="flex justify-between text-sm text-on-surface-variant mt-2">
<span>
{printable.meta.textbookTitle && `${printable.meta.textbookTitle}`}
{printable.meta.chapterTitle && ` · ${printable.meta.chapterTitle}`}
</span>
<span>
{printable.meta.teacherName && `${t("export.teacher")}: ${printable.meta.teacherName}`}
{printable.meta.className && ` · ${t("export.class")}: ${printable.meta.className}`}
</span>
</div>
<div className="text-xs text-on-surface-variant mt-1 text-right">
{t("export.totalDuration", { count: printable.meta.totalDurationMin })}
{printable.meta.lastSavedAt &&
` · ${t("export.lastSavedAt")}: ${new Date(printable.meta.lastSavedAt).toLocaleString()}`}
</div>
</div>
{/* 课文正文 */}
{printable.textbookContent && (
<section className="mb-6">
<h2 className="text-lg font-semibold border-l-4 border-primary pl-2 mb-2">
{t("editor.textbookContent")}
</h2>
<div className="text-sm whitespace-pre-wrap leading-relaxed">
{printable.textbookContent}
</div>
</section>
)}
{/* 教学环节列表 */}
{printable.sections.length === 0 ? (
<p className="text-center text-on-surface-variant py-8">
{t("export.empty")}
</p>
) : (
printable.sections.map((section, idx) => (
<section key={`${section.type}-${idx}`} className="mb-4 break-inside-avoid">
<h2 className="text-lg font-semibold border-l-4 border-primary pl-2 mb-2">
{idx + 1}. {section.title}
</h2>
<div className="pl-4 space-y-1">
{section.lines.length === 0 ? (
<p className="text-sm text-on-surface-variant italic">
{t("export.emptySection")}
</p>
) : (
section.lines.map((line, i) => (
<p key={i} className="text-sm leading-relaxed">
{line}
</p>
))
)}
</div>
</section>
))
)}
{/* 页脚 */}
<div className="border-t border-outline-variant mt-8 pt-2 text-xs text-on-surface-variant text-center print:fixed print:bottom-2 print:left-0 print:right-0">
{t("export.footerHint", { variant: t(`export.variant${variant === "detailed" ? "Detailed" : "Concise"}`) })}
</div>
</div>
</FocusTrap>
</div>
</div>
);
}

View File

@@ -1,24 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, 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";
import { X, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react";
import type { ExerciseItem } from "../types";
import { isRecord } from "@/shared/lib/type-guards";
interface Props {
planId: string;
blockId: string;
classes: { id: string; name: string }[];
/** V5-3题目列表从 exercise-block 传入,用于预览步骤)*/
items: ExerciseItem[];
onClose: () => void;
onPublished: () => void;
}
type Step = "select" | "preview" | "confirm";
/** V5-3从 inline 题目内容中安全提取题干预览文本 */
function extractStemPreview(content: unknown): string {
if (!isRecord(content)) return "";
const stem = content.stem;
if (typeof stem === "string") return stem.slice(0, 80);
const text = content.text;
if (typeof text === "string") return text.slice(0, 80);
return "";
}
/** V5-3题型 i18n 键映射 */
function questionTypeKey(type: string): string {
const known = ["single_choice", "multiple_choice", "true_false", "short_answer", "essay", "text", "judgment"];
return known.includes(type) ? `questionBank.type.${type}` : "questionBank.type.single_choice";
}
export function PublishHomeworkDialog({
planId,
blockId,
classes,
items,
onClose,
onPublished,
}: Props) {
@@ -26,6 +49,7 @@ export function PublishHomeworkDialog({
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
const tracker = useLessonPlanTrackerSafe();
const [step, setStep] = useState<Step>("select");
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
const [availableAt, setAvailableAt] = useState("");
const [dueAt, setDueAt] = useState("");
@@ -41,12 +65,44 @@ export function PublishHomeworkDialog({
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
async function handlePublish() {
if (!service) return;
// V5-3计算总分
const totalScore = useMemo(
() => items.reduce((sum, it) => sum + (it.score ?? 0), 0),
[items],
);
// V5-3选中的班级数
const selectedClassCount = selectedClasses.length;
function handleSelectClass(classId: string) {
setSelectedClasses((prev) =>
prev.includes(classId) ? prev.filter((x) => x !== classId) : [...prev, classId],
);
}
function handleNextFromSelect() {
if (selectedClasses.length === 0) {
setError(t("publish.selectClass"));
return;
}
setError(null);
setStep("preview");
}
function handleNextFromPreview() {
setStep("confirm");
}
function handleBackToSelect() {
setStep("select");
}
function handleBackToPreview() {
setStep("preview");
}
async function handlePublish() {
if (!service) return;
setLoading(true);
setError(null);
try {
@@ -76,78 +132,190 @@ export function PublishHomeworkDialog({
}
}
// V5-3步骤指示器文案
const stepLabel =
step === "select"
? t("publish.step1")
: step === "preview"
? t("publish.step2")
: t("publish.step3");
const stepNumber = step === "select" ? 1 : step === "preview" ? 2 : 3;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
role="dialog"
aria-modal="true"
aria-label={t("publish.title")}
className="bg-surface rounded-lg shadow-xl w-96"
className="bg-surface rounded-lg shadow-xl w-[640px] max-h-[90vh] flex flex-col"
>
<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")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="text-sm font-medium">{t("publish.classLabel")}</label>
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
{classes.map((c) => (
<label
key={c.id}
className="flex items-center gap-2"
>
<input
type="checkbox"
checked={selectedClasses.includes(c.id)}
onChange={() =>
setSelectedClasses(
selectedClasses.includes(c.id)
? selectedClasses.filter((x) => x !== c.id)
: [...selectedClasses, c.id],
)
}
/>
<span className="text-sm">{c.name}</span>
</label>
))}
<div className="flex justify-between items-center p-4 border-b">
<div className="flex items-center gap-2">
<h3 className="font-title-md">{t("publish.title")}</h3>
<span className="text-xs text-on-surface-variant">
{t("publish.stepIndicator", { current: stepNumber, total: 3, label: stepLabel })}
</span>
</div>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div>
<label className="text-sm font-medium">
{t("publish.availableAtLabel")}
</label>
<input
type="datetime-local"
value={availableAt}
onChange={(e) => setAvailableAt(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1"
/>
<div className="p-4 space-y-3 overflow-y-auto flex-1">
{/* 步骤 1选班级 + 时间 */}
{step === "select" && (
<>
<div>
<label className="text-sm font-medium">{t("publish.classLabel")}</label>
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
{classes.map((c) => (
<label key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedClasses.includes(c.id)}
onChange={() => handleSelectClass(c.id)}
/>
<span className="text-sm">{c.name}</span>
</label>
))}
</div>
</div>
<div>
<label className="text-sm font-medium">
{t("publish.availableAtLabel")}
</label>
<input
type="datetime-local"
value={availableAt}
onChange={(e) => setAvailableAt(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t("publish.dueAtLabel")}
</label>
<input
type="datetime-local"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1"
/>
</div>
{error && <p className="text-error text-sm">{error}</p>}
</>
)}
{/* 步骤 2预览题目 + 总分 + 班级 */}
{step === "preview" && (
<>
<div className="bg-surface-container-high rounded p-3 text-sm space-y-1">
<div>
{t("publish.previewClassCount")}: <strong>{selectedClassCount}</strong>
</div>
<div>
{t("publish.previewQuestionCount")}: <strong>{items.length}</strong>
</div>
<div>
{t("publish.previewTotalScore")}: <strong>{totalScore}</strong>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-2">
{t("publish.previewQuestionList")}
</label>
<ol className="space-y-2 list-decimal list-inside text-sm">
{items.map((item, idx) => (
<li
key={`${item.questionId}-${idx}`}
className="border border-outline-variant rounded p-2"
>
<div className="flex justify-between items-start gap-2">
<div className="flex-1">
<div className="text-xs text-on-surface-variant">
{t(questionTypeKey(item.inlineContent?.type ?? "single_choice"))} · {t("publish.previewSource", { source: t(`questionBank.source.${item.source}`) })}
</div>
{item.source === "inline" && item.inlineContent ? (
<div className="mt-1 text-sm">
{extractStemPreview(item.inlineContent.content) || t("publish.previewNoStem")}
</div>
) : (
<div className="mt-1 text-sm font-mono text-xs">
ID: {item.questionId}
</div>
)}
</div>
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
{t("publish.previewScore", { score: item.score })}
</span>
</div>
</li>
))}
</ol>
</div>
</>
)}
{/* 步骤 3确认发布 */}
{step === "confirm" && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-5 h-5 text-primary" />
<span className="font-medium">{t("publish.confirmTitle")}</span>
</div>
<div className="bg-surface-container-high rounded p-3 text-sm space-y-1">
<div>{t("publish.confirmClassCount", { count: selectedClassCount })}</div>
<div>{t("publish.confirmQuestionCount", { count: items.length })}</div>
<div>{t("publish.confirmTotalScore", { score: totalScore })}</div>
{availableAt && (
<div>{t("publish.confirmAvailableAt", { time: availableAt })}</div>
)}
{dueAt && (
<div>{t("publish.confirmDueAt", { time: dueAt })}</div>
)}
</div>
<p className="text-xs text-on-surface-variant">
{t("publish.confirmWarning")}
</p>
{error && <p className="text-error text-sm">{error}</p>}
</div>
)}
</div>
<div>
<label className="text-sm font-medium">
{t("publish.dueAtLabel")}
</label>
<input
type="datetime-local"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1"
/>
{/* 步骤导航按钮 */}
<div className="p-4 border-t flex justify-between gap-2">
{step === "select" && (
<>
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handleNextFromSelect}>
{t("publish.next")} <ChevronRight className="w-4 h-4 ml-1" />
</Button>
</>
)}
{step === "preview" && (
<>
<Button variant="outline" onClick={handleBackToSelect}>
<ChevronLeft className="w-4 h-4 mr-1" /> {t("publish.back")}
</Button>
<Button onClick={handleNextFromPreview}>
{t("publish.next")} <ChevronRight className="w-4 h-4 ml-1" />
</Button>
</>
)}
{step === "confirm" && (
<>
<Button variant="outline" onClick={handleBackToPreview} disabled={loading}>
<ChevronLeft className="w-4 h-4 mr-1" /> {t("publish.back")}
</Button>
<Button onClick={handlePublish} disabled={loading}>
{loading ? t("publish.publishing") : t("publish.publish")}
</Button>
</>
)}
</div>
{error && <p className="text-error text-sm">{error}</p>}
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handlePublish} disabled={loading}>
{loading ? t("publish.publishing") : t("publish.publish")}
</Button>
</div>
</FocusTrap>
</div>
</div>

View File

@@ -8,10 +8,11 @@ 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 { X, ChevronDown, ChevronRight } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
import type { ExerciseItem } from "../types"
import type { QuestionType } from "@/modules/questions/types"
import { isRecord } from "@/shared/lib/type-guards"
// 类型守卫:验证字符串是否为有效的 QuestionType避免 as 断言)
function isQuestionType(v: string): v is QuestionType {
@@ -118,6 +119,54 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
}
}
// V5-6展开题目详情题干/选项/答案)
const [expandedId, setExpandedId] = useState<string | null>(null)
function toggleExpand(id: string) {
setExpandedId((prev) => (prev === id ? null : id))
}
// V5-6从 content 中安全提取题干
function extractStem(content: unknown): string {
if (typeof content === "string") return content
if (isRecord(content)) {
const stem = content.stem
if (typeof stem === "string") return stem
const text = content.text
if (typeof text === "string") return text
}
return ""
}
// V5-6从 content 中安全提取选项列表
function extractOptions(content: unknown): { label: string; text: string; isCorrect?: boolean }[] {
if (!isRecord(content)) return []
const options = content.options
if (!Array.isArray(options)) return []
return options
.filter((o): o is Record<string, unknown> => isRecord(o))
.map((o, i) => ({
label: typeof o.label === "string" ? o.label : String.fromCharCode(65 + i),
text: typeof o.text === "string" ? o.text : "",
isCorrect: typeof o.isCorrect === "boolean" ? o.isCorrect : undefined,
}))
}
// V5-6从 content 中安全提取答案
function extractAnswer(content: unknown): string {
if (!isRecord(content)) return ""
const answer = content.answer
if (typeof answer === "string") return answer
if (typeof answer === "number") return String(answer)
if (Array.isArray(answer)) {
return answer
.filter((a): a is string | number => typeof a === "string" || typeof a === "number")
.map((a) => String(a))
.join(", ")
}
return ""
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
@@ -155,20 +204,90 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
</p>
) : (
<div className="space-y-2">
{questions.map((q) => (
<div
key={q.id}
className="border rounded p-2 flex justify-between items-center"
>
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
<span className="text-xs text-on-surface-variant mr-2">
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
</span>
<Button size="sm" variant="outline" onClick={() => add(q)}>
{t("questionBank.add")}
</Button>
</div>
))}
{questions.map((q) => {
const isExpanded = expandedId === q.id
const stem = extractStem(q.content)
const options = extractOptions(q.content)
const answer = extractAnswer(q.content)
return (
<div
key={q.id}
className="border rounded p-2"
>
<div className="flex justify-between items-center">
<button
className="flex-1 text-left flex items-start gap-2 mr-2"
onClick={() => toggleExpand(q.id)}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 mt-1 flex-shrink-0" aria-hidden="true" />
) : (
<ChevronRight className="w-3 h-3 mt-1 flex-shrink-0" aria-hidden="true" />
)}
<span className="text-sm truncate flex-1">
{previewText(q.content)}
</span>
</button>
<span className="text-xs text-on-surface-variant mr-2 whitespace-nowrap">
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
</span>
<Button size="sm" variant="outline" onClick={() => add(q)}>
{t("questionBank.add")}
</Button>
</div>
{isExpanded && (
<div className="mt-2 pl-5 space-y-2 text-sm border-t pt-2">
{/* V5-6题干 */}
{stem && (
<div>
<div className="text-xs text-on-surface-variant font-medium">
{t("questionBank.stemLabel")}
</div>
<div className="mt-0.5 whitespace-pre-wrap">{stem}</div>
</div>
)}
{/* V5-6选项 */}
{options.length > 0 && (
<div>
<div className="text-xs text-on-surface-variant font-medium">
{t("questionBank.optionsLabel")}
</div>
<ul className="mt-0.5 space-y-0.5">
{options.map((opt, i) => (
<li
key={i}
className={`flex items-start gap-1 ${opt.isCorrect ? "text-primary font-medium" : ""}`}
>
<span className="flex-shrink-0">{opt.label}.</span>
<span>{opt.text}</span>
{opt.isCorrect && (
<span className="text-xs text-primary"></span>
)}
</li>
))}
</ul>
</div>
)}
{/* V5-6答案 */}
{answer && (
<div>
<div className="text-xs text-on-surface-variant font-medium">
{t("questionBank.correctAnswer")}
</div>
<div className="mt-0.5 text-primary">{answer}</div>
</div>
)}
{!stem && options.length === 0 && !answer && (
<p className="text-xs text-on-surface-variant italic">
{t("questionBank.noDetail")}
</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>

View File

@@ -0,0 +1,273 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Calendar, Trash2, Plus } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { toast } from "sonner";
import {
getLessonPlanSchedulesAction,
createLessonPlanScheduleAction,
deleteLessonPlanScheduleAction,
} from "../actions-schedules";
import type { LessonPlanScheduleRecord } from "../data-access-schedules";
interface ScheduleOption {
id: string;
name: string;
}
interface Props {
planId: string;
classes: ScheduleOption[];
onClose: () => void;
onScheduled?: () => void;
}
/**
* V5-7课时绑定对话框
*
* 功能:
* 1. 列出课案已绑定的课时
* 2. 添加新课时绑定(选班级 + 日期 + 节次 + 时长)
* 3. 删除课时绑定
*
* 不通过 service 注入,直接调用 actions因为是新增功能且仅教师使用
*/
export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props) {
const t = useTranslations("lessonPreparation");
const [schedules, setSchedules] = useState<LessonPlanScheduleRecord[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
// 表单状态
const [classId, setClassId] = useState("");
const [scheduledDate, setScheduledDate] = useState("");
const [period, setPeriod] = useState(1);
const [durationMin, setDurationMin] = useState(40);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadSchedules();
}, [planId]);
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
async function loadSchedules() {
setLoading(true);
try {
const res = await getLessonPlanSchedulesAction(planId);
if (res.success && res.data) {
setSchedules(res.data.items);
}
} catch (e) {
console.error("[ScheduleDialog] load failed", e);
toast.error(t("error.getOne"));
} finally {
setLoading(false);
}
}
async function handleAdd() {
if (!classId) {
setError(t("schedule.selectClass"));
return;
}
if (!scheduledDate) {
setError(t("schedule.selectDate"));
return;
}
setSubmitting(true);
setError(null);
try {
const res = await createLessonPlanScheduleAction({
planId,
classId,
scheduledDate,
period,
durationMin,
});
if (res.success) {
toast.success(t("schedule.addSuccess"));
void loadSchedules();
onScheduled?.();
} else {
setError(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[ScheduleDialog] add failed", e);
setError(t("error.save"));
} finally {
setSubmitting(false);
}
}
async function handleDelete(id: string) {
try {
const res = await deleteLessonPlanScheduleAction(id);
if (res.success) {
toast.success(t("schedule.deleteSuccess"));
void loadSchedules();
onScheduled?.();
} else {
toast.error(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[ScheduleDialog] delete failed", e);
toast.error(t("error.save"));
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
role="dialog"
aria-modal="true"
aria-label={t("schedule.title")}
className="bg-surface rounded-lg shadow-xl w-[560px] 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 flex items-center gap-2">
<Calendar className="w-4 h-4" aria-hidden="true" />
{t("schedule.title")}
</h3>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-4 overflow-y-auto flex-1 space-y-4">
{/* 已绑定课时列表 */}
<div>
<label className="text-sm font-medium block mb-2">
{t("schedule.boundList")}
</label>
{loading ? (
<p className="text-sm text-on-surface-variant text-center py-4">
{t("version.loading")}
</p>
) : schedules.length === 0 ? (
<p className="text-sm text-on-surface-variant text-center py-4">
{t("schedule.empty")}
</p>
) : (
<ul className="space-y-1">
{schedules.map((s) => (
<li
key={s.id}
className="flex items-center gap-2 border rounded p-2 text-sm"
>
<span className="flex-1">
<strong>{s.className}</strong>
<span className="text-on-surface-variant ml-2">
{s.scheduledDate} · {t("schedule.period", { n: s.period })} · {t("schedule.duration", { n: s.durationMin })}
</span>
</span>
<button
className="text-error hover:bg-error/10 p-1 rounded"
onClick={() => void handleDelete(s.id)}
aria-label={t("schedule.delete")}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</div>
{/* 添加新绑定 */}
<div className="border-t pt-3 space-y-2">
<label className="text-sm font-medium block">
{t("schedule.addNew")}
</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.classLabel")}
</label>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
>
<option value="">{t("schedule.selectClass")}</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.dateLabel")}
</label>
<input
type="date"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.periodLabel")}
</label>
<select
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{t("schedule.period", { n })}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-on-surface-variant">
{t("schedule.durationLabel")}
</label>
<input
type="number"
min={5}
max={180}
value={durationMin}
onChange={(e) => setDurationMin(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
/>
</div>
</div>
{error && <p className="text-error text-sm">{error}</p>}
<Button
size="sm"
onClick={handleAdd}
disabled={submitting}
className="w-full"
>
<Plus className="w-4 h-4 mr-1" />
{t("schedule.add")}
</Button>
</div>
</div>
<div className="p-4 border-t flex justify-end">
<Button variant="outline" onClick={onClose}>
{t("action.close")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);
}

View File

@@ -9,9 +9,39 @@ import type { TextbookPickerOption, ChapterPickerOption } from "../providers/les
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 { Book, ChevronRight, FileText, Loader2, Search, Clock } from "lucide-react";
import type { LessonPlanTemplate } from "../types";
/** V5-9localStorage 中最近使用教材的存储键 */
const RECENT_TEXTBOOKS_KEY = "lesson-prep:recent-textbooks";
const MAX_RECENT = 5;
/** 读取最近使用教材 ID 列表 */
function readRecentTextbookIds(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(RECENT_TEXTBOOKS_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr.filter((x): x is string => typeof x === "string").slice(0, MAX_RECENT);
} catch {
return [];
}
}
/** 写入最近使用教材 ID 列表(新选择放在最前,去重,最多 MAX_RECENT 条) */
function writeRecentTextbookId(id: string): void {
if (typeof window === "undefined") return;
try {
const prev = readRecentTextbookIds();
const next = [id, ...prev.filter((x) => x !== id)].slice(0, MAX_RECENT);
window.localStorage.setItem(RECENT_TEXTBOOKS_KEY, JSON.stringify(next));
} catch {
// localStorage 不可用时静默失败
}
}
export function TemplatePicker() {
const t = useTranslations("lessonPreparation");
const router = useRouter();
@@ -33,6 +63,42 @@ export function TemplatePicker() {
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
// P1-6个人模板
const [personalTemplates, setPersonalTemplates] = useState<LessonPlanTemplate[]>([]);
// V5-9教材搜索 + 最近使用
const [searchQuery, setSearchQuery] = useState("");
const [recentIds, setRecentIds] = useState<string[]>([]);
// V5-9客户端挂载后读取最近使用教材
useEffect(() => {
setRecentIds(readRecentTextbookIds());
}, []);
// V5-9客户端模糊搜索过滤教材标题/学科/年级/出版社)
const filteredTextbooks = useMemo(() => {
if (!searchQuery.trim()) return textbooks;
const q = searchQuery.trim().toLowerCase();
return textbooks.filter((tb) => {
const title = (tb.title ?? "").toLowerCase();
const subject = (tb.subject ?? "").toLowerCase();
const grade = (tb.grade ?? "").toLowerCase();
return title.includes(q) || subject.includes(q) || grade.includes(q);
});
}, [textbooks, searchQuery]);
// V5-9最近使用的教材按 recentIds 顺序,且仍存在于 textbooks 列表中)
const recentTextbooks = useMemo(() => {
if (recentIds.length === 0) return [];
return recentIds
.map((id) => textbooks.find((tb) => tb.id === id))
.filter((tb): tb is TextbookPickerOption => tb !== undefined);
}, [recentIds, textbooks]);
// V5-9选择教材时记录到 localStorage
const handleTextbookSelect = useCallback((id: string) => {
setTextbookId(id);
setChapterId("");
if (id) writeRecentTextbookId(id);
setRecentIds(readRecentTextbookIds());
}, []);
// 派生:当前教材的章节是否正在加载
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
@@ -176,24 +242,70 @@ export function TemplatePicker() {
) : textbooks.length === 0 ? (
<p className="text-on-surface-variant text-sm">{t("picker.noTextbooks")}</p>
) : (
<select
value={textbookId}
onChange={(e) => {
setTextbookId(e.target.value);
setChapterId("");
}}
required
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
>
<option value="">{t("picker.selectTextbook")}</option>
{textbooks.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.title}
{tb.subject ? ` · ${tb.subject}` : ""}
{tb.grade ? ` · ${tb.grade}` : ""}
</option>
))}
</select>
<div className="space-y-2">
{/* V5-9搜索框 */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-on-surface-variant" aria-hidden="true" />
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("picker.searchTextbookPlaceholder")}
className="w-full border border-outline-variant rounded-lg pl-8 pr-3 py-2 bg-surface text-sm"
aria-label={t("picker.searchTextbookLabel")}
/>
</div>
{/* V5-9最近使用教材无搜索词时显示 */}
{!searchQuery.trim() && recentTextbooks.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium text-on-surface-variant flex items-center gap-1">
<Clock className="w-3 h-3" aria-hidden="true" />
{t("picker.recentSection")}
</div>
<div className="flex flex-wrap gap-1">
{recentTextbooks.map((tb) => (
<button
type="button"
key={tb.id}
onClick={() => handleTextbookSelect(tb.id)}
className={cn(
"text-xs px-2 py-1 rounded border transition-colors",
textbookId === tb.id
? "border-primary bg-primary/10 text-primary"
: "border-outline-variant hover:border-primary/50",
)}
>
{tb.title}
{tb.grade ? ` · ${tb.grade}` : ""}
</button>
))}
</div>
</div>
)}
{/* 教材下拉框(过滤后) */}
<select
value={textbookId}
onChange={(e) => handleTextbookSelect(e.target.value)}
required
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
>
<option value="">{t("picker.selectTextbook")}</option>
{filteredTextbooks.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.title}
{tb.subject ? ` · ${tb.subject}` : ""}
{tb.grade ? ` · ${tb.grade}` : ""}
</option>
))}
</select>
{searchQuery.trim() && filteredTextbooks.length === 0 && (
<p className="text-xs text-on-surface-variant">
{t("picker.searchEmpty")}
</p>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,124 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Plus, Minus, Pencil, Check } from "lucide-react";
import type { LessonPlanDocument } from "../types";
import { diffDocuments, hasChanges, type NodeDiff } from "../lib/version-diff";
import { getNodeColor } from "../lib/node-summary";
import { cn } from "@/shared/lib/utils";
interface Props {
oldDoc: LessonPlanDocument;
newDoc: LessonPlanDocument;
/** 旧版本号(用于标题展示) */
oldVersionNo?: number;
}
/**
* V5-16 T2版本对比视图。
*
* 以列表形式展示两个版本文档的节点级差异,
* 支持 added/removed/modified/unchanged 四种状态标记。
*/
export function VersionDiffView({ oldDoc, newDoc, oldVersionNo }: Props) {
const t = useTranslations("lessonPreparation");
const result = useMemo(() => diffDocuments(oldDoc, newDoc), [oldDoc, newDoc]);
const changed = hasChanges(result);
return (
<div className="space-y-3">
{/* 摘要条 */}
<div className="flex items-center gap-3 text-xs flex-wrap">
<span className="text-on-surface-variant">
{t("diff.summary", {
added: result.summary.added,
removed: result.summary.removed,
modified: result.summary.modified,
})}
</span>
{oldVersionNo !== undefined && (
<span className="text-on-surface-variant">
{t("diff.comparing", { version: oldVersionNo })}
</span>
)}
</div>
{!changed ? (
<div className="text-sm text-on-surface-variant p-3 rounded border border-outline-variant bg-surface-container-low">
{t("diff.noChanges")}
</div>
) : (
<ul className="space-y-1.5">
{result.diffs
.filter((d) => d.type !== "unchanged")
.map((d, idx) => (
<DiffItem key={idx} diff={d} t={t} />
))}
</ul>
)}
{/* unchanged 计数(折叠) */}
{result.summary.unchanged > 0 && (
<div className="text-xs text-on-surface-variant flex items-center gap-1">
<Check className="w-3 h-3" aria-hidden="true" />
{t("diff.unchangedCount", { count: result.summary.unchanged })}
</div>
)}
</div>
);
}
function DiffItem({
diff,
t,
}: {
diff: NodeDiff;
t: ReturnType<typeof useTranslations>;
}) {
const node = diff.newNode ?? diff.oldNode;
const color = node ? getNodeColor(node.type) : "#999";
const title = node?.title || node?.type || "";
const icon = {
added: <Plus className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
removed: <Minus className="w-3.5 h-3.5 text-error" aria-hidden="true" />,
modified: <Pencil className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />,
unchanged: <Check className="w-3.5 h-3.5 text-on-surface-variant" aria-hidden="true" />,
}[diff.type];
const label = t(`diff.${diff.type}`);
return (
<li
className={cn(
"flex items-start gap-2 px-2.5 py-1.5 rounded border text-sm",
diff.type === "added" && "border-primary/40 bg-primary-container/20",
diff.type === "removed" && "border-error/40 bg-error-container/20",
diff.type === "modified" && "border-tertiary/40 bg-tertiary-container/20",
)}
>
<span
className="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
{icon}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-container-highest font-medium flex-shrink-0">
{label}
</span>
<span className="truncate font-medium">{title}</span>
</div>
{diff.type === "modified" && diff.changedFields && diff.changedFields.length > 0 && (
<div className="text-xs text-on-surface-variant mt-0.5">
{t("diff.changedFields")}:{" "}
{diff.changedFields.map((f) => t(`diff.field.${f}`)).join(", ")}
</div>
)}
</div>
</li>
);
}

View File

@@ -19,13 +19,16 @@ import {
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
import { formatDateTime } from "@/shared/lib/utils";
import type { LessonPlanVersion } from "../types";
import { VersionDiffView } from "./version-diff-view";
import type { LessonPlanDocument, LessonPlanVersion } from "../types";
interface Props {
open: boolean;
onClose: () => void;
planId: string;
onReverted: () => void;
/** V5-16当前文档用于版本对比 */
currentDoc?: LessonPlanDocument;
}
export function VersionHistoryDrawer({
@@ -33,6 +36,7 @@ export function VersionHistoryDrawer({
onClose,
planId,
onReverted,
currentDoc,
}: Props) {
const t = useTranslations("lessonPreparation");
const ctx = useLessonPlanContextSafe();
@@ -40,6 +44,8 @@ export function VersionHistoryDrawer({
const tracker = useLessonPlanTrackerSafe();
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
const [loading, setLoading] = useState(false);
/** V5-16当前正在对比的版本null 表示显示列表) */
const [comparingVersion, setComparingVersion] = useState<LessonPlanVersion | null>(null);
// P1-1 修复ESC 键关闭抽屉open 时才监听)
useEffect(() => {
@@ -97,56 +103,93 @@ export function VersionHistoryDrawer({
<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 ? (
<VersionListSkeleton />
) : versions.length === 0 ? (
<p className="text-on-surface-variant">{t("version.empty")}</p>
) : (
<div className="flex flex-col gap-2">
{versions.map((v) => (
<div
key={v.id}
className="border border-outline-variant rounded-lg p-3"
{/* V5-16版本对比视图当 comparingVersion 存在时显示) */}
{comparingVersion && currentDoc ? (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-headline-md text-headline-md">
{t("diff.comparing", { version: comparingVersion.versionNo })}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setComparingVersion(null)}
>
<div className="flex justify-between items-center">
<span className="font-title-md">v{v.versionNo}</span>
{v.isAuto && (
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
{t("version.auto")}
</span>
)}
</div>
<p className="text-sm text-on-surface-variant">
{v.label ?? t("version.manual")}
</p>
<p className="text-xs text-on-surface-variant mt-1">
{formatDateTime(v.createdAt)}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="mt-2">
{t("version.revert")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("version.revertTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("version.revertConfirm", { versionNo: v.versionNo })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleRevert(v.versionNo)}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{t("diff.back")}
</Button>
</div>
<VersionDiffView
oldDoc={comparingVersion.content}
newDoc={currentDoc}
oldVersionNo={comparingVersion.versionNo}
/>
</div>
) : (
<>
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
{loading ? (
<VersionListSkeleton />
) : versions.length === 0 ? (
<p className="text-on-surface-variant">{t("version.empty")}</p>
) : (
<div className="flex flex-col gap-2">
{versions.map((v) => (
<div
key={v.id}
className="border border-outline-variant rounded-lg p-3"
>
<div className="flex justify-between items-center">
<span className="font-title-md">v{v.versionNo}</span>
{v.isAuto && (
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
{t("version.auto")}
</span>
)}
</div>
<p className="text-sm text-on-surface-variant">
{v.label ?? t("version.manual")}
</p>
<p className="text-xs text-on-surface-variant mt-1">
{formatDateTime(v.createdAt)}
</p>
<div className="flex gap-2 mt-2">
{/* V5-16 T2对比当前按钮 */}
{currentDoc && (
<Button
variant="outline"
size="sm"
onClick={() => setComparingVersion(v)}
>
{t("diff.compareWithCurrent")}
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
{t("version.revert")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("version.revertTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("version.revertConfirm", { versionNo: v.versionNo })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleRevert(v.versionNo)}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</>
)}
</FocusTrap>
</div>