Compare commits
48 Commits
bb8d415eb7
...
v1.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 94823c0821 | |||
| b608ec85ba | |||
| 6048d71759 | |||
| 37d14a22d5 | |||
| a02d103cfe | |||
| 21c6233fc0 | |||
| 4ca5c17e2e | |||
| cf7a37eccc | |||
| 44eed97fe0 | |||
| 9e134916a4 | |||
| ba60a9b1c4 | |||
| 132ca5c82c | |||
| 185649db7c | |||
| 9d926750b2 | |||
| a91d917fa9 | |||
| 34f3d088ad | |||
| c6ce44f8b4 | |||
| 429956c594 | |||
| 16b6a18096 | |||
| 95c2154087 | |||
| 0abfb32194 | |||
| bcfd306643 | |||
| 335b708b04 | |||
| a83f8bd670 | |||
| 4dc3bbb13f | |||
| 8cfe31da61 | |||
| 7965768f3d | |||
| 012cac23de | |||
| d929bf96aa | |||
| cce9fb9ed8 | |||
| 9eefe24a33 | |||
| f559fc4c4b | |||
| 55b1831da2 | |||
| c6df703b96 | |||
| 7cc574f9a1 | |||
| 2208af7070 | |||
| df19752a1e | |||
| c07d1fd2bd | |||
| 4f85a86077 | |||
| f0650a9c39 | |||
| 4f42a2faff | |||
| 536707f812 | |||
| 1550ccb693 | |||
| 4042395e3e | |||
| 383440ed5b | |||
| 94f6a9666b | |||
| 10185178e2 | |||
| 0f83183620 |
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
# Git 与编辑器
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
|
||||
# 运行时数据与本地配置
|
||||
data/
|
||||
app.ini
|
||||
tmp-cookie.txt
|
||||
|
||||
# 编译产物与前端缓存
|
||||
dist/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
embed_static/static/spa/
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
# 文档与截图(不参与镜像构建)
|
||||
docs/
|
||||
|
||||
# 计划文件
|
||||
*.plan.md
|
||||
3
.gitignore
vendored
@@ -23,3 +23,6 @@ tmp-cookie.txt
|
||||
*.swp
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
|
||||
frontend/tmp-lucide-paths.json
|
||||
|
||||
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 内容
|
||||
- 保存后评论更新正确
|
||||
138
.trae/documents/fix-comment-scroll-position-plan.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 修复主页右侧栏评论点击定位问题
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户在主页右侧栏点击评论时,如果该评论不是帖子的第一个评论(`floor > 0`),页面跳转后没有定位到该评论上。
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 问题 1:`jumpToFloor` 使用 `scrollIntoView` 而非 `pageRef.scrollTo`
|
||||
|
||||
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L235-L242)
|
||||
|
||||
当前 `jumpToFloor` 函数使用 `el.scrollIntoView()` 来滚动:
|
||||
|
||||
```typescript
|
||||
const jumpToFloor = useCallback((floor: number) => {
|
||||
const el = document.getElementById(`floor-${floor}`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// ...
|
||||
}, []);
|
||||
```
|
||||
|
||||
但是页面使用了自定义滚动容器 `pageRef`(类名为 `.post-detail-page`),且 `useGlobalWheelScroll` 钩子拦截了滚轮事件,将其转换为对 `pageRef.scrollTop` 的直接操作。这导致原生 `scrollIntoView` 可能无法正确触发滚动。
|
||||
|
||||
对比 `jumpToHeadingHash` 函数(第 244-262 行),它正确地使用了 `pageRef.scrollTo()`:
|
||||
|
||||
```typescript
|
||||
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
|
||||
// ...
|
||||
const root = pageRef.current;
|
||||
if (root) {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||
root.scrollTo({ top: Math.max(0, top), behavior });
|
||||
}
|
||||
// ...
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 问题 2:`#floor-N` 定位缺少重试机制
|
||||
|
||||
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L264-L273)
|
||||
|
||||
当前 `#floor-N` 定位只有一次 80ms 延迟,没有重试机制:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (loading || !post) return;
|
||||
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||
if (!m) return;
|
||||
const floor = Number(m[1]);
|
||||
if (!floor) return;
|
||||
const t = window.setTimeout(() => jumpToFloor(floor), 80);
|
||||
return () => clearTimeout(t);
|
||||
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||
```
|
||||
|
||||
对比 `#heading-N` 定位(第 275-299 行),它有完善的重试机制(最多 30 次,每次 50ms)。
|
||||
|
||||
## 修改方案
|
||||
|
||||
### 修改 1:修改 `jumpToFloor` 函数使用 `pageRef.scrollTo`
|
||||
|
||||
将 `jumpToFloor` 函数改为使用与 `jumpToHeadingHash` 相同的滚动方式:
|
||||
|
||||
```typescript
|
||||
const jumpToFloor = useCallback((floor: number) => {
|
||||
const el = document.getElementById(`floor-${floor}`);
|
||||
if (!el) return false;
|
||||
|
||||
const root = pageRef.current;
|
||||
if (root) {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
setHighlightFloor(floor);
|
||||
clearTimeout(highlightTimer.current);
|
||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||
return true;
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 修改 2:为 `#floor-N` 定位添加重试机制
|
||||
|
||||
将 `#floor-N` 定位的 `useEffect` 改为类似 `#heading-N` 的重试机制:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (loading || !post) return;
|
||||
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||
if (!m) return;
|
||||
const floor = Number(m[1]);
|
||||
if (!floor) return;
|
||||
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
let timer = 0;
|
||||
|
||||
const tryJump = () => {
|
||||
if (cancelled) return;
|
||||
if (jumpToFloor(floor)) return;
|
||||
attempts += 1;
|
||||
if (attempts < 30) {
|
||||
timer = window.setTimeout(tryJump, 50);
|
||||
}
|
||||
};
|
||||
|
||||
timer = window.setTimeout(tryJump, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||
```
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/PostDetailPage.tsx`:修改 `jumpToFloor` 函数和 `#floor-N` 定位的 `useEffect`
|
||||
|
||||
## 风险评估
|
||||
|
||||
- **低风险**:修改仅限于评论定位逻辑,不影响其他功能
|
||||
- **需测试**:需要验证页面加载时评论定位是否正常工作,以及页面内部导航时是否正常
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. 进入主页,点击右侧栏的一个非第一条评论
|
||||
2. 验证页面跳转到帖子详情后,是否正确定位到目标评论
|
||||
3. 验证评论高亮效果是否正常显示
|
||||
4. 测试第一条评论(floor=0)的定位是否仍然正常工作
|
||||
5. 在帖子详情页直接加载带 hash 的 URL(如 `/post/123#floor-5`),验证定位效果
|
||||
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 编译通过。
|
||||
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 静态文件服务方案
|
||||
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "de7e4cff",
|
||||
"configHash": "9a7296da",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "8c168d3c",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
55
Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
||||
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像
|
||||
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||
#
|
||||
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
|
||||
# docker build --build-arg IMAGE_PREFIX= ...
|
||||
|
||||
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
||||
ARG VERSION=dev
|
||||
|
||||
# ── Stage 1: 前端构建(Vite → embed_static/static/spa)────────────────────
|
||||
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm config set registry https://registry.npmmirror.com \
|
||||
&& npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)────────────────────────
|
||||
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
|
||||
ARG VERSION
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/jiang13 ./cmd/jiang13
|
||||
|
||||
# ── Stage 3: 运行镜像 ───────────────────────────────────────────────────────
|
||||
FROM ${IMAGE_PREFIX}alpine:3.21
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache ca-certificates tzdata wget su-exec \
|
||||
&& adduser -D -u 1000 -h /app jiang13
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/jiang13 /app/jiang13
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chown jiang13:jiang13 /app/jiang13 \
|
||||
&& chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||
|
||||
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"]
|
||||
32
Makefile
@@ -4,13 +4,15 @@
|
||||
APP_NAME := jiang13
|
||||
MAIN_PKG := ./cmd/jiang13
|
||||
BUILD_DIR := dist
|
||||
VERSION := 1.0.0
|
||||
DEV_DATA_DIR := dist/data
|
||||
VERSION := 1.1.5
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||
|
||||
GO := go
|
||||
GOFLAGS := -trimpath
|
||||
|
||||
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build
|
||||
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build docker compose-up compose-down
|
||||
|
||||
all: build
|
||||
|
||||
@@ -54,22 +56,37 @@ build-all: frontend-build
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
|
||||
## 本地运行(仅后端,使用已 embed 的前端)
|
||||
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致)
|
||||
run:
|
||||
$(GO) run $(MAIN_PKG)
|
||||
@mkdir -p $(DEV_DATA_DIR)
|
||||
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR)
|
||||
|
||||
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出)
|
||||
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
|
||||
dev:
|
||||
@echo "前端热更新: http://localhost:5173"
|
||||
@echo "后端 API : http://localhost:3000"
|
||||
@echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)"
|
||||
@mkdir -p $(DEV_DATA_DIR)
|
||||
@trap 'kill 0' INT; \
|
||||
$(GO) run $(MAIN_PKG) & \
|
||||
$(GO) run $(MAIN_PKG) --dev --data $(DEV_DATA_DIR) & \
|
||||
cd frontend && (test -d node_modules || npm install) && npm run dev
|
||||
|
||||
## 清理编译产物
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
## 构建 Docker 镜像
|
||||
docker:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
|
||||
|
||||
## Docker Compose 启动
|
||||
compose-up:
|
||||
docker compose up -d --build
|
||||
|
||||
## Docker Compose 停止
|
||||
compose-down:
|
||||
docker compose down
|
||||
|
||||
help:
|
||||
@echo "姜十三论坛编译命令:"
|
||||
@echo " make build - 编译当前平台"
|
||||
@@ -79,3 +96,6 @@ help:
|
||||
@echo " make build-all - 编译全部平台"
|
||||
@echo " make run - 仅启动后端(:3000)"
|
||||
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||
@echo " make docker - 构建 Docker 镜像"
|
||||
@echo " make compose-up - Docker Compose 启动"
|
||||
@echo " make compose-down - Docker Compose 停止"
|
||||
|
||||
105
README.md
@@ -11,6 +11,7 @@
|
||||
|
||||
[](https://bbs.iioio.com/)
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||
[](go.mod)
|
||||
[](frontend/package.json)
|
||||
[](#)
|
||||
@@ -116,6 +117,7 @@
|
||||
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||
|
||||
---
|
||||
|
||||
@@ -152,7 +154,90 @@ build.bat -Target build-linux
|
||||
build.bat -Target build-all
|
||||
```
|
||||
|
||||
### 2. 启动
|
||||
### 2. Docker 部署(推荐)
|
||||
|
||||
**一键启动(本地构建):**
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
```bat
|
||||
.\build.bat -Target compose-up
|
||||
```
|
||||
|
||||
```bash
|
||||
make compose-up
|
||||
```
|
||||
|
||||
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
|
||||
|
||||
**拉取已构建镜像(Docker Hub):**
|
||||
|
||||
```bash
|
||||
docker pull hangzhang714128/jiang13-forum:latest
|
||||
docker run -d --name jiang13 \
|
||||
-p 3000:3000 \
|
||||
-v jiang13-data:/data \
|
||||
--restart unless-stopped \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。
|
||||
|
||||
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||
|
||||
**可选环境变量(容器编排):**
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
||||
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
||||
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||
| `JIANG13_CONFIG` | 配置文件路径 |
|
||||
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||
|
||||
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||
|
||||
**发布镜像到 Docker Hub(手动):**
|
||||
|
||||
```bash
|
||||
docker login
|
||||
.\build.bat -Target docker # Windows
|
||||
# make docker # Linux/macOS
|
||||
docker push hangzhang714128/jiang13-forum:1.1.5
|
||||
docker push hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
或直接构建:
|
||||
|
||||
```bash
|
||||
docker build --build-arg VERSION=1.1.5 -t hangzhang714128/jiang13-forum:1.1.5 -t hangzhang714128/jiang13-forum:latest .
|
||||
docker push hangzhang714128/jiang13-forum:1.1.5
|
||||
docker push hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
停止服务:`docker compose down` / `.\build.bat -Target compose-down` / `make compose-down`
|
||||
|
||||
**构建失败(无法连接 auth.docker.io)?** Dockerfile 已默认经 DaoCloud 拉取基础镜像。若仍超时,可在 Docker Desktop → Settings → Docker Engine 添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": ["https://docker.m.daocloud.io"]
|
||||
}
|
||||
```
|
||||
|
||||
保存并重启 Docker 后重试 `.\build.bat -Target docker`。PowerShell 中请用 `.\build.bat`(带 `.\` 前缀)。
|
||||
|
||||
**1Panel 部署提示:**
|
||||
|
||||
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
||||
2. 端口映射 `3000:3000`
|
||||
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
||||
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
|
||||
|
||||
### 3. 直接启动(二进制)
|
||||
|
||||
把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||
|
||||
@@ -172,7 +257,7 @@ cp app.ini.example /opt/jiang13/app.ini
|
||||
./jiang13
|
||||
```
|
||||
|
||||
### 3. 首次使用
|
||||
### 4. 首次使用
|
||||
|
||||
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||
2. **第一个注册的用户自动成为管理员**
|
||||
@@ -208,7 +293,9 @@ JWT_SECRET =
|
||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
||||
|
||||
### 4. 注册为系统服务(可选)
|
||||
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`、`JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
|
||||
|
||||
### 5. 注册为系统服务(可选)
|
||||
|
||||
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
|
||||
|
||||
@@ -260,6 +347,8 @@ make dev
|
||||
|
||||
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
||||
|
||||
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
|
||||
|
||||
**何时需要完整构建:**
|
||||
|
||||
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
||||
@@ -295,6 +384,10 @@ jiang13-forum/
|
||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||
├── config/ # app.ini 与命令行配置
|
||||
├── app.ini.example # 配置文件示例
|
||||
├── Dockerfile # 多阶段 Docker 构建
|
||||
├── docker-compose.yml # 单容器 Compose 部署
|
||||
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限)
|
||||
├── .dockerignore
|
||||
├── model/ # GORM 模型与数据库迁移
|
||||
├── service/ # 业务逻辑
|
||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||
@@ -313,13 +406,15 @@ jiang13-forum/
|
||||
|
||||
```
|
||||
data/
|
||||
├── jiang13.db # SQLite 主数据库
|
||||
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||
├── monitor.db # 网站监控 page_views
|
||||
├── jiang13.log # 运行日志
|
||||
├── filter_words.txt # 敏感词配置
|
||||
├── .jwt_secret # JWT 密钥(自动生成)
|
||||
├── logs/access/ # 网站监控请求日志(按日 jsonl)
|
||||
├── uploads/avatars/ # 用户头像
|
||||
├── uploads/posts/ # 帖子正文图片
|
||||
└── jiang13_backup_*.db # 后台导出的备份
|
||||
└── jiang13_backup_*.db # 后台导出的主库备份
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -13,3 +13,13 @@ DATA = data
|
||||
[security]
|
||||
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||
JWT_SECRET =
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; 维护者选项(默认关闭;普通自托管无需开启)
|
||||
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
|
||||
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
|
||||
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
|
||||
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
|
||||
; ---------------------------------------------------------------------------
|
||||
; [community]
|
||||
; HUB = false
|
||||
|
||||
42
build.ps1
@@ -3,7 +3,7 @@
|
||||
# .\build.ps1 -Target build-windows
|
||||
|
||||
param(
|
||||
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'help')]
|
||||
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
|
||||
[string]$Target = 'build'
|
||||
)
|
||||
|
||||
@@ -11,7 +11,9 @@ $ErrorActionPreference = 'Stop'
|
||||
$AppName = 'jiang13'
|
||||
$MainPkg = './cmd/jiang13'
|
||||
$BuildDir = 'dist'
|
||||
$Version = '1.0.0'
|
||||
$DevDataDir = 'dist/data'
|
||||
$Version = '1.1.5'
|
||||
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||
$Ldlags = "-s -w -X main.version=$Version"
|
||||
|
||||
function Ensure-Dir($path) {
|
||||
@@ -76,6 +78,9 @@ switch ($Target) {
|
||||
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
||||
Write-Host '.\build.ps1 -Target tidy'
|
||||
Write-Host '.\build.ps1 -Target clean'
|
||||
Write-Host '.\build.ps1 -Target docker build Docker image'
|
||||
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build'
|
||||
Write-Host '.\build.ps1 -Target compose-down docker compose down'
|
||||
Write-Host ''
|
||||
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
||||
}
|
||||
@@ -86,22 +91,25 @@ switch ($Target) {
|
||||
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
||||
}
|
||||
'run' {
|
||||
go run $MainPkg
|
||||
Ensure-Dir $DevDataDir
|
||||
go run $MainPkg --data $DevDataDir
|
||||
}
|
||||
'dev' {
|
||||
$root = (Get-Location).Path
|
||||
Ensure-Dir $DevDataDir
|
||||
Write-Host ''
|
||||
Write-Host '[dev] 前端热更新: http://localhost:5173' -ForegroundColor Green
|
||||
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green
|
||||
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green
|
||||
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
|
||||
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green
|
||||
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
|
||||
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
|
||||
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
|
||||
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
|
||||
Start-Process powershell -ArgumentList @(
|
||||
'-NoExit', '-Command',
|
||||
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg"
|
||||
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev --data '$DevDataDir'"
|
||||
) | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Push-Location frontend
|
||||
90| try {
|
||||
try {
|
||||
if (-not (Test-Path node_modules)) { npm install }
|
||||
npm run dev
|
||||
} finally {
|
||||
@@ -132,4 +140,20 @@ switch ($Target) {
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
Write-Host '[ok] all platforms done' -ForegroundColor Green
|
||||
}
|
||||
'docker' {
|
||||
Write-Host "[docker] build $RegistryImage`:$Version and latest" -ForegroundColor Cyan
|
||||
docker build --build-arg "VERSION=$Version" -t "${RegistryImage}:$Version" -t "${RegistryImage}:latest" .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed' }
|
||||
Write-Host '[ok] docker image built' -ForegroundColor Green
|
||||
}
|
||||
'compose-up' {
|
||||
docker compose up -d --build
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' }
|
||||
Write-Host '[ok] compose started' -ForegroundColor Green
|
||||
}
|
||||
'compose-down' {
|
||||
docker compose down
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' }
|
||||
Write-Host '[ok] compose stopped' -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import (
|
||||
"github.com/kardianos/service"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
appsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
appsvc.SetAppVersion(version)
|
||||
cfg, err := config.Parse()
|
||||
if err != nil {
|
||||
log.Fatalf("配置解析失败: %v", err)
|
||||
|
||||
@@ -72,11 +72,15 @@ func (p *program) setup() error {
|
||||
|
||||
log.Println("========================================")
|
||||
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
||||
log.Printf(" 版本: %s", version)
|
||||
log.Println("========================================")
|
||||
|
||||
if err := model.InitDB(cfg.DBPath()); err != nil {
|
||||
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||
}
|
||||
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
|
||||
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||
}
|
||||
|
||||
engine, err := router.Setup(cfg)
|
||||
if err != nil {
|
||||
|
||||
410
cmd/monitor-geo-check/main.go
Normal file
@@ -0,0 +1,410 @@
|
||||
// 只读诊断:检查 Geo 库与 page_views 地理字段,或对指定 IP 试查中文映射结果。
|
||||
//
|
||||
// go run ./cmd/monitor-geo-check -db data/monitor.db
|
||||
// go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", filepath.Join("data", "monitor.db"), "含 page_views 的 SQLite(默认 data/monitor.db)")
|
||||
ipLookup := flag.String("ip", "", "对指定 IP 试查 BIN+ASN(不写库)")
|
||||
flag.Parse()
|
||||
|
||||
abs, err := filepath.Abs(*dbPath)
|
||||
if err != nil {
|
||||
fatalf("解析路径失败: %v", err)
|
||||
}
|
||||
dataDir := filepath.Dir(abs)
|
||||
|
||||
if *ipLookup == "" {
|
||||
if st, err := os.Stat(abs); err != nil || st.IsDir() {
|
||||
fatalf("数据库不存在: %s", abs)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("数据库: %s\n", abs)
|
||||
fmt.Printf("数据目录: %s\n\n", dataDir)
|
||||
|
||||
suite := service.NewGeoIPService(dataDir)
|
||||
defer suite.Close()
|
||||
printGeoFiles(dataDir, suite)
|
||||
|
||||
if ip := strings.TrimSpace(*ipLookup); ip != "" {
|
||||
lookupIP(suite, ip)
|
||||
fmt.Println()
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(abs), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
fatalf("打开数据库失败: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
fatalf("获取连接失败: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if !tableExists(db, "page_views") {
|
||||
fatalf("表 page_views 不存在(请确认指向 data/monitor.db,或尚未写入过浏览量)")
|
||||
}
|
||||
|
||||
have := columnSet(db)
|
||||
printColumnPresence(have)
|
||||
|
||||
since := time.Now().AddDate(0, 0, -30)
|
||||
var total, recentNonBot, withCountry, withRegion, withCity, withASN int64
|
||||
_ = db.Table("page_views").Count(&total).Error
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ?", since, false).Count(&recentNonBot).Error
|
||||
|
||||
fmt.Println("=== page_views 统计(近 30 日非 bot)===")
|
||||
fmt.Printf("总量: %d\n", total)
|
||||
fmt.Printf("近 30 日非 bot: %d\n", recentNonBot)
|
||||
|
||||
if have["country"] {
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND country <> ''", since, false).Count(&withCountry).Error
|
||||
fmt.Printf("有国家: %d\n", withCountry)
|
||||
} else {
|
||||
fmt.Println("有国家: (列缺失)")
|
||||
}
|
||||
if have["region"] || have["region_iso"] {
|
||||
cond := "created_at >= ? AND is_bot = ?"
|
||||
parts := []string{}
|
||||
if have["region"] {
|
||||
parts = append(parts, "region <> ''")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
parts = append(parts, "region_iso <> ''")
|
||||
}
|
||||
_ = db.Table("page_views").
|
||||
Where(cond+" AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||
Count(&withRegion).Error
|
||||
fmt.Printf("有省字段: %d\n", withRegion)
|
||||
} else {
|
||||
fmt.Println("有省字段: (列缺失)")
|
||||
}
|
||||
if have["city"] {
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND city <> ''", since, false).Count(&withCity).Error
|
||||
fmt.Printf("有城市: %d\n", withCity)
|
||||
} else {
|
||||
fmt.Println("有城市: (列缺失)")
|
||||
}
|
||||
if have["asn"] {
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND asn > 0", since, false).Count(&withASN).Error
|
||||
fmt.Printf("有 ASN: %d\n", withASN)
|
||||
} else {
|
||||
fmt.Println("有 ASN: (列缺失)")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 近 30 日省级 Top(country / region / region_iso)===")
|
||||
if !(have["region"] || have["region_iso"]) {
|
||||
fmt.Println("(省列尚未迁移,跳过)")
|
||||
} else {
|
||||
type regionRow struct {
|
||||
Country string
|
||||
Region string
|
||||
RegionISO string `gorm:"column:region_iso"`
|
||||
Count int64
|
||||
}
|
||||
parts := []string{}
|
||||
if have["region"] {
|
||||
parts = append(parts, "region <> ''")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
parts = append(parts, "region_iso <> ''")
|
||||
}
|
||||
selParts := []string{}
|
||||
groupParts := []string{}
|
||||
if have["country"] {
|
||||
selParts = append(selParts, "country")
|
||||
groupParts = append(groupParts, "country")
|
||||
} else {
|
||||
selParts = append(selParts, "'' as country")
|
||||
}
|
||||
if have["region"] {
|
||||
selParts = append(selParts, "region")
|
||||
groupParts = append(groupParts, "region")
|
||||
} else {
|
||||
selParts = append(selParts, "'' as region")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
selParts = append(selParts, "region_iso")
|
||||
groupParts = append(groupParts, "region_iso")
|
||||
} else {
|
||||
selParts = append(selParts, "'' as region_iso")
|
||||
}
|
||||
selParts = append(selParts, "COUNT(*) as count")
|
||||
var regions []regionRow
|
||||
_ = db.Table("page_views").
|
||||
Select(strings.Join(selParts, ", ")).
|
||||
Where("created_at >= ? AND is_bot = ? AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||
Group(strings.Join(groupParts, ", ")).
|
||||
Order("count DESC").
|
||||
Limit(20).
|
||||
Scan(®ions).Error
|
||||
if len(regions) == 0 {
|
||||
fmt.Println("(空)")
|
||||
} else {
|
||||
for i, r := range regions {
|
||||
fmt.Printf("%2d. %s | %s | %s | %d\n", i+1, r.Country, r.Region, r.RegionISO, r.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 对账抽样(近 30 日非 bot)===")
|
||||
printGeoReconcileSamples(db, have, since)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
|
||||
cols := []string{"id", "created_at", "path", "is_bot"}
|
||||
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||
if have[c] {
|
||||
cols = append(cols, c)
|
||||
}
|
||||
}
|
||||
type sample struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
IP string
|
||||
Country string
|
||||
Region string
|
||||
RegionISO string `gorm:"column:region_iso"`
|
||||
City string
|
||||
ASN uint
|
||||
ASOrg string `gorm:"column:as_org"`
|
||||
Path string
|
||||
IsBot bool
|
||||
}
|
||||
var samples []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(strings.Join(cols, ", ")).
|
||||
Where("created_at >= ?", since).
|
||||
Order("id DESC").
|
||||
Limit(10).
|
||||
Scan(&samples).Error
|
||||
if len(samples) == 0 {
|
||||
fmt.Println("(无近 30 日 pageview)")
|
||||
} else {
|
||||
for _, s := range samples {
|
||||
fmt.Printf("#%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q bot=%v path=%s\n",
|
||||
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.IsBot, s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
regionReady := have["region"] || have["region_iso"]
|
||||
if !regionReady || withRegion == 0 {
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "提示: 近 30 日无省级字段。中国地图「暂无省级访问数据」属预期。")
|
||||
if !regionReady {
|
||||
fmt.Fprintln(os.Stderr, "当前库缺 region/region_iso 列:请先启动一次主程序以完成 AutoMigrate。")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
|
||||
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
|
||||
fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) {
|
||||
type sample struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
IP string
|
||||
Country string
|
||||
Region string
|
||||
RegionISO string `gorm:"column:region_iso"`
|
||||
City string
|
||||
ASN uint
|
||||
ASOrg string `gorm:"column:as_org"`
|
||||
Path string
|
||||
}
|
||||
selCols := []string{"id", "created_at", "path"}
|
||||
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||
if have[c] {
|
||||
selCols = append(selCols, c)
|
||||
}
|
||||
}
|
||||
sel := strings.Join(selCols, ", ")
|
||||
|
||||
printBlock := func(title string, n int64, rows []sample) {
|
||||
fmt.Printf("%s: %d\n", title, n)
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range rows {
|
||||
fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n",
|
||||
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if have["city"] {
|
||||
var n int64
|
||||
_ = db.Table("page_views").
|
||||
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||
Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("英文城市名(可能未中文化)", n, rows)
|
||||
} else {
|
||||
fmt.Println("英文城市名: (city 列缺失)")
|
||||
}
|
||||
|
||||
if have["region"] || have["region_iso"] {
|
||||
cond := "created_at >= ? AND is_bot = ? AND country <> ''"
|
||||
emptyParts := []string{}
|
||||
if have["region"] {
|
||||
emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)")
|
||||
}
|
||||
where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")"
|
||||
var n int64
|
||||
_ = db.Table("page_views").Where(where, since, false).Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where(where, since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("有国家但省为空", n, rows)
|
||||
} else {
|
||||
fmt.Println("有国家但省为空: (省列缺失)")
|
||||
}
|
||||
|
||||
if have["asn"] {
|
||||
var n int64
|
||||
_ = db.Table("page_views").
|
||||
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||
Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("asn=0(无运营商)", n, rows)
|
||||
} else {
|
||||
fmt.Println("asn=0: (asn 列缺失)")
|
||||
}
|
||||
}
|
||||
|
||||
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
|
||||
fmt.Println("=== Geo 数据文件 ===")
|
||||
v4, v6, asn, country := suite.Paths()
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
path string
|
||||
ok bool
|
||||
}{
|
||||
{"IP2LOCATION-LITE-DB3.BIN", v4, suite.BINV4Available()},
|
||||
{"IP2LOCATION-LITE-DB3.IPV6.BIN", v6, suite.BINV6Available()},
|
||||
{"GeoLite2-ASN.mmdb", asn, suite.ASNAvailable()},
|
||||
{"GeoLite2-Country.mmdb", country, suite.CountryAvailable()},
|
||||
} {
|
||||
st, err := os.Stat(item.path)
|
||||
if err != nil {
|
||||
fmt.Printf("%s: 不存在\n", item.name)
|
||||
continue
|
||||
}
|
||||
loaded := "未加载"
|
||||
if item.ok {
|
||||
loaded = "已加载"
|
||||
}
|
||||
fmt.Printf("%s: 存在 (%d bytes, mtime %s) · %s\n", item.name, st.Size(), st.ModTime().Format(time.RFC3339), loaded)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func lookupIP(suite *service.GeoIPService, ipStr string) {
|
||||
fmt.Printf("=== IP 试查 %s(中文映射后)===\n", ipStr)
|
||||
info := suite.Lookup(ipStr)
|
||||
if info.Country == "" && info.Region == "" && info.City == "" && info.ASN == 0 {
|
||||
fmt.Println("未解析到地理信息(请确认 BIN/ASN 文件存在且 IP 为公网地址)")
|
||||
return
|
||||
}
|
||||
fmt.Printf("国家 ISO: %s (%s)\n", info.Country, service.CountryLabelZh(info.Country))
|
||||
if info.Region != "" || info.RegionISO != "" {
|
||||
fmt.Printf("省/州: %s / %s\n", info.Region, info.RegionISO)
|
||||
}
|
||||
if info.City != "" {
|
||||
fmt.Printf("城市: %s\n", info.City)
|
||||
}
|
||||
if info.ASN > 0 || info.ASOrg != "" {
|
||||
fmt.Printf("ASN: AS%d %s\n", info.ASN, info.ASOrg)
|
||||
}
|
||||
}
|
||||
|
||||
func tableExists(db *gorm.DB, name string) bool {
|
||||
var n int
|
||||
err := db.Raw(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
|
||||
).Scan(&n).Error
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
func columnSet(db *gorm.DB) map[string]bool {
|
||||
type colRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var cols []colRow
|
||||
_ = db.Raw(`PRAGMA table_info(page_views)`).Scan(&cols).Error
|
||||
have := map[string]bool{}
|
||||
for _, c := range cols {
|
||||
have[c.Name] = true
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
func printColumnPresence(have map[string]bool) {
|
||||
need := []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"}
|
||||
fmt.Println("=== page_views 列 ===")
|
||||
missing := false
|
||||
for _, n := range need {
|
||||
if have[n] {
|
||||
fmt.Printf("%s: 有\n", n)
|
||||
} else {
|
||||
fmt.Printf("%s: 缺失\n", n)
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
if missing {
|
||||
fmt.Fprintln(os.Stderr, "提示: 缺列时请先启动一次主程序以完成监控库 AutoMigrate。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -30,11 +30,16 @@ type Config struct {
|
||||
LogFile string
|
||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||
ServiceAction string
|
||||
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||
DevMode bool
|
||||
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
|
||||
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
|
||||
CommunityHub bool
|
||||
}
|
||||
|
||||
// Parse 解析命令行与 app.ini,并初始化数据目录
|
||||
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
||||
//
|
||||
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值
|
||||
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
|
||||
func Parse() (*Config, error) {
|
||||
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
||||
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||
@@ -42,6 +47,7 @@ func Parse() (*Config, error) {
|
||||
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
|
||||
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
|
||||
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
|
||||
flag.Parse()
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||
@@ -49,12 +55,20 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
||||
}
|
||||
|
||||
workPath, err := resolveWorkPath(*workFlag)
|
||||
workPathInput := strings.TrimSpace(*workFlag)
|
||||
if workPathInput == "" {
|
||||
workPathInput = envOrDefault(envWorkPath)
|
||||
}
|
||||
workPath, err := resolveWorkPath(workPathInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configFile, err := resolveConfigPath(workPath, *configFlag)
|
||||
configInput := strings.TrimSpace(*configFlag)
|
||||
if configInput == "" {
|
||||
configInput = envOrDefault(envConfig)
|
||||
}
|
||||
configFile, err := resolveConfigPath(workPath, configInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,11 +84,17 @@ func Parse() (*Config, error) {
|
||||
}
|
||||
|
||||
port := fileCfg.Port
|
||||
if p := envIntOrZero(envHTTPPort); p > 0 {
|
||||
port = p
|
||||
}
|
||||
if *portFlag > 0 {
|
||||
port = *portFlag
|
||||
}
|
||||
|
||||
dataInput := fileCfg.DataRel
|
||||
if v := envOrDefault(envData); v != "" {
|
||||
dataInput = v
|
||||
}
|
||||
if strings.TrimSpace(*dataFlag) != "" {
|
||||
dataInput = *dataFlag
|
||||
}
|
||||
@@ -83,9 +103,17 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
jwtSecret := strings.TrimSpace(*jwtFlag)
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = fileCfg.JWTSecret
|
||||
jwtSecret := fileCfg.JWTSecret
|
||||
if v := envOrDefault(envJWTSecret); v != "" {
|
||||
jwtSecret = v
|
||||
}
|
||||
if strings.TrimSpace(*jwtFlag) != "" {
|
||||
jwtSecret = strings.TrimSpace(*jwtFlag)
|
||||
}
|
||||
|
||||
communityHub := fileCfg.CommunityHub
|
||||
if v := envBoolOrNil(envCommunityHub); v != nil {
|
||||
communityHub = *v
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
@@ -96,6 +124,8 @@ func Parse() (*Config, error) {
|
||||
JWTSecret: jwtSecret,
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
DevMode: *devFlag,
|
||||
CommunityHub: communityHub,
|
||||
}
|
||||
|
||||
needDirs := action == "" || action == "install"
|
||||
@@ -163,6 +193,7 @@ func ensureDataDirs(dataDir string) error {
|
||||
filepath.Join(dataDir, "uploads", "avatars"),
|
||||
filepath.Join(dataDir, "uploads", "posts"),
|
||||
filepath.Join(dataDir, "uploads", "site"),
|
||||
filepath.Join(dataDir, "logs", "access"),
|
||||
} {
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||
@@ -197,11 +228,16 @@ func validServiceAction(action string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath 返回 SQLite 数据库文件路径
|
||||
// DBPath 返回主库 SQLite 路径
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "jiang13.db")
|
||||
}
|
||||
|
||||
// MonitorDBPath 返回监控独立库路径(page_views)
|
||||
func (c *Config) MonitorDBPath() string {
|
||||
return filepath.Join(c.DataDir, "monitor.db")
|
||||
}
|
||||
|
||||
// AvatarUploadDir 返回头像上传目录
|
||||
func (c *Config) AvatarUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||
|
||||
51
config/env.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认)
|
||||
const (
|
||||
envWorkPath = "JIANG13_WORK_PATH"
|
||||
envConfig = "JIANG13_CONFIG"
|
||||
envHTTPPort = "JIANG13_HTTP_PORT"
|
||||
envData = "JIANG13_DATA"
|
||||
envJWTSecret = "JIANG13_JWT_SECRET"
|
||||
envCommunityHub = "JIANG13_COMMUNITY_HUB"
|
||||
)
|
||||
|
||||
func envOrDefault(key string) string {
|
||||
return strings.TrimSpace(os.Getenv(key))
|
||||
}
|
||||
|
||||
func envIntOrZero(key string) int {
|
||||
v := envOrDefault(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// envBoolOrNil 解析布尔环境变量;未设置返回 nil
|
||||
func envBoolOrNil(key string) *bool {
|
||||
v := strings.ToLower(envOrDefault(key))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
switch v {
|
||||
case "1", "true", "yes", "on":
|
||||
t := true
|
||||
return &t
|
||||
case "0", "false", "no", "off":
|
||||
f := false
|
||||
return &f
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ const (
|
||||
|
||||
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
|
||||
type fileSettings struct {
|
||||
Port int
|
||||
DataRel string
|
||||
JWTSecret string
|
||||
Port int
|
||||
DataRel string
|
||||
JWTSecret string
|
||||
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
|
||||
}
|
||||
|
||||
func defaultFileSettings() fileSettings {
|
||||
@@ -62,6 +63,12 @@ func loadAppINI(path string) (fileSettings, error) {
|
||||
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
||||
}
|
||||
|
||||
if sec, err := cfg.GetSection("community"); err == nil {
|
||||
if k := sec.Key("HUB"); k.String() != "" {
|
||||
out.CommunityHub = k.MustBool(false)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
# 姜十三论坛 — Docker Compose 单服务部署
|
||||
# 启动:docker compose up -d --build
|
||||
# 或:make compose-up / build.bat -Target compose-up
|
||||
|
||||
services:
|
||||
jiang13:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
# 可选:挂载自定义 app.ini(只读)
|
||||
# - ./app.ini:/app/app.ini:ro
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret)
|
||||
# JIANG13_JWT_SECRET: your-secret-here
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 15s
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
jiang13-data:
|
||||
15
docker-entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
|
||||
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。
|
||||
|
||||
DATA_DIR="/data"
|
||||
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R jiang13:jiang13 "$DATA_DIR"
|
||||
exec su-exec jiang13 /app/jiang13 "$@"
|
||||
fi
|
||||
|
||||
exec /app/jiang13 "$@"
|
||||
105
docs/docker-data.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Docker 数据持久化说明
|
||||
|
||||
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
|
||||
|
||||
## 挂载对应关系
|
||||
|
||||
| 宿主机 | 容器内 |
|
||||
|--------|--------|
|
||||
| 任意目录或 named volume(如 `jiang13-data`) | **`/data`** |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
docker run -d --name jiang13 \
|
||||
-p 3000:3000 \
|
||||
-v /你的路径/jiang13-data:/data \
|
||||
--restart unless-stopped \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
或在 `docker-compose.yml` 中:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
# 或绑定宿主机目录:
|
||||
# - ./data:/data
|
||||
```
|
||||
|
||||
## `/data` 目录结构
|
||||
|
||||
数据库与附件都在同一个 `/data` 挂载内:
|
||||
|
||||
```
|
||||
/data/
|
||||
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||
├── monitor.db # 网站监控 page_views
|
||||
├── uploads/ # 附件根目录
|
||||
│ ├── avatars/ # 用户头像
|
||||
│ └── posts/ # 帖子图片等
|
||||
├── logs/
|
||||
│ └── access/ # 网站监控请求日志(按日 jsonl)
|
||||
│ └── YYYY-MM-DD.jsonl
|
||||
├── IP2LOCATION-LITE-DB3.BIN # 可选:IPv4 城市库(监控)
|
||||
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选:IPv6 城市库
|
||||
├── GeoLite2-ASN.mmdb # 可选:ASN/运营商
|
||||
├── GeoLite2-Country.mmdb # 可选:国家兜底
|
||||
├── .jwt_secret # JWT 密钥(自动生成)
|
||||
├── filter_words.txt # 敏感词配置
|
||||
└── jiang13.log # 运行日志(若启用)
|
||||
```
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| 主数据库 | `/data/jiang13.db` |
|
||||
| 监控浏览量 | `/data/monitor.db` |
|
||||
| 附件 | `/data/uploads/` |
|
||||
| 请求日志(监控) | `/data/logs/access/` |
|
||||
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN`、`GeoLite2-ASN.mmdb` 等 |
|
||||
|
||||
官方镜像不要求拆成两个 volume;挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
|
||||
|
||||
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP(不做脱敏)。
|
||||
|
||||
## 设计说明:为何只挂一个 `/data`
|
||||
|
||||
对本项目(单容器、SQLite、默认可本地存附件)而言,**一个数据卷是合理默认**:
|
||||
|
||||
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
|
||||
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
|
||||
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
|
||||
|
||||
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
|
||||
|
||||
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
|
||||
|
||||
## 后续计划(可选增强,非必须)
|
||||
|
||||
按优先级,仅在有明确需求时做:
|
||||
|
||||
1. **文档与面板提示(优先)**
|
||||
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
|
||||
- 本页保持为权威说明,避免用户误以为漏了一个卷。
|
||||
|
||||
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
|
||||
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
# 推荐:父目录一次挂载
|
||||
- /ssd/jiang13:/data
|
||||
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
|
||||
- /ssd/jiang13:/data
|
||||
- /hdd/jiang13-uploads:/data/uploads
|
||||
```
|
||||
|
||||
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
|
||||
|
||||
3. **产品级:可配置上传根目录(中期)**
|
||||
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
|
||||
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
|
||||
|
||||
4. **不做的事**
|
||||
- 不为「看起来像 WordPress」而强制双卷。
|
||||
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。
|
||||
79
docs/monitor.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 网站监控设计说明
|
||||
|
||||
管理端「网站监控」:轻量自建访问统计,对标 1Panel 思路——**请求流水写文件、访客地理写独立库**,开启后不拖慢前台读帖。
|
||||
|
||||
## 双通道数据
|
||||
|
||||
| 数据 | 存储 | 用途 |
|
||||
|------|------|------|
|
||||
| 请求日志 | `{DataDir}/logs/access/YYYY-MM-DD.jsonl` | 请求日志页、请求数/流量/4xx·5xx、实时曲线 |
|
||||
| page_views | `{DataDir}/monitor.db`(SQLite) | 浏览量/访客、国家/省/城市/运营商排行与地图 |
|
||||
|
||||
请求日志**只写 jsonl**,不进 SQLite。删除过期 `.jsonl` 即释放磁盘。
|
||||
|
||||
`page_views` **不进主库** `jiang13.db`。后台「导出备份」只含主库,不含浏览量。
|
||||
|
||||
### 文件行格式(JSONL)
|
||||
|
||||
每行一条 JSON,字段包括:`t`(RFC3339)、`method`、`path`、`status`、`bytes`、`duration_ms`、`ip`、`ua`、`referer`、`country`、`region`、`region_iso`、`city`、`asn`、`as_org`、`is_bot`。
|
||||
|
||||
### 保留天数
|
||||
|
||||
- `monitor_retention_days`:仅 **page_views**(默认 30)
|
||||
- `monitor_access_log_retention_days`:请求日志文件(默认 7)
|
||||
|
||||
## 地理数据文件
|
||||
|
||||
放在数据目录(与 `jiang13.db` 同级),有则加载、无则静默降级:
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `IP2LOCATION-LITE-DB3.BIN` | IPv4 国家/省/城市 |
|
||||
| `IP2LOCATION-LITE-DB3.IPV6.BIN` | IPv6 国家/省/城市 |
|
||||
| `GeoLite2-ASN.mmdb` | ASN / 运营商 |
|
||||
| `GeoLite2-Country.mmdb` | 可选:BIN 未命中时国家兜底 |
|
||||
|
||||
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
|
||||
|
||||
写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。
|
||||
|
||||
**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。
|
||||
|
||||
## 写入路径(性能)
|
||||
|
||||
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。
|
||||
2. 后台 flush:出队 → Geo + 中文映射 → append 当日 jsonl + 更新内存实时环。
|
||||
3. 队列上限 8192,满则丢弃新日志(保护内存与磁盘)。
|
||||
4. pageview 信标 QPS 低,可在写入时同步 Geo 后写独立 `monitor.db`(不占用主库连接)。
|
||||
|
||||
**热路径零 Geo、零写库、零写文件。**
|
||||
|
||||
## 请求日志口径(排除规则)
|
||||
|
||||
默认只关心**前台访客**,排除管理员刷后台产生的壳请求:
|
||||
|
||||
- `/admin`、`/api/admin/`
|
||||
- `/api/me`、`/api/site-branding`
|
||||
- `/health`、`/uploads/`、`/media/`、静态后缀、`/api/monitor/pageview` 等
|
||||
|
||||
设置页可「恢复推荐排除规则」。不强制覆盖用户已保存的自定义列表。
|
||||
|
||||
浏览量/访客仍来自前台路由 pageview(后台路径本身不上报)。
|
||||
|
||||
## 实时与聚合
|
||||
|
||||
- 近 1 分钟 / 近 1 小时:内存环(flush 时更新),避免扫全文件。
|
||||
- 今日请求数等:内存日累加器(进程内;重启后自新流量累积,论坛量级可接受)。
|
||||
- 地理排行:仅 `page_views`。
|
||||
|
||||
## 诊断
|
||||
|
||||
```bash
|
||||
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||
```
|
||||
|
||||
会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。
|
||||
## 明确不做
|
||||
|
||||
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库
|
||||
- 不做街道级地图;Docker 镜像不内置 BIN/MMDB
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
||||
spaBrandTitleFn func() string
|
||||
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
||||
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
||||
spaBrandTitleFn func() string
|
||||
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
||||
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
|
||||
)
|
||||
|
||||
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
||||
@@ -29,19 +30,58 @@ func SetSPABrandingJSON(fn func() []byte) {
|
||||
spaBrandJSONFn = fn
|
||||
}
|
||||
|
||||
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
|
||||
func SetSPAFaviconURL(fn func() string) {
|
||||
spaBrandFaviconFn = fn
|
||||
}
|
||||
|
||||
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
||||
func SetupEmbed(r *gin.Engine) error {
|
||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
||||
r.GET("/assets/*filepath", func(c *gin.Context) {
|
||||
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
|
||||
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
|
||||
c.Writer = w
|
||||
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
|
||||
}
|
||||
|
||||
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
|
||||
type cacheOnOKWriter struct {
|
||||
gin.ResponseWriter
|
||||
cacheControl string
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *cacheOnOKWriter) WriteHeader(code int) {
|
||||
if !w.wroteHeader {
|
||||
w.wroteHeader = true
|
||||
if code == http.StatusOK {
|
||||
w.Header().Set("Cache-Control", w.cacheControl)
|
||||
}
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
|
||||
func ServeSPA(c *gin.Context) {
|
||||
ServeSPAWithMeta(c, nil)
|
||||
@@ -61,7 +101,7 @@ func ServeSPANoIndex(c *gin.Context) {
|
||||
|
||||
// IsSPARoute 判断是否应由 SPA 处理
|
||||
func IsSPARoute(path string) bool {
|
||||
if path == "/robots.txt" || path == "/sitemap.xml" {
|
||||
if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(path, "/api") ||
|
||||
@@ -69,6 +109,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
|
||||
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 <head>,不写 #root,避免刷新闪屏)
|
||||
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
|
||||
|
||||
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
|
||||
// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。
|
||||
type SPAPageMeta struct {
|
||||
Title string // 完整 <title>
|
||||
Description string
|
||||
@@ -23,6 +27,8 @@ type SPAPageMeta struct {
|
||||
Robots string // 如 noindex,nofollow
|
||||
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
||||
Status int // HTTP 状态码,0 视为 200
|
||||
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
|
||||
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
|
||||
}
|
||||
|
||||
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
||||
@@ -37,8 +43,9 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
||||
return
|
||||
}
|
||||
data = applySPAPageMeta(data, meta)
|
||||
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Data(status, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
@@ -56,15 +63,20 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
||||
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
||||
}
|
||||
|
||||
var head strings.Builder
|
||||
writeMeta(&head, "description", meta.Description)
|
||||
writeMeta(&head, "keywords", meta.Keywords)
|
||||
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
|
||||
var seo strings.Builder
|
||||
writeMeta(&seo, "description", meta.Description)
|
||||
writeMeta(&seo, "keywords", meta.Keywords)
|
||||
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
||||
head.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
||||
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
||||
}
|
||||
if favicon := spaFaviconHref(); favicon != "" {
|
||||
seo.WriteString(`<link rel="icon" href="` + html.EscapeString(favicon) + `"/>`)
|
||||
seo.WriteString(`<link rel="shortcut icon" href="` + html.EscapeString(favicon) + `"/>`)
|
||||
}
|
||||
robots := strings.TrimSpace(meta.Robots)
|
||||
if robots != "" {
|
||||
writeMeta(&head, "robots", robots)
|
||||
writeMeta(&seo, "robots", robots)
|
||||
}
|
||||
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
@@ -75,36 +87,63 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
||||
if locale == "" {
|
||||
locale = "zh_CN"
|
||||
}
|
||||
writeProp(&head, "og:type", ogType)
|
||||
writeProp(&head, "og:site_name", meta.SiteName)
|
||||
writeProp(&head, "og:locale", locale)
|
||||
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
|
||||
writeProp(&head, "og:description", meta.Description)
|
||||
writeProp(&head, "og:url", meta.Canonical)
|
||||
writeProp(&head, "og:image", meta.OGImage)
|
||||
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
|
||||
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||
writeMetaName(&head, "twitter:description", meta.Description)
|
||||
writeMetaName(&head, "twitter:image", meta.OGImage)
|
||||
writeProp(&seo, "og:type", ogType)
|
||||
writeProp(&seo, "og:site_name", meta.SiteName)
|
||||
writeProp(&seo, "og:locale", locale)
|
||||
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
|
||||
writeProp(&seo, "og:description", meta.Description)
|
||||
writeProp(&seo, "og:url", meta.Canonical)
|
||||
writeProp(&seo, "og:image", meta.OGImage)
|
||||
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
|
||||
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||
writeMetaName(&seo, "twitter:description", meta.Description)
|
||||
writeMetaName(&seo, "twitter:image", meta.OGImage)
|
||||
|
||||
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
||||
head.WriteString(`<script type="application/ld+json">`)
|
||||
head.WriteString(jsonld)
|
||||
head.WriteString(`</script>`)
|
||||
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
|
||||
seo.WriteString(`<script type="application/ld+json">`)
|
||||
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
|
||||
seo.WriteString(`</script>`)
|
||||
}
|
||||
|
||||
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
|
||||
if boot := spaBrandingBootScript(); boot != "" {
|
||||
head.WriteString(boot)
|
||||
if seo.Len() > 0 {
|
||||
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
|
||||
}
|
||||
|
||||
if head.Len() > 0 {
|
||||
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
|
||||
// —— 可执行 boot 脚本仍放在 </head> 前 ——
|
||||
var boot strings.Builder
|
||||
if s := spaBrandingBootScript(); s != "" {
|
||||
boot.WriteString(s)
|
||||
}
|
||||
if s := spaHomeBootScript(meta.BootJSON); s != "" {
|
||||
boot.WriteString(s)
|
||||
}
|
||||
if boot.Len() > 0 {
|
||||
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
|
||||
}
|
||||
|
||||
if root := strings.TrimSpace(meta.RootHTML); root != "" {
|
||||
data = injectSPARootHTML(data, root)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO)
|
||||
func spaHomeBootScript(raw []byte) string {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || !json.Valid(raw) {
|
||||
return ""
|
||||
}
|
||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
|
||||
}
|
||||
|
||||
// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白)
|
||||
func injectSPARootHTML(data []byte, rootHTML string) []byte {
|
||||
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
|
||||
}
|
||||
|
||||
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
||||
func spaBrandingBootScript() string {
|
||||
if spaBrandJSONFn == nil {
|
||||
@@ -114,7 +153,6 @@ func spaBrandingBootScript() string {
|
||||
if len(raw) == 0 || !json.Valid(raw) {
|
||||
return ""
|
||||
}
|
||||
// 防止 JSON 字符串中的 </script> 提前闭合标签
|
||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
||||
}
|
||||
@@ -154,3 +192,11 @@ func firstNonEmpty(vals ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
|
||||
func spaFaviconHref() string {
|
||||
if spaBrandFaviconFn == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(spaBrandFaviconFn())
|
||||
}
|
||||
|
||||
@@ -2,26 +2,10 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="拾三一隅,自在交流" />
|
||||
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||
<style>
|
||||
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
|
||||
html { scrollbar-gutter: stable; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-header { height: 56px; flex-shrink: 0; }
|
||||
.site-footer { flex-shrink: 0; }
|
||||
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
|
||||
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.sidebar { width: 210px; flex-shrink: 0; }
|
||||
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.aside-panel { width: 280px; flex-shrink: 0; }
|
||||
@media (max-width: 1100px) { .aside-panel { display: none; } }
|
||||
@media (max-width: 768px) { .sidebar { display: none; } }
|
||||
</style>
|
||||
<!-- 样式由 Vite 构建注入外链 CSS,不在此内联 style -->
|
||||
<script>
|
||||
(function () {
|
||||
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||
@@ -29,6 +13,33 @@
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
/* 锁死手机/平板双指与手势缩放;头像裁剪 / 图片灯箱放行 pinch */
|
||||
(function () {
|
||||
function inZoomAllowArea(target) {
|
||||
return !!(
|
||||
target &&
|
||||
target.closest &&
|
||||
target.closest('.avatar-crop-stage, .image-lightbox')
|
||||
);
|
||||
}
|
||||
function blockGesture(e) {
|
||||
if (inZoomAllowArea(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
function blockMultiTouch(e) {
|
||||
if (e.touches && e.touches.length > 1) {
|
||||
if (inZoomAllowArea(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
}
|
||||
document.addEventListener('gesturestart', blockGesture, { passive: false });
|
||||
document.addEventListener('gesturechange', blockGesture, { passive: false });
|
||||
document.addEventListener('gestureend', blockGesture, { passive: false });
|
||||
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
|
||||
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
101
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "jiang13-forum-web",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
@@ -15,6 +18,8 @@
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@svg-maps/china": "^2.0.0",
|
||||
"@svg-maps/world": "^2.0.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-code-block": "^3.26.1",
|
||||
@@ -46,7 +51,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
@@ -352,6 +358,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -1915,6 +1974,18 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svg-maps/china": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svg-maps/china/-/china-2.0.0.tgz",
|
||||
"integrity": "sha512-/B0a4hYJHRtfcUG+x6BudQFWhLQyPoaN2WbqfdEeWeXnwef6oXaE93Msrq2odu72c09vE+G4Ds9+n60Qsr4p1g==",
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/@svg-maps/world": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svg-maps/world/-/world-2.0.0.tgz",
|
||||
"integrity": "sha512-VmYYnyygJ5Zhr48xPqukFBoq4aCBgVNIjBzqBnWKXSdHhrRK3pntdvbCa8lMsxwVCFzEw9a+smIMEGI2sC1xcQ==",
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||
@@ -4410,6 +4481,34 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
@@ -16,6 +19,8 @@
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@svg-maps/china": "^2.0.0",
|
||||
"@svg-maps/world": "^2.0.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-code-block": "^3.26.1",
|
||||
@@ -47,7 +52,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
|
||||
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 |