Files
jiang13-forum/.trae/documents/emoji_rich_comment_plan.md
freefire 536707f812 feat: 新增贴纸系统,重构评论编辑器与内容处理
1. 新增多平台贴纸库:贴吧、微博、知乎、小红书、抖音、B站共500+贴纸,搭配颜文字贴纸
2. 实现贴纸选择器面板,支持分类浏览、键盘导航和懒加载
3. 重构评论内容渲染:替换旧的@提及高亮逻辑,新增HTML净化与双向兼容
4. 重构评论编辑器为Tiptap富文本版本,支持表情插入、格式编辑
5. 优化评论编辑体验,修复滚动位置保留问题,调整评论区UI样式
6. 移除旧版EmojiPicker工具类,新增贴纸数据管理模块
2026-08-08 02:24:04 +08:00

23 KiB
Raw Blame History

评论表情包改造 + 富文本评论编辑器计划

一、摘要

将评论系统从「纯文本 textarea + Unicode Emoji」升级为「Tiptap 富文本编辑器 + 姜十三专属 SVG 贴纸」,实现:

  1. 去除所有 Unicode Emoji替换为自定义 SVG 贴纸(懒加载)
  2. 姜十三专属表情包:萌系吉祥物表情 + 中文网络流行语文字气泡(混合风格)
  3. 评论复用帖子编辑器核心能力(精简变体),支持代码块、链接、图片、格式化等

二、当前状态分析

2.1 评论输入:纯文本 textarea

CommentBox.tsx 使用 <textarea> 输入评论,功能包括:

  • @ 用户提及textarea 选区扫描 + API 搜索用户)
  • Unicode Emoji 面板(EmojiPicker.tsx + emojis.ts
  • 隐私评论开关
  • 无富文本格式化能力

2.2 评论渲染:纯文本转义

CommentContent.tsx 通过 content.tshighlightMentions() 渲染评论:

  • escapeWithBreaks() 转义 HTML 并将 \n 转为 <br>
  • 正则匹配 @username 包裹为可点击 <span class="mention">
  • 不支持 HTML/Markdown 渲染

2.3 评论编辑:纯 textarea

CommentThreadList.tsx 编辑模式使用 <textarea> 直接修改文本。

2.4 帖子编辑器Tiptap 富文本

ArticleEditor.tsx 是功能完整的 Tiptap 编辑器900 行),包含:

  • 富文本模式 + Markdown 模式切换
  • 工具栏:标题/加粗/斜体/下划线/删除线/分割线/引用/列表/代码块/表格/链接/图片/图组
  • 门控区块:登录可见/回复可见/积分可见
  • 全屏模式
  • forwardRef 暴露 getHTML() / isEmpty() / focus()
  • 使用 DOMPurify + POST_CONTENT_PURIFY_CONFIG 净化 HTML

2.5 后端评论存储

  • models.go: Comment.Content 字段类型为 text,存储纯文本
  • comment.go: Create() 仅做 TrimSpace + 敏感词过滤,无 HTML 净化
  • handlers.go: APICreateComment 从 FormData 取 content 字段
  • sanitize_html.go: SanitizePostHTML 已有 HTML 白名单策略bluemonday仅用于帖子,未用于评论

2.6 表情数据

emojis.ts 定义约 160 个 Unicode Emoji 字符,无分类、无搜索。


三、修改方案

Part A姜十三专属 SVG 贴纸系统

A1. 贴纸数据定义

新建 frontend/src/data/stickers.ts

export interface Sticker {
  id: string;          // 唯一ID如 "j13-happy"
  name: string;        // 名称,如 "开心"
  category: StickerCategory;
  aliases?: string[];  // 搜索别名
  svg: string;         // 完整 SVG 字符串(含 viewBox 0 0 64 64
}

export type StickerCategory = '热门' | '姜十三' | '文字气泡';

export const STICKER_CATEGORIES: StickerCategory[] = ['热门', '姜十三', '文字气泡'];
export const STICKERS: Sticker[] = [ /* ... */ ];

贴纸内容设计(约 40 个):

分类 数量 内容
姜十三 16 萌系姜色ginger色 #D4A574圆脸吉祥物额头带"13"标记,各种表情:开心/大笑/哭泣/生气/惊讶/思考/点赞/心心眼/睡觉/疑惑/酷/捂脸/送花/鼓掌/加油/拜托
文字气泡 16 圆角气泡 + 中文网络流行语666/大佬/同问/给力/沙发/学习了/已赞/佩服/妙啊/牛批/感谢/收藏了/围观/催更/瑞思拜/芜湖
热门 8 从上述两类中精选最常用的 8 个

