Files
NextEdu/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx
SpecialX 214ebec976 refactor(design-tokens): 全量体系化重建设计令牌
Primitive + Semantic 双层令牌架构,HEX->HSL,明暗双份,@theme inline 暴露为 Tailwind 类。

- 新建 src/app/styles/tokens/ 6 个令牌文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
- globals.css 改为 @import 引入,477->258 行
- 清理 91 处 #hex 硬编码颜色 -> hsl(var(--*))
- 清理 10 处硬编码字体 -> var(--font-family-*)
- 清理 100 文件 Tailwind 任意值(Tier 1 映射/Tier 3 注释豁免)
- 清理 M3 Surface 死代码,升级 --lp-* 令牌(HEX->HSL + 暗色补全)
- 新建 ESLint 自定义规则 no-hardcoded-design-tokens(单词边界正则)
- eslint.config.mjs 新增 no-restricted-syntax 禁止 #hex + 自定义规则加载(pathToFileURL)
- 项目规则新增设计令牌规范强制章节
- 架构图 004/005 同步设计令牌体系节点
- known-issues.md 追加设计令牌问题分类(7 个规则表)

验证: tsc --noEmit 0 errors, npm run lint 0 errors/12 warnings(均为既有问题)
2026-07-05 01:03:19 +08:00

381 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">
{/* arbitrary-value: dialog fixed width */}
<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>;
}