Files
NextEdu/src/modules/lesson-preparation/components/curriculum-map-view.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

124 lines
4.8 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.
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>
);
}