66 Commits

Author SHA1 Message Date
f3f50d7e90 chore: bump version to 1.1.8 2026-09-02 19:40:35 +08:00
67bef6dc08 chore: 同步 Cursor 构建规则到 Trae,优化 .gitignore
- 新增 .trae/rules/build-scripts.mdc,与 Cursor 的 .cursor/rules/build-scripts.mdc 保持一致
- .gitignore 从忽略整个 .trae/ 改为仅忽略 .trae/documents/、.trae/chat/、.trae/image/ 等临时产物
- 保留 .trae/rules/ 可被 git 追踪,规则可跨机器同步
- 取消追踪已存在的 .trae/documents/ 临时规划文档
2026-09-02 19:34:49 +08:00
23e304ac53 chore: 在 .gitignore 中添加 .trae/ 目录 2026-09-02 19:30:33 +08:00
cad53942e8 fix: 修复消息未读数同步和帖子详情快照逻辑
- MessagesPage: 新增 updateConvUnread 统一更新会话未读数及 session 缓存,
  缓存命中时仍调用 markConversationRead 并触发未读刷新,
  全部标为已读时同步更新 session 缓存避免 stale 数据
- PostDetailPage: 仅 POP 导航类型使用 session 快照恢复,
  非 POP 导航和下拉刷新始终强制重拉确保拿到最新数据
2026-09-02 19:29:01 +08:00
76be8926f2 feat: 完善表情选择与评论富文本展示
发帖编辑器支持 OwO 贴纸;评论区复用文章列表、段落与代码块阅读态,并优化表情面板间距与悬停反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:57:41 +08:00
4d93e455f9 fix: 私信表情插入编辑框,不再作为图片附件发送
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:12:33 +08:00
c958dcfd80 chore: 发布 Docker 镜像版本 1.1.7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:01:36 +08:00
84bfbd9a3e fix: 帖子详情右栏默认头像与全站配色一致
作者卡占位头像改为深绿底白字,与 post-avatar 默认样式统一。
2026-09-02 02:50:23 +08:00
500e452c2f feat: 重设计私信页并优化开源展柜体验
私信改为飞书风布局,支持缩略图表情/图片、按会话草稿与用户搜索发起会话;主页发私信直达消息页。开源展柜修复空列表粘滞并改版目录式 UI。
2026-09-02 02:45:29 +08:00
1c0f7ede55 fix: 待审通知实时回填审核态并重构消息页体验 2026-09-02 01:39:27 +08:00
2e9c42a34c chore: 发布 Docker 镜像版本 1.1.6
同步更新日志与 README 下载链接;含编辑器链接/图片选择器与首页最新活动排序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:02:00 +08:00
dff60dde6a fix: 首页默认排序改为最近活动时间线
reply 按发帖与最后评论的较晚时间混排,零回复新帖不再沉底;默认文案改为「最新」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:54:44 +08:00
ea9e0058d4 fix: 链接对话框站内搜索收进高级选项
默认只显示网址与文字;展开高级选项后再搜站内内容,点选时同步更新链接文字。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:33:24 +08:00
d5aa36416c feat: 增强编辑器链接对话框并统一图片插入选择器
链接支持网址/文字与站内搜索,新标签由全站设置控制;文章与评论共用已上传图片选择器。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:22:03 +08:00
2159280af5 style: 三栏主滚动区统一细绿色滚动条
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 19:12:24 +08:00
bc9a031890 feat: 单页详情右侧栏与帖子共用目录布局
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:34:59 +08:00
0d81787125 fix: 从正文同步派生帖子目录,避免预取缓存竞态导致空树
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:25:42 +08:00
68713c053b docs: 增加更新日志并指向 Gitea Release 下载
让 Docker 与 Windows/Linux 用户能对照同一版本号获取新包。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:09:41 +08:00
94823c0821 chore: 发布 Docker 镜像版本 1.1.5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:16:29 +08:00
b608ec85ba fix: 桌面隐藏导航汉堡,恢复评论数与共 N 条右对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:11:29 +08:00
6048d71759 fix: 帖行评论数勿贴右缘,手机 SSR 顶栏/页脚与 React 同构
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:06:57 +08:00
37d14a22d5 fix: 发版后软刷新检测入口壳并在 chunk 404 时硬刷新
避免 Logo/下拉刷新仍跑旧 index.js,以及站内消息预取失败弹出英文 toast。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:53:39 +08:00
a02d103cfe fix: 排序栏 count 紧跟标签,FeedSortBar 与 SSR 统一为 a 标签
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:48:18 +08:00
21c6233fc0 feat: 排序标签强制刷新、推荐帖仅 featured,软刷新同步 limits/品牌文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:32:21 +08:00
4ca5c17e2e fix: 拆除爬虫专用 HTML,并补齐 /favicon.ico 与 head 图标注入
人机统一走 SPA/首页 SSR;约定路径与 link rel=icon 便于爬虫识别站点图标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:15:06 +08:00
cf7a37eccc chore: 忽略 lucide path 抽取临时文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:08:47 +08:00
44eed97fe0 feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动
补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:07:38 +08:00
9e134916a4 fix: 软刷新齐套前保留旧画面,避免一点击就卸光
Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
2026-09-01 04:56:10 +08:00
ba60a9b1c4 feat: 调整 Feed 排序为新评论/新帖子/推荐帖
默认按新评论;推荐排序优先 featured,并将用户可见「精华」文案改为「推荐」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:59:01 +08:00
132ca5c82c fix: 用 Zustand 缓存 Feed 列表,返回时恢复滚动且不重复请求
点击 Logo 仍强制刷新;后退忽略 history 上的 refreshFeed,并支持过期静默刷新与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:50:33 +08:00
185649db7c fix: 增加手机端排序栏顶部间距
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 23:56:40 +08:00
9d926750b2 chore: 发布 Docker 镜像 1.1.4
含城市中文映射补全与监控概览双通道口径对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 09:07:50 +08:00
a91d917fa9 fix: 对齐监控概览双通道口径文案与诊断抽样
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:58:03 +08:00
34f3d088ad fix: 补全 IP2Location 中国城市中文映射并按省消歧
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:40:12 +08:00
c6ce44f8b4 chore: 发布 Docker 镜像 1.1.3
包含网站监控与独立 monitor.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 07:58:54 +08:00
429956c594 feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 07:55:45 +08:00
16b6a18096 chore: 发布 Docker 镜像 1.1.2
包含可选社区上报、精选展柜与仪表盘支持条。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 04:26:26 +08:00
95c2154087 feat: 可选社区上报与官方精选展柜
默认关闭,仪表盘一键开关;枢纽由运维配置收报,人工精选后展示于 /showcase。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 04:22:59 +08:00
0abfb32194 chore: 发布 Docker 镜像 1.1.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 00:25:20 +08:00
bcfd306643 feat: 评论管理操作收进更多菜单,减少管理员界面杂乱
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 00:08:16 +08:00
335b708b04 chore: 发布 Docker 镜像 1.1.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 23:27:52 +08:00
a83f8bd670 feat: 帖子管理收进右上角菜单,手机端评论输入默认折叠
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 22:58:55 +08:00
4dc3bbb13f fix: 单页正文改用 post-detail-content 对齐帖子排版
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:33:09 +08:00
8cfe31da61 fix: 锁死页面双指缩放,正文原图与灯箱 pinch 放大
禁止手机/平板整页捏合缩放;帖子与单页正文直接显示原图(含外链可点开灯箱),灯箱内支持双指缩放与拖动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:11:00 +08:00
7965768f3d fix: 注册用户无头像时不再误用游客灰底样式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:05:14 +08:00
012cac23de feat: 去掉 Feed 顶栏统计并强化开源码桶
按帖子列表风格展示码桶,补齐语言/Star/Fork 与论坛作者关联回填,避免未绑定仓库与空列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:58:04 +08:00
d929bf96aa fix: 首页帖子列表标题去掉加粗
保留 15px 字号,字重改回 400。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:11:47 +08:00
cce9fb9ed8 feat: 友链入口迁至站点区并支持左右页脚独立开关
修复左侧栏/页脚开关无法关闭的问题,改为专用设置接口可靠写入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:04:22 +08:00
9eefe24a33 feat: 优化单页编辑、列表留白与友链申请体验
单页全屏编辑并禁用内容门控;对齐 Feed 留白;友链申请区前置并收敛弹框样式;略增大帖子列表标题字号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:24 +08:00
f559fc4c4b fix: 适配手机端搜索入口与底部 Sheet 面板
顶栏改为可伸展搜索 pill,面板改为自底部滑出并优化筛选条与触控布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 23:39:32 +08:00
55b1831da2 feat: 重设计搜索帖子 UI/UX,新增筛选面板与结果页 chips
将高级筛选移入独立面板,统一 URL 状态与清除逻辑,并优化移动端搜索与空结果反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 19:21:55 +08:00
c6df703b96 fix: 占位头像默认白字并保留 hover 描边反馈
修复 user-link inherit 覆盖 post-avatar 白字的问题,头像链接 hover 时用淡绿描边替代变绿字。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 19:01:18 +08:00
7cc574f9a1 feat: 优化首页帖子列表并新增右侧栏最新注册组件
首页列表精简 meta 与统计展示,板块色标前移并淡化;有回复时显示最后回复人。右侧栏新增最新注册(4 列头像网格),友链改为标签块并排换行。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 18:50:09 +08:00
2208af7070 feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 06:23:54 +08:00
df19752a1e chore: 移除 GitHub Actions 工作流,避免 Gitea 检查失败
仓库托管在 Gitea,镜像改由本地手动构建推送到 Docker Hub。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 18:37:01 +08:00
c07d1fd2bd feat: 新增 Docker 容器化部署,支持 Docker Hub 镜像发布
Some checks failed
Docker / docker (push) Has been cancelled
多阶段 Dockerfile、Compose、/health 探活与 JIANG13_* 环境变量;
entrypoint 自动修正 /data 卷权限,适配 1Panel 等平台;更新 README 部署文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 18:30:53 +08:00
4f85a86077 fix: 修复生产环境表情无法显示的问题
1. 后端新增/stickers路由静态文件服务,设置1天缓存
2. 排除/stickers前缀的SPA路由拦截
3. 优化评论区表情按钮样式与可访问性
4. 移除编辑器中未使用的标题工具与引用工具
2026-08-12 02:49:26 +08:00
f0650a9c39 fix(post-detail): 修复评论定位不准确和重试机制缺失问题
将jumpToFloor改为使用pageRef.scrollTo替代scrollIntoView适配自定义滚动容器,同时为floor锚点定位添加完善的重试机制,对齐heading锚点的处理逻辑
2026-08-11 22:52:55 +08:00
4f42a2faff fix(comment): 修复评论框提交后重置和聚焦的问题
1. 为CommentEditor的focus方法添加preventScroll可选参数并透传调用
2. 使用ref记录上次提交计数,避免首次挂载时重复清空输入框
3. 仅在submitCount实际变化时重置表单并聚焦编辑器
2026-08-08 15:09:43 +08:00
536707f812 feat: 新增贴纸系统,重构评论编辑器与内容处理
1. 新增多平台贴纸库:贴吧、微博、知乎、小红书、抖音、B站共500+贴纸,搭配颜文字贴纸
2. 实现贴纸选择器面板,支持分类浏览、键盘导航和懒加载
3. 重构评论内容渲染:替换旧的@提及高亮逻辑,新增HTML净化与双向兼容
4. 重构评论编辑器为Tiptap富文本版本,支持表情插入、格式编辑
5. 优化评论编辑体验,修复滚动位置保留问题,调整评论区UI样式
6. 移除旧版EmojiPicker工具类,新增贴纸数据管理模块
2026-08-08 02:24:04 +08:00
1550ccb693 Revert "feat: 实现基础滚动位置恢复功能"
This reverts commit 4042395e3e.
2026-08-08 02:11:10 +08:00
4042395e3e feat: 实现基础滚动位置恢复功能
1. 新增 useScrollRestoration hook 用于在布局组件挂载时恢复滚动位置
2. 新增 scrollRestore 工具模块实现基于 sessionStorage 的滚动位置保存与恢复
3. 在 MainLayout 和 AdminLayout 中接入滚动恢复hook
4. 在应用入口初始化全局滚动保存监听
2026-08-07 21:46:58 +08:00
383440ed5b feat(frontend): add posting compose component suite
新增了发帖相关的完整组件模块:包括顶部导航栏ComposeHeader、正文编辑区ComposeDocument,以及发布设置栏ComposeContextBar,同时添加了vite依赖缓存配置文件。
2026-08-07 17:45:50 +08:00
94f6a9666b 增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。
补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 07:39:33 +08:00
10185178e2 补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 06:32:06 +08:00
0f83183620 增加板块内置顶,与全局置顶分离:首页仅抬升全局置顶,板块列表优先全局再板块。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 05:14:00 +08:00
405 changed files with 37766 additions and 3203 deletions

25
.dockerignore Normal file
View 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
View File

@@ -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

View 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「快速开始」如需对外暴露再更新

View File

@@ -0,0 +1,8 @@
{
"hash": "de7e4cff",
"configHash": "9a7296da",
"lockfileHash": "e3b0c442",
"browserHash": "8c168d3c",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

55
Dockerfile Normal file
View 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"]

View File

@@ -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
View File

@@ -11,6 +11,7 @@
[![在线演示](https://img.shields.io/badge/Demo-bbs.iioio.com-18a058?style=flat-square)](https://bbs.iioio.com/) [![在线演示](https://img.shields.io/badge/Demo-bbs.iioio.com-18a058?style=flat-square)](https://bbs.iioio.com/)
[![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod)
[![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json) [![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json)
[![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
@@ -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 # 后台导出的主库备份
``` ```
--- ---

View File

@@ -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

View File

@@ -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,22 +91,25 @@ 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' -ForegroundColor Green Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
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" "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
90| try { try {
if (-not (Test-Path node_modules)) { npm install } if (-not (Test-Path node_modules)) { npm install }
npm run dev npm run dev
} finally { } finally {
@@ -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
}
} }

View File

@@ -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)

View File

@@ -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 {

View 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(&regions).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)
}

View File

@@ -30,11 +30,16 @@ type Config struct {
LogFile string LogFile string
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行 // 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
ServiceAction string ServiceAction string
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
DevMode bool
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
CommunityHub bool
} }
// Parse 解析命令行与 app.ini,并初始化数据目录 // Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
// //
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值 // 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
func Parse() (*Config, error) { 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", "", "工作目录(默认:可执行文件所在目录)")
@@ -42,6 +47,7 @@ func Parse() (*Config, error) {
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)") dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)") jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status") serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
flag.Parse() flag.Parse()
action := strings.ToLower(strings.TrimSpace(*serviceFlag)) action := strings.ToLower(strings.TrimSpace(*serviceFlag))
@@ -49,12 +55,20 @@ func Parse() (*Config, error) {
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag) 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
} }
@@ -70,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
} }
@@ -83,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{
@@ -96,6 +124,8 @@ func Parse() (*Config, error) {
JWTSecret: jwtSecret, JWTSecret: jwtSecret,
LogFile: filepath.Join(absData, "jiang13.log"), LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action, ServiceAction: action,
DevMode: *devFlag,
CommunityHub: communityHub,
} }
needDirs := action == "" || action == "install" needDirs := action == "" || action == "install"
@@ -163,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)
@@ -197,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
View 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
}
}

View File

@@ -18,9 +18,10 @@ const (
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径) // fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
type fileSettings struct { 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
View 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
View 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
View 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
View 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

View File

@@ -18,6 +18,16 @@
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。 把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
## 自定义单页(推荐)
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
| 文件 | 标题 | slug | 操作 |
| --- | --- | --- | --- |
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
访问路径:`/page/changelog`。
## 可选短文 ## 可选短文
若站务区希望不止一篇,见 [optional.md](./optional.md): 若站务区希望不止一篇,见 [optional.md](./optional.md):

View 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 文件,再把新版本写到本页最上方。

View File

@@ -14,9 +14,10 @@ import (
var staticFS embed.FS var staticFS embed.FS
var ( 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

View File

@@ -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())
}

View File

@@ -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>

View File

@@ -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
}
}
} }
} }
} }

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Some files were not shown because too many files have changed in this diff Show More