SVG 设计规范:

  • viewBox="0 0 64 64" 统一尺寸
  • 所有 fill 使用内联颜色(不依赖 CSS 变量)
  • 姜十三吉祥物主色系:#D4A574(姜色)/ #FFF3E0(浅姜)/ #E8B87C(深姜)
  • 文字气泡:#FF6B6B(红)/ #4ECDC4(青)/ #FFE66D(黄)/ #95E1D3(绿)四色循环
  • 线条圆润stroke-linecap: round

A2. SVG 贴纸渲染组件

新建 frontend/src/components/emoji/StickerSvg.tsx

interface StickerSvgProps {
  id: string;
  size?: number;       // 默认 28
  className?: string;
}
  • STICKERS 查找对应 id渲染 dangerouslySetInnerHTML={{ __html: sticker.svg }}
  • SVG 已自包含 fill 颜色,无需额外样式

A3. 贴纸选择器(懒加载)

新建 frontend/src/components/emoji/StickerPicker.tsx

interface StickerPickerProps {
  onSelect: (stickerId: string) => void;
}

UI 结构:

┌──────────────────────────────┐
│ [热门] [姜十三] [文字气泡]    │ ← 分类 Tab
├──────────────────────────────┤
│ [🙂] [😂] [❤️] [👍] [🎉] ... │ ← SVG 贴纸网格6列桌面/4列移动端
└──────────────────────────────┘

懒加载策略:

  • 贴纸数据按分类拆分为独立 chunkdata/stickers/j13.tsdata/stickers/text.tsdata/stickers/hot.ts
  • StickerPicker 使用 React.lazy() + Suspense 按需加载当前分类
  • 切换分类时才加载对应 chunk首次打开只加载"热门"分类
  • 每个贴纸 SVG 在组件挂载时渲染(已在数据中内联,无需额外网络请求)

交互:

  • 分类 Tab 点击切换,带下划线动画
  • 贴纸 hover: transform: scale(1.15), 0.1s 过渡
  • 点击贴纸触发 onSelect(sticker.id)
  • 键盘导航:方向键浏览 + Enter 选中

A4. 删除旧 Emoji 系统

  • 删除 emojis.tsEMOJI_LIST 导出)
  • 删除 EmojiPicker.tsx
  • 保留 .emoji-picker* CSS 类(避免影响其他可能的引用),新增 .sticker-picker*

Part B评论富文本编辑器精简变体

B1. 创建 CommentEditor 组件

新建 frontend/src/components/CommentEditor.tsx

不直接复用 ArticleEditor900 行,含全屏/Markdown/门控区块等评论不需要的功能),而是创建独立的精简 Tiptap 编辑器,复用 ArticleEditor 的扩展组件

export interface CommentEditorHandle {
  getHTML: () => string;
  isEmpty: () => boolean;
  focus: () => void;
}

interface CommentEditorProps {
  value: string;
  onChange: (html: string) => void;
  placeholder?: string;
}

