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:
@@ -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/A5:AI 差异化对话框。
|
||||
*
|
||||
* 三个 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>;
|
||||
}
|
||||
184
src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
Normal file
184
src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
Normal 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/A2:AI 反馈闭环对话框(含解释性展示) */
|
||||
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;
|
||||
}
|
||||
281
src/modules/lesson-preparation/components/attachment-picker.tsx
Normal file
281
src/modules/lesson-preparation/components/attachment-picker.tsx
Normal 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-5:ESC 关闭
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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() };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
162
src/modules/lesson-preparation/components/curriculum-heatmap.tsx
Normal file
162
src/modules/lesson-preparation/components/curriculum-heatmap.tsx
Normal 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];
|
||||
}
|
||||
@@ -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-17:AI 反馈对话框
|
||||
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21:AI 差异化对话框
|
||||
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/A2:AI 反馈按钮 */}
|
||||
<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/A5:AI 差异化与课标核对按钮 */}
|
||||
<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/A2:AI 反馈对话框 */}
|
||||
{showAiFeedback && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<AiFeedbackDialog
|
||||
doc={editor.doc}
|
||||
onClose={() => setShowAiFeedback(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
)}
|
||||
|
||||
{/* V5-21 A3/A4/A5:AI 差异化与课标核对对话框 */}
|
||||
{showAiDifferentiation && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<AiDifferentiationDialog
|
||||
doc={editor.doc}
|
||||
textbookId={textbookId}
|
||||
onClose={() => setShowAiDifferentiation(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
{/* 顶部信息条 */}
|
||||
|
||||
@@ -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 时显示未知类型提示 */}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
174
src/modules/lesson-preparation/components/print-view.tsx
Normal file
174
src/modules/lesson-preparation/components/print-view.tsx
Normal 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-4:esc 关闭
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
273
src/modules/lesson-preparation/components/schedule-dialog.tsx
Normal file
273
src/modules/lesson-preparation/components/schedule-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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-9:localStorage 中最近使用教材的存储键 */
|
||||
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>
|
||||
|
||||
|
||||
124
src/modules/lesson-preparation/components/version-diff-view.tsx
Normal file
124
src/modules/lesson-preparation/components/version-diff-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user