diff --git a/.trae/documents/fix-sticker-serving-plan.md b/.trae/documents/fix-sticker-serving-plan.md new file mode 100644 index 0000000..3c172de --- /dev/null +++ b/.trae/documents/fix-sticker-serving-plan.md @@ -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 静态文件服务方案 diff --git a/embed_static/embed.go b/embed_static/embed.go index aad0da8..d3724ee 100644 --- a/embed_static/embed.go +++ b/embed_static/embed.go @@ -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 diff --git a/frontend/src/components/CommentEditor.tsx b/frontend/src/components/CommentEditor.tsx index 7ce81d3..de1a841 100644 --- a/frontend/src/components/CommentEditor.tsx +++ b/frontend/src/components/CommentEditor.tsx @@ -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(function CommentEdi return
; } - const tools: { icon: React.ReactNode; title: string; active?: boolean; action: () => void }[] = [ - { icon: H, 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: , title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() }, { icon: , title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() }, { icon: , title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() }, { icon: , title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() }, - { icon: , title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() }, { icon: , title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() }, { icon: , title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() }, { icon: , title: '代码块', active: editor.isActive('codeBlock'), action: openCodeBlockDialog }, { icon: , title: '链接', active: editor.isActive('link'), action: setLink }, { icon: , title: '上传图片', action: setImage }, - { icon: OwO, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v) }, + { icon: OwO, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v), className: 'article-tool-btn--owo' }, ]; return ( @@ -262,7 +251,7 @@ const CommentEditor = forwardRef(function CommentEdi