fix: 修复生产环境表情无法显示的问题
1. 后端新增/stickers路由静态文件服务,设置1天缓存 2. 排除/stickers前缀的SPA路由拦截 3. 优化评论区表情按钮样式与可访问性 4. 移除编辑器中未使用的标题工具与引用工具
This commit is contained in:
55
.trae/documents/fix-sticker-serving-plan.md
Normal file
55
.trae/documents/fix-sticker-serving-plan.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 修复生产环境表情图片无法显示 Implementation Plan
|
||||
|
||||
## Repository Research
|
||||
|
||||
### 问题分析
|
||||
|
||||
表情图片在生产部署后无法加载,原因是 **Go 后端缺少 `/stickers/*` 路由的静态文件服务注册**。
|
||||
|
||||
当前架构:
|
||||
1. 前端 emoji 数据中的 URL 为 `/stickers/{platform}/{file}.avif`(在 [emojiData.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/data/stickers/emojiData.ts) 中通过 `toLocalUrl()` 生成)
|
||||
2. Vite 构建时将 `frontend/public/stickers/` 原样复制到 `embed_static/static/spa/stickers/`
|
||||
3. Go 的 `//go:embed static/*` 会将 `embed_static/static/` 下所有文件(包括 stickers)打包进二进制
|
||||
4. 但 [embed.go](file:///c:/Users/freefire/Documents/jiang13-forum/embed_static/embed.go) 的 `SetupEmbed()` **只注册了 `/assets/*filepath` 路由**(用于 JS/CSS chunk),没有注册 `/stickers/*filepath`
|
||||
5. 同时 `IsSPARoute()` 函数也没有排除 `/stickers` 前缀,导致请求被 SPA NoRoute fallback 处理,返回 index.html 而非图片
|
||||
|
||||
开发模式之所以正常,是因为 Vite dev server 自动托管了 `public/stickers/` 下的静态文件。
|
||||
|
||||
### 当前代码状态
|
||||
|
||||
- `embed.go` 第 13 行: `//go:embed static/*` — 正确嵌入了所有静态资源(含 stickers)
|
||||
- `embed.go` 第 33-43 行: `SetupEmbed()` — 只处理了 `static/spa/assets`,缺少 stickers 路由
|
||||
- `embed.go` 第 63-77 行: `IsSPARoute()` — 缺少 `/stickers` 前缀排除
|
||||
|
||||
## Files and Modules
|
||||
|
||||
- `embed_static/embed.go`: 新增 `/stickers/*filepath` 文件服务路由;在 `IsSPARoute` 中排除 `/stickers` 前缀
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **在 `SetupEmbed` 中注册 `/stickers` 路由**
|
||||
- 参照现有 `/assets` 路由的模式,添加对 `static/spa/stickers` 子目录的文件服务
|
||||
- 使用 `fs.Sub(staticFS, "static/spa/stickers")` 获取 stickers 子文件系统
|
||||
- 注册 `GET /stickers/*filepath` 路由,直接透传,不设置长期缓存(表情可能更新)
|
||||
|
||||
2. **在 `IsSPARoute` 中排除 `/stickers` 前缀**
|
||||
- 在现有 `strings.HasPrefix(path, "/assets")` 附近添加 `strings.HasPrefix(path, "/stickers")`
|
||||
- 确保表情请求不会被 SPA fallback 拦截
|
||||
|
||||
## Dependencies and Considerations
|
||||
|
||||
- stickers 目录下存储的是 `.avif` 图片文件,不需要设置特殊 MIME type(http.FileServer 会根据扩展名自动识别)
|
||||
- stickers 资源不含哈希指纹,不应设置 `immutable` 缓存头(与 `/assets` 不同),但可以设置短过期缓存
|
||||
- 需确保 `fs.Sub` 路径与实际构建输出路径一致:`embed_static/static/spa/stickers/`
|
||||
|
||||
## Validation
|
||||
|
||||
- 重新构建前端:`cd frontend && npm run build`
|
||||
- 重新构建 Go 二进制:`go build -o jiang13-linux-amd64`
|
||||
- 部署后访问 `/stickers/tieba/tb_01.avif` 应能直接返回图片内容(HTTP 200)
|
||||
- 在评论框中打开表情选择器,所有平台的表情应正常显示
|
||||
|
||||
## Risks
|
||||
|
||||
- **风险**: 如果未来在 `public/` 下新增其他静态资源目录(如 `avatars/`、`flags/` 等),需要同样在 `embed.go` 中注册对应路由
|
||||
- **应对**: 可考虑后续重构为自动扫描 `public/` 下所有子目录并自动注册路由,或改为统一的 SPA 静态文件服务方案
|
||||
@@ -39,6 +39,15 @@ func SetupEmbed(r *gin.Engine) error {
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
|
||||
if sub, err := fs.Sub(staticFS, "static/spa/stickers"); err == nil {
|
||||
fileServer := http.StripPrefix("/stickers", http.FileServer(http.FS(sub)))
|
||||
r.GET("/stickers/*filepath", func(c *gin.Context) {
|
||||
// stickers 不含哈希指纹,设置适中的缓存
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,6 +78,7 @@ func IsSPARoute(path string) bool {
|
||||
strings.HasPrefix(path, "/uploads") ||
|
||||
strings.HasPrefix(path, "/media") ||
|
||||
strings.HasPrefix(path, "/assets") ||
|
||||
strings.HasPrefix(path, "/stickers") ||
|
||||
strings.HasPrefix(path, "/oauth") ||
|
||||
strings.HasPrefix(path, "/.well-known") {
|
||||
return false
|
||||
|
||||
@@ -8,7 +8,7 @@ import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Quote,
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
|
||||
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
@@ -229,28 +229,17 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
|
||||
return <div className="comment-editor"><div className="article-editor-bar" /><div className="article-editor-body" /></div>;
|
||||
}
|
||||
|
||||
const tools: { icon: React.ReactNode; title: string; active?: boolean; action: () => void }[] = [
|
||||
{ icon: <strong>H</strong>, title: '标题', active: editor.isActive('heading'), action: () => {
|
||||
for (let l = 2; l <= 4; l++) {
|
||||
if (editor.isActive('heading', { level: l })) {
|
||||
if (l === 4) editor.chain().focus().setParagraph().run();
|
||||
else editor.chain().focus().toggleHeading({ level: (l + 1) as 2 | 3 | 4 }).run();
|
||||
return;
|
||||
}
|
||||
}
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
}},
|
||||
const tools: { icon: React.ReactNode; title: string; active?: boolean; action: () => void; className?: string }[] = [
|
||||
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
|
||||
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
|
||||
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
|
||||
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
|
||||
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: openCodeBlockDialog },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: setLink },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
|
||||
{ icon: <span className="article-tool-btn__owo">OwO</span>, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v) },
|
||||
{ icon: <span className="article-tool-btn__owo">OwO</span>, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v), className: 'article-tool-btn--owo' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -262,7 +251,7 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
|
||||
<button
|
||||
ref={i === tools.length - 1 ? stickerBtnRef : undefined}
|
||||
type="button"
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}`}
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
aria-label={t.title}
|
||||
|
||||
@@ -5561,19 +5561,24 @@ a.post-title:visited {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.comment-editor .article-tool-btn strong { font-size: 11px; }
|
||||
.comment-editor .article-tool-btn--owo {
|
||||
width: auto;
|
||||
min-width: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* OwO 文本按钮:与 SVG 图标视觉对齐 */
|
||||
/* OwO 文本按钮:与 SVG 图标视觉对齐,fill button area for consistent focus ring */
|
||||
.article-tool-btn__owo {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
transform: translateY(-0.5px);
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.comment-editor .article-editor-body {
|
||||
@@ -5642,6 +5647,9 @@ a.post-title:visited {
|
||||
.sticker-picker-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.sticker-picker-tab { padding: 6px 10px; font-size: 12px; }
|
||||
.comment-editor .article-tool-btn { width: 30px; height: 30px; }
|
||||
.comment-editor .article-tool-btn--owo { width: auto; min-width: 30px; padding: 0 8px; }
|
||||
.comment-editor .article-editor-tools { flex-wrap: wrap; }
|
||||
.comment-editor .article-editor-bar { padding: 4px 6px; }
|
||||
}
|
||||
|
||||
/* Waline 嵌套评论列表 — 与正文共用 .page-wrap 滚动 */
|
||||
@@ -8099,6 +8107,11 @@ button.profile-stat:hover strong {
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
.article-tool-btn:focus-visible {
|
||||
outline: 2px solid var(--j13-green);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.article-tool-btn.active {
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
color: var(--j13-green);
|
||||
|
||||
BIN
jiang13-linux-amd64
Normal file
BIN
jiang13-linux-amd64
Normal file
Binary file not shown.
Reference in New Issue
Block a user