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