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(均为既有问题)
124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
import type { JSX } from "react";
|
||
import { useMemo } from "react";
|
||
import { useTranslations } from "next-intl";
|
||
import { cn } from "@/shared/lib/utils";
|
||
import type { GradeOption, SubjectOption } from "@/modules/school/data-access";
|
||
import type { StandardsCoverageCell } from "@/modules/lesson-preparation/data-access-analytics";
|
||
|
||
interface Props {
|
||
grades: GradeOption[];
|
||
subjects: SubjectOption[];
|
||
heatmap: StandardsCoverageCell[];
|
||
}
|
||
|
||
/** 根据覆盖率返回背景色 */
|
||
function getCoverageColor(percent: number): string {
|
||
if (percent === 0) return "bg-muted/40 text-muted-foreground";
|
||
if (percent < 25) return "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300";
|
||
if (percent < 50) return "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300";
|
||
if (percent < 75) return "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300";
|
||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300";
|
||
}
|
||
|
||
export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Element {
|
||
const t = useTranslations("lessonPreparation");
|
||
// 构建查找表:subjectId|gradeId -> cell
|
||
const cellMap = useMemo(() => {
|
||
const map = new Map<string, StandardsCoverageCell>();
|
||
for (const cell of heatmap) {
|
||
const key = `${cell.subjectId ?? ""}|${cell.gradeId ?? ""}`;
|
||
map.set(key, cell);
|
||
}
|
||
return map;
|
||
}, [heatmap]);
|
||
|
||
if (grades.length === 0 || subjects.length === 0) {
|
||
return (
|
||
<div className="rounded-lg border p-8 text-center text-muted-foreground">
|
||
暂无年级或学科数据
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full border-collapse text-sm">
|
||
<thead>
|
||
<tr>
|
||
{/* arbitrary-value: table column min width */}
|
||
<th className="border border-border bg-muted/50 p-2 text-left font-medium sticky left-0 z-10 min-w-[100px]">
|
||
学科\年级
|
||
</th>
|
||
{grades.map((g) => (
|
||
<th
|
||
key={g.id}
|
||
className="border border-border bg-muted/50 p-2 text-center font-medium min-w-20"
|
||
>
|
||
{g.name}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{subjects.map((s) => (
|
||
<tr key={s.id}>
|
||
{/* arbitrary-value: table column min width */}
|
||
<td className="border border-border p-2 font-medium sticky left-0 z-10 bg-card min-w-[100px]">
|
||
{s.name}
|
||
</td>
|
||
{grades.map((g) => {
|
||
const key = `${s.id}|${g.id}`;
|
||
const cell = cellMap.get(key);
|
||
const total = cell?.totalPlans ?? 0;
|
||
const linked = cell?.standardsLinkedPlans ?? 0;
|
||
const percent = cell?.coveragePercent ?? 0;
|
||
return (
|
||
<td
|
||
key={key}
|
||
className={cn(
|
||
"border border-border p-2 text-center transition-colors hover:opacity-80 cursor-default",
|
||
getCoverageColor(percent),
|
||
)}
|
||
title={`学科:${s.name}\年级:${g.name}\n课案总数:${total}\n已关联课标:${linked}\n覆盖率:${percent}%`}
|
||
>
|
||
{total === 0 ? (
|
||
<span className="text-xs opacity-60">—</span>
|
||
) : (
|
||
<div className="flex flex-col items-center">
|
||
<span className="text-lg font-bold">{percent}%</span>
|
||
<span className="text-xs opacity-70">
|
||
{linked}/{total}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 图例 */}
|
||
<div className="flex flex-wrap items-center gap-4 text-xs">
|
||
<span className="text-muted-foreground">{t("v4.heatmap.legendTitle")}</span>
|
||
<LegendItem color="bg-muted/40" label={t("v4.heatmap.noData")} />
|
||
<LegendItem color="bg-red-100 dark:bg-red-950/50" label="0-25%" />
|
||
<LegendItem color="bg-amber-100 dark:bg-amber-950/50" label="25-50%" />
|
||
<LegendItem color="bg-blue-100 dark:bg-blue-950/50" label="50-75%" />
|
||
<LegendItem color="bg-emerald-100 dark:bg-emerald-950/50" label="75-100%" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LegendItem({ color, label }: { color: string; label: string }): JSX.Element {
|
||
return (
|
||
<span className="flex items-center gap-1">
|
||
<span className={cn("inline-block w-4 h-4 rounded border border-border", color)} />
|
||
{label}
|
||
</span>
|
||
);
|
||
} |