使用的 Tiptap 扩展(复用现有):

  • StarterKit(含 heading H2-H4、bold/italic/strike、blockquote、bulletList/orderedList、horizontalRule
  • Underline(来自 @tiptap/extension-underline已安装
  • Link(来自 @tiptap/extension-link已安装
  • ArticleCodeBlock(来自 ArticleCodeBlockExtension.tsx,复用)
  • Placeholder(来自 @tiptap/extension-placeholder已安装
  • TabIndent(来自 TabIndentExtension.tsx,复用)
  • ArticleImage(来自 ArticleImageExtension.tsx,复用,用于插入图片)

不使用的扩展(评论场景不需要):

  • TableKit(表格)
  • ImageGroup(图组)
  • MembersOnly / ReplyOnly / PointsOnly(门控区块)
  • ClearFloatParagraph(清除浮动段落)
  • Markdown 模式
  • 全屏模式

工具栏按钮(精简版):

[H] [B] [I] [U] [S] [引用] [列表] [有序列表] [代码块] [链接] [图片] [贴纸]

贴纸集成:

  • 工具栏增加贴纸按钮Lucide Sticker 图标)
  • 点击弹出 StickerPicker
  • 选中贴纸后,将 SVG 作为 inline <img> 插入编辑器:
    const sticker = STICKERS.find(s => s.id === id);
    const dataUri = `data:image/svg+xml,${encodeURIComponent(sticker.svg)}`;
    editor.chain().focus().setImage({ src: dataUri, alt: sticker.name }).run();
    

HTML 净化:

  • 使用 DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) 与 ArticleEditor 一致
  • POST_CONTENT_PURIFY_CONFIG 来自 postContent.ts,复用现有配置

B2. 改造 CommentBox

修改 CommentBox.tsx

主要变更:

  1. CommentEditor 替换 <textarea>
  2. CommentEditorHandle ref 替代 textareaRef
  3. content 状态存储 HTML 而非纯文本
  4. 移除 EmojiPicker 导入和使用,贴纸功能已集成到 CommentEditor
  5. 移除 owoRefshowEmoji 状态
  6. @ 提及功能:暂时保留为文本输入(在编辑器中输入 @username,渲染时由 processCommentHtml 处理高亮),编辑器内自动补全作为后续增强
  7. insertEmoji 改为 insertSticker,调用 CommentEditor ref 方法
  8. 隐私评论开关保留
  9. 发送时 content 为 HTML直接传给 API

改动前:

<textarea ref={textareaRef} value={content} onChange={handleChange} ... />
<button ref={owoRef} onClick={() => setShowEmoji(v => !v)}>OwO</button>
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}

改动后:

<CommentEditor ref={editorRef} value={content} onChange={setContent} placeholder="说点什么吧…" />

B3. 改造评论编辑模式

修改 CommentThreadList.tsx

编辑模式从 <textarea> 改为 CommentEditor

// 改动前
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={3} />

// 改动后
<CommentEditor value={editText} onChange={setEditText} placeholder="编辑评论…" />

需要 import CommentEditor,并在 handleSave 中提交 HTML 内容。


Part C评论内容渲染

C1. 更新 content.ts 支持富文本

修改 content.ts

当前 highlightMentions() 处理纯文本(转义 HTML + 换行 + @高亮)。需要新增 HTML 处理能力。

import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';

/** 判断内容是否为 HTML包含常见 HTML 标签) */
function isHtmlContent(text: string): boolean {
  return /<(?:p|div|span|br|h[1-6]|ul|ol|li|pre|code|blockquote|a|img|table|strong|em|u|s)\b/i.test(text);
}

/** 在 HTML 文本节点中高亮 @ 提及DOM 遍历,避免破坏标签) */
function processMentionsInHtml(html: string): string {
  const div = document.createElement('div');
  div.innerHTML = html;
  const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT);
  const textNodes: Text[] = [];
  let node: Node | null;
  while ((node = walker.nextNode())) {
    textNodes.push(node as Text);
  }
  for (const textNode of textNodes) {
    const text = textNode.textContent ?? '';
    if (!/@[\w\u4e00-\u9fa5_-]/.test(text)) continue;
    const frag = document.createDocumentFragment();
    const parts = text.split(/(@[\w\u4e00-\u9fa5_-]+)/);
    for (const part of parts) {
      const m = part.match(/^@([\w\u4e00-\u9fa5_-]+)$/);
      if (m) {
        const span = document.createElement('span');
        span.className = 'mention';
        span.setAttribute('data-name', m[1]);
        span.setAttribute('role', 'link');
        span.setAttribute('tabindex', '0');
        span.textContent = part;
        frag.appendChild(span);
      } else if (part) {
        frag.appendChild(document.createTextNode(part));
      }
    }
    textNode.parentNode?.replaceChild(frag, textNode);
  }
  return div.innerHTML;
}

/** 渲染评论内容HTML 净化 + @提及高亮,兼容旧版纯文本 */
export function renderCommentContent(content: string): string {
  if (isHtmlContent(content)) {
    // 新版 HTML 评论
    const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG);
    return processMentionsInHtml(sanitized);
  }
  // 旧版纯文本评论(向后兼容)
  return escapeWithBreaks(content).replace(
    /@([\w\u4e00-\u9fa5_-]+)/g,
    '<span class="mention" data-name="$1" role="link" tabindex="0">@$1</span>',
  );
}

保留原 highlightMentions() 函数不删除(可能有其他引用),新增 renderCommentContent()

C2. 更新 CommentContent 组件

修改 CommentContent.tsx

import { renderCommentContent } from '../utils/content';

// 改动前
dangerouslySetInnerHTML={{ __html: highlightMentions(content) }}

// 改动后
dangerouslySetInnerHTML={{ __html: renderCommentContent(content) }}

评论内容中的贴纸 <img> 标签会被 POST_CONTENT_PURIFY_CONFIG 保留(已允许 <img> + src),自然渲染为 SVG 图。


Part D后端评论 HTML 净化

D1. 评论创建时净化

修改 comment.go

func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
    content := SanitizePostHTML(strings.TrimSpace(in.Content))  // 新增 HTML 净化
    content = s.filter.Filter(content)                           // 敏感词过滤
    // ... 其余不变
}

D2. 评论更新时净化

修改 comment.go

func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
    // ...
    content = SanitizePostHTML(strings.TrimSpace(content))  // 新增
    content = s.filter.Filter(content)                       // 敏感词过滤
    // ... 其余不变
}

SanitizePostHTML 已在 sanitize_html.go 中定义,使用 bluemonday 白名单策略,允许 Tiptap 产出的 HTML 标签和属性,禁止 <script><style> 等。


Part ECSS 样式

E1. 贴纸选择器样式

修改 global.css 区域

新增样式块(在现有 .emoji-picker* 样式之后):

/* 贴纸选择器 */
.sticker-picker {
  display: flex;
  flex-direction: column;
  margin-top: 8px;
  border: 1px solid var(--j13-border-light);
  border-radius: 8px;
  background: var(--j13-bg-surface);
  max-height: 280px;
  overflow: hidden;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}

.sticker-picker-tabs {
  display: flex;
  border-bottom: 1px solid var(--j13-border-light);
  padding: 0 8px;
}

.sticker-picker-tab {
  border: none;
  background: none;
  padding: 8px 12px;
  font-size: 13px;
  color: var(--color-text-3);
  cursor: pointer;
  border-bottom: 2px solid transparent;
  transition: color 0.15s, border-color 0.15s;
}

.sticker-picker-tab.active {
  color: var(--j13-green);
  border-bottom-color: var(--j13-green);
}

.sticker-picker-grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 4px;
  padding: 10px;
  overflow-y: auto;
  flex: 1;
}

.sticker-picker-item {
  border: none;
  background: none;
  padding: 4px;
  cursor: pointer;
  border-radius: 8px;
  transition: background 0.1s, transform 0.1s;
  display: flex;
  align-items: center;
  justify-content: center;
}

.sticker-picker-item:hover {
  background: var(--color-fill-2);
  transform: scale(1.15);
}

/* 贴纸加载占位 */
.sticker-picker-loading {
  grid-column: 1 / -1;
  text-align: center;
  padding: 24px;
  color: var(--color-text-4);
  font-size: 13px;
}

/* 移动端 */
@media (max-width: 640px) {
  .sticker-picker-grid {
    grid-template-columns: repeat(4, 1fr);
  }
}

E2. 评论编辑器样式

新增 .comment-editor 相关样式,复用 .article-editor-bar.article-tool-btn 等现有类名,仅做覆盖调整:

/* 评论富文本编辑器 */
.comment-editor .article-editor-bar {
  padding: 4px 8px;
}

.comment-editor .article-tool-btn {
  width: 28px;
  height: 28px;
}

.comment-editor .article-editor-content {
  min-height: 80px;
  max-height: 300px;
  overflow-y: auto;
  padding: 8px 12px;
  font-size: 14px;
}

.comment-editor .article-editor-status {
  padding: 4px 10px;
}

/* 评论内贴纸图片 */
.comment-sticker {
  display: inline-block;
  vertical-align: middle;
  width: 28px;
  height: 28px;
  margin: 0 2px;
}

四、文件变更清单

新增文件

文件路径 用途
frontend/src/data/stickers/hot.ts 热门贴纸数据(懒加载 chunk
frontend/src/data/stickers/j13.ts 姜十三吉祥物贴纸数据(懒加载 chunk
frontend/src/data/stickers/text.ts 文字气泡贴纸数据(懒加载 chunk
frontend/src/data/stickers/index.ts 贴纸类型定义 + 统一导出
frontend/src/components/emoji/StickerSvg.tsx SVG 贴纸渲染组件
frontend/src/components/emoji/StickerPicker.tsx 贴纸选择器(懒加载)
frontend/src/components/CommentEditor.tsx 评论富文本编辑器(精简 Tiptap

修改文件

文件 变更
CommentBox.tsx textarea → CommentEditor移除 EmojiPickercontent 改为 HTML
CommentThreadList.tsx 编辑模式 textarea → CommentEditor
CommentContent.tsx 使用 renderCommentContent() 替代 highlightMentions()
content.ts 新增 renderCommentContent() + processMentionsInHtml()
global.css 新增 .sticker-picker*.comment-editor* 样式
comment.go Create() 增加 SanitizePostHTML
comment.go Update() 增加 SanitizePostHTML

删除文件

文件 原因
emojis.ts Unicode Emoji 数据不再需要
EmojiPicker.tsx 被 StickerPicker 替代

五、假设与决策

5.1 编辑器决策

  • 不直接复用 ArticleEditor 组件,而是创建独立的 CommentEditor原因
    • ArticleEditor 900 行,含全屏/Markdown/门控区块等评论不需要的功能
    • 复用 ArticleEditor 的 Tiptap 扩展组件ArticleCodeBlock、ArticleImage、TabIndent 等),避免代码重复
    • CommentEditor 更轻量,每个评论实例加载更快
  • @ 提及:暂不在编辑器中实现自动补全(需要 Tiptap Mention 扩展或自定义扩展),用户手动输入 @username,渲染时由 processMentionsInHtml 高亮。后续可增强为 Tiptap Mention 扩展。

5.2 贴纸存储格式

  • 贴纸以 data:image/svg+xml URI 内联在 <img src="...">
  • 每个 SVG 约 300-800 字节,单条评论即使 10 个贴纸也仅 ~8KB
  • 后端 SanitizePostHTML 白名单已允许 <img src="...">,无需额外修改
  • 旧评论(纯文本)不受影响,renderCommentContent 自动检测并兼容

5.3 懒加载策略

  • 贴纸数据按分类拆分为 3 个 chunkhot/j13/textReact.lazy() 动态导入
  • 首次打开选择器只加载"热门"chunk8 个贴纸),切换分类时才加载其他 chunk
  • 每个 chunk 约 5-10KB加载延迟 < 100ms

5.4 向后兼容

  • 旧评论为纯文本,新 renderCommentContent() 通过 isHtmlContent() 检测自动走旧路径escapeWithBreaks + 正则高亮)
  • 新评论为 HTML走 DOMPurify 净化 + DOM 遍历高亮路径
  • 后端 SanitizePostHTML 对纯文本也安全bluemonday 会保留纯文本,仅过滤危险标签)

六、实施顺序

  1. Part A:贴纸数据 + 组件stickers/*.ts → StickerSvg → StickerPicker
  2. Part D:后端评论 HTML 净化comment.go Create/Update 加 SanitizePostHTML
  3. Part BCommentEditor 组件 → CommentBox 集成 → CommentThreadList 编辑模式
  4. Part Ccontent.ts 渲染函数 → CommentContent 更新
  5. Part ECSS 样式
  6. 删除旧 EmojiPicker / emojis.ts
  7. 验证测试

七、验证步骤

  1. 贴纸系统验证

    • 点击贴纸按钮选择器弹出3 个分类 Tab 可切换
    • 切换分类时加载对应贴纸Network 面板确认懒加载)
    • 点击贴纸后编辑器中出现对应 SVG 图
  2. 评论编辑器验证

    • 评论框支持加粗/斜体/下划线/删除线/标题/引用/列表/代码块/链接/图片/贴纸
    • 代码块支持语法高亮和折叠
    • 图片可上传并插入
    • 无全屏/Markdown/表格/门控区块按钮
  3. 评论渲染验证

    • 新评论 HTML 正确渲染(格式化、代码块、图片、贴纸)
    • @username 在 HTML 评论中正确高亮为可点击链接
    • 旧评论(纯文本)仍正常渲染
    • 贴纸图片在评论中正确显示
  4. 后端验证

    • 发送含 <script>alert(1)</script> 的评论,后端净化后移除 script 标签
    • 发送正常 HTML 评论,后端存储完整 HTML
    • 旧纯文本评论正常存储和渲染
  5. 编辑模式验证

    • 编辑已有评论时CommentEditor 正确加载 HTML 内容
    • 保存后评论更新正确