Compare commits
2 Commits
4042395e3e
...
536707f812
| Author | SHA1 | Date | |
|---|---|---|---|
| 536707f812 | |||
| 1550ccb693 |
151
.trae/documents/emoji-effect-adjustment.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# 表情效果调整计划
|
||||||
|
|
||||||
|
## 问题分析
|
||||||
|
|
||||||
|
当前实现存在 5 个问题,根因如下:
|
||||||
|
|
||||||
|
### 1. 插入表情后有选择状态(蓝色高亮)
|
||||||
|
- **根因**: Tiptap `setImage` 插入图片节点后,节点处于 "node-selected" 状态,显示蓝色选中框
|
||||||
|
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L155-163
|
||||||
|
|
||||||
|
### 2. 输入框表情太大
|
||||||
|
- **根因**: CSS 选择器 `img[src^="data:image/svg"]` 只匹配旧 SVG data URI,不匹配新的 AVIF URL(`/stickers/tieba/tb_01.avif`),导致表情图片无尺寸约束,以原始大尺寸渲染
|
||||||
|
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5584-5603
|
||||||
|
|
||||||
|
### 3. 插入表情后光标换行
|
||||||
|
- **根因**: `ArticleImage.configure({ inline: false })` 使图片为块级节点,插入后自动换行
|
||||||
|
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L92
|
||||||
|
|
||||||
|
### 4. 表情栏目太宽松
|
||||||
|
- **根因**: Grid 仅 6 列,gap 4px,padding 10px
|
||||||
|
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5502-5510
|
||||||
|
|
||||||
|
### 5. 颜文字太小
|
||||||
|
- **根因**: `.sticker-picker-text` 的 `font-size: 8px`,极小
|
||||||
|
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5606-5616
|
||||||
|
|
||||||
|
## 改动方案
|
||||||
|
|
||||||
|
### 文件 1: CommentEditor.tsx
|
||||||
|
**改动 A — 图片改为内联** (L92):
|
||||||
|
```typescript
|
||||||
|
// 旧
|
||||||
|
ArticleImage.configure({ inline: false, allowBase64: true }),
|
||||||
|
// 新
|
||||||
|
ArticleImage.configure({ inline: true, allowBase64: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 B — insertSticker 插入后取消选中和换行** (L155-163):
|
||||||
|
```typescript
|
||||||
|
const insertSticker = useCallback((sticker: Sticker) => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (sticker.type === 'text' && sticker.text) {
|
||||||
|
editor.chain().focus().insertContent(sticker.text).run();
|
||||||
|
} else if (sticker.url) {
|
||||||
|
// 插入内联图片 + 尾随零宽空格,确保光标在图片后面而非选中图片
|
||||||
|
editor.chain().focus().insertContent([
|
||||||
|
{ type: 'image', attrs: { src: sticker.url, alt: sticker.name } },
|
||||||
|
{ type: 'text', text: '\u200b' },
|
||||||
|
]).run();
|
||||||
|
}
|
||||||
|
setShowSticker(false);
|
||||||
|
}, [editor]);
|
||||||
|
```
|
||||||
|
> 用 `insertContent` + 零宽空格替代 `setImage`,避免 node-selected 状态,光标自然落在图片后。
|
||||||
|
|
||||||
|
### 文件 2: StickerPicker.tsx
|
||||||
|
**改动 C — 键盘导航列数匹配新网格** (L44):
|
||||||
|
```typescript
|
||||||
|
// 旧
|
||||||
|
const cols = 6;
|
||||||
|
// 新
|
||||||
|
const cols = 8;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件 3: global.css
|
||||||
|
**改动 D — 表情选择器密度提升** (L5502-5510):
|
||||||
|
```css
|
||||||
|
.sticker-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr); /* 6 → 8 */
|
||||||
|
gap: 2px; /* 4px → 2px */
|
||||||
|
padding: 6px; /* 10px → 6px */
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 E — 选择器项缩小** (L5512-5522):
|
||||||
|
```css
|
||||||
|
.sticker-picker-item {
|
||||||
|
padding: 2px; /* 4px → 2px */
|
||||||
|
/* 其余不变 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 F — 颜文字字体放大** (L5606-5616):
|
||||||
|
```css
|
||||||
|
.sticker-picker-text {
|
||||||
|
font-size: 14px; /* 8px → 14px */
|
||||||
|
line-height: 1.4; /* 1.2 → 1.4 */
|
||||||
|
/* 其余不变 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 G — 替换旧 SVG 选择器为通用贴纸选择器** (L5584-5603):
|
||||||
|
|
||||||
|
删除旧的 `img[src^="data:image/svg"]` 选择器,替换为基于 `/stickers/` 路径的选择器:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 编辑器内贴纸 img 尺寸约束 */
|
||||||
|
.comment-editor .article-prosemirror img[src*="/stickers/"],
|
||||||
|
.comment-editor .article-editor-content img[src*="/stickers/"] {
|
||||||
|
display: inline-block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评论正文中的贴纸 img */
|
||||||
|
.floor-body img[src*="/stickers/"],
|
||||||
|
.comment-body img[src*="/stickers/"] {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0 1px;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 H — 移动端网格也改为 8 列** (L5619-5624):
|
||||||
|
```css
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sticker-picker { max-height: 240px; }
|
||||||
|
.sticker-picker-grid { grid-template-columns: repeat(6, 1fr); } /* 移动端 6 列 */
|
||||||
|
.sticker-picker-tab { padding: 6px 10px; font-size: 12px; }
|
||||||
|
.comment-editor .article-tool-btn { width: 30px; height: 30px; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> 移动端保持 6 列(屏幕窄),桌面端 8 列。
|
||||||
|
|
||||||
|
## 不改动
|
||||||
|
- `ArticleImageExtension.tsx` — 无需修改,`inline: true` 通过 `configure()` 传入即可
|
||||||
|
- `kaomoji.ts` / `emojiData.ts` / `hot.ts` — 数据层不变
|
||||||
|
- `CommentContent.tsx` — 渲染层不变(CSS 覆盖即可)
|
||||||
|
- PostEditor(帖子编辑器)— 不受影响,仍使用 `inline: false`
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
1. `npm run build` 无报错
|
||||||
|
2. 浏览器验证:
|
||||||
|
- 选择表情后插入无蓝色选中框
|
||||||
|
- 表情在输入框中显示 28px,内联在文字中
|
||||||
|
- 插入表情后光标紧跟表情后方,不换行
|
||||||
|
- 表情选择器 8 列密度更高
|
||||||
|
- 颜文字标签内字体清晰可读
|
||||||
597
.trae/documents/emoji_rich_comment_plan.md
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
# 评论表情包改造 + 富文本评论编辑器计划
|
||||||
|
|
||||||
|
## 一、摘要
|
||||||
|
|
||||||
|
将评论系统从「纯文本 textarea + Unicode Emoji」升级为「Tiptap 富文本编辑器 + 姜十三专属 SVG 贴纸」,实现:
|
||||||
|
1. 去除所有 Unicode Emoji,替换为自定义 SVG 贴纸(懒加载)
|
||||||
|
2. 姜十三专属表情包:萌系吉祥物表情 + 中文网络流行语文字气泡(混合风格)
|
||||||
|
3. 评论复用帖子编辑器核心能力(精简变体),支持代码块、链接、图片、格式化等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、当前状态分析
|
||||||
|
|
||||||
|
### 2.1 评论输入:纯文本 textarea
|
||||||
|
|
||||||
|
[CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) 使用 `<textarea>` 输入评论,功能包括:
|
||||||
|
- `@` 用户提及(textarea 选区扫描 + API 搜索用户)
|
||||||
|
- Unicode Emoji 面板([EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx) + [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts))
|
||||||
|
- 隐私评论开关
|
||||||
|
- 无富文本格式化能力
|
||||||
|
|
||||||
|
### 2.2 评论渲染:纯文本转义
|
||||||
|
|
||||||
|
[CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) 通过 [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) 的 `highlightMentions()` 渲染评论:
|
||||||
|
- `escapeWithBreaks()` 转义 HTML 并将 `\n` 转为 `<br>`
|
||||||
|
- 正则匹配 `@username` 包裹为可点击 `<span class="mention">`
|
||||||
|
- 不支持 HTML/Markdown 渲染
|
||||||
|
|
||||||
|
### 2.3 评论编辑:纯 textarea
|
||||||
|
|
||||||
|
[CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) 编辑模式使用 `<textarea>` 直接修改文本。
|
||||||
|
|
||||||
|
### 2.4 帖子编辑器:Tiptap 富文本
|
||||||
|
|
||||||
|
[ArticleEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/ArticleEditor.tsx) 是功能完整的 Tiptap 编辑器(900 行),包含:
|
||||||
|
- 富文本模式 + Markdown 模式切换
|
||||||
|
- 工具栏:标题/加粗/斜体/下划线/删除线/分割线/引用/列表/代码块/表格/链接/图片/图组
|
||||||
|
- 门控区块:登录可见/回复可见/积分可见
|
||||||
|
- 全屏模式
|
||||||
|
- `forwardRef` 暴露 `getHTML()` / `isEmpty()` / `focus()`
|
||||||
|
- 使用 `DOMPurify` + `POST_CONTENT_PURIFY_CONFIG` 净化 HTML
|
||||||
|
|
||||||
|
### 2.5 后端评论存储
|
||||||
|
|
||||||
|
- [models.go](file:///c:/Users/freefire/Documents/jiang13-forum/model/models.go#L143-L164): `Comment.Content` 字段类型为 `text`,存储纯文本
|
||||||
|
- [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176): `Create()` 仅做 `TrimSpace` + 敏感词过滤,**无 HTML 净化**
|
||||||
|
- [handlers.go](file:///c:/Users/freefire/Documents/jiang13-forum/handler/handlers.go#L508-L530): `APICreateComment` 从 FormData 取 `content` 字段
|
||||||
|
- [sanitize_html.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go): `SanitizePostHTML` 已有 HTML 白名单策略(bluemonday),但**仅用于帖子,未用于评论**
|
||||||
|
|
||||||
|
### 2.6 表情数据
|
||||||
|
|
||||||
|
[emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) 定义约 160 个 Unicode Emoji 字符,无分类、无搜索。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、修改方案
|
||||||
|
|
||||||
|
### Part A:姜十三专属 SVG 贴纸系统
|
||||||
|
|
||||||
|
#### A1. 贴纸数据定义
|
||||||
|
|
||||||
|
**新建** `frontend/src/data/stickers.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StickerSvgProps {
|
||||||
|
id: string;
|
||||||
|
size?: number; // 默认 28
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 从 `STICKERS` 查找对应 id,渲染 `dangerouslySetInnerHTML={{ __html: sticker.svg }}`
|
||||||
|
- SVG 已自包含 fill 颜色,无需额外样式
|
||||||
|
|
||||||
|
#### A3. 贴纸选择器(懒加载)
|
||||||
|
|
||||||
|
**新建** `frontend/src/components/emoji/StickerPicker.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StickerPickerProps {
|
||||||
|
onSelect: (stickerId: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI 结构:**
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ [热门] [姜十三] [文字气泡] │ ← 分类 Tab
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ [🙂] [😂] [❤️] [👍] [🎉] ... │ ← SVG 贴纸网格(6列桌面/4列移动端)
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**懒加载策略:**
|
||||||
|
- 贴纸数据按分类拆分为独立 chunk:`data/stickers/j13.ts`、`data/stickers/text.ts`、`data/stickers/hot.ts`
|
||||||
|
- `StickerPicker` 使用 `React.lazy()` + `Suspense` 按需加载当前分类
|
||||||
|
- 切换分类时才加载对应 chunk,首次打开只加载"热门"分类
|
||||||
|
- 每个贴纸 SVG 在组件挂载时渲染(已在数据中内联,无需额外网络请求)
|
||||||
|
|
||||||
|
**交互:**
|
||||||
|
- 分类 Tab 点击切换,带下划线动画
|
||||||
|
- 贴纸 hover: `transform: scale(1.15)`, 0.1s 过渡
|
||||||
|
- 点击贴纸触发 `onSelect(sticker.id)`
|
||||||
|
- 键盘导航:方向键浏览 + Enter 选中
|
||||||
|
|
||||||
|
#### A4. 删除旧 Emoji 系统
|
||||||
|
|
||||||
|
- **删除** [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts)(`EMOJI_LIST` 导出)
|
||||||
|
- **删除** [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx)
|
||||||
|
- **保留** `.emoji-picker*` CSS 类(避免影响其他可能的引用),新增 `.sticker-picker*` 类
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part B:评论富文本编辑器(精简变体)
|
||||||
|
|
||||||
|
#### B1. 创建 CommentEditor 组件
|
||||||
|
|
||||||
|
**新建** `frontend/src/components/CommentEditor.tsx`
|
||||||
|
|
||||||
|
不直接复用 ArticleEditor(900 行,含全屏/Markdown/门控区块等评论不需要的功能),而是创建独立的精简 Tiptap 编辑器,**复用 ArticleEditor 的扩展组件**。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleCodeBlockExtension.tsx),复用)
|
||||||
|
- `Placeholder`(来自 @tiptap/extension-placeholder,已安装)
|
||||||
|
- `TabIndent`(来自 [TabIndentExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/TabIndentExtension.tsx),复用)
|
||||||
|
- `ArticleImage`(来自 [ArticleImageExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleImageExtension.tsx),复用,用于插入图片)
|
||||||
|
|
||||||
|
**不使用的扩展(评论场景不需要):**
|
||||||
|
- ~~TableKit~~(表格)
|
||||||
|
- ~~ImageGroup~~(图组)
|
||||||
|
- ~~MembersOnly / ReplyOnly / PointsOnly~~(门控区块)
|
||||||
|
- ~~ClearFloatParagraph~~(清除浮动段落)
|
||||||
|
- ~~Markdown 模式~~
|
||||||
|
- ~~全屏模式~~
|
||||||
|
|
||||||
|
**工具栏按钮(精简版):**
|
||||||
|
```
|
||||||
|
[H] [B] [I] [U] [S] [引用] [列表] [有序列表] [代码块] [链接] [图片] [贴纸]
|
||||||
|
```
|
||||||
|
|
||||||
|
**贴纸集成:**
|
||||||
|
- 工具栏增加贴纸按钮(Lucide `Sticker` 图标)
|
||||||
|
- 点击弹出 `StickerPicker`
|
||||||
|
- 选中贴纸后,将 SVG 作为 inline `<img>` 插入编辑器:
|
||||||
|
```typescript
|
||||||
|
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](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/postContent.ts),复用现有配置
|
||||||
|
|
||||||
|
#### B2. 改造 CommentBox
|
||||||
|
|
||||||
|
**修改** [CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx)
|
||||||
|
|
||||||
|
**主要变更:**
|
||||||
|
1. 用 `CommentEditor` 替换 `<textarea>`
|
||||||
|
2. 用 `CommentEditorHandle` ref 替代 `textareaRef`
|
||||||
|
3. `content` 状态存储 HTML 而非纯文本
|
||||||
|
4. 移除 `EmojiPicker` 导入和使用,贴纸功能已集成到 `CommentEditor`
|
||||||
|
5. 移除 `owoRef` 和 `showEmoji` 状态
|
||||||
|
6. `@` 提及功能:暂时保留为文本输入(在编辑器中输入 `@username`,渲染时由 `processCommentHtml` 处理高亮),编辑器内自动补全作为后续增强
|
||||||
|
7. `insertEmoji` 改为 `insertSticker`,调用 `CommentEditor` ref 方法
|
||||||
|
8. 隐私评论开关保留
|
||||||
|
9. 发送时 `content` 为 HTML,直接传给 API
|
||||||
|
|
||||||
|
**改动前:**
|
||||||
|
```tsx
|
||||||
|
<textarea ref={textareaRef} value={content} onChange={handleChange} ... />
|
||||||
|
<button ref={owoRef} onClick={() => setShowEmoji(v => !v)}>OwO</button>
|
||||||
|
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动后:**
|
||||||
|
```tsx
|
||||||
|
<CommentEditor ref={editorRef} value={content} onChange={setContent} placeholder="说点什么吧…" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B3. 改造评论编辑模式
|
||||||
|
|
||||||
|
**修改** [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297)
|
||||||
|
|
||||||
|
编辑模式从 `<textarea>` 改为 `CommentEditor`:
|
||||||
|
```tsx
|
||||||
|
// 改动前
|
||||||
|
<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](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts)
|
||||||
|
|
||||||
|
当前 `highlightMentions()` 处理纯文本(转义 HTML + 换行 + @高亮)。需要新增 HTML 处理能力。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx)
|
||||||
|
|
||||||
|
```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](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176)
|
||||||
|
|
||||||
|
```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](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376)
|
||||||
|
|
||||||
|
```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](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go) 中定义,使用 bluemonday 白名单策略,允许 Tiptap 产出的 HTML 标签和属性,禁止 `<script>`、`<style>` 等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part E:CSS 样式
|
||||||
|
|
||||||
|
#### E1. 贴纸选择器样式
|
||||||
|
|
||||||
|
**修改** [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) 区域
|
||||||
|
|
||||||
|
新增样式块(在现有 `.emoji-picker*` 样式之后):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 贴纸选择器 */
|
||||||
|
.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` 等现有类名,仅做覆盖调整:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 评论富文本编辑器 */
|
||||||
|
.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](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) | textarea → CommentEditor,移除 EmojiPicker,content 改为 HTML |
|
||||||
|
| [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) | 编辑模式 textarea → CommentEditor |
|
||||||
|
| [CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) | 使用 `renderCommentContent()` 替代 `highlightMentions()` |
|
||||||
|
| [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) | 新增 `renderCommentContent()` + `processMentionsInHtml()` |
|
||||||
|
| [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) | 新增 `.sticker-picker*` 和 `.comment-editor*` 样式 |
|
||||||
|
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176) | Create() 增加 `SanitizePostHTML` |
|
||||||
|
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376) | Update() 增加 `SanitizePostHTML` |
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
|
||||||
|
| 文件 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) | Unicode Emoji 数据不再需要 |
|
||||||
|
| [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/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 个 chunk(hot/j13/text),`React.lazy()` 动态导入
|
||||||
|
- 首次打开选择器只加载"热门"chunk(8 个贴纸),切换分类时才加载其他 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 B**:CommentEditor 组件 → CommentBox 集成 → CommentThreadList 编辑模式
|
||||||
|
4. **Part C**:content.ts 渲染函数 → CommentContent 更新
|
||||||
|
5. **Part E**:CSS 样式
|
||||||
|
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 内容
|
||||||
|
- 保存后评论更新正确
|
||||||
117
.trae/documents/fix-post-scroll-persist-plan.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 修复:再次进入帖子时滚动位置异常保留
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
浏览帖子滑到下方 → 返回主页 → 再次进入同一帖子,滚动条停在之前阅读的位置,而非顶部。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
项目未设置 `history.scrollRestoration`(默认 `'auto'`)。浏览器在导航时会对**内部滚动容器**(`overflow: auto` 的 `.page-wrap`)执行滚动位置恢复。虽然 PostDetailPage 重新挂载后 `.page-wrap` 是新 DOM 元素,浏览器仍会在 paint 前将其 `scrollTop` 恢复到上次记录的值。
|
||||||
|
|
||||||
|
项目没有 ScrollToTop 机制来覆盖此行为。
|
||||||
|
|
||||||
|
## 与刷新恢复的冲突
|
||||||
|
|
||||||
|
之前实现的 `useScrollRestoration`(MainLayout mount-only)负责刷新场景的位置恢复。本修复必须与之共存:
|
||||||
|
|
||||||
|
| 场景 | 期望行为 | 处理者 |
|
||||||
|
|---|---|---|
|
||||||
|
| 刷新帖子页 | 恢复到上次位置 | `useScrollRestoration`(sessionStorage + rAF) |
|
||||||
|
| SPA 导航进入帖子 | 从顶部开始 | 本修复(重置 scrollTop=0) |
|
||||||
|
| `/post/123` → `/post/456` | 从顶部开始 | 本修复(重置 scrollTop=0) |
|
||||||
|
|
||||||
|
**区分依据**:`pagehide` 时 `saveScrollPositions` 将当前 URL 的滚动记录存入 sessionStorage。刷新后该记录存在;SPA 导航进入时该记录不存在(从未为此 URL 保存过,或已被 `restoreScrollPositions` 消费清除)。因此 PostDetailPage 渲染 `.page-wrap` 时检查 sessionStorage 是否有当前 URL 的记录即可区分两种场景。
|
||||||
|
|
||||||
|
**时序保证**:React 的 effect 执行顺序是子组件先于父组件。PostDetailPage 的 `useLayoutEffect` 在 MainLayout 的 `useEffect`(含 `useScrollRestoration`)之前执行。此时 sessionStorage 记录尚未被消费,`hasPendingScrollRestore()` 返回准确值。
|
||||||
|
|
||||||
|
## 改动计划
|
||||||
|
|
||||||
|
### 1. `frontend/src/utils/scrollRestore.ts` — 新增 `hasPendingScrollRestore`
|
||||||
|
|
||||||
|
在现有文件中新增导出函数:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** 检查 sessionStorage 中是否有指定 URL 的待恢复滚动记录(不消费/不删除) */
|
||||||
|
export function hasPendingScrollRestore(url: string = getCurrentUrl()): boolean {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(storageKey(url));
|
||||||
|
if (!raw) return false;
|
||||||
|
const entry = JSON.parse(raw) as SavedPositions;
|
||||||
|
return !!entry?.containers && Object.keys(entry.containers).length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
纯读取,不删除记录(`restoreScrollPositions` 的 rAF 循环仍需要它来恢复)。
|
||||||
|
|
||||||
|
### 2. 新建 `frontend/src/hooks/useScrollToTopOnMount.ts`
|
||||||
|
|
||||||
|
通用 hook,在滚动容器渲染就绪后重置到顶部(刷新场景跳过):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||||
|
import { hasPendingScrollRestore } from '../utils/scrollRestore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动容器渲染就绪后重置到顶部。
|
||||||
|
* - 首次就绪:刷新场景(sessionStorage 有记录)跳过,让 useScrollRestoration 恢复;
|
||||||
|
* SPA 导航(无记录)重置到顶部。
|
||||||
|
* - deps 变化(如 postId 变化):始终重置到顶部。
|
||||||
|
* - 同 deps 的 ready 状态变化(如评论刷新导致 loading 短暂为 true):不处理,避免误重置。
|
||||||
|
*/
|
||||||
|
export function useScrollToTopOnMount(
|
||||||
|
scrollRef: RefObject<HTMLElement | null>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
ready: boolean,
|
||||||
|
): void {
|
||||||
|
const lastKeyRef = useRef<string | null>(null);
|
||||||
|
const key = JSON.stringify(deps);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ready || !scrollRef.current) return;
|
||||||
|
|
||||||
|
if (lastKeyRef.current === null) {
|
||||||
|
lastKeyRef.current = key;
|
||||||
|
if (!hasPendingScrollRestore()) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastKeyRef.current !== key) {
|
||||||
|
lastKeyRef.current = key;
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [key, ready]); // ready 变化时重新检查
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `frontend/src/pages/PostDetailPage.tsx` — 调用 hook
|
||||||
|
|
||||||
|
在 `pageRef` 定义之后(L108 之后)、`useGlobalWheelScroll` 调用处附近添加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useScrollToTopOnMount } from '../hooks/useScrollToTopOnMount';
|
||||||
|
// ...
|
||||||
|
useScrollToTopOnMount(pageRef, [postId], !loading && !!post);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `deps: [postId]` — `/post/123` → `/post/456` 时重置
|
||||||
|
- `ready: !loading && !!post` — `.page-wrap` 仅在此时渲染(L446 loading return、L447 !post return),pageRef.current 才有效
|
||||||
|
|
||||||
|
### 不需要改动的部分
|
||||||
|
|
||||||
|
- **其他页面**(FavoritesPage、MessagesPage、ProfilePage 等):用户仅报告了帖子页问题。这些页面内容较短,滚动保留不明显。如后续报告可复用此 hook。
|
||||||
|
- **MainLayout / AdminLayout**:`useScrollRestoration` 不变。
|
||||||
|
- **`history.scrollRestoration`**:不设为 `'manual'`,避免影响 back/forward 时其他页面的浏览器原生恢复。
|
||||||
|
|
||||||
|
## 验证步骤
|
||||||
|
|
||||||
|
1. **SPA 导航进入帖子**:从首页点击帖子 → 滑到下方 → 返回首页 → 再次点击同一帖子 → 应从顶部开始。
|
||||||
|
2. **帖子间切换**:`/post/123` 滑到下方 → 直接导航到 `/post/456` → 应从顶部开始。
|
||||||
|
3. **刷新保持位置**:在帖子页滑到下方 → F5 刷新 → 应恢复到原位置(`useScrollRestoration` 生效,hook 跳过)。
|
||||||
|
4. **硬刷新保持位置**:同上但用 Ctrl+F5 → 应恢复到原位置。
|
||||||
|
5. **首次访问**:从未访问过的帖子 → 应从顶部开始(sessionStorage 无记录)。
|
||||||
|
6. **诊断无错误**:TypeScript 编译通过。
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
# Gitea 全局滚动位置保持机制分析
|
|
||||||
|
|
||||||
> 任务:分析 Gitea 如何做到全局任意界面、任意位置,浏览器强制刷新(包括硬刷新 Ctrl+F5)仍不会让页面回到顶部。
|
|
||||||
|
|
||||||
## 摘要(核心结论,已根据用户实测修正)
|
|
||||||
|
|
||||||
经过对 Gitea 仓库的深度二轮检索,**Gitea 并没有实现任何自定义的滚动位置恢复机制**——不使用 sessionStorage/localStorage 保存滚动位置、没有 pagehide/beforeunload 监听、没有 Service Worker、没有 scrollRestoration 操作。它的核心策略是 **"不覆盖浏览器默认行为"**。
|
|
||||||
|
|
||||||
**关键修正**:之前推断"硬刷新(Ctrl+F5)会跳回顶部"是错误的。实测确认 Gitea 在硬刷新后**同样保持滚动位置**。真实原因是:**Chrome / Edge 等 Chromium 内核浏览器的原生"重载滚动恢复"功能**——浏览器在重载前将滚动位置保存在**内存**中,新页面加载完成后恢复。这个内存状态独立于 HTTP 缓存,因此**硬刷新(只绕过 HTTP 缓存)不会清除它**。
|
|
||||||
|
|
||||||
核心要点:
|
|
||||||
- 软刷新(F5/Ctrl+R)和硬刷新(Ctrl+F5/Ctrl+Shift+R)**都保持滚动位置**(在 Chrome/Edge 上)
|
|
||||||
- 这是**浏览器原生行为**,不是 Gitea 的代码实现的
|
|
||||||
- Gitea 做对的是"没有破坏它":未设置 `history.scrollRestoration = 'manual'`,没有干扰重载流程
|
|
||||||
- **浏览器差异**:Chrome/Edge 保持;Firefox 在硬刷新后可能回到顶部
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 确切机制
|
|
||||||
|
|
||||||
| 维度 | 实际情况 |
|
|
||||||
|---|---|
|
|
||||||
| sessionStorage/localStorage 保存滚动位置? | **没有** |
|
|
||||||
| `history.scrollRestoration`? | **未设置**(保持浏览器默认 `'auto'`) |
|
|
||||||
| 自定义 JS 模块? | **不存在** |
|
|
||||||
| 后端驱动? | **没有** |
|
|
||||||
| 实际机制 | 浏览器原生 `history.scrollRestoration = 'auto'` 默认行为 |
|
|
||||||
|
|
||||||
**关键事实**:htmx 2.x(Gitea 使用的版本)**不会**设置 `history.scrollRestoration = 'manual'`。在 htmx 2.0.8 完整源码中搜索 `scrollRestoration`,结果为 **0 处匹配**。这一点至关重要——如果 htmx 设了 `manual`,浏览器就不会在刷新时自动恢复,但 htmx 2.x 没有这样做。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 涉及的源码文件
|
|
||||||
|
|
||||||
### 2.1 Gitea 侧关键文件
|
|
||||||
|
|
||||||
**`web_src/js/htmx.ts`**(htmx 配置入口,最关键)
|
|
||||||
```typescript
|
|
||||||
// https://github.com/go-gitea/gitea/blob/main/web_src/js/htmx.ts
|
|
||||||
import htmx from 'htmx.org';
|
|
||||||
import 'idiomorph/htmx';
|
|
||||||
import type {HtmxResponseInfo} from 'htmx.org';
|
|
||||||
import {showErrorToast} from './modules/toast.ts';
|
|
||||||
|
|
||||||
type HtmxEvent = Event & {detail: HtmxResponseInfo};
|
|
||||||
|
|
||||||
export function initHtmx() {
|
|
||||||
window.htmx = htmx;
|
|
||||||
htmx.config.requestClass = 'is-loading';
|
|
||||||
htmx.config.scrollIntoViewOnBoost = false; // 仅禁用 boost 元素的自动滚动入视图,不影响刷新恢复
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:sendError', (event: Partial<HtmxEvent>) => {
|
|
||||||
showErrorToast(`Network error when calling ${event.detail!.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
document.body.addEventListener('htmx:responseError', (event: Partial<HtmxEvent>) => {
|
|
||||||
showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
要点:
|
|
||||||
- `scrollIntoViewOnBoost = false` 仅控制 htmx boost 导航时是否将目标元素滚动入视图,**与页面刷新后的滚动恢复无关**
|
|
||||||
- **没有任何 `history.scrollRestoration` 赋值**
|
|
||||||
|
|
||||||
**`templates/base/head.tmpl`**(基础 HTML 模板的 body 标签)
|
|
||||||
```html
|
|
||||||
<body hx-headers='{"x-csrf-token": "{{.CsrfToken}}"}'
|
|
||||||
hx-swap="outerHTML" hx-ext="morph" hx-push-url="false">
|
|
||||||
```
|
|
||||||
- `hx-push-url="false"` 意味着 htmx 导航默认**不**向浏览器历史压入新 URL,不干扰浏览器原生的历史/滚动追踪
|
|
||||||
|
|
||||||
**其他已检查文件(均无滚动恢复代码)**:
|
|
||||||
|
|
||||||
| 文件 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| `web_src/js/bootstrap.ts` | 全局错误处理器,设置 `__webpack_public_path__` |
|
|
||||||
| `web_src/js/globals.ts` | 仅加载 jQuery |
|
|
||||||
| `web_src/js/index.ts` | 入口文件,`onDomReady` 内动态 import `index-domready.ts` |
|
|
||||||
| `web_src/js/index-domready.ts` | 调用 `initHtmx` 及所有 `init*` 初始化函数 |
|
|
||||||
| `web_src/js/features/common-page.ts` | 导航栏切换、页脚语言/主题选择器、下拉菜单 |
|
|
||||||
| `web_src/js/utils/dom.ts` | 滚动相关代码仅用于 textarea 自动调整高度,与页面滚动恢复无关 |
|
|
||||||
| `templates/base/head_script.tmpl` | 仅设置 `window._globalHandlerErrors` 和 `window.config`,无滚动恢复代码 |
|
|
||||||
|
|
||||||
在 `web_src/js/features/` 完整目录列表中**没有**任何名为 `async-loader`、`global-fetch`、`common-fetch`、`scroll-restoration`、`stick-to-bottom` 的文件。
|
|
||||||
|
|
||||||
### 2.2 htmx 2.x 源码侧(对比)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// htmx 2.0.8 src/htmx.js 的 config 默认值
|
|
||||||
config: {
|
|
||||||
historyEnabled: true,
|
|
||||||
historyCacheSize: 10, // sessionStorage 中缓存 10 页 HTML 快照
|
|
||||||
refreshOnHistoryMiss: false,
|
|
||||||
scrollIntoViewOnBoost: true, // Gitea 覆盖为 false
|
|
||||||
// 注意:没有 scrollRestoration 字段
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- htmx 源码 URL: https://github.com/bigskysoftware/htmx/blob/master/src/htmx.js
|
|
||||||
- 在完整源码中搜索 `scrollRestoration`、`beforeunload`、`pagehide`、`popstate`,结果均为 0 匹配
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 与 htmx "异步/渐进加载"模式的集成
|
|
||||||
|
|
||||||
Gitea 的异步加载模式基于 **htmx 2.0.8 + idiomorph 0.7.4**:
|
|
||||||
- **`package.json` 依赖**:`"htmx.org": "2.0.8"`, `"idiomorph": "0.7.4"`
|
|
||||||
- **body 配置**:`hx-swap="outerHTML"` + `hx-ext="morph"` + `hx-push-url="false"`
|
|
||||||
- 很多页面导航通过 htmx 发起 AJAX 请求获取 HTML 片段,用 idiomorph 做 DOM morph 替换
|
|
||||||
|
|
||||||
**与滚动恢复的关系**:
|
|
||||||
1. 由于 `hx-push-url="false"`,大多数 htmx 导航**不创建新的历史条目**,浏览器 URL 不变,浏览器原生的滚动位置追踪不被干扰
|
|
||||||
2. htmx 2.x **不设置** `history.scrollRestoration = 'manual'`(搜索 htmx 源码 0 处匹配),所以浏览器默认的 `'auto'` 恢复始终生效
|
|
||||||
3. `htmx.config.scrollIntoViewOnBoost = false` 禁用了 htmx 在 boost 导航后将目标元素滚动入视图的行为,避免 htmx 导航时意外改变滚动位置
|
|
||||||
4. htmx 的 history cache(`historyCacheSize: 10`,存储在 sessionStorage)仅用于 back/forward 时的 HTML 快照恢复,**不涉及刷新时的滚动位置恢复**
|
|
||||||
|
|
||||||
**简言之**:Gitea 的 htmx 集成**刻意不干扰**浏览器原生的滚动恢复,通过"不设置 manual、不 push URL"让浏览器自己处理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 硬刷新 vs 软刷新 —— 修正后的澄清
|
|
||||||
|
|
||||||
### 之前推断的错误
|
|
||||||
之前认为"硬刷新(Ctrl+F5)会绕过缓存,浏览器不会恢复滚动位置"——这个推断是**错误的**。
|
|
||||||
|
|
||||||
### 真实机制:浏览器内存中的滚动状态
|
|
||||||
|
|
||||||
硬刷新(Ctrl+F5 / Ctrl+Shift+R)的作用范围被广泛误解:
|
|
||||||
|
|
||||||
| 操作 | 清除 HTTP 缓存 | 清除 sessionStorage | 清除内存滚动状态 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| F5 软刷新 | 否 | 否 | 否 |
|
|
||||||
| Ctrl+F5 硬刷新 | **是** | **否** | **否** |
|
|
||||||
| 关闭标签页 | — | 是 | 是 |
|
|
||||||
|
|
||||||
**关键点**:浏览器在重载前将当前滚动位置保存在**内存**中(绑定到当前 history 条目),新页面加载完成后自动恢复。这个内存状态:
|
|
||||||
- 独立于 HTTP 缓存 → 硬刷新不清除它
|
|
||||||
- 独立于 sessionStorage/localStorage → 不需要 JS 主动保存
|
|
||||||
- 是 Chrome/Edge 的原生功能,与 `history.scrollRestoration` 相关但作用于重载场景
|
|
||||||
|
|
||||||
### 软刷新(F5 / Ctrl+R)—— 有效
|
|
||||||
- 浏览器原生恢复滚动位置
|
|
||||||
- 所有现代浏览器均支持
|
|
||||||
|
|
||||||
### 硬刷新(Ctrl+F5 / Ctrl+Shift+R)—— **也有效(Chrome/Edge)**
|
|
||||||
- 硬刷新只绕过 HTTP 缓存(强制重新下载资源)
|
|
||||||
- **不影响**浏览器的内存滚动状态
|
|
||||||
- Chrome/Edge 在硬刷新后**仍会恢复滚动位置**
|
|
||||||
- 这是用户在 Gitea 上观察到的现象的真实原因
|
|
||||||
|
|
||||||
### 浏览器差异(重要)
|
|
||||||
|
|
||||||
| 浏览器 | F5 软刷新 | Ctrl+F5 硬刷新 |
|
|
||||||
|---|---|---|
|
|
||||||
| Chrome / Edge (Chromium) | ✅ 保持滚动 | ✅ 保持滚动 |
|
|
||||||
| Firefox | ✅ 保持滚动 | ❌ 可能回到顶部 |
|
|
||||||
| Safari | 视版本而定 | 视版本而定 |
|
|
||||||
|
|
||||||
**结论**:Gitea 在硬刷新后保持滚动位置,**不是因为 Gitea 实现了什么机制,而是因为 Chrome/Edge 浏览器原生就这么做**。如果在 Firefox 上硬刷新 Gitea,可能会观察到滚动位置丢失。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 为什么"全局任意界面任意位置"都生效?
|
|
||||||
|
|
||||||
由于 Gitea 依赖的是浏览器原生重载滚动恢复(Chrome/Edge 的内存级机制),这个行为是**浏览器层面的全局机制**,与具体页面无关:
|
|
||||||
- 浏览器在页面卸载前自动将当前滚动位置保存在内存中(绑定到当前 history 条目)
|
|
||||||
- 重新加载时(无论软/硬刷新),浏览器在新页面 load 完成后自动恢复到记录的滚动位置
|
|
||||||
- 这个机制对任何 URL、任何滚动位置都生效,不需要每个页面单独配置
|
|
||||||
- Gitea 是 MPA(多页应用,服务端渲染 HTML),每次刷新都返回完整 HTML,浏览器能准确恢复
|
|
||||||
|
|
||||||
Gitea 做对的不是"实现了滚动恢复",而是"**没有破坏浏览器已有的滚动恢复**"。许多 SPA 框架(包括某些 React Router 配置)会显式设置 `history.scrollRestoration = 'manual'` 并自行管理滚动,一旦自行管理失败(比如异步加载竞态),反而会导致刷新后跳回顶部。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 局限性总结(已修正)
|
|
||||||
|
|
||||||
1. ~~硬刷新不支持~~ → **修正**:Chrome/Edge 上硬刷新也保持滚动位置;Firefox 上可能丢失
|
|
||||||
2. **完全依赖浏览器行为**:不同浏览器/版本的刷新滚动恢复行为有差异(Chrome/Edge vs Firefox)
|
|
||||||
3. **无降级方案**:如果浏览器原生的滚动恢复因任何原因失效(例如浏览器扩展干扰),Gitea 没有备用的 sessionStorage 方案
|
|
||||||
4. **MPA 架构的天然优势**:Gitea 是服务端渲染的 MPA,每次刷新返回完整 HTML,浏览器能准确恢复滚动位置。SPA 架构由于初始 HTML 为空、内容由 JS 异步渲染,浏览器在 `load` 事件时尝试恢复往往落空——这是 SPA 的天然劣势
|
|
||||||
5. **异步内容加载的竞态**:如果页面有异步加载的内容,浏览器恢复滚动位置时目标位置内容可能尚未加载完成,可能导致恢复位置偏差
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 与当前项目(jiang13-forum)的对比
|
|
||||||
|
|
||||||
当前项目是 **React + TypeScript + Vite 的 SPA**,使用 react-router-dom。现状:
|
|
||||||
- 全局**无** scroll restoration 逻辑
|
|
||||||
- 仅 [VirtualPostList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/VirtualPostList.tsx#L135-L156) 通过 `restoreScrollTop` 属性做了局部滚动恢复
|
|
||||||
- React Router v6 默认**不**自动恢复滚动位置(需要 `ScrollRestoration` 组件或第三方方案)
|
|
||||||
|
|
||||||
若要在 jiang13-forum 实现 Gitea 同等效果,可参考的方向(仅作信息参考,不在本次任务范围内实施):
|
|
||||||
- 在路由层使用 React Router 的 `<ScrollRestolation />` 组件
|
|
||||||
- 或在应用入口确认未设置 `history.scrollRestoration = 'manual'`
|
|
||||||
- 注意:React SPA 的刷新滚动恢复比 MPA 复杂,因为初始 HTML 通常为空,内容由 JS 异步渲染,浏览器在 `load` 事件时尝试恢复可能落空,需要额外的 `useEffect` + sessionStorage 兜底
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 一句话总结
|
|
||||||
|
|
||||||
**Gitea 没有实现任何自定义的滚动位置恢复机制。它在软刷新和硬刷新后都能保持滚动位置(在 Chrome/Edge 上),是因为浏览器原生会在重载前将滚动位置保存在内存中并在重载后恢复——这个内存状态独立于 HTTP 缓存,所以硬刷新也不会清除它。Gitea 只是"没有破坏"这个浏览器原生行为。但这是 Chromium 内核浏览器的特性,Firefox 在硬刷新后可能丢失滚动位置。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 参考来源
|
|
||||||
|
|
||||||
- Gitea htmx 配置:https://github.com/go-gitea/gitea/blob/main/web_src/js/htmx.ts
|
|
||||||
- Gitea body 模板:https://github.com/go-gitea/gitea/blob/main/templates/base/head.tmpl
|
|
||||||
- htmx 2.x 源码:https://github.com/bigskysoftware/htmx/blob/master/src/htmx.js
|
|
||||||
- MDN history.scrollRestoration:https://developer.mozilla.org/en-US/docs/Web/API/History/scrollRestoration
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
# 刷新无闪烁优化方案
|
|
||||||
|
|
||||||
> **目标**:消除浏览器刷新时的视觉闪烁,让刷新体验接近 Gitea(内容未变化时肉眼完全感知不到重绘)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、根因分析
|
|
||||||
|
|
||||||
### Gitea 为什么「无闪烁」
|
|
||||||
|
|
||||||
Gitea 是 **服务端渲染(SSR)的 MPA**,浏览器收到的 HTML 响应中**已经包含完整渲染的内容**(帖子、列表、评论)。刷新时:
|
|
||||||
1. 浏览器从服务器取回**已填好内容**的 HTML(非空壳)
|
|
||||||
2. 一次性解析 → 布局 → 绘制,内容直接到位
|
|
||||||
3. 浏览器原生 scrollRestoration 在同一帧恢复滚动位置
|
|
||||||
4. **没有任何骨架 → 内容的切换**,整页只发生**一次** paint
|
|
||||||
|
|
||||||
### jiang13-forum 当前刷新的视觉路径
|
|
||||||
|
|
||||||
作为 **React SPA**,刷新时会经历以下可见的阶段(全部可被肉眼察觉):
|
|
||||||
|
|
||||||
| 阶段 | 触发者 | 视觉 | 持续时间 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| ① 空白 → 顶栏布局出现 | `index.html` 内联 CSS + React 挂载顶栏 | 三栏骨架先出现,但中间主内容区为空 | ~50-150ms |
|
|
||||||
| ② 路由 Suspense fallback | `lazyWithRetry` + `App.tsx` 中 `<Suspense>` | `FeedPageSkeleton` 骨架(首页)或 `PageLoader` 转圈(其他页)进入主内容区 | ~50-100ms |
|
|
||||||
| ③ 数据 loading 骨架 | 各页面组件 `setLoading(true)` → 早期 return 骨架/转圈 | 鱼骨骨架 / Spinner 可见 | ~100-400ms(网络请求) |
|
|
||||||
| ④ 骨架 → 真实内容切换 | 数据返回后 `setLoading(false)` | 骨架消失,真实内容瞬间出现,**出现明显跳变** | 瞬时 |
|
|
||||||
| ⑤ 滚动位置恢复(延迟) | `useScrollRestoration` → rAF 循环等内容高度足够 | 先看到内容在 scrollTop=0,然后才跳到目标位置,**可见的先顶后跳** | 内容渲染后 ~0-4 帧 |
|
|
||||||
|
|
||||||
用户所说的"虽然不会回到顶部再恢复滚动的情况,但基本全都有刷新",指的是阶段 ① ~ ④ 的可见变化:骨架出现、骨架消失、内容到位,这是**多次 paint 的切换闪烁**;而阶段 ⑤ 先顶后跳可能被掩盖但仍存在。
|
|
||||||
|
|
||||||
### 核心问题
|
|
||||||
|
|
||||||
- 首页 `HomePage`:刷新后内存 `feedCache` 丢失,`posts.length === 0`,L239 条件命中 → `FeedPageSkeleton` 骨架可见 → 数据回来后切到真实内容 → 闪烁。
|
|
||||||
- 帖子详情 `PostDetailPage`:`loading=true` 时 L446 命中 → `<Spinner size="lg" />` 居中转圈 → 数据回来后切到真实内容 → 闪烁。
|
|
||||||
- 其他页面(消息、收藏、个人主页、后台各页):均有 `loading ? <Spinner ... />` 早期 return → 闪烁。
|
|
||||||
- 滚动恢复晚于内容首 paint:内容先在顶部画出来,下一帧才跳回目标位置 → 先顶后跳的闪烁(虽然比之前回顶再跳好很多,但仍可感知)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、总体策略
|
|
||||||
|
|
||||||
采用 **三层防御** 分层消除闪烁:
|
|
||||||
|
|
||||||
1. **S0 层 — 首屏内容预缓存(sessionStorage 级)**
|
|
||||||
- 仿照 `feedCache.ts` 的结构,将**上次渲染的真实内容**(posts 列表、post 详情 + 评论、其他轻量列表数据)在 `pagehide` 时持久化到 sessionStorage。
|
|
||||||
- 刷新首帧用 sessionStorage 中的旧数据**直接渲染真实内容**,完全绕过骨架/转圈。
|
|
||||||
- 后台异步重新拉取数据,若与缓存相同则**静默无任何变化**(像 Gitea 一样肉眼无感);若不同则**最小化 diff 更新**,不改滚动位置。
|
|
||||||
|
|
||||||
2. **S1 层 — 滚动恢复提前到首 paint 之前**
|
|
||||||
- 不再依赖 mount 后 rAF 循环。改用 `pageshow` 事件 + `useLayoutEffect`(在浏览器 paint 前执行),在首帧前就把内容区域的 `scrollTop` 设好。
|
|
||||||
- 配合 S0,因为有旧内容撑高了 `scrollHeight`,首 paint 前就能设到目标位置,用户第一眼看到的就是正确位置。
|
|
||||||
|
|
||||||
3. **S2 层 — 首帧渲染完成前延迟可见性(兜底)**
|
|
||||||
- 对没有 sessionStorage 缓存的页面(如首次访问、无痕窗口、sessionStorage 被清),无法走 S0。在容器上加 `visibility: hidden` → 骨架/内容全部就绪且滚动位置设置后再 `visibility: visible`。用户看到的第一帧就是"正确内容 + 正确位置",避免看到中间过渡。
|
|
||||||
- 配合超时(300ms)兜底,极端慢网情况下强行显示,避免白屏。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、具体改动计划
|
|
||||||
|
|
||||||
### 3.1 新建 sessionStorage 持久化缓存工具
|
|
||||||
|
|
||||||
**文件**:`frontend/src/utils/pageContentCache.ts`
|
|
||||||
|
|
||||||
- 定义持久化缓存结构:
|
|
||||||
```ts
|
|
||||||
type CachedHomeFeed = { posts, postTotal, page, ts };
|
|
||||||
type CachedPostDetail = { post, comments, liked, favorited, canEdit, ts };
|
|
||||||
type CachedGenericList = { items: unknown[], ts }; // 用于消息/收藏/个人等
|
|
||||||
```
|
|
||||||
- `saveHomeFeed(boardId, keyword, tag, author, titleOnly, sort, data)` → `sessionStorage.setItem('j13-cache:feed:...', JSON.stringify(...))`
|
|
||||||
- `loadHomeFeed(...)` → 读取 + 有效性校验(posts 数组非空)
|
|
||||||
- `savePostDetail(postId, data)` / `loadPostDetail(postId)`
|
|
||||||
- `saveGeneric(key, data)` / `loadGeneric(key)`(用于其他页面)
|
|
||||||
- 所有 save 在 `pagehide` 或 `useEffect cleanup` 时触发;加载时若 `ts` 超过 10 分钟,视为过时而不加载(防止用户次日访问出现完全过期内容)。
|
|
||||||
|
|
||||||
### 3.2 扩展 scrollRestore.ts,提供提前恢复能力
|
|
||||||
|
|
||||||
**文件**:`frontend/src/utils/scrollRestore.ts`
|
|
||||||
|
|
||||||
- 新增 `readSavedScrollTop(containerSelector): number | null`:纯读取不等待,不触发 rAF。
|
|
||||||
- 新增 `pageshow` 监听(在 `initScrollRestore` 中注册):`pageshow` 时若首帧尚未 paint,立即尝试设置各容器的 `scrollTop`。
|
|
||||||
- 保留现有的 rAF 重试循环作为兜底(S0 未命中或内容还不够高时)。
|
|
||||||
|
|
||||||
### 3.3 HomePage — 用持久化缓存消掉骨架
|
|
||||||
|
|
||||||
**文件**:`frontend/src/pages/HomePage.tsx`
|
|
||||||
|
|
||||||
- 初始化阶段(原 L139-L177 的 useEffect):
|
|
||||||
- 若 `getFeedCache(...)`(内存 Map)无数据,**先查 `loadHomeFeed(...)`(sessionStorage 持久化)**。
|
|
||||||
- 命中 → 直接用旧数据渲染,`loading=false`,`setRestoreScrollTop(cached.scrollTop)`;同时启动网络请求,回来后**仅在数据有差异**时才更新 state(posts / postTotal),避免不必要的重渲染。
|
|
||||||
- 未命中 → 保留当前骨架逻辑。
|
|
||||||
- `pagehide` 时(或在 scrollRestore 的 save 中联动)调用 `saveHomeFeed`。
|
|
||||||
- **效果**:刷新首帧就是真实帖子列表(上次浏览时看到的),骨架完全不显示;网络回来若内容未变,无任何视觉变化;若内容有新帖子,只是在列表顶部插入新条目(不影响当前浏览位置的可见区域)。
|
|
||||||
|
|
||||||
### 3.4 PostDetailPage — 用持久化缓存消掉 Spinner
|
|
||||||
|
|
||||||
**文件**:`frontend/src/pages/PostDetailPage.tsx`
|
|
||||||
|
|
||||||
- 数据加载 useEffect(L170-L208):
|
|
||||||
- 启动网络请求**之前**先查 `loadPostDetail(postId)`。
|
|
||||||
- 命中 → `setPost(cached.post)`, `setComments(cached.comments)`, `setLiked(cached.liked)`, `setFavorited(cached.favorited)`, `setCanEdit(cached.canEdit)`, `setLoading(false)`;网络请求回来后 diff 更新。
|
|
||||||
- 未命中 → 保留当前 Spinner 逻辑。
|
|
||||||
- `pagehide` 时(或 effect cleanup)调用 `savePostDetail`。
|
|
||||||
- 删除 L446 命中 Spinner 时的 `post-detail-loading` 空白页渲染(持久化命中时不走此路)。
|
|
||||||
|
|
||||||
### 3.5 其他有 `loading ? <Spinner>` 早期 return 的页面(可选但建议)
|
|
||||||
|
|
||||||
涉及:`FavoritesPage.tsx`、`MessagesPage.tsx`、`ProfilePage.tsx`、`UserProfilePage.tsx`、`AdminBadgesPage.tsx`、`AdminMediaPage.tsx`、`AdminCommentsPage.tsx`、`AdminPostsPage.tsx`、`AdminUsersPage.tsx`、`BoardsManagePage.tsx` 等。
|
|
||||||
|
|
||||||
- 使用 `saveGeneric` / `loadGeneric` 包装:在 `loading=true` 但 sessionStorage 有缓存时,先渲染旧数据不显示 Spinner,网络回来后更新。
|
|
||||||
- 若不纳入首版范围,至少应用 S2(visibility 兜底)消除转圈 → 内容的切换闪烁。
|
|
||||||
|
|
||||||
### 3.6 S2 兜底:首帧完成前不可见(index.html + App 层)
|
|
||||||
|
|
||||||
**文件**:`frontend/index.html`
|
|
||||||
- 在 `<body>` 上加 `class="j13-prepaint"`,对应内联 CSS:
|
|
||||||
```css
|
|
||||||
.j13-prepaint .main-content,
|
|
||||||
.j13-prepaint .admin-main { visibility: hidden; }
|
|
||||||
```
|
|
||||||
|
|
||||||
**文件**:`frontend/src/hooks/useScrollRestoration.ts`(新建 hook 功能扩展 或 新建 `useFirstFrameReveal.ts`)
|
|
||||||
- 在布局组件(MainLayout / AdminLayout)mount 后:
|
|
||||||
- 条件 A:数据已渲染(有缓存或网络已回)且滚动已设好 → 立即 `document.body.classList.remove('j13-prepaint')`
|
|
||||||
- 条件 B:无缓存且等待网络 → `setTimeout(300ms)` 后强制 reveal,避免白屏
|
|
||||||
- 条件 C:rAF 检测到 paint 已发生过(`document.visibilityState` + 首次 paint marker)→ reveal
|
|
||||||
|
|
||||||
### 3.7 验证点
|
|
||||||
|
|
||||||
- 首页有帖子时刷新:首帧直接看到上次的真实内容 + 正确滚动位置,无骨架闪过;若网络回来帖子相同,肉眼完全无变化(达到 Gitea 级别)。
|
|
||||||
- 帖子详情页有长文时刷新:同上,无 Spinner,内容+位置直接到位。
|
|
||||||
- 首次访问 / 无缓存页面:最多有 300ms 以内白屏(或 reveal 后骨架短暂出现),不出现"骨架→内容"切换闪烁。
|
|
||||||
- SPA 内导航(点链接)行为完全不受影响,`useScrollRestoration` 的 mount-only 特性保证只在刷新/跨布局切换时触发。
|
|
||||||
- sessionStorage 条目数量上限:按 URL+参数维度,最坏几十条,占用 KB 级内存,无存储压力。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、风险与权衡
|
|
||||||
|
|
||||||
| 风险 | 影响 | 应对 |
|
|
||||||
|---|---|---|
|
|
||||||
| sessionStorage 数据过时(刷新后看到旧帖子/旧内容) | 短暂,但用户担心读到过期数据 | **已考虑**:ts 超过 10min 不加载;且网络请求仍在后台进行,回来后立即更新。与 Gitea 的"先画旧内容,后续若有新帖自然出现"体验一致。 |
|
|
||||||
| 旧内容的渲染高度与新内容不一致 → 滚动恢复偏差 | 轻微偏差(±几帖高度) | rAF 兜底循环会等新内容高度到位后再精调。S0 的目标是"首帧不闪烁",精细恢复由 S1 兜底完成。 |
|
|
||||||
| S2 的 visibility:hidden 导致极端慢网下短暂白屏 | 少数极端网络 | 设置 300ms 超时强制 reveal;与"先骨架再切内容"的闪烁相比,短暂白屏观感更优(与 Gitea 的整页重绘等价)。 |
|
|
||||||
| S0 存储的数据量(10 篇帖子 + 详情页全文) | KB 级,可忽略 | 不存图片的 base64,只存 API 返回的 JSON。 |
|
|
||||||
| 后台管理页的数据也做 S0 是否有意义? | 管理页刷新频率低 | 首版只对 HomePage + PostDetailPage(高频刷新页面)做 S0,其他页做 S2 兜底即可。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、改动文件清单(确认版)
|
|
||||||
|
|
||||||
| 文件 | 操作 | 所属层级 |
|
|
||||||
|---|---|---|
|
|
||||||
| `frontend/src/utils/pageContentCache.ts` | 新建 | S0 |
|
|
||||||
| `frontend/src/utils/scrollRestore.ts` | 修改(加 readSavedScrollTop / pageshow 恢复) | S1 |
|
|
||||||
| `frontend/src/pages/HomePage.tsx` | 修改(持久化缓存命中逻辑 + 后台静默更新) | S0 |
|
|
||||||
| `frontend/src/pages/PostDetailPage.tsx` | 修改(持久化缓存命中逻辑 + 后台静默更新) | S0 |
|
|
||||||
| `frontend/index.html` | 修改(加 j13-prepaint 类 + CSS) | S2 |
|
|
||||||
| `frontend/src/layouts/MainLayout.tsx` | 修改(调用 reveal 逻辑) | S2 |
|
|
||||||
| `frontend/src/layouts/AdminLayout.tsx` | 修改(调用 reveal 逻辑) | S2 |
|
|
||||||
BIN
frontend/public/stickers/bilibili/bl_01.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_02.avif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/stickers/bilibili/bl_03.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_04.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_05.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_06.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_07.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_08.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_09.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_10.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_11.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_12.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_13.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_14.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_15.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_16.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_17.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_18.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_19.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_20.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_21.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_22.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_23.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/bilibili/bl_24.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_25.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_26.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_27.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_28.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_29.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_30.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_31.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_32.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_33.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/bilibili/bl_34.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_35.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_01.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_02.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_03.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_04.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_05.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_06.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_07.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/douyin/dy_08.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_09.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_10.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/douyin/dy_11.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_12.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/douyin/dy_13.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_14.avif
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/stickers/douyin/dy_15.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_16.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_17.avif
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/stickers/douyin/dy_18.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_19.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_20.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_21.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_22.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_23.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_24.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_25.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/douyin/dy_26.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_27.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_28.avif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/stickers/douyin/dy_29.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_30.avif
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/stickers/douyin/dy_31.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_32.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_33.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_34.avif
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/stickers/douyin/dy_35.avif
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/public/stickers/douyin/dy_36.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/tieba/tb_01.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_02.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/tieba/tb_03.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_04.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_05.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/tieba/tb_06.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/tieba/tb_07.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_08.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/tieba/tb_09.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/tieba/tb_10.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_11.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_12.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/tieba/tb_13.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/tieba/tb_14.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_15.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_16.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/tieba/tb_17.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/tieba/tb_18.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/tieba/tb_19.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_20.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/tieba/tb_21.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/tieba/tb_22.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/tieba/tb_23.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/tieba/tb_24.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |