Files
NextEdu/src/modules/lesson-preparation/components/blocks/rich-text-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

215 lines
7.5 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 { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { RichTextBlockData, RichTextAttachment } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { AttachmentPicker } from "../attachment-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { Tag, Paperclip, Trash2 } from "lucide-react";
interface Props {
data: RichTextBlockData;
hint?: string;
textbookId?: string;
chapterId?: string;
/** V5-5当前课案 ID用于附件库 picker */
planId?: string;
/** V5-5当前 block ID用于附件关联 */
blockId?: string;
onUpdate: (data: RichTextBlockData) => void;
}
export function RichTextBlock({
data,
hint,
textbookId,
chapterId,
planId,
blockId,
onUpdate,
}: Props) {
const t = useTranslations("lessonPreparation");
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: hint ?? t("richText.placeholder") }),
// V5-5集成 Tiptap Image 扩展,支持图片直接嵌入富文本
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: {
class: "rich-text-image max-w-full h-auto rounded",
},
}),
],
content: data.html,
immediatelyRender: false,
onUpdate: ({ editor }) => {
onUpdate({ ...data, html: editor.getHTML() });
},
editorProps: {
attributes: {
// arbitrary-value: tiptap editor fixed size
class:
"prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2",
},
},
});
// 外部 content 变化时同步(如版本回退)
useEffect(() => {
if (editor && !editor.isDestroyed && data.html !== editor.getHTML()) {
editor.commands.setContent(data.html);
}
}, [data.html, editor]);
const [showKpPicker, setShowKpPicker] = useState(false);
const [showAttachmentPicker, setShowAttachmentPicker] = useState(false); // V5-5
// V5-5从素材库选择附件后插入到富文本图片用 Image 命令,其余追加到 attachments 列表)
function handleSelectAttachment(attachment: RichTextAttachment) {
const currentAttachments = data.attachments ?? [];
if (attachment.kind === "image" && editor) {
// 图片直接插入富文本
editor.commands.setImage({ src: attachment.url, alt: attachment.displayName });
}
// 所有附件(含图片)都记录到 attachments 列表,便于后续管理与素材库复用
onUpdate({
...data,
attachments: [...currentAttachments, attachment],
});
}
// V5-5移除已嵌入的附件
function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = data.attachments ?? [];
onUpdate({
...data,
attachments: currentAttachments.filter((a) => a.attachmentId !== attachmentId),
});
}
// V5-5渲染附件区块
const attachments = data.attachments ?? [];
return (
<div>
<EditorContent editor={editor} />
<div className="flex items-center gap-2 mt-2 px-3 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>
{/* V5-5素材库入口 */}
{planId && (
<button
onClick={() => setShowAttachmentPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Paperclip className="w-3 h-3" />
{t("attachment.insert")}
</button>
)}
</div>
{/* V5-5已嵌入附件列表非图片附件在此展示图片已在富文本中渲染 */}
{attachments.length > 0 && (
<div className="mt-2 px-3 space-y-1">
<div className="text-xs text-on-surface-variant">
{t("attachment.embeddedCount", { count: attachments.length })}
</div>
{attachments
.filter((a) => a.kind !== "image")
.map((a) => (
<div
key={a.attachmentId}
className="flex items-center gap-2 text-xs border border-outline-variant rounded p-1"
>
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 hover:underline truncate"
>
{a.displayName}
</a>
<span className="text-on-surface-variant">{a.kind}</span>
<button
onClick={() => handleRemoveAttachment(a.attachmentId)}
className="text-error hover:bg-error/10 p-0.5 rounded"
aria-label={t("attachment.remove")}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
</div>
))}
{/* V5-5图片附件单独展示缩略图即使已在富文本中内联此处也展示便于管理 */}
{attachments.some((a) => a.kind === "image") && (
<div className="flex flex-wrap gap-2">
{attachments
.filter((a) => a.kind === "image")
.map((a) => (
<div
key={a.attachmentId}
className="relative group border border-outline-variant rounded"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={a.url}
alt={a.displayName}
className="w-16 h-16 object-cover rounded"
/>
<button
onClick={() => handleRemoveAttachment(a.attachmentId)}
className="absolute top-0 right-0 bg-error text-on-error rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={t("attachment.remove")}
>
<Trash2 className="w-2.5 h-2.5" aria-hidden="true" />
</button>
</div>
))}
</div>
)}
</div>
)}
{showKpPicker && (
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-5素材库 picker */}
{showAttachmentPicker && planId && (
<LessonPlanErrorBoundary>
<AttachmentPicker
planId={planId}
blockId={blockId}
selectedIds={attachments.map((a) => a.attachmentId)}
onSelect={handleSelectAttachment}
onClose={() => setShowAttachmentPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}