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(均为既有问题)
257 lines
8.6 KiB
TypeScript
257 lines
8.6 KiB
TypeScript
"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() };
|
||
});
|
||
}
|