Compare commits
61 Commits
4042395e3e
...
v1.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| f3f50d7e90 | |||
| 67bef6dc08 | |||
| 23e304ac53 | |||
| cad53942e8 | |||
| 76be8926f2 | |||
| 4d93e455f9 | |||
| c958dcfd80 | |||
| 84bfbd9a3e | |||
| 500e452c2f | |||
| 1c0f7ede55 | |||
| 2e9c42a34c | |||
| dff60dde6a | |||
| ea9e0058d4 | |||
| d5aa36416c | |||
| 2159280af5 | |||
| bc9a031890 | |||
| 0d81787125 | |||
| 68713c053b | |||
| 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 |
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
|
||||||
6
.gitignore
vendored
@@ -20,6 +20,12 @@ tmp-cookie.txt
|
|||||||
# 编辑器 / OS
|
# 编辑器 / OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.trae/documents/
|
||||||
|
.trae/chat/
|
||||||
|
.trae/image/
|
||||||
*.swp
|
*.swp
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
|
||||||
|
frontend/tmp-lucide-paths.json
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
# Gitea 全局滚动位置保持机制分析
|
|
||||||
|
|
||||||
> 任务:分析 Gitea 如何做到全局任意界面、任意位置,浏览器强制刷新(包括硬刷新 Ctrl+F5)仍不会让页面回到顶部。
|
|
||||||
|
|
||||||
## 摘要(核心结论,已根据用户实测修正)
|
|
||||||
|
|
||||||
经过对 Gitea 仓库的深度二轮检索,**Gitea 并没有实现任何自定义的滚动位置恢复机制**——不使用 sessionStorage/localStorage 保存滚动位置、没有 pagehide/beforeunload 监听、没有 Service Worker、没有 scrollRestoration 操作。它的核心策略是 **"不覆盖浏览器默认行为"**。
|
|
||||||
|
|
||||||
**关键修正**:之前推断"硬刷新(Ctrl+F5)会跳回顶部"是错误的。实测确认 Gitea 在硬刷新后**同样保持滚动位置**。真实原因是:**Chrome / Edge 等 Chromium 内核浏览器的原生"重载滚动恢复"功能**——浏览器在重载前将滚动位置保存在**内存**中,新页面加载完成后恢复。这个内存状态独立于 HTTP 缓存,因此**硬刷新(只绕过 HTTP 缓存)不会清除它**。
|
|
||||||
|
|
||||||
核心要点:
|
|
||||||
- 软刷新(F5/Ctrl+R)和硬刷新(Ctrl+F5/Ctrl+Shift+R)**都保持滚动位置**(在 Chrome/Edge 上)
|
|
||||||
- 这是**浏览器原生行为**,不是 Gitea 的代码实现的
|
|
||||||
- Gitea 做对的是"没有破坏它":未设置 `history.scrollRestoration = 'manual'`,没有干扰重载流程
|
|
||||||
- **浏览器差异**:Chrome/Edge 保持;Firefox 在硬刷新后可能回到顶部
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 确切机制
|
|
||||||
|
|
||||||
| 维度 | 实际情况 |
|
|
||||||
|---|---|
|
|
||||||
| sessionStorage/localStorage 保存滚动位置? | **没有** |
|
|
||||||
| `history.scrollRestoration`? | **未设置**(保持浏览器默认 `'auto'`) |
|
|
||||||
| 自定义 JS 模块? | **不存在** |
|
|
||||||
| 后端驱动? | **没有** |
|
|
||||||
| 实际机制 | 浏览器原生 `history.scrollRestoration = 'auto'` 默认行为 |
|
|
||||||
|
|
||||||
**关键事实**:htmx 2.x(Gitea 使用的版本)**不会**设置 `history.scrollRestoration = 'manual'`。在 htmx 2.0.8 完整源码中搜索 `scrollRestoration`,结果为 **0 处匹配**。这一点至关重要——如果 htmx 设了 `manual`,浏览器就不会在刷新时自动恢复,但 htmx 2.x 没有这样做。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 涉及的源码文件
|
|
||||||
|
|
||||||
### 2.1 Gitea 侧关键文件
|
|
||||||
|
|
||||||
**`web_src/js/htmx.ts`**(htmx 配置入口,最关键)
|
|
||||||
```typescript
|
|
||||||
// https://github.com/go-gitea/gitea/blob/main/web_src/js/htmx.ts
|
|
||||||
import htmx from 'htmx.org';
|
|
||||||
import 'idiomorph/htmx';
|
|
||||||
import type {HtmxResponseInfo} from 'htmx.org';
|
|
||||||
import {showErrorToast} from './modules/toast.ts';
|
|
||||||
|
|
||||||
type HtmxEvent = Event & {detail: HtmxResponseInfo};
|
|
||||||
|
|
||||||
export function initHtmx() {
|
|
||||||
window.htmx = htmx;
|
|
||||||
htmx.config.requestClass = 'is-loading';
|
|
||||||
htmx.config.scrollIntoViewOnBoost = false; // 仅禁用 boost 元素的自动滚动入视图,不影响刷新恢复
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:sendError', (event: Partial<HtmxEvent>) => {
|
|
||||||
showErrorToast(`Network error when calling ${event.detail!.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
document.body.addEventListener('htmx:responseError', (event: Partial<HtmxEvent>) => {
|
|
||||||
showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
要点:
|
|
||||||
- `scrollIntoViewOnBoost = false` 仅控制 htmx boost 导航时是否将目标元素滚动入视图,**与页面刷新后的滚动恢复无关**
|
|
||||||
- **没有任何 `history.scrollRestoration` 赋值**
|
|
||||||
|
|
||||||
**`templates/base/head.tmpl`**(基础 HTML 模板的 body 标签)
|
|
||||||
```html
|
|
||||||
<body hx-headers='{"x-csrf-token": "{{.CsrfToken}}"}'
|
|
||||||
hx-swap="outerHTML" hx-ext="morph" hx-push-url="false">
|
|
||||||
```
|
|
||||||
- `hx-push-url="false"` 意味着 htmx 导航默认**不**向浏览器历史压入新 URL,不干扰浏览器原生的历史/滚动追踪
|
|
||||||
|
|
||||||
**其他已检查文件(均无滚动恢复代码)**:
|
|
||||||
|
|
||||||
| 文件 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| `web_src/js/bootstrap.ts` | 全局错误处理器,设置 `__webpack_public_path__` |
|
|
||||||
| `web_src/js/globals.ts` | 仅加载 jQuery |
|
|
||||||
| `web_src/js/index.ts` | 入口文件,`onDomReady` 内动态 import `index-domready.ts` |
|
|
||||||
| `web_src/js/index-domready.ts` | 调用 `initHtmx` 及所有 `init*` 初始化函数 |
|
|
||||||
| `web_src/js/features/common-page.ts` | 导航栏切换、页脚语言/主题选择器、下拉菜单 |
|
|
||||||
| `web_src/js/utils/dom.ts` | 滚动相关代码仅用于 textarea 自动调整高度,与页面滚动恢复无关 |
|
|
||||||
| `templates/base/head_script.tmpl` | 仅设置 `window._globalHandlerErrors` 和 `window.config`,无滚动恢复代码 |
|
|
||||||
|
|
||||||
在 `web_src/js/features/` 完整目录列表中**没有**任何名为 `async-loader`、`global-fetch`、`common-fetch`、`scroll-restoration`、`stick-to-bottom` 的文件。
|
|
||||||
|
|
||||||
### 2.2 htmx 2.x 源码侧(对比)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// htmx 2.0.8 src/htmx.js 的 config 默认值
|
|
||||||
config: {
|
|
||||||
historyEnabled: true,
|
|
||||||
historyCacheSize: 10, // sessionStorage 中缓存 10 页 HTML 快照
|
|
||||||
refreshOnHistoryMiss: false,
|
|
||||||
scrollIntoViewOnBoost: true, // Gitea 覆盖为 false
|
|
||||||
// 注意:没有 scrollRestoration 字段
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- htmx 源码 URL: https://github.com/bigskysoftware/htmx/blob/master/src/htmx.js
|
|
||||||
- 在完整源码中搜索 `scrollRestoration`、`beforeunload`、`pagehide`、`popstate`,结果均为 0 匹配
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 与 htmx "异步/渐进加载"模式的集成
|
|
||||||
|
|
||||||
Gitea 的异步加载模式基于 **htmx 2.0.8 + idiomorph 0.7.4**:
|
|
||||||
- **`package.json` 依赖**:`"htmx.org": "2.0.8"`, `"idiomorph": "0.7.4"`
|
|
||||||
- **body 配置**:`hx-swap="outerHTML"` + `hx-ext="morph"` + `hx-push-url="false"`
|
|
||||||
- 很多页面导航通过 htmx 发起 AJAX 请求获取 HTML 片段,用 idiomorph 做 DOM morph 替换
|
|
||||||
|
|
||||||
**与滚动恢复的关系**:
|
|
||||||
1. 由于 `hx-push-url="false"`,大多数 htmx 导航**不创建新的历史条目**,浏览器 URL 不变,浏览器原生的滚动位置追踪不被干扰
|
|
||||||
2. htmx 2.x **不设置** `history.scrollRestoration = 'manual'`(搜索 htmx 源码 0 处匹配),所以浏览器默认的 `'auto'` 恢复始终生效
|
|
||||||
3. `htmx.config.scrollIntoViewOnBoost = false` 禁用了 htmx 在 boost 导航后将目标元素滚动入视图的行为,避免 htmx 导航时意外改变滚动位置
|
|
||||||
4. htmx 的 history cache(`historyCacheSize: 10`,存储在 sessionStorage)仅用于 back/forward 时的 HTML 快照恢复,**不涉及刷新时的滚动位置恢复**
|
|
||||||
|
|
||||||
**简言之**:Gitea 的 htmx 集成**刻意不干扰**浏览器原生的滚动恢复,通过"不设置 manual、不 push URL"让浏览器自己处理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 硬刷新 vs 软刷新 —— 修正后的澄清
|
|
||||||
|
|
||||||
### 之前推断的错误
|
|
||||||
之前认为"硬刷新(Ctrl+F5)会绕过缓存,浏览器不会恢复滚动位置"——这个推断是**错误的**。
|
|
||||||
|
|
||||||
### 真实机制:浏览器内存中的滚动状态
|
|
||||||
|
|
||||||
硬刷新(Ctrl+F5 / Ctrl+Shift+R)的作用范围被广泛误解:
|
|
||||||
|
|
||||||
| 操作 | 清除 HTTP 缓存 | 清除 sessionStorage | 清除内存滚动状态 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| F5 软刷新 | 否 | 否 | 否 |
|
|
||||||
| Ctrl+F5 硬刷新 | **是** | **否** | **否** |
|
|
||||||
| 关闭标签页 | — | 是 | 是 |
|
|
||||||
|
|
||||||
**关键点**:浏览器在重载前将当前滚动位置保存在**内存**中(绑定到当前 history 条目),新页面加载完成后自动恢复。这个内存状态:
|
|
||||||
- 独立于 HTTP 缓存 → 硬刷新不清除它
|
|
||||||
- 独立于 sessionStorage/localStorage → 不需要 JS 主动保存
|
|
||||||
- 是 Chrome/Edge 的原生功能,与 `history.scrollRestoration` 相关但作用于重载场景
|
|
||||||
|
|
||||||
### 软刷新(F5 / Ctrl+R)—— 有效
|
|
||||||
- 浏览器原生恢复滚动位置
|
|
||||||
- 所有现代浏览器均支持
|
|
||||||
|
|
||||||
### 硬刷新(Ctrl+F5 / Ctrl+Shift+R)—— **也有效(Chrome/Edge)**
|
|
||||||
- 硬刷新只绕过 HTTP 缓存(强制重新下载资源)
|
|
||||||
- **不影响**浏览器的内存滚动状态
|
|
||||||
- Chrome/Edge 在硬刷新后**仍会恢复滚动位置**
|
|
||||||
- 这是用户在 Gitea 上观察到的现象的真实原因
|
|
||||||
|
|
||||||
### 浏览器差异(重要)
|
|
||||||
|
|
||||||
| 浏览器 | F5 软刷新 | Ctrl+F5 硬刷新 |
|
|
||||||
|---|---|---|
|
|
||||||
| Chrome / Edge (Chromium) | ✅ 保持滚动 | ✅ 保持滚动 |
|
|
||||||
| Firefox | ✅ 保持滚动 | ❌ 可能回到顶部 |
|
|
||||||
| Safari | 视版本而定 | 视版本而定 |
|
|
||||||
|
|
||||||
**结论**:Gitea 在硬刷新后保持滚动位置,**不是因为 Gitea 实现了什么机制,而是因为 Chrome/Edge 浏览器原生就这么做**。如果在 Firefox 上硬刷新 Gitea,可能会观察到滚动位置丢失。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 为什么"全局任意界面任意位置"都生效?
|
|
||||||
|
|
||||||
由于 Gitea 依赖的是浏览器原生重载滚动恢复(Chrome/Edge 的内存级机制),这个行为是**浏览器层面的全局机制**,与具体页面无关:
|
|
||||||
- 浏览器在页面卸载前自动将当前滚动位置保存在内存中(绑定到当前 history 条目)
|
|
||||||
- 重新加载时(无论软/硬刷新),浏览器在新页面 load 完成后自动恢复到记录的滚动位置
|
|
||||||
- 这个机制对任何 URL、任何滚动位置都生效,不需要每个页面单独配置
|
|
||||||
- Gitea 是 MPA(多页应用,服务端渲染 HTML),每次刷新都返回完整 HTML,浏览器能准确恢复
|
|
||||||
|
|
||||||
Gitea 做对的不是"实现了滚动恢复",而是"**没有破坏浏览器已有的滚动恢复**"。许多 SPA 框架(包括某些 React Router 配置)会显式设置 `history.scrollRestoration = 'manual'` 并自行管理滚动,一旦自行管理失败(比如异步加载竞态),反而会导致刷新后跳回顶部。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 局限性总结(已修正)
|
|
||||||
|
|
||||||
1. ~~硬刷新不支持~~ → **修正**:Chrome/Edge 上硬刷新也保持滚动位置;Firefox 上可能丢失
|
|
||||||
2. **完全依赖浏览器行为**:不同浏览器/版本的刷新滚动恢复行为有差异(Chrome/Edge vs Firefox)
|
|
||||||
3. **无降级方案**:如果浏览器原生的滚动恢复因任何原因失效(例如浏览器扩展干扰),Gitea 没有备用的 sessionStorage 方案
|
|
||||||
4. **MPA 架构的天然优势**:Gitea 是服务端渲染的 MPA,每次刷新返回完整 HTML,浏览器能准确恢复滚动位置。SPA 架构由于初始 HTML 为空、内容由 JS 异步渲染,浏览器在 `load` 事件时尝试恢复往往落空——这是 SPA 的天然劣势
|
|
||||||
5. **异步内容加载的竞态**:如果页面有异步加载的内容,浏览器恢复滚动位置时目标位置内容可能尚未加载完成,可能导致恢复位置偏差
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 与当前项目(jiang13-forum)的对比
|
|
||||||
|
|
||||||
当前项目是 **React + TypeScript + Vite 的 SPA**,使用 react-router-dom。现状:
|
|
||||||
- 全局**无** scroll restoration 逻辑
|
|
||||||
- 仅 [VirtualPostList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/VirtualPostList.tsx#L135-L156) 通过 `restoreScrollTop` 属性做了局部滚动恢复
|
|
||||||
- React Router v6 默认**不**自动恢复滚动位置(需要 `ScrollRestoration` 组件或第三方方案)
|
|
||||||
|
|
||||||
若要在 jiang13-forum 实现 Gitea 同等效果,可参考的方向(仅作信息参考,不在本次任务范围内实施):
|
|
||||||
- 在路由层使用 React Router 的 `<ScrollRestolation />` 组件
|
|
||||||
- 或在应用入口确认未设置 `history.scrollRestoration = 'manual'`
|
|
||||||
- 注意:React SPA 的刷新滚动恢复比 MPA 复杂,因为初始 HTML 通常为空,内容由 JS 异步渲染,浏览器在 `load` 事件时尝试恢复可能落空,需要额外的 `useEffect` + sessionStorage 兜底
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 一句话总结
|
|
||||||
|
|
||||||
**Gitea 没有实现任何自定义的滚动位置恢复机制。它在软刷新和硬刷新后都能保持滚动位置(在 Chrome/Edge 上),是因为浏览器原生会在重载前将滚动位置保存在内存中并在重载后恢复——这个内存状态独立于 HTTP 缓存,所以硬刷新也不会清除它。Gitea 只是"没有破坏"这个浏览器原生行为。但这是 Chromium 内核浏览器的特性,Firefox 在硬刷新后可能丢失滚动位置。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 参考来源
|
|
||||||
|
|
||||||
- Gitea htmx 配置:https://github.com/go-gitea/gitea/blob/main/web_src/js/htmx.ts
|
|
||||||
- Gitea body 模板:https://github.com/go-gitea/gitea/blob/main/templates/base/head.tmpl
|
|
||||||
- htmx 2.x 源码:https://github.com/bigskysoftware/htmx/blob/master/src/htmx.js
|
|
||||||
- MDN history.scrollRestoration:https://developer.mozilla.org/en-US/docs/Web/API/History/scrollRestoration
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
# 刷新无闪烁优化方案
|
|
||||||
|
|
||||||
> **目标**:消除浏览器刷新时的视觉闪烁,让刷新体验接近 Gitea(内容未变化时肉眼完全感知不到重绘)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、根因分析
|
|
||||||
|
|
||||||
### Gitea 为什么「无闪烁」
|
|
||||||
|
|
||||||
Gitea 是 **服务端渲染(SSR)的 MPA**,浏览器收到的 HTML 响应中**已经包含完整渲染的内容**(帖子、列表、评论)。刷新时:
|
|
||||||
1. 浏览器从服务器取回**已填好内容**的 HTML(非空壳)
|
|
||||||
2. 一次性解析 → 布局 → 绘制,内容直接到位
|
|
||||||
3. 浏览器原生 scrollRestoration 在同一帧恢复滚动位置
|
|
||||||
4. **没有任何骨架 → 内容的切换**,整页只发生**一次** paint
|
|
||||||
|
|
||||||
### jiang13-forum 当前刷新的视觉路径
|
|
||||||
|
|
||||||
作为 **React SPA**,刷新时会经历以下可见的阶段(全部可被肉眼察觉):
|
|
||||||
|
|
||||||
| 阶段 | 触发者 | 视觉 | 持续时间 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| ① 空白 → 顶栏布局出现 | `index.html` 内联 CSS + React 挂载顶栏 | 三栏骨架先出现,但中间主内容区为空 | ~50-150ms |
|
|
||||||
| ② 路由 Suspense fallback | `lazyWithRetry` + `App.tsx` 中 `<Suspense>` | `FeedPageSkeleton` 骨架(首页)或 `PageLoader` 转圈(其他页)进入主内容区 | ~50-100ms |
|
|
||||||
| ③ 数据 loading 骨架 | 各页面组件 `setLoading(true)` → 早期 return 骨架/转圈 | 鱼骨骨架 / Spinner 可见 | ~100-400ms(网络请求) |
|
|
||||||
| ④ 骨架 → 真实内容切换 | 数据返回后 `setLoading(false)` | 骨架消失,真实内容瞬间出现,**出现明显跳变** | 瞬时 |
|
|
||||||
| ⑤ 滚动位置恢复(延迟) | `useScrollRestoration` → rAF 循环等内容高度足够 | 先看到内容在 scrollTop=0,然后才跳到目标位置,**可见的先顶后跳** | 内容渲染后 ~0-4 帧 |
|
|
||||||
|
|
||||||
用户所说的"虽然不会回到顶部再恢复滚动的情况,但基本全都有刷新",指的是阶段 ① ~ ④ 的可见变化:骨架出现、骨架消失、内容到位,这是**多次 paint 的切换闪烁**;而阶段 ⑤ 先顶后跳可能被掩盖但仍存在。
|
|
||||||
|
|
||||||
### 核心问题
|
|
||||||
|
|
||||||
- 首页 `HomePage`:刷新后内存 `feedCache` 丢失,`posts.length === 0`,L239 条件命中 → `FeedPageSkeleton` 骨架可见 → 数据回来后切到真实内容 → 闪烁。
|
|
||||||
- 帖子详情 `PostDetailPage`:`loading=true` 时 L446 命中 → `<Spinner size="lg" />` 居中转圈 → 数据回来后切到真实内容 → 闪烁。
|
|
||||||
- 其他页面(消息、收藏、个人主页、后台各页):均有 `loading ? <Spinner ... />` 早期 return → 闪烁。
|
|
||||||
- 滚动恢复晚于内容首 paint:内容先在顶部画出来,下一帧才跳回目标位置 → 先顶后跳的闪烁(虽然比之前回顶再跳好很多,但仍可感知)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、总体策略
|
|
||||||
|
|
||||||
采用 **三层防御** 分层消除闪烁:
|
|
||||||
|
|
||||||
1. **S0 层 — 首屏内容预缓存(sessionStorage 级)**
|
|
||||||
- 仿照 `feedCache.ts` 的结构,将**上次渲染的真实内容**(posts 列表、post 详情 + 评论、其他轻量列表数据)在 `pagehide` 时持久化到 sessionStorage。
|
|
||||||
- 刷新首帧用 sessionStorage 中的旧数据**直接渲染真实内容**,完全绕过骨架/转圈。
|
|
||||||
- 后台异步重新拉取数据,若与缓存相同则**静默无任何变化**(像 Gitea 一样肉眼无感);若不同则**最小化 diff 更新**,不改滚动位置。
|
|
||||||
|
|
||||||
2. **S1 层 — 滚动恢复提前到首 paint 之前**
|
|
||||||
- 不再依赖 mount 后 rAF 循环。改用 `pageshow` 事件 + `useLayoutEffect`(在浏览器 paint 前执行),在首帧前就把内容区域的 `scrollTop` 设好。
|
|
||||||
- 配合 S0,因为有旧内容撑高了 `scrollHeight`,首 paint 前就能设到目标位置,用户第一眼看到的就是正确位置。
|
|
||||||
|
|
||||||
3. **S2 层 — 首帧渲染完成前延迟可见性(兜底)**
|
|
||||||
- 对没有 sessionStorage 缓存的页面(如首次访问、无痕窗口、sessionStorage 被清),无法走 S0。在容器上加 `visibility: hidden` → 骨架/内容全部就绪且滚动位置设置后再 `visibility: visible`。用户看到的第一帧就是"正确内容 + 正确位置",避免看到中间过渡。
|
|
||||||
- 配合超时(300ms)兜底,极端慢网情况下强行显示,避免白屏。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、具体改动计划
|
|
||||||
|
|
||||||
### 3.1 新建 sessionStorage 持久化缓存工具
|
|
||||||
|
|
||||||
**文件**:`frontend/src/utils/pageContentCache.ts`
|
|
||||||
|
|
||||||
- 定义持久化缓存结构:
|
|
||||||
```ts
|
|
||||||
type CachedHomeFeed = { posts, postTotal, page, ts };
|
|
||||||
type CachedPostDetail = { post, comments, liked, favorited, canEdit, ts };
|
|
||||||
type CachedGenericList = { items: unknown[], ts }; // 用于消息/收藏/个人等
|
|
||||||
```
|
|
||||||
- `saveHomeFeed(boardId, keyword, tag, author, titleOnly, sort, data)` → `sessionStorage.setItem('j13-cache:feed:...', JSON.stringify(...))`
|
|
||||||
- `loadHomeFeed(...)` → 读取 + 有效性校验(posts 数组非空)
|
|
||||||
- `savePostDetail(postId, data)` / `loadPostDetail(postId)`
|
|
||||||
- `saveGeneric(key, data)` / `loadGeneric(key)`(用于其他页面)
|
|
||||||
- 所有 save 在 `pagehide` 或 `useEffect cleanup` 时触发;加载时若 `ts` 超过 10 分钟,视为过时而不加载(防止用户次日访问出现完全过期内容)。
|
|
||||||
|
|
||||||
### 3.2 扩展 scrollRestore.ts,提供提前恢复能力
|
|
||||||
|
|
||||||
**文件**:`frontend/src/utils/scrollRestore.ts`
|
|
||||||
|
|
||||||
- 新增 `readSavedScrollTop(containerSelector): number | null`:纯读取不等待,不触发 rAF。
|
|
||||||
- 新增 `pageshow` 监听(在 `initScrollRestore` 中注册):`pageshow` 时若首帧尚未 paint,立即尝试设置各容器的 `scrollTop`。
|
|
||||||
- 保留现有的 rAF 重试循环作为兜底(S0 未命中或内容还不够高时)。
|
|
||||||
|
|
||||||
### 3.3 HomePage — 用持久化缓存消掉骨架
|
|
||||||
|
|
||||||
**文件**:`frontend/src/pages/HomePage.tsx`
|
|
||||||
|
|
||||||
- 初始化阶段(原 L139-L177 的 useEffect):
|
|
||||||
- 若 `getFeedCache(...)`(内存 Map)无数据,**先查 `loadHomeFeed(...)`(sessionStorage 持久化)**。
|
|
||||||
- 命中 → 直接用旧数据渲染,`loading=false`,`setRestoreScrollTop(cached.scrollTop)`;同时启动网络请求,回来后**仅在数据有差异**时才更新 state(posts / postTotal),避免不必要的重渲染。
|
|
||||||
- 未命中 → 保留当前骨架逻辑。
|
|
||||||
- `pagehide` 时(或在 scrollRestore 的 save 中联动)调用 `saveHomeFeed`。
|
|
||||||
- **效果**:刷新首帧就是真实帖子列表(上次浏览时看到的),骨架完全不显示;网络回来若内容未变,无任何视觉变化;若内容有新帖子,只是在列表顶部插入新条目(不影响当前浏览位置的可见区域)。
|
|
||||||
|
|
||||||
### 3.4 PostDetailPage — 用持久化缓存消掉 Spinner
|
|
||||||
|
|
||||||
**文件**:`frontend/src/pages/PostDetailPage.tsx`
|
|
||||||
|
|
||||||
- 数据加载 useEffect(L170-L208):
|
|
||||||
- 启动网络请求**之前**先查 `loadPostDetail(postId)`。
|
|
||||||
- 命中 → `setPost(cached.post)`, `setComments(cached.comments)`, `setLiked(cached.liked)`, `setFavorited(cached.favorited)`, `setCanEdit(cached.canEdit)`, `setLoading(false)`;网络请求回来后 diff 更新。
|
|
||||||
- 未命中 → 保留当前 Spinner 逻辑。
|
|
||||||
- `pagehide` 时(或 effect cleanup)调用 `savePostDetail`。
|
|
||||||
- 删除 L446 命中 Spinner 时的 `post-detail-loading` 空白页渲染(持久化命中时不走此路)。
|
|
||||||
|
|
||||||
### 3.5 其他有 `loading ? <Spinner>` 早期 return 的页面(可选但建议)
|
|
||||||
|
|
||||||
涉及:`FavoritesPage.tsx`、`MessagesPage.tsx`、`ProfilePage.tsx`、`UserProfilePage.tsx`、`AdminBadgesPage.tsx`、`AdminMediaPage.tsx`、`AdminCommentsPage.tsx`、`AdminPostsPage.tsx`、`AdminUsersPage.tsx`、`BoardsManagePage.tsx` 等。
|
|
||||||
|
|
||||||
- 使用 `saveGeneric` / `loadGeneric` 包装:在 `loading=true` 但 sessionStorage 有缓存时,先渲染旧数据不显示 Spinner,网络回来后更新。
|
|
||||||
- 若不纳入首版范围,至少应用 S2(visibility 兜底)消除转圈 → 内容的切换闪烁。
|
|
||||||
|
|
||||||
### 3.6 S2 兜底:首帧完成前不可见(index.html + App 层)
|
|
||||||
|
|
||||||
**文件**:`frontend/index.html`
|
|
||||||
- 在 `<body>` 上加 `class="j13-prepaint"`,对应内联 CSS:
|
|
||||||
```css
|
|
||||||
.j13-prepaint .main-content,
|
|
||||||
.j13-prepaint .admin-main { visibility: hidden; }
|
|
||||||
```
|
|
||||||
|
|
||||||
**文件**:`frontend/src/hooks/useScrollRestoration.ts`(新建 hook 功能扩展 或 新建 `useFirstFrameReveal.ts`)
|
|
||||||
- 在布局组件(MainLayout / AdminLayout)mount 后:
|
|
||||||
- 条件 A:数据已渲染(有缓存或网络已回)且滚动已设好 → 立即 `document.body.classList.remove('j13-prepaint')`
|
|
||||||
- 条件 B:无缓存且等待网络 → `setTimeout(300ms)` 后强制 reveal,避免白屏
|
|
||||||
- 条件 C:rAF 检测到 paint 已发生过(`document.visibilityState` + 首次 paint marker)→ reveal
|
|
||||||
|
|
||||||
### 3.7 验证点
|
|
||||||
|
|
||||||
- 首页有帖子时刷新:首帧直接看到上次的真实内容 + 正确滚动位置,无骨架闪过;若网络回来帖子相同,肉眼完全无变化(达到 Gitea 级别)。
|
|
||||||
- 帖子详情页有长文时刷新:同上,无 Spinner,内容+位置直接到位。
|
|
||||||
- 首次访问 / 无缓存页面:最多有 300ms 以内白屏(或 reveal 后骨架短暂出现),不出现"骨架→内容"切换闪烁。
|
|
||||||
- SPA 内导航(点链接)行为完全不受影响,`useScrollRestoration` 的 mount-only 特性保证只在刷新/跨布局切换时触发。
|
|
||||||
- sessionStorage 条目数量上限:按 URL+参数维度,最坏几十条,占用 KB 级内存,无存储压力。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、风险与权衡
|
|
||||||
|
|
||||||
| 风险 | 影响 | 应对 |
|
|
||||||
|---|---|---|
|
|
||||||
| sessionStorage 数据过时(刷新后看到旧帖子/旧内容) | 短暂,但用户担心读到过期数据 | **已考虑**:ts 超过 10min 不加载;且网络请求仍在后台进行,回来后立即更新。与 Gitea 的"先画旧内容,后续若有新帖自然出现"体验一致。 |
|
|
||||||
| 旧内容的渲染高度与新内容不一致 → 滚动恢复偏差 | 轻微偏差(±几帖高度) | rAF 兜底循环会等新内容高度到位后再精调。S0 的目标是"首帧不闪烁",精细恢复由 S1 兜底完成。 |
|
|
||||||
| S2 的 visibility:hidden 导致极端慢网下短暂白屏 | 少数极端网络 | 设置 300ms 超时强制 reveal;与"先骨架再切内容"的闪烁相比,短暂白屏观感更优(与 Gitea 的整页重绘等价)。 |
|
|
||||||
| S0 存储的数据量(10 篇帖子 + 详情页全文) | KB 级,可忽略 | 不存图片的 base64,只存 API 返回的 JSON。 |
|
|
||||||
| 后台管理页的数据也做 S0 是否有意义? | 管理页刷新频率低 | 首版只对 HomePage + PostDetailPage(高频刷新页面)做 S0,其他页做 S2 兜底即可。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、改动文件清单(确认版)
|
|
||||||
|
|
||||||
| 文件 | 操作 | 所属层级 |
|
|
||||||
|---|---|---|
|
|
||||||
| `frontend/src/utils/pageContentCache.ts` | 新建 | S0 |
|
|
||||||
| `frontend/src/utils/scrollRestore.ts` | 修改(加 readSavedScrollTop / pageshow 恢复) | S1 |
|
|
||||||
| `frontend/src/pages/HomePage.tsx` | 修改(持久化缓存命中逻辑 + 后台静默更新) | S0 |
|
|
||||||
| `frontend/src/pages/PostDetailPage.tsx` | 修改(持久化缓存命中逻辑 + 后台静默更新) | S0 |
|
|
||||||
| `frontend/index.html` | 修改(加 j13-prepaint 类 + CSS) | S2 |
|
|
||||||
| `frontend/src/layouts/MainLayout.tsx` | 修改(调用 reveal 逻辑) | S2 |
|
|
||||||
| `frontend/src/layouts/AdminLayout.tsx` | 修改(调用 reveal 逻辑) | S2 |
|
|
||||||
36
.trae/rules/build-scripts.mdc
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 编译与构建脚本
|
||||||
|
|
||||||
|
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||||
|
|
||||||
|
## 运行编译(优先用封装命令,不要猜命令)
|
||||||
|
|
||||||
|
| 平台 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| Windows | `build.bat` 或 `build.bat -Target <target>` |
|
||||||
|
| Linux / macOS | `make` 或 `make <target>` |
|
||||||
|
|
||||||
|
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
|
||||||
|
|
||||||
|
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||||
|
|
||||||
|
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||||
|
|
||||||
|
## 修改构建脚本时的约定
|
||||||
|
|
||||||
|
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
||||||
|
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
||||||
|
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
||||||
|
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||||
|
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
||||||
|
|
||||||
|
## 新增 target 检查清单
|
||||||
|
|
||||||
|
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
|
||||||
|
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
|
||||||
|
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
|
||||||
|
- [ ] README「快速开始」如需对外暴露再更新
|
||||||
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
|
APP_NAME := jiang13
|
||||||
MAIN_PKG := ./cmd/jiang13
|
MAIN_PKG := ./cmd/jiang13
|
||||||
BUILD_DIR := dist
|
BUILD_DIR := dist
|
||||||
VERSION := 1.0.0
|
DEV_DATA_DIR := dist/data
|
||||||
|
VERSION := 1.1.8
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||||
|
|
||||||
GO := go
|
GO := go
|
||||||
GOFLAGS := -trimpath
|
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
|
all: build
|
||||||
|
|
||||||
@@ -54,22 +56,37 @@ build-all: frontend-build
|
|||||||
tidy:
|
tidy:
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|
||||||
## 本地运行(仅后端,使用已 embed 的前端)
|
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致)
|
||||||
run:
|
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:
|
dev:
|
||||||
@echo "前端热更新: http://localhost:5173"
|
@echo "前端热更新: http://localhost:5173"
|
||||||
@echo "后端 API : http://localhost:3000"
|
@echo "后端 API : http://localhost:3000"
|
||||||
|
@echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)"
|
||||||
|
@mkdir -p $(DEV_DATA_DIR)
|
||||||
@trap 'kill 0' INT; \
|
@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
|
cd frontend && (test -d node_modules || npm install) && npm run dev
|
||||||
|
|
||||||
## 清理编译产物
|
## 清理编译产物
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
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:
|
help:
|
||||||
@echo "姜十三论坛编译命令:"
|
@echo "姜十三论坛编译命令:"
|
||||||
@echo " make build - 编译当前平台"
|
@echo " make build - 编译当前平台"
|
||||||
@@ -79,3 +96,6 @@ help:
|
|||||||
@echo " make build-all - 编译全部平台"
|
@echo " make build-all - 编译全部平台"
|
||||||
@echo " make run - 仅启动后端(:3000)"
|
@echo " make run - 仅启动后端(:3000)"
|
||||||
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||||
|
@echo " make docker - 构建 Docker 镜像"
|
||||||
|
@echo " make compose-up - Docker Compose 启动"
|
||||||
|
@echo " make compose-down - Docker Compose 停止"
|
||||||
|
|||||||
135
README.md
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
[](https://bbs.iioio.com/)
|
[](https://bbs.iioio.com/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](frontend/package.json)
|
[](frontend/package.json)
|
||||||
[](#)
|
[](#)
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
[快速开始](#-快速开始) ·
|
[快速开始](#-快速开始) ·
|
||||||
[界面预览](#-界面预览) ·
|
[界面预览](#-界面预览) ·
|
||||||
[功能亮点](#-功能亮点) ·
|
[功能亮点](#-功能亮点) ·
|
||||||
|
[更新日志](docs/site-posts/changelog.md) ·
|
||||||
[路线图](ROADMAP.md) ·
|
[路线图](ROADMAP.md) ·
|
||||||
[参与贡献](CONTRIBUTING.md)
|
[参与贡献](CONTRIBUTING.md)
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@
|
|||||||
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||||
|
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,9 +155,119 @@ build.bat -Target build-linux
|
|||||||
build.bat -Target build-all
|
build.bat -Target build-all
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 启动
|
### 2. Docker 部署(推荐)
|
||||||
|
|
||||||
把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
**一键启动(本地构建):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
```bat
|
||||||
|
.\build.bat -Target compose-up
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make compose-up
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
|
||||||
|
|
||||||
|
版本变更与升级说明见 **[更新日志](docs/site-posts/changelog.md)**。Docker 与 Windows / Linux 单文件共用同一版本号。
|
||||||
|
|
||||||
|
- Docker 标签:[hub.docker.com/r/hangzhang714128/jiang13-forum/tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags)
|
||||||
|
- 预编译包:[Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases)
|
||||||
|
|
||||||
|
**拉取已构建镜像(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.7
|
||||||
|
docker push hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg VERSION=1.1.7 -t hangzhang714128/jiang13-forum:1.1.7 -t hangzhang714128/jiang13-forum:latest .
|
||||||
|
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||||
|
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. 直接启动(二进制)
|
||||||
|
|
||||||
|
不想自己编译时,从 [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) 下载与 Docker 同版本号的文件即可:
|
||||||
|
|
||||||
|
| 文件 | 平台 |
|
||||||
|
|------|------|
|
||||||
|
| `jiang13-*-windows-amd64.exe` | Windows x64 |
|
||||||
|
| `jiang13-*-linux-amd64` | Linux x64 |
|
||||||
|
|
||||||
|
当前 **1.1.7:** [Windows](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe) · [Linux](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
|
||||||
|
|
||||||
|
把文件放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
.\jiang13-1.1.7-windows-amd64.exe
|
||||||
|
# 或改名为 jiang13.exe 后:
|
||||||
|
.\jiang13.exe
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
chmod +x jiang13-1.1.7-linux-amd64
|
||||||
|
./jiang13-1.1.7-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
本地刚编译的产物在 `dist/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows
|
# Windows
|
||||||
@@ -172,7 +285,7 @@ cp app.ini.example /opt/jiang13/app.ini
|
|||||||
./jiang13
|
./jiang13
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 首次使用
|
### 4. 首次使用
|
||||||
|
|
||||||
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||||
2. **第一个注册的用户自动成为管理员**
|
2. **第一个注册的用户自动成为管理员**
|
||||||
@@ -208,7 +321,9 @@ JWT_SECRET =
|
|||||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||||
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
| `--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` 并重启服务,不必重新安装。
|
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
|
||||||
|
|
||||||
@@ -260,6 +375,8 @@ make dev
|
|||||||
|
|
||||||
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
||||||
|
|
||||||
|
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
|
||||||
|
|
||||||
**何时需要完整构建:**
|
**何时需要完整构建:**
|
||||||
|
|
||||||
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
||||||
@@ -295,6 +412,10 @@ jiang13-forum/
|
|||||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||||
├── config/ # app.ini 与命令行配置
|
├── config/ # app.ini 与命令行配置
|
||||||
├── app.ini.example # 配置文件示例
|
├── app.ini.example # 配置文件示例
|
||||||
|
├── Dockerfile # 多阶段 Docker 构建
|
||||||
|
├── docker-compose.yml # 单容器 Compose 部署
|
||||||
|
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限)
|
||||||
|
├── .dockerignore
|
||||||
├── model/ # GORM 模型与数据库迁移
|
├── model/ # GORM 模型与数据库迁移
|
||||||
├── service/ # 业务逻辑
|
├── service/ # 业务逻辑
|
||||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||||
@@ -313,13 +434,15 @@ jiang13-forum/
|
|||||||
|
|
||||||
```
|
```
|
||||||
data/
|
data/
|
||||||
├── jiang13.db # SQLite 主数据库
|
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||||
|
├── monitor.db # 网站监控 page_views
|
||||||
├── jiang13.log # 运行日志
|
├── jiang13.log # 运行日志
|
||||||
├── filter_words.txt # 敏感词配置
|
├── filter_words.txt # 敏感词配置
|
||||||
├── .jwt_secret # JWT 密钥(自动生成)
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── logs/access/ # 网站监控请求日志(按日 jsonl)
|
||||||
├── uploads/avatars/ # 用户头像
|
├── uploads/avatars/ # 用户头像
|
||||||
├── uploads/posts/ # 帖子正文图片
|
├── uploads/posts/ # 帖子正文图片
|
||||||
└── jiang13_backup_*.db # 后台导出的备份
|
└── jiang13_backup_*.db # 后台导出的主库备份
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ DATA = data
|
|||||||
[security]
|
[security]
|
||||||
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||||
JWT_SECRET =
|
JWT_SECRET =
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; 维护者选项(默认关闭;普通自托管无需开启)
|
||||||
|
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
|
||||||
|
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
|
||||||
|
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
|
||||||
|
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; [community]
|
||||||
|
; HUB = false
|
||||||
|
|||||||
32
build.ps1
@@ -3,7 +3,7 @@
|
|||||||
# .\build.ps1 -Target build-windows
|
# .\build.ps1 -Target build-windows
|
||||||
|
|
||||||
param(
|
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'
|
[string]$Target = 'build'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ $ErrorActionPreference = 'Stop'
|
|||||||
$AppName = 'jiang13'
|
$AppName = 'jiang13'
|
||||||
$MainPkg = './cmd/jiang13'
|
$MainPkg = './cmd/jiang13'
|
||||||
$BuildDir = 'dist'
|
$BuildDir = 'dist'
|
||||||
$Version = '1.0.0'
|
$DevDataDir = 'dist/data'
|
||||||
|
$Version = '1.1.8'
|
||||||
|
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||||
$Ldlags = "-s -w -X main.version=$Version"
|
$Ldlags = "-s -w -X main.version=$Version"
|
||||||
|
|
||||||
function Ensure-Dir($path) {
|
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 dev backend + Vite HMR (recommended for frontend dev)'
|
||||||
Write-Host '.\build.ps1 -Target tidy'
|
Write-Host '.\build.ps1 -Target tidy'
|
||||||
Write-Host '.\build.ps1 -Target clean'
|
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 ''
|
||||||
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
||||||
}
|
}
|
||||||
@@ -86,18 +91,21 @@ switch ($Target) {
|
|||||||
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
||||||
}
|
}
|
||||||
'run' {
|
'run' {
|
||||||
go run $MainPkg
|
Ensure-Dir $DevDataDir
|
||||||
|
go run $MainPkg --data $DevDataDir
|
||||||
}
|
}
|
||||||
'dev' {
|
'dev' {
|
||||||
$root = (Get-Location).Path
|
$root = (Get-Location).Path
|
||||||
|
Ensure-Dir $DevDataDir
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green
|
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green
|
||||||
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -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] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
|
||||||
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
|
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
'-NoExit', '-Command',
|
'-NoExit', '-Command',
|
||||||
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev"
|
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev --data '$DevDataDir'"
|
||||||
) | Out-Null
|
) | Out-Null
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
Push-Location frontend
|
Push-Location frontend
|
||||||
@@ -132,4 +140,20 @@ switch ($Target) {
|
|||||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||||
Write-Host '[ok] all platforms done' -ForegroundColor Green
|
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"
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"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() {
|
func main() {
|
||||||
|
appsvc.SetAppVersion(version)
|
||||||
cfg, err := config.Parse()
|
cfg, err := config.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("配置解析失败: %v", err)
|
log.Fatalf("配置解析失败: %v", err)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
|
forumsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
"git.iioio.com/freefire/jiang13-forum/router"
|
"git.iioio.com/freefire/jiang13-forum/router"
|
||||||
)
|
)
|
||||||
@@ -72,11 +73,18 @@ func (p *program) setup() error {
|
|||||||
|
|
||||||
log.Println("========================================")
|
log.Println("========================================")
|
||||||
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
||||||
|
log.Printf(" 版本: %s", version)
|
||||||
log.Println("========================================")
|
log.Println("========================================")
|
||||||
|
|
||||||
if err := model.InitDB(cfg.DBPath()); err != nil {
|
if err := model.InitDB(cfg.DBPath()); err != nil {
|
||||||
return fmt.Errorf("数据库初始化失败: %w", err)
|
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := forumsvc.BackfillModerationNotifyRefs(); err != nil {
|
||||||
|
log.Printf("待审通知关联字段回填警告: %v", err)
|
||||||
|
}
|
||||||
|
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
|
||||||
|
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
engine, err := router.Setup(cfg)
|
engine, err := router.Setup(cfg)
|
||||||
if err != nil {
|
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)
|
||||||
|
}
|
||||||
@@ -32,11 +32,14 @@ type Config struct {
|
|||||||
ServiceAction string
|
ServiceAction string
|
||||||
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||||
DevMode bool
|
DevMode bool
|
||||||
|
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
|
||||||
|
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
|
||||||
|
CommunityHub bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse 解析命令行与 app.ini,并初始化数据目录
|
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
||||||
//
|
//
|
||||||
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值
|
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
|
||||||
func Parse() (*Config, error) {
|
func Parse() (*Config, error) {
|
||||||
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
||||||
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||||
@@ -52,12 +55,20 @@ func Parse() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -73,11 +84,17 @@ func Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
port := fileCfg.Port
|
port := fileCfg.Port
|
||||||
|
if p := envIntOrZero(envHTTPPort); p > 0 {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
if *portFlag > 0 {
|
if *portFlag > 0 {
|
||||||
port = *portFlag
|
port = *portFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
dataInput := fileCfg.DataRel
|
dataInput := fileCfg.DataRel
|
||||||
|
if v := envOrDefault(envData); v != "" {
|
||||||
|
dataInput = v
|
||||||
|
}
|
||||||
if strings.TrimSpace(*dataFlag) != "" {
|
if strings.TrimSpace(*dataFlag) != "" {
|
||||||
dataInput = *dataFlag
|
dataInput = *dataFlag
|
||||||
}
|
}
|
||||||
@@ -86,9 +103,17 @@ func Parse() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtSecret := strings.TrimSpace(*jwtFlag)
|
jwtSecret := fileCfg.JWTSecret
|
||||||
if jwtSecret == "" {
|
if v := envOrDefault(envJWTSecret); v != "" {
|
||||||
jwtSecret = fileCfg.JWTSecret
|
jwtSecret = v
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*jwtFlag) != "" {
|
||||||
|
jwtSecret = strings.TrimSpace(*jwtFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
communityHub := fileCfg.CommunityHub
|
||||||
|
if v := envBoolOrNil(envCommunityHub); v != nil {
|
||||||
|
communityHub = *v
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
@@ -100,6 +125,7 @@ func Parse() (*Config, error) {
|
|||||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||||
ServiceAction: action,
|
ServiceAction: action,
|
||||||
DevMode: *devFlag,
|
DevMode: *devFlag,
|
||||||
|
CommunityHub: communityHub,
|
||||||
}
|
}
|
||||||
|
|
||||||
needDirs := action == "" || action == "install"
|
needDirs := action == "" || action == "install"
|
||||||
@@ -167,6 +193,7 @@ func ensureDataDirs(dataDir string) error {
|
|||||||
filepath.Join(dataDir, "uploads", "avatars"),
|
filepath.Join(dataDir, "uploads", "avatars"),
|
||||||
filepath.Join(dataDir, "uploads", "posts"),
|
filepath.Join(dataDir, "uploads", "posts"),
|
||||||
filepath.Join(dataDir, "uploads", "site"),
|
filepath.Join(dataDir, "uploads", "site"),
|
||||||
|
filepath.Join(dataDir, "logs", "access"),
|
||||||
} {
|
} {
|
||||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||||
@@ -201,11 +228,16 @@ func validServiceAction(action string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBPath 返回 SQLite 数据库文件路径
|
// DBPath 返回主库 SQLite 路径
|
||||||
func (c *Config) DBPath() string {
|
func (c *Config) DBPath() string {
|
||||||
return filepath.Join(c.DataDir, "jiang13.db")
|
return filepath.Join(c.DataDir, "jiang13.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonitorDBPath 返回监控独立库路径(page_views)
|
||||||
|
func (c *Config) MonitorDBPath() string {
|
||||||
|
return filepath.Join(c.DataDir, "monitor.db")
|
||||||
|
}
|
||||||
|
|
||||||
// AvatarUploadDir 返回头像上传目录
|
// AvatarUploadDir 返回头像上传目录
|
||||||
func (c *Config) AvatarUploadDir() string {
|
func (c *Config) AvatarUploadDir() string {
|
||||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type fileSettings struct {
|
|||||||
Port int
|
Port int
|
||||||
DataRel string
|
DataRel string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultFileSettings() fileSettings {
|
func defaultFileSettings() fileSettings {
|
||||||
@@ -62,6 +63,12 @@ func loadAppINI(path string) (fileSettings, error) {
|
|||||||
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
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
|
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
|
||||||
@@ -18,6 +18,16 @@
|
|||||||
|
|
||||||
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
||||||
|
|
||||||
|
## 自定义单页(推荐)
|
||||||
|
|
||||||
|
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
|
||||||
|
|
||||||
|
| 文件 | 标题 | slug | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
|
||||||
|
|
||||||
|
访问路径:`/page/changelog`。
|
||||||
|
|
||||||
## 可选短文
|
## 可选短文
|
||||||
|
|
||||||
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||||
|
|||||||
160
docs/site-posts/changelog.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# 更新日志
|
||||||
|
|
||||||
|
> 建议标题:更新日志
|
||||||
|
> 建议 slug:`changelog`
|
||||||
|
> 建议路径:`/page/changelog`
|
||||||
|
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
|
||||||
|
> 复制正文时:从下方第一个 `---` **之后**开始粘贴(Markdown 模式)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
|
||||||
|
|
||||||
|
**当前版本:1.1.7**(2026-09-02)
|
||||||
|
|
||||||
|
## 去哪看新版本
|
||||||
|
|
||||||
|
| 渠道 | 看什么 | 地址 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本页 | 版本号、变更说明、升级注意 | 本站 `/page/changelog` |
|
||||||
|
| Docker | 镜像标签(`latest` 与 `1.1.x`) | [Docker Hub · Tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags) |
|
||||||
|
| Windows / Linux | 预编译单文件(与 Docker 同版本) | [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) |
|
||||||
|
| 源码 | 提交记录与自行编译 | [Gitea 仓库](https://git.iioio.com/freefire/jiang13-forum) |
|
||||||
|
|
||||||
|
**1.1.7 直接下载:**
|
||||||
|
|
||||||
|
- Windows x64:[jiang13-1.1.7-windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe)
|
||||||
|
- Linux x64:[jiang13-1.1.7-linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
|
||||||
|
- 校验文件:[SHA256SUMS.txt](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/SHA256SUMS.txt)
|
||||||
|
|
||||||
|
拉取指定版本镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull hangzhang714128/jiang13-forum:1.1.7
|
||||||
|
# 或始终跟随最新
|
||||||
|
docker pull hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:停掉正在跑的进程或服务,用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`),**不要动**旁边的 `data/` 与 `app.ini`,再启动即可。
|
||||||
|
|
||||||
|
Linux:赋予执行权限后放到原目录覆盖,同样保留 `data/` 与 `app.ini`。
|
||||||
|
|
||||||
|
## 升级注意
|
||||||
|
|
||||||
|
- 一般只需换镜像或换 exe,数据目录向后兼容。
|
||||||
|
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
|
||||||
|
- 升级后若页面样式异常,强制刷新浏览器(Ctrl+F5)。程序也会在前端资源失效时自动硬刷新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.1.7 — 2026-09-02
|
||||||
|
|
||||||
|
私信与开源展柜体验升级,待审通知的审核态更及时。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 私信改为飞书风布局:缩略图表情/图片、按会话草稿、用户搜索发起会话
|
||||||
|
- 主页「发私信」直达消息页对应会话
|
||||||
|
- 开源展柜改为目录式 UI,空列表不再粘滞
|
||||||
|
- 消息页体验重构;待审通知打开后实时回填审核态
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子详情右栏默认头像改为深绿底白字,与列表默认头像配色一致
|
||||||
|
|
||||||
|
## 1.1.6 — 2026-09-01
|
||||||
|
|
||||||
|
编辑器插入体验与首页默认 Feed 排序更合理。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 链接对话框支持网址、链接文字与站内帖子/单页搜索;站内搜索收进「高级选项」,点选同步链接文字
|
||||||
|
- 文章与评论共用统一图片插入选择器(上传 / 链接 / 已上传)
|
||||||
|
- 链接是否新标签打开交由全站设置,对话框不再单独开关
|
||||||
|
- 首页默认「最新」按发帖与最后评论的较晚时间混排,零回复新帖不再沉底
|
||||||
|
- 单页详情右侧栏与帖子共用目录布局;三栏主滚动区统一细绿色滚动条
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子目录从正文同步派生,避免预取缓存竞态导致空树
|
||||||
|
|
||||||
|
## 1.1.5 — 2026-09-01
|
||||||
|
|
||||||
|
首页首屏与交互更稳,Feed 排序语义更清楚。
|
||||||
|
本版起同时提供 **Docker 镜像** 与 **Windows / Linux 预编译单文件**(Gitea Release `v1.1.5`)。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 首页改为 Go SSR 与 React hydrate(注水)同构,打开首页不再先闪一层空壳再跳内容
|
||||||
|
- Feed 排序改为:**新评论 / 新帖子 / 推荐帖**;推荐帖只出精华(featured)
|
||||||
|
- 点击排序会强制刷新列表;软刷新时同步站点限额与品牌文案
|
||||||
|
- 返回列表用缓存恢复滚动位置,不再重复请求
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 发版后软刷新会检测入口壳;chunk(代码分片)404 时自动硬刷新,避免卡在旧页面
|
||||||
|
- 软刷新齐套前保留旧画面,避免一点击就把内容卸光
|
||||||
|
- 补齐 `/favicon.ico` 与页面 head 图标;拆除爬虫专用 HTML
|
||||||
|
- 桌面端隐藏多余的导航汉堡按钮
|
||||||
|
- 帖行评论数、列表「共 N 条」右对齐;排序栏数字紧跟标签
|
||||||
|
- 手机端 SSR 顶栏 / 页脚与 React 一致,减少闪动
|
||||||
|
|
||||||
|
## 1.1.4 — 2026-08-31
|
||||||
|
|
||||||
|
网站监控的地理信息与口径更准确。
|
||||||
|
|
||||||
|
- 补全 IP2Location(IP 地理位置库)中国城市中文名,同名城市按省消歧
|
||||||
|
- 对齐监控概览「双通道」口径文案与诊断抽样说明
|
||||||
|
|
||||||
|
## 1.1.3 — 2026-08-31
|
||||||
|
|
||||||
|
管理后台可看站点访问情况。
|
||||||
|
|
||||||
|
- 新增管理端 **网站监控**:浏览量写入独立 `monitor.db`,与主库分开
|
||||||
|
|
||||||
|
## 1.1.2 — 2026-08-31
|
||||||
|
|
||||||
|
可选接入官方社区展柜。
|
||||||
|
|
||||||
|
- 可选社区上报,以及官方精选展柜(自建站可出现在官方社区列表中)
|
||||||
|
|
||||||
|
## 1.1.1 — 2026-08-31
|
||||||
|
|
||||||
|
- 评论管理操作收进「更多」菜单,管理员界面更干净
|
||||||
|
|
||||||
|
## 1.1.0 — 2026-08-30
|
||||||
|
|
||||||
|
一批社区功能与界面整理,版本号从 1.0 跨到 1.1。
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
|
||||||
|
- 友链申请、独立友链页;页脚左右区域可分别开关
|
||||||
|
- 自定义单页(后台「单页管理」,如本页)
|
||||||
|
- 投票帖、悬赏帖、抽奖帖
|
||||||
|
- 侧栏签到;右侧栏「最新注册」
|
||||||
|
- 搜索重设计:筛选面板、结果页 chips(筛选标签)
|
||||||
|
- 帖子管理收进右上角菜单;手机端评论输入默认折叠
|
||||||
|
|
||||||
|
**优化**
|
||||||
|
|
||||||
|
- 首页帖子列表密度与标题样式
|
||||||
|
- 开源码桶展示加强,去掉 Feed 顶栏统计
|
||||||
|
- 单页编辑、列表留白与友链申请体验
|
||||||
|
- 开发与发行版共用 `dist/data` 数据目录,避免两套数据打架
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 手机端搜索入口与底部 Sheet(抽屉面板)
|
||||||
|
- 占位头像对比度;无头像用户不再误用游客灰底
|
||||||
|
- 页面双指缩放锁死,正文原图与灯箱仍可 pinch(捏合)放大
|
||||||
|
- 单页正文排版与帖子详情对齐
|
||||||
|
|
||||||
|
## 1.0.0 — 2026-08-23
|
||||||
|
|
||||||
|
首次发布 Docker 镜像 `hangzhang714128/jiang13-forum:1.0.0`。
|
||||||
|
|
||||||
|
当时已包含论坛核心能力:板块与发帖、楼层评论、点赞收藏、私信、TipTap 富文本、贴纸、修订历史、管理后台、OIDC(开放身份连接)/ SSO(单点登录)、邮件验证码、Gitea 仓库同步、SQLite 单二进制部署,以及 Docker 一键运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
之后发版时:Docker 推 `x.y.z` + `latest`,Gitea 打 tag `vx.y.z` 并挂上对应 exe / linux 文件,再把新版本写到本页最上方。
|
||||||
@@ -17,6 +17,7 @@ var (
|
|||||||
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
||||||
spaBrandTitleFn func() string
|
spaBrandTitleFn func() string
|
||||||
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
||||||
|
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
||||||
@@ -29,19 +30,58 @@ func SetSPABrandingJSON(fn func() []byte) {
|
|||||||
spaBrandJSONFn = fn
|
spaBrandJSONFn = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
|
||||||
|
func SetSPAFaviconURL(fn func() string) {
|
||||||
|
spaBrandFaviconFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
||||||
func SetupEmbed(r *gin.Engine) error {
|
func SetupEmbed(r *gin.Engine) error {
|
||||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||||
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
||||||
r.GET("/assets/*filepath", func(c *gin.Context) {
|
r.GET("/assets/*filepath", func(c *gin.Context) {
|
||||||
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404
|
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
|
||||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
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)
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return nil
|
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 入口(仅注入站点默认标题)
|
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
|
||||||
func ServeSPA(c *gin.Context) {
|
func ServeSPA(c *gin.Context) {
|
||||||
ServeSPAWithMeta(c, nil)
|
ServeSPAWithMeta(c, nil)
|
||||||
@@ -61,7 +101,7 @@ func ServeSPANoIndex(c *gin.Context) {
|
|||||||
|
|
||||||
// IsSPARoute 判断是否应由 SPA 处理
|
// IsSPARoute 判断是否应由 SPA 处理
|
||||||
func IsSPARoute(path string) bool {
|
func IsSPARoute(path string) bool {
|
||||||
if path == "/robots.txt" || path == "/sitemap.xml" {
|
if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(path, "/api") ||
|
if strings.HasPrefix(path, "/api") ||
|
||||||
@@ -69,6 +109,7 @@ func IsSPARoute(path string) bool {
|
|||||||
strings.HasPrefix(path, "/uploads") ||
|
strings.HasPrefix(path, "/uploads") ||
|
||||||
strings.HasPrefix(path, "/media") ||
|
strings.HasPrefix(path, "/media") ||
|
||||||
strings.HasPrefix(path, "/assets") ||
|
strings.HasPrefix(path, "/assets") ||
|
||||||
|
strings.HasPrefix(path, "/stickers") ||
|
||||||
strings.HasPrefix(path, "/oauth") ||
|
strings.HasPrefix(path, "/oauth") ||
|
||||||
strings.HasPrefix(path, "/.well-known") {
|
strings.HasPrefix(path, "/.well-known") {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
type SPAPageMeta struct {
|
||||||
Title string // 完整 <title>
|
Title string // 完整 <title>
|
||||||
Description string
|
Description string
|
||||||
@@ -23,6 +27,8 @@ type SPAPageMeta struct {
|
|||||||
Robots string // 如 noindex,nofollow
|
Robots string // 如 noindex,nofollow
|
||||||
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
||||||
Status int // HTTP 状态码,0 视为 200
|
Status int // HTTP 状态码,0 视为 200
|
||||||
|
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
|
||||||
|
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
||||||
@@ -37,8 +43,9 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data = applySPAPageMeta(data, meta)
|
data = applySPAPageMeta(data, meta)
|
||||||
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希
|
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
|
||||||
c.Header("Cache-Control", "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)
|
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>"))
|
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var head strings.Builder
|
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
|
||||||
writeMeta(&head, "description", meta.Description)
|
var seo strings.Builder
|
||||||
writeMeta(&head, "keywords", meta.Keywords)
|
writeMeta(&seo, "description", meta.Description)
|
||||||
|
writeMeta(&seo, "keywords", meta.Keywords)
|
||||||
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
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)
|
robots := strings.TrimSpace(meta.Robots)
|
||||||
if robots != "" {
|
if robots != "" {
|
||||||
writeMeta(&head, "robots", robots)
|
writeMeta(&seo, "robots", robots)
|
||||||
}
|
}
|
||||||
|
|
||||||
ogType := strings.TrimSpace(meta.OGType)
|
ogType := strings.TrimSpace(meta.OGType)
|
||||||
@@ -75,36 +87,63 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
|||||||
if locale == "" {
|
if locale == "" {
|
||||||
locale = "zh_CN"
|
locale = "zh_CN"
|
||||||
}
|
}
|
||||||
writeProp(&head, "og:type", ogType)
|
writeProp(&seo, "og:type", ogType)
|
||||||
writeProp(&head, "og:site_name", meta.SiteName)
|
writeProp(&seo, "og:site_name", meta.SiteName)
|
||||||
writeProp(&head, "og:locale", locale)
|
writeProp(&seo, "og:locale", locale)
|
||||||
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
|
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
|
||||||
writeProp(&head, "og:description", meta.Description)
|
writeProp(&seo, "og:description", meta.Description)
|
||||||
writeProp(&head, "og:url", meta.Canonical)
|
writeProp(&seo, "og:url", meta.Canonical)
|
||||||
writeProp(&head, "og:image", meta.OGImage)
|
writeProp(&seo, "og:image", meta.OGImage)
|
||||||
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
|
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
|
||||||
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
|
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||||
writeMetaName(&head, "twitter:description", meta.Description)
|
writeMetaName(&seo, "twitter:description", meta.Description)
|
||||||
writeMetaName(&head, "twitter:image", meta.OGImage)
|
writeMetaName(&seo, "twitter:image", meta.OGImage)
|
||||||
|
|
||||||
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
||||||
head.WriteString(`<script type="application/ld+json">`)
|
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
|
||||||
head.WriteString(jsonld)
|
seo.WriteString(`<script type="application/ld+json">`)
|
||||||
head.WriteString(`</script>`)
|
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
|
||||||
|
seo.WriteString(`</script>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
|
if seo.Len() > 0 {
|
||||||
if boot := spaBrandingBootScript(); boot != "" {
|
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
|
||||||
head.WriteString(boot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if head.Len() > 0 {
|
// —— 可执行 boot 脚本仍放在 </head> 前 ——
|
||||||
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
|
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
|
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__=...; 内联脚本
|
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
||||||
func spaBrandingBootScript() string {
|
func spaBrandingBootScript() string {
|
||||||
if spaBrandJSONFn == nil {
|
if spaBrandJSONFn == nil {
|
||||||
@@ -114,7 +153,6 @@ func spaBrandingBootScript() string {
|
|||||||
if len(raw) == 0 || !json.Valid(raw) {
|
if len(raw) == 0 || !json.Valid(raw) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// 防止 JSON 字符串中的 </script> 提前闭合标签
|
|
||||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||||
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
||||||
}
|
}
|
||||||
@@ -154,3 +192,11 @@ func firstNonEmpty(vals ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
|
||||||
|
func spaFaviconHref() string {
|
||||||
|
if spaBrandFaviconFn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(spaBrandFaviconFn())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,26 +2,10 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="拾三一隅,自在交流" />
|
<meta name="description" content="拾三一隅,自在交流" />
|
||||||
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||||
<style>
|
<!-- 样式由 Vite 构建注入外链 CSS,不在此内联 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>
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var theme = localStorage.getItem('j13-theme') || 'light';
|
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||||
@@ -29,6 +13,33 @@
|
|||||||
document.documentElement.style.colorScheme = theme;
|
document.documentElement.style.colorScheme = theme;
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
101
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "jiang13-forum-web",
|
"name": "jiang13-forum-web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
"@radix-ui/react-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-label": "^2.1.9",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@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",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tiptap/core": "^3.26.1",
|
"@tiptap/core": "^3.26.1",
|
||||||
"@tiptap/extension-code-block": "^3.26.1",
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
@@ -46,7 +51,8 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
@@ -352,6 +358,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
@@ -1915,6 +1974,18 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tanstack/react-virtual": {
|
||||||
"version": "3.14.2",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||||
@@ -4410,6 +4481,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
"@radix-ui/react-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-label": "^2.1.9",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@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",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tiptap/core": "^3.26.1",
|
"@tiptap/core": "^3.26.1",
|
||||||
"@tiptap/extension-code-block": "^3.26.1",
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
@@ -47,7 +52,8 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.3",
|
"@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 |
BIN
frontend/public/stickers/tieba/tb_01.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/tieba/tb_02.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |