Files
NextEdu/src/modules/lesson-preparation/components/blocks/blackboard-block.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

257 lines
8.6 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 { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Tag, Eye, Pencil } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { isBlackboardLayout } from "../../lib/type-guards";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
interface Props {
data: BlackboardBlockData;
textbookId?: string;
chapterId?: string;
onUpdate: (data: BlackboardBlockData) => void;
}
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
/**
* V5-14 F4板书可视化工具。
*
* 在原有纯文本编辑基础上增加轻量级可视化预览(不引入新库):
* - structure结构式按行解析缩进渲染为带连接线的层级树
* - mindmap思维导图第一行为中心其余为分支节点
* - text文字式等宽字体直接展示
*
* 编辑/预览模式切换,避免双栏占满侧边面板。
*/
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [showKpPicker, setShowKpPicker] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("blackboard.hint")}
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="text-xs font-medium block mb-1">
{t("blackboard.layoutLabel")}
</label>
<select
value={data.layout}
onChange={(e) => {
const value = e.target.value;
if (isBlackboardLayout(value)) {
onUpdate({ ...data, layout: value });
}
}}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
>
{LAYOUTS.map((l) => (
<option key={l} value={l}>
{t(`blackboard.layout.${l}`)}
</option>
))}
</select>
</div>
{/* 编辑/预览切换 */}
<div className="flex border border-outline-variant rounded overflow-hidden self-end">
<button
type="button"
onClick={() => setPreviewMode(false)}
aria-pressed={!previewMode}
className={`px-2 py-1 text-xs inline-flex items-center gap-1 ${
!previewMode ? "bg-primary text-on-primary" : "bg-surface text-on-surface-variant"
}`}
title={t("blackboard.editMode")}
>
<Pencil className="w-3 h-3" aria-hidden="true" />
{t("blackboard.editMode")}
</button>
<button
type="button"
onClick={() => setPreviewMode(true)}
aria-pressed={previewMode}
className={`px-2 py-1 text-xs inline-flex items-center gap-1 ${
previewMode ? "bg-primary text-on-primary" : "bg-surface text-on-surface-variant"
}`}
title={t("blackboard.previewMode")}
>
<Eye className="w-3 h-3" aria-hidden="true" />
{t("blackboard.previewMode")}
</button>
</div>
</div>
{/* 内容区:编辑模式 or 预览模式 */}
{previewMode ? (
<BlackboardPreview content={data.content} layout={data.layout} t={t} />
) : (
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.contentLabel")}
</label>
{/* arbitrary-value: textarea min height */}
<textarea
value={data.content}
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[120px] font-mono"
placeholder={t("blackboard.contentPlaceholder")}
/>
<p className="text-xs text-on-surface-variant mt-1">
{t("blackboard.editHint")}
</p>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{data.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.linked", { count: data.knowledgePointIds.length })}
</span>
)}
<button
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
{t("knowledgePoint.annotate")}
</button>
</div>
{showKpPicker && (
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}
/**
* 板书可视化预览。纯 CSS + 文本解析,不引入新库。
*
* - structure按行解析前导空格/Tab 缩进,渲染为带竖线的层级树
* - mindmap第一行为中心节点其余为放射分支
* - text等宽字体直接展示
*/
function BlackboardPreview({
content,
layout,
t,
}: {
content: string;
layout: BlackboardBlockData["layout"];
t: ReturnType<typeof useTranslations>;
}) {
const parsed = useMemo(() => parseContent(content, layout), [content, layout]);
if (!content.trim()) {
return (
<div className="text-sm text-on-surface-variant text-center py-6 border border-dashed border-outline-variant rounded">
{t("blackboard.previewEmpty")}
</div>
);
}
if (layout === "text") {
return (
// arbitrary-value: content min height
<pre
className="text-sm font-mono whitespace-pre-wrap border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]"
>
{content}
</pre>
);
}
if (layout === "mindmap") {
const center = parsed[0]?.text ?? "";
const branches = parsed.slice(1);
return (
// arbitrary-value: content min height
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
{/* 中心节点 */}
<div className="flex justify-center mb-3">
<span className="px-3 py-1 rounded-full bg-primary text-on-primary text-sm font-medium text-center max-w-[80%]">
{center || t("blackboard.untitled")}
</span>
</div>
{/* 分支节点 */}
{branches.length > 0 && (
<ul className="space-y-1.5">
{branches.map((node, idx) => (
<li
key={idx}
className="flex items-center gap-2 text-sm"
style={{ paddingLeft: `${node.level * 12}px` }}
>
<span className="text-primary" aria-hidden="true"></span>
<span className="px-2 py-0.5 rounded bg-surface border border-outline-variant">
{node.text}
</span>
</li>
))}
</ul>
)}
</div>
);
}
// structure层级树
return (
// arbitrary-value: content min height
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
<ul className="space-y-1">
{parsed.map((node, idx) => (
<li
key={idx}
className="flex items-start gap-1 text-sm"
style={{ paddingLeft: `${node.level * 14}px` }}
>
<span className="text-on-surface-variant mt-0.5" aria-hidden="true">
{node.level === 0 ? "●" : "├"}
</span>
<span className={node.level === 0 ? "font-medium" : ""}>
{node.text}
</span>
</li>
))}
</ul>
</div>
);
}
/** 解析文本为节点列表(按缩进识别层级) */
interface ParsedNode {
level: number;
text: string;
}
function parseContent(
content: string,
_layout: BlackboardBlockData["layout"],
): ParsedNode[] {
const lines = content.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.trim());
return lines.map((line) => {
// 前导空格(每 2 空格或 1 Tab 算一级)
const leadingMatch = line.match(/^[\t ]*/);
const leading = leadingMatch ? leadingMatch[0] : "";
const tabs = (leading.match(/\t/g) ?? []).length;
const spaces = (leading.match(/ /g) ?? []).length;
const level = tabs + spaces;
return { level, text: line.trim() };
});
}