Files
NextEdu/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx
SpecialX 25dca843be 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
2026-07-04 10:22:10 +08:00

380 lines
12 KiB
TypeScript
Raw Blame History

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