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(均为既有问题)
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
"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">
|
||
{/* 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>;
|
||
}
|