120 Commits

Author SHA1 Message Date
20aee9c566 chore: 清理 SSR 改造后的历史遗留死代码与误提交产物
- 删除图形验证码整条死链:service/captcha.go、APICaptcha、
  GET /api/captcha、CaptchaService 装配、ErrCaptchaInvalid 及前端
  api.captcha()(注册/登录已改用邮箱验证码,Verify 无任何调用方)
- 删除 GET /api/posts/hot 死路由:APIHotPosts + PostService.HotPosts
  (热门排序走 /api/posts?sort=hot)
- 删除 GET /api/posts/:id/revisions/:revId 死路由:
  APIPostRevisionDetail + PostService.GetRevision + ErrRevisionNotFound
  及前端 api.postRevision()(历史面板仅用列表接口)
- 停止跟踪误提交的 27.5MB Linux 二进制 jiang13-linux-amd64 与
  .vite 依赖缓存;.gitignore 补充对应规则并移除已失效的
  embed_static 条目
2026-09-11 00:39:58 +08:00
45cc864971 feat(ssr): 扩展 SSR 覆盖至 /page/:slug 与 /user/:id
- feedPaths 白名单加入自定义单页与用户主页(兼容 .html 伪静态)
- head.ts 新增 buildPageHead(ogType=article, WebPage JSON-LD)
  与 buildUserHead(ogType=profile, Person JSON-LD)
- feedData 新增 loadPageData / loadUserData,并行取页面/用户
  数据 + 壳层,404 走 buildNotFoundHead + noindex
- feedSeed 按 useSessionResource 的 key 约定 seed:
  sitepage:${slug}、user:${id}、user:${id}:posts:1:${size},
  使 SitePageView / UserProfilePage 首帧直接命中快照渲染
- MainLayout postOutline 首帧派生扩展 /page/:slug 分支,
  文章目录随正文一同 SSR
- 验证:两页 SSR HTML 含完整内容与 head,0 hydration 错误,
  hydration 后仅剩 pageview 请求;404 正确;已有页面无回归
2026-09-10 19:26:14 +08:00
3c6d8cb891 feat(ssr): 帖子页全量 SSR——作者卡/目录首帧服务端渲染,消除冗余首屏请求
- 帖子快照携带作者主页资料与统计(authorProfile/authorStats),
  SSR 端与客户端预热均取 /api/users/:id;PostAuthorCard 首帧直接渲染,
  不再挂载后请求,统计数字不再显示 — 占位
- MainLayout 首帧从已 seed 的帖子快照派生右栏 outline(作者卡+文章目录),
  修复 outline 仅靠 effect 上报导致 SSR 不渲染右栏的问题
- 壳层首屏请求门控:useAuth 有种子跳过 /api/me、branding 缓存命中不重拉、
  侧栏三个区块首屏用 seed 缓存、未读数首屏跳过即时请求(轮询/事件刷新保留)
- serverDom(linkedom) 修复:DOMParser 片段包裹、属性顺序、SVG 自闭合展开
- 验证:首页与帖子页 hydration 后仅剩 pageview 上报,0 hydration 错误
2026-09-10 05:04:31 +08:00
2392768868 feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复
- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined:
  抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫
- 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到
  各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记
- 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false,
  useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出,
  head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块)
- 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、
  探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image
- Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、
  自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生
  历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView +
  scroll-margin-top 适配 sticky 顶栏
2026-09-09 20:53:28 +08:00
3702e3d1f2 chore: Tailwind CSS 3.4 迁移到 4.x(CSS-first 配置 + @tailwindcss/vite)
- 升级 tailwindcss@4.3 + @tailwindcss/vite,移除 postcss/autoprefixer/tailwindcss-animate
- tailwindcss-animate 替换为 tw-animate-css(v4 兼容的 animate-in/out 工具类)
- global.css 改用 @import "tailwindcss" + @theme inline 映射 shadcn 设计令牌
- @custom-variant dark 替代 darkMode:'class';@source 显式纳入 pages/ 目录
- 删除 tailwind.config.ts / postcss.config.js,components.json 同步 v4 约定
- vite.config.ts 接入 tailwindcss 插件,__dirname 改 import.meta.dirname
2026-09-09 02:54:56 +08:00
230006a905 chore: Docker 双服务 + Makefile/build.ps1 双进程目标 + README 架构更新
- Dockerfile 改为 Node 24 运行时:Vike 产物 + Go API 二进制共用镜像
- docker-compose 双容器:web(:3000 对外 SSR) + api(:3001 内部,持 /data 卷)
- entrypoint 改用 gosu 降权(Debian 基础镜像),健康检查用 node fetch
- Makefile/build.ps1:build 不再打包前端,新增 serve(本地双进程)与 web-build
- README 同步双服务架构、端口/环境变量、部署与开发说明
2026-09-09 02:48:28 +08:00
ba39376a0e refactor: Go 端收缩,移除 SPA 服务与 embed_static
- 删除 embed_static(内嵌 SPA 静态资源)与 handler/ssr_home.go、ssr_icons.go
- router 移除前端页面服务,NoRoute 统一返回 404 JSON
- seo.go 仅保留 robots.txt / sitemap.xml / favicon.ico
- 数据聚合逻辑迁移到 handler/home_boot.go(/api/home-boot)
- 启动日志改为提示前端由 Node 渲染服务器提供
2026-09-09 02:41:53 +08:00
1ce0f123fa feat: Node 渲染服务器(express + renderPage + API 反代)
- server/index.mjs:生产模式 Express 服务,静态资源 immutable 缓存
- /api /uploads /media 等前缀反代到 Go API,xfwd 透传客户端 IP
- Vike renderPage 承接 SSR,渲染失败返回 500 兜底页
- /healthz 健康检查端点
2026-09-09 02:41:39 +08:00
c99238c1e7 feat: Vike +data 服务端取数 / SEO Head 移植 / 404 错误页 / 服务端 DOM 模拟
- feedData.ts 聚合首页/板块/帖子详情数据,缺失内容 throw render(404)
- Go 侧 spa_meta/seo 的 head 生成逻辑移植到 src/ssr/head.ts
- _error 页复用站点壳渲染 404,+data 从 abortReason 恢复壳层数据
- linkedom 模拟服务端 DOM,DOMPurify 封装为 SSR 透传的 sanitizeHtml
- 新增 /api/home-boot 聚合端点(handler/home_boot.go)供 SSR 冷启动灌缓存
2026-09-09 02:41:30 +08:00
e71c77b5ca feat: Vike 骨架(pages feed+spa / renderer 双端入口 / AppShell 拆分 / serverClient)
vite.config 接入 vike 插件并改产出 dist/client+dist/server;feed 白名单 route 匹配 /、/board/:id、/post/:id(.html);spa catch-all ssr:false;onRenderHtml renderToReadableStream 等 allReady 输出完整 HTML 并带主题/缩放脚本;onRenderClient 沿用样式表等待+冷启动预热+hydrate 回退;getHomeStoreState 作用域 + hydrating 变体入 RequestScope;删除 index.html 与 main.tsx
2026-09-09 01:21:58 +08:00
27132e89c2 feat: SSR 安全防卫与缓存作用域化(branding/limits/pages/auth/session)
theme/useTheme 服务端防卫 window/document;branding/limits/pages 缓存读请求作用域、seed 只写作用域;homeBoot 拆出 seedHomeBoot 与帖子详情快照构建器供 prefetchRoute 复用;getHomeStoreState 改返回 state 修复命令式访问
2026-09-09 01:05:12 +08:00
86a317c862 feat: SSR 请求级作用域(AsyncLocalStorage)+ homeStore 工厂化 + layoutCache 作用域适配 2026-09-09 00:35:19 +08:00
52d328ffc3 chore: 工具链升级到最新版本(Vite 8 / React 19 / react-router 8 / TS 7 / Go 1.26 依赖更新)
- 前端:vite ^8.2.2、react/react-dom ^19.2.8、react-router ^8.3.1(移除 react-router-dom)、typescript ^7.0.2、zustand ^5.0.15;适配升级带来的类型变化(react-hook-form Resolver、ProseMirror node.parent、tiptap focus 参数、timer undefined 等)
- 后端:升级 gin/jwt/minio-go/gorm/modernc-sqlite/x-crypto 等直接依赖至最新;gowebp v0.1.1 已是最新版本
- tsconfig 移除 baseUrl,paths 改为相对路径以兼容 TS7
2026-09-09 00:14:12 +08:00
711cd8b759 feat: 帖子全局/板块置顶支持权重排序,优化列表与详情渲染 2026-09-08 23:54:03 +08:00
de2f5e8be8 docs: changelog 排版优化,每个版本统一分隔线 + 📦 图标 + 日期格式统一 2026-09-02 19:51:12 +08:00
5c774f9a83 docs: 更新 changelog 为 v1.1.8 2026-09-02 19:49:13 +08:00
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
bb8d415eb7 支持评论级联软删与后台回收站,并完善 Markdown 代码围栏嵌套。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 17:49:05 +08:00
e6e7ed73e3 优化个人中心信息架构与手机编辑体验,置顶改为文字标识。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:41:34 +08:00
6b0a1d4281 增加用户认证、等级、徽章与积分体系,并优化管理后台体验。
覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 02:42:18 +08:00
b075495540 支持评论点赞与举报,并统一帖子/评论的举报入口交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 01:06:02 +08:00
c05cc472cf 增加回复与待审提醒:站内消息与 SMTP 邮件通知。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:34:16 +08:00
322ac14055 优化评论区展示:弱化用户名样式、调整回复布局与编辑时限。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:33:06 +08:00
e8b28a37e4 项目截图、readme更新 2026-08-05 17:11:05 +08:00
cc51c49272 增加回复可见功能 2026-08-05 12:44:00 +08:00
5b2d9a4ea4 编辑工具增加表格输入菜单,优化表格样式 2026-08-05 12:21:14 +08:00
e50047e1c8 代码块优化,增加aardio、R语言代码高亮等 2026-08-05 11:58:35 +08:00
860dffd547 修复帖内 CSS 污染整页:前后端禁止 style 并消毒 HTML。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 13:04:10 +08:00
2a34699f64 为板块编辑对话框补充 Description,消除无障碍警告。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 00:22:42 +08:00
b24f6c23ea 优化帖子详情与列表展示,并新增问答帖类型与已解决状态。
统一标题锚点定位、编辑底栏固定与操作栏分层,列表徽章前置并区分置顶/问答配色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 00:19:32 +08:00
3aca5c42c5 头像裁剪改为保留源格式上传,避免服务端只存 WebP。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 18:30:27 +08:00
2c06eaccb2 修复发帖长文布局与手机下拉刷新,发版后自动重载失效 chunk,并整理站务文案。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 18:18:35 +08:00
61b47363e9 新增站务公告板块发帖素材与发布说明。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:39:27 +08:00
dd9e185847 修复无板块时空态导航失效,去除发帖本地草稿,并种子默认板块。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:12:17 +08:00
81f5d451bc 精简 app.ini:仅保留端口/数据目录/JWT,业务项改由管理后台配置。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:58:30 +08:00
48db333272 移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:37:40 +08:00
060b7707cb 支持公开用户主页、帖子图缩略图与编辑器图组排版。
新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:29:50 +08:00
822eef96be 新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:58:22 +08:00
9487c8ab02 支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。
引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:09:58 +08:00
962bb15298 chore: 不再跟踪 embed_static/static/spa 构建产物
SPA 由 Vite 输出至 embed_static,发布前通过 build 脚本构建即可。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:41:32 +08:00
55c256654b 支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。
新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:13:32 +08:00
e038d4f9e9 add cursor rules 2026-06-26 05:27:06 +08:00
f527c91c27 chore: add .gitattributes to fix cross-platform line endings 2026-06-26 05:26:07 +08:00
773639fd7c 修复头部用户菜单关闭后按钮残留高亮,优化 hover 与 focus 样式。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:16:54 +08:00
9767b54192 限制首页 Feed 自动加载上限,超出后支持手动加载更多历史帖子。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:11:33 +08:00
0eae3f8c47 恢复 README 原有排版:居中标题、徽章、截图表格与 HTML 布局。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:00:44 +08:00
57172eb053 完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 05:57:15 +08:00
e4d1dd139e 更新 README 界面截图,同步 Feed 排序与 TipTap 发帖等新 UI。
改进截图脚本:登录后抓取发帖页,使用富文本示例帖并优化等待逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:15:52 +08:00
b451703642 完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。
同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:11:38 +08:00
1d273066b0 升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 03:05:45 +08:00
d0555de28e 统一 React 管理后台,修复评论换行与帖子置顶
- /admin/* 全部由 React SPA 渲染,替代旧版 HTML 后台页面
- 新增仪表盘、帖子/评论/用户管理、系统设置与 JSON API
- 帖子详情页支持管理员置顶;评论换行显示修复

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 23:06:44 +08:00
9230f7272d 添加路线图与 Issue 模板,呈现待办事项
- ROADMAP.md 记录已知缺陷、计划功能与已完成项
- README 增加开发状态说明
- Gitea Issue 模板与预填议题文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 23:01:18 +08:00
b27289bbea 开源准备:重写 README 展示 UI/UX,更新模块路径
- 新增界面截图与 CONTRIBUTING.md
- README 以三栏布局、暗色主题、移动端等视觉亮点为主
- go module 迁移至 git.iioio.com/freefire/jiang13-forum

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:15:51 +08:00
99ae0ccdaa 合并 Gitea 远程初始提交,保留本地项目文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:09:20 +08:00
e1c1708715 初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:08:52 +08:00
539 changed files with 90322 additions and 37 deletions

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

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# Git 与编辑器
.git
.gitignore
.idea
.vscode
.cursor
# 运行时数据与本地配置
data/
app.ini
tmp-cookie.txt
# 编译产物与前端缓存
dist/
frontend/node_modules/
frontend/dist/
node_modules/
.vite/
# 文档与截图(不参与镜像构建)
docs/
# 计划文件
*.plan.md

17
.gitattributes vendored Normal file
View File

@@ -0,0 +1,17 @@
# 跨平台行尾:仓库内统一 LF,避免 Windows autocrlf 造成「假 modified」
* text=auto eol=lf
# Windows 批处理在 cmd 下需要 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# 二进制
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.exe binary

View File

@@ -0,0 +1,56 @@
name: 缺陷报告
about: 报告一个可复现的问题
title: "[Bug] "
labels:
- bug
body:
- type: markdown
attributes:
value: |
感谢反馈!请尽量提供复现步骤,方便我们定位问题。
- type: textarea
id: description
attributes:
label: 问题描述
description: 简要说明发生了什么
placeholder: 评论回复换行后,页面上显示为一行…
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤
placeholder: |
1. 打开帖子详情页
2. 在评论框输入多行文字
3. 点击发送
4. 观察到换行丢失
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
validations:
required: true
- type: input
id: version
attributes:
label: 版本 / 提交
placeholder: main @ abc1234 或 v1.0.0
- type: input
id: environment
attributes:
label: 环境
placeholder: Windows 11 / Chrome 125 / Go 1.26
- type: textarea
id: screenshots
attributes:
label: 截图或日志
description: 可粘贴图片或相关日志

View File

@@ -0,0 +1,44 @@
name: 功能建议
about: 提出新功能或改进现有体验
title: "[Feature] "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
欢迎提出想法!请先搜索已有 Issue,避免重复。
- type: textarea
id: problem
attributes:
label: 要解决什么问题?
description: 从用户场景出发描述痛点
placeholder: 管理员在帖子详情页缺少某某操作入口…
validations:
required: true
- type: textarea
id: solution
attributes:
label: 期望的方案
description: 你心目中的实现方式(可简略)
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 备选方案
description: 如有其他思路可一并说明
- type: dropdown
id: area
attributes:
label: 涉及模块
options:
- 前台 UI/UX
- 管理后台
- 评论系统
- 帖子 / 板块
- 认证 / 权限
- 部署 / 构建
- 其他
validations:
required: true

49
.gitignore vendored
View File

@@ -1,27 +1,30 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
# 运行时数据(数据库、JWT 密钥、日志、头像)
/data/
# 本地配置(保留 app.ini.example)
/app.ini
# 前端依赖与构建缓存
/node_modules/
/frontend/node_modules/
/frontend/dist/
/.vite/
# Go 编译产物
/dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
/jiang13-*
# Test binary, built with `go test -c`
*.test
# 临时文件
tmp-cookie.txt
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# 编辑器 / OS
.idea/
.vscode/
.trae/documents/
.trae/chat/
.trae/image/
*.swp
Thumbs.db
.DS_Store

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

52
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,52 @@
# 参与贡献
感谢你对姜十三论坛的关注!这是一个开源项目,欢迎提交 Issue 和 Pull Request。
## 开发环境
**要求:** Go 1.26+、Node.js 18+
```bat
REM Windows:一键启动后端 + 前端热更新(请用 build.bat)
build.bat -Target dev
REM 浏览器访问 http://localhost:5173
```
```bash
# Linux / macOS
make dev
```
## 提交规范
- 一个 PR 只做一件事,保持 diff 小而清晰
- 前端改动请确认浅色 / 暗色主题下都正常
- 涉及 UI 变更时,建议在 PR 中附上截图
## 完整构建
发布单二进制前需先构建前端并 embed:
```bat
build.bat REM Windows
```
```bash
make build # Linux / macOS
```
## 报告问题
在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)):
1. 复现步骤
2. 期望行为 vs 实际行为
3. 环境信息(系统、浏览器、Go/Node 版本)
4. 截图或日志(如有)
演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/)
已知问题与计划功能见 [ROADMAP.md](ROADMAP.md)。
## 行为准则
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# 姜十三论坛 — 多阶段构建:Node 前端(Vike SSR)+ Go API,双服务共用镜像
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
#
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
# docker build --build-arg IMAGE_PREFIX= ...
#
# 运行形态(docker compose 双容器,同一镜像两种角色):
# api —— Go 二进制,纯 API(:3001 内部,/data 数据卷)
# web —— Node 渲染服务器(对外 :3000:SSR + 静态资源 + API 反代)
# 镜像默认入口为 api 角色;web 角色在 compose 中以 node 用户覆盖 entrypoint。
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
ARG VERSION=dev
# ── Stage 1: 前端构建(Vike → dist/client 静态资源 + dist/server SSR 入口)──
FROM ${IMAGE_PREFIX}node:24-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;仅 API,无内嵌前端)─────
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 . .
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/jiang13 ./cmd/jiang13
# ── Stage 3: 运行镜像(Node 运行时承载 SSR 渲染与静态资源,附带 Go API 二进制)
FROM ${IMAGE_PREFIX}node:24-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
ENV TZ=Asia/Shanghai \
NODE_ENV=production
WORKDIR /app
COPY --from=builder /out/jiang13 /app/jiang13
COPY --from=frontend /src/frontend /app/frontend
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /app/jiang13 /docker-entrypoint.sh
# 以 root 启动,entrypoint 修正 /data 卷权限后 gosu 降权为 uid 1000(node 用户)
VOLUME ["/data"]
EXPOSE 3000 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["--work-path", "/app", "--data", "/data", "--port", "3001"]

27
LICENSE
View File

@@ -2,17 +2,20 @@ MIT License
Copyright (c) 2026 freefire
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

111
Makefile Normal file
View File

@@ -0,0 +1,111 @@
# 姜十三论坛 Jiang13 Forum - Makefile
# Go 1.26 单二进制编译,与 Gitea 打包方式一致
APP_NAME := jiang13
MAIN_PKG := ./cmd/jiang13
BUILD_DIR := dist
DEV_DATA_DIR := dist/data
VERSION := 1.1.8
LDFLAGS := -s -w -X main.version=$(VERSION)
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
GO := go
GOFLAGS := -trimpath
.PHONY: all build web-build build-windows build-linux build-darwin build-all clean run serve dev tidy help docker compose-up compose-down
all: build
## 前端构建(Vike:产出 frontend/dist/client 静态资源 + dist/server SSR 入口)
web-build:
cd frontend && (test -d node_modules || npm install) && npm run build
## 编译当前平台二进制(纯 Go SQLite,无需 CGO;Go 仅含 API,前端由 Node 渲染)
build:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64
build-windows:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
## Linux amd64
build-linux:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
## macOS arm64 (Apple Silicon)
build-darwin:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
## 跨平台全量编译
build-all:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 全平台编译完成"
## 整理依赖
tidy:
$(GO) mod tidy
## 本地运行(仅 Go API :3000,无前端页面;数据目录与 dist 二进制一致)
run:
@mkdir -p $(DEV_DATA_DIR)
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR)
## 生产模式本地运行(Node :3000 对外 SSR + Go API :3001)
serve: build web-build
@mkdir -p $(DEV_DATA_DIR)
@trap 'kill 0' INT; \
$(BUILD_DIR)/$(APP_NAME) --data $(DEV_DATA_DIR) --port 3001 & \
cd frontend && PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
## 前端热更新开发(Vike dev :5173 + Go API :3000,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
dev:
@echo "前端热更新: http://localhost:5173"
@echo "后端 API : http://localhost:3000"
@echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)"
@mkdir -p $(DEV_DATA_DIR)
@trap 'kill 0' INT; \
$(GO) run $(MAIN_PKG) --dev --data $(DEV_DATA_DIR) & \
cd frontend && (test -d node_modules || npm install) && npm run dev
## 清理编译产物
clean:
rm -rf $(BUILD_DIR)
## 构建 Docker 镜像
docker:
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
## Docker Compose 启动(web :3000 + api :3001 双容器)
compose-up:
docker compose up -d --build
## Docker Compose 停止
compose-down:
docker compose down
help:
@echo "姜十三论坛编译命令:"
@echo " make build - 编译 Go API 二进制(当前平台)"
@echo " make web-build - 构建前端(Vike:client + server 产物)"
@echo " make serve - 生产模式本地运行(Node :3000 + Go API :3001)"
@echo " make build-windows - 编译 Windows"
@echo " make build-linux - 编译 Linux"
@echo " make build-darwin - 编译 macOS"
@echo " make build-all - 编译全部平台"
@echo " make run - 仅启动 Go API(:3000)"
@echo " make dev - 前端热更新开发(:5173 + :3000)"
@echo " make docker - 构建 Docker 镜像"
@echo " make compose-up - Docker Compose 启动"
@echo " make compose-down - Docker Compose 停止"

490
README.md
View File

@@ -1,3 +1,489 @@
# jiang13-forum
<div align="center">
轻量自用论坛:Go 单二进制 + React SPA 内嵌 + SQLite,面向小圈子内部交流。
# 姜十三论坛 Jiang13 Forum
**能聊 · 好看 · 好装**
面向小圈子、团队与同好社群的轻量现代化论坛。
编译为单个 Go 二进制,前端 SPA(单页应用)内嵌,内置 SQLite,拷到服务器即可运行。
<br>
[![在线演示](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)
[![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)
[![React](https://img.shields.io/badge/React-19-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)](#)
[在线演示](https://bbs.iioio.com/) ·
[快速开始](#-快速开始) ·
[界面预览](#-界面预览) ·
[功能亮点](#-功能亮点) ·
[更新日志](docs/site-posts/changelog.md) ·
[路线图](ROADMAP.md) ·
[参与贡献](CONTRIBUTING.md)
<br>
<img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
<sub>浅色主题 · 三栏布局 · Feed 排序 · 板块导航 · 标签云</sub>
<br>
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)
> 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎提 Issue / PR 共建。
</div>
---
## 它是什么
姜十三论坛不做大而全的社区平台,只做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
| 场景 | 说明 |
|------|------|
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 项目配套社区 | 可与 Gitea 等通过 OIDC(开放身份连接)做 SSO(单点登录) |
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
---
## 界面预览
截图来自演示站 [bbs.iioio.com](https://bbs.iioio.com/)。
<table>
<tr>
<td width="50%" align="center">
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
<br><b>浅色主题</b><br>
<sub>左栏板块 · Feed 排序 · 右栏热门 / 标签 / 评论</sub>
</td>
<td width="50%" align="center">
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
<br><b>暗色主题</b><br>
<sub>一键切换 · 护眼阅读 · 全局色彩自适应</sub>
</td>
</tr>
<tr>
<td width="50%" align="center">
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
<br><b>帖子详情</b><br>
<sub>文章目录 · 标签 · 作者卡片 · 修订信息</sub>
</td>
<td width="50%" align="center">
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
<br><b>富文本渲染</b><br>
<sub>TipTap 排版 · 图片 · 代码高亮 · 目录导航</sub>
</td>
</tr>
</table>
<p align="center">
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
<br><b>移动端</b> — 板块快捷筛选 · Feed 排序 · 触控友好列表
</p>
---
## 功能亮点
### 界面与交互
| 特性 | 说明 |
|------|------|
| **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 |
| **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 |
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
| **主题切换** | 浅色 / 暗色,跟随系统偏好并本地记忆 |
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
### 社区功能
- 用户注册 / 登录(bcrypt + JWT Cookie);**首个注册用户自动成为管理员**
- 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华
- 帖子修订历史与 diff(差异)对比;可配置普通用户编辑时限
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
- 可选:邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储
### 部署体验
- **双服务架构** — Node(:3000 对外:SSR + 静态资源 + API 反代)+ Go(:3001 内部:纯 API),职责单一、可独立扩缩
- **服务端渲染** — 首页 / 板块 / 帖子详情由 Vike SSR 真渲染,SEO 友好、首屏直出;其余页面 SPA 接管
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
- **跨平台** — Windows / Linux / macOS 一键编译 Go API
- **系统服务** — 内置 Linux systemd / Windows Service 注册
- **Docker 双容器** — 同一镜像两种角色,`docker compose up` 一键起
---
## 快速开始
### 1. 编译
Go API 二进制(不含前端,Windows):
```bat
build.bat
```
> 请通过 `build.bat` 调用(内部已处理 ExecutionPolicy)。不要直接 `.\build.ps1`,也不要在 Windows 上使用系统自带的 Embarcadero `make`。
**Linux / macOS:**
```bash
make build
```
**手动分步(全平台):**
```bash
go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
```
前端构建(Vike,产出 `frontend/dist/client` + `dist/server`):
```bash
cd frontend && npm install && npm run build
```
本地生产模式一键起双服务(Node :3000 对外 + Go API :3001):
```bash
make serve # Linux / macOS
build.bat -Target serve # Windows
```
跨平台编译:
```bat
build.bat -Target build-windows
build.bat -Target build-linux
build.bat -Target build-all
```
### 2. Docker 部署(推荐)
**一键启动(本地构建):**
```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):**
同一镜像两种角色,建议直接用 `docker-compose.yml`(已含完整配置):
```bash
docker pull hangzhang714128/jiang13-forum:latest
docker compose up -d
```
Compose 会启动两个容器:
| 容器 | 角色 | 端口 | 说明 |
|------|------|------|------|
| `web` | Node 渲染服务器 | `3000` 对外 | Vike SSR + 静态资源 + API 反代(`GO_API_URL=http://api:3001`) |
| `api` | Go API | `3001` 内部 | 纯 API 服务,持有 `/data` 数据卷 |
**数据持久化:** `api` 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`,适配 1Panel 等面板挂载的目录。
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
**可选环境变量(容器编排):**
| 变量 | 容器 | 说明 |
|------|------|------|
| `GO_API_URL` | web | Go API 地址(默认 `http://127.0.0.1:3001`,Compose 中为 `http://api:3001`) |
| `PORT` | web | Node 渲染服务器监听端口(默认 `3000`) |
| `JIANG13_HTTP_PORT` | api | HTTP 端口(默认 `3000`,Compose 中为 `3001`) |
| `JIANG13_DATA` | api | 数据目录(默认 `/data`) |
| `JIANG13_JWT_SECRET` | api | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
| `JIANG13_CONFIG` | api | 配置文件路径 |
| `JIANG13_WORK_PATH` | api | 工作目录 |
| `JIANG13_COMMUNITY_HUB` | api | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
**健康检查:** Go API `GET /health`、Node 渲染服务器 `GET /healthz`,均返回 200,供 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. 用 Compose 编排(`docker-compose.yml`)或手动创建两个容器:`api`(Go,内部 `3001`,挂载 `/data`)与 `web`(Node,映射 `3000:3000`)
2. `web` 容器环境变量 `GO_API_URL` 指向 `api` 容器(如 `http://api:3001`)
3. 数据卷只需挂在 `api` 容器的 `/data`(镜像会自动修正目录权限)
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
### 3. 直接启动(二进制)
Go 二进制仅提供 API,完整站点需同时运行 Node 渲染服务器(构建前端后 `frontend/server/index.mjs`)。最简单的方式是用上面的 Docker Compose,或本地 `make serve` / `build.bat -Target serve` 一键起双进程。
如需手动部署二进制,从 [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)
两个进程一起跑(Go API :3001 + Node :3000 对外):
```bash
# 1) 启动 Go API
./jiang13 --port 3001 --data ./data
# 2) 另开终端,构建并启动 Node 渲染服务器
cd frontend && npm install && npm run build
PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
```
Windows:
```powershell
.\jiang13.exe --port 3001 --data .\data
# 另开 PowerShell:
cd frontend; npm install; npm run build
$env:PORT = '3000'; $env:GO_API_URL = 'http://127.0.0.1:3001'; npm start
```
### 4. 首次使用
1. 浏览器打开 `http://localhost:3000/register` 注册账号
2. **第一个注册的用户自动成为管理员**
3. 登录后访问 `http://localhost:3000/admin` 进入后台
### 配置文件(`app.ini`)
默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。
```ini
[server]
HTTP_PORT = 3000
[paths]
DATA = data
[security]
JWT_SECRET =
```
完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
### 启动参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
| `--config` | `{work-path}/app.ini` | 配置文件路径 |
| `--port` | (读配置 / `3000`) | HTTP 监听端口 |
| `--data` | (读配置 / `data`) | 数据目录 |
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`、`JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
### 5. 注册为系统服务(可选)
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
**Ubuntu / Linux(systemd,需 root):**
```bash
sudo mkdir -p /opt/jiang13
sudo cp jiang13 /opt/jiang13/
sudo /opt/jiang13/jiang13 --service install
sudo /opt/jiang13/jiang13 --service start
sudo systemctl enable jiang13
```
**Windows(Windows Service,需管理员 PowerShell):**
```powershell
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
Copy-Item .\jiang13.exe C:\jiang13\
C:\jiang13\jiang13.exe --service install
C:\jiang13\jiang13.exe --service start
```
> 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。
---
## 技术栈
| 层级 | 技术 |
|------|------|
| **后端** | Go 1.26 · Gin · GORM · SQLite(纯 API 服务) |
| **前端** | React 19 · Vike SSR · react-router · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| **构建** | Vite 8(Rolldown)双构建:`dist/client` 静态资源 + `dist/server` SSR 入口 |
| **渲染** | Node 24 · Express + Vike `renderPage`(:3000 对外,API 反代至 Go :3001) |
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
---
## 前端开发
日常改前端不需要重新完整构建,Vike dev 支持秒级热更新(HMR,热模块替换):
```bat
build.bat -Target dev
```
```bash
make dev
```
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。首页 / 板块 / 帖子详情在 dev 下同样走 SSR(`vike dev`),与生产行为一致;其余页面为 SPA 壳。
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
**何时需要完整构建:**
- 修改 Go 代码 → `build.bat` / `make build`(仅编译 Go API)
- 验证生产 SSR / 发布部署 → `make serve`(构建前端 + 双进程起本地产)
- 更新 README 界面截图 → 见下方「更新截图」
> 开发时请访问 `:5173`;`:3000` 只有在生产模式(Node 渲染服务器)下才有页面。
### 更新截图
默认从演示站抓取到 `docs/screenshots/`(需本机已安装 Playwright):
```bash
npm install -D playwright
npx playwright install chromium
node scripts/capture-screenshots.mjs
```
| 环境变量 | 说明 | 默认 |
|----------|------|------|
| `J13_URL` | 抓取目标 | `https://bbs.iioio.com` |
| `J13_POST_ID` | 详情页帖子 ID | `1` |
| `J13_RICH_POST_ID` | 富文本展示帖 ID | `8` |
| `J13_USER` / `J13_PASS` | 发帖页登录(可选) | `admin` / `admin123` |
本地站点示例:`J13_URL=http://localhost:3000 node scripts/capture-screenshots.mjs`
---
## 项目结构
```
jiang13-forum/
├── cmd/jiang13/ # 程序入口(含系统服务注册)
├── config/ # app.ini 与命令行配置
├── app.ini.example # 配置文件示例
├── Dockerfile # 多阶段构建(前端 + Go API 共用镜像,双容器运行)
├── docker-compose.yml # 双容器 Compose 部署(web :3000 + api :3001)
├── docker-entrypoint.sh # api 容器启动脚本(修正 /data 卷权限)
├── .dockerignore
├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑
├── handler/ # HTTP 处理器(API + /api/home-boot SSR 聚合端点)
├── middleware/ # JWT 鉴权等
├── router/ # 路由注册(仅 API)
├── frontend/
│ ├── pages/ # Vike 页面(feed=SSR 白名单 / spa=catch-all 壳 / _error)
│ ├── renderer/ # Vike 双端渲染入口(onRenderHtml / onRenderClient)
│ ├── server/ # Node 渲染服务器(express + renderPage + API 反代)
│ └── src/ # React 源码(含 src/ssr 服务端取数与作用域管理)
├── docs/screenshots/ # README 界面截图
├── ROADMAP.md # 路线图与已知问题
└── scripts/ # 开发辅助脚本(含截图)
```
---
## 数据目录
```
data/
├── jiang13.db # SQLite 主数据库(不含浏览量)
├── monitor.db # 网站监控 page_views
├── jiang13.log # 运行日志
├── filter_words.txt # 敏感词配置
├── .jwt_secret # JWT 密钥(自动生成)
├── logs/access/ # 网站监控请求日志(按日 jsonl)
├── uploads/avatars/ # 用户头像
├── uploads/posts/ # 帖子正文图片
└── jiang13_backup_*.db # 后台导出的主库备份
```
---
## 开发状态
项目**积极开发中**,欢迎参与共建。完整列表见 **[ROADMAP.md](ROADMAP.md)**。
| 类型 | 示例 |
|------|------|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 |
| ✅ 管理后台 | React SPA:仪表盘、置顶 / 精华、禁言、系统设置 |
| 📋 计划中 | 通知动态优化、邮件提醒 |
---
## 参与贡献
欢迎提交 Issue 和 Pull Request!详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
在线体验与反馈也可直接在演示站进行:[https://bbs.iioio.com/](https://bbs.iioio.com/)
---
## 许可证
[MIT](LICENSE) — 自由使用、修改与分发。

75
ROADMAP.md Normal file
View File

@@ -0,0 +1,75 @@
# 路线图 ROADMAP
> 姜十三论坛仍在积极开发中,功能尚未完善。
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
---
## 开发状态概览
| 模块 | 状态 | 说明 |
|------|------|------|
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
| 评论系统 | ✅ | 换行显示已修复 |
| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(管理后台配置 ROOT_URL 与 OAuth 应用) |
---
## 🐛 已知缺陷(Bug)
_当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_
---
## 📋 计划中(Planned)
| 优先级 | 功能 | 说明 |
|--------|------|------|
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
---
## 🚧 进行中(In Progress)
_当前无公开认领任务。_
---
## ✅ 已完成(Done)
- [x] React 管理后台(仪表盘、板块、帖子、评论、用户、设置)
- [x] 帖子置顶(帖子详情 + 管理后台)
- [x] 评论回复换行正确显示
- [x] 三栏布局 + 虚拟滚动帖列表
- [x] 浅色 / 暗色主题切换
- [x] 移动端响应式适配
- [x] 用户注册登录、JWT 鉴权
- [x] OIDC Provider(对接 Gitea SSO:Discovery / Authorize / Token / UserInfo)
- [x] OAuth 应用管理(密钥哈希、多客户端、登出端点、groups 映射)
- [x] 板块管理、发帖、TipTap 富文本编辑
- [x] 帖子正文图片本地上传
- [x] 帖子修订历史与 diff 对比
- [x] Feed 排序(最新发帖 / 最新回复 / 热门讨论)
- [x] 可配置编辑时限与论坛参数(限流、字数上限等)
- [x] 楼层式评论、引用回复、@ 高亮
- [x] 点赞、收藏、热门帖
- [x] 敏感词过滤、发帖限流
- [x] 站内私信
- [x] 回复提醒与待审提醒(站内消息 + SMTP 邮件)
- [x] SQLite 备份、单二进制部署
---
## 如何参与
1. 在 Issues 挑选任务(预填内容见 [docs/issue-templates.md](docs/issue-templates.md))
2. Fork → 分支 → PR,详见 [CONTRIBUTING.md](CONTRIBUTING.md)
3. 有新想法先开 Issue 讨论,避免重复劳动
---
_最后更新:2026-08-05_

25
app.ini.example Normal file
View File

@@ -0,0 +1,25 @@
; 姜十三论坛 Jiang13 Forum — 配置文件示例(风格类似 Gitea app.ini)
; 复制为程序工作目录下的 app.ini 后修改。也可直接启动程序,首次会自动生成。
; 修改后重启进程/服务生效。命令行 --port / --data 等优先级更高。
; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。
[server]
HTTP_PORT = 3000
[paths]
; 相对路径相对于工作目录(默认可执行文件所在目录)
DATA = data
[security]
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
JWT_SECRET =
; ---------------------------------------------------------------------------
; 维护者选项(默认关闭;普通自托管无需开启)
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
; ---------------------------------------------------------------------------
; [community]
; HUB = false

4
build.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
exit /b %ERRORLEVEL%

179
build.ps1 Normal file
View File

@@ -0,0 +1,179 @@
# Jiang13 Forum - Windows build script (replaces GNU Make)
# Usage: .\build.ps1
# .\build.ps1 -Target build-windows
param(
[ValidateSet('build', 'web-build', 'frontend', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'tidy', 'run', 'serve', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
[string]$Target = 'build'
)
$ErrorActionPreference = 'Stop'
$AppName = 'jiang13'
$MainPkg = './cmd/jiang13'
$BuildDir = 'dist'
$DevDataDir = 'dist/data'
$Version = '1.1.8'
$RegistryImage = 'hangzhang714128/jiang13-forum'
$Ldlags = "-s -w -X main.version=$Version"
function Ensure-Dir($path) {
if (-not (Test-Path $path)) {
New-Item -ItemType Directory -Path $path | Out-Null
}
}
function Build-Frontend {
Write-Host '[frontend] npm run build (Vike: client + server)...' -ForegroundColor Cyan
Push-Location frontend
try {
if (-not (Test-Path node_modules)) {
npm install
}
npm run build
if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' }
} finally {
Pop-Location
}
}
function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
Ensure-Dir $BuildDir
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
# 纯 Go SQLite(glebarez),交叉编译无需 C 工具链
$prevCgo = $env:CGO_ENABLED
$env:CGO_ENABLED = '0'
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
$OutFile = "$OutFile.exe"
}
$outPath = Join-Path $BuildDir $OutFile
Write-Host "[go] build -> $outPath (CGO_ENABLED=0)" -ForegroundColor Cyan
try {
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
Write-Host "[ok] $outPath" -ForegroundColor Green
} finally {
if ($null -eq $prevCgo) {
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
} else {
$env:CGO_ENABLED = $prevCgo
}
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
}
}
switch ($Target) {
'help' {
Write-Host '.\build.ps1 build Go API binary (current platform)'
Write-Host '.\build.ps1 -Target web-build build frontend (Vike client + server)'
Write-Host '.\build.ps1 -Target serve production run: Node :3000 + Go API :3001'
Write-Host '.\build.ps1 -Target build-windows'
Write-Host '.\build.ps1 -Target build-linux'
Write-Host '.\build.ps1 -Target build-all'
Write-Host '.\build.ps1 -Target run Go API only (port 3000, no pages)'
Write-Host '.\build.ps1 -Target dev Vike HMR :5173 + Go API :3000 (recommended for frontend dev)'
Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean'
Write-Host '.\build.ps1 -Target docker build Docker image'
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build'
Write-Host '.\build.ps1 -Target compose-down docker compose down'
Write-Host ''
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
}
'web-build' { Build-Frontend }
'frontend' { Build-Frontend }
'tidy' { go mod tidy }
'clean' {
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
Write-Host '[ok] cleaned dist' -ForegroundColor Green
}
'run' {
Ensure-Dir $DevDataDir
go run $MainPkg --data $DevDataDir
}
'serve' {
Build-Frontend
Build-Go -OutFile $AppName
Ensure-Dir $DevDataDir
$root = (Get-Location).Path
$goExe = Join-Path $root "$BuildDir\$AppName.exe"
Write-Host '[serve] 正在新窗口启动 Go API (:3001)...' -ForegroundColor Cyan
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root'; & '$goExe' --data '$DevDataDir' --port 3001"
) | Out-Null
Start-Sleep -Seconds 2
Write-Host '[serve] 启动 Node 渲染服务器 (:3000)...' -ForegroundColor Cyan
Push-Location frontend
try {
$env:PORT = '3000'
$env:GO_API_URL = 'http://127.0.0.1:3001'
npm start
} finally {
Remove-Item Env:PORT -ErrorAction SilentlyContinue
Remove-Item Env:GO_API_URL -ErrorAction SilentlyContinue
Pop-Location
}
}
'dev' {
$root = (Get-Location).Path
Ensure-Dir $DevDataDir
Write-Host ''
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vike HMR)' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev --data '$DevDataDir'"
) | Out-Null
Start-Sleep -Seconds 2
Push-Location frontend
try {
if (-not (Test-Path node_modules)) { npm install }
npm run dev
} finally {
Pop-Location
}
}
'build' {
Build-Go -OutFile $AppName
}
'build-windows' {
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
}
'build-linux' {
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
}
'build-darwin' {
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
}
'build-all' {
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
Write-Host '[ok] all platforms done' -ForegroundColor Green
}
'docker' {
Write-Host "[docker] build $RegistryImage`:$Version and latest" -ForegroundColor Cyan
docker build --build-arg "VERSION=$Version" -t "${RegistryImage}:$Version" -t "${RegistryImage}:latest" .
if ($LASTEXITCODE -ne 0) { throw 'docker build failed' }
Write-Host '[ok] docker image built' -ForegroundColor Green
}
'compose-up' {
docker compose up -d --build
if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' }
Write-Host '[ok] compose started' -ForegroundColor Green
}
'compose-down' {
docker compose down
if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' }
Write-Host '[ok] compose stopped' -ForegroundColor Green
}
}

48
cmd/jiang13/main.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"fmt"
"log"
"os"
"github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config"
appsvc "git.iioio.com/freefire/jiang13-forum/service"
)
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
var version = "dev"
func main() {
appsvc.SetAppVersion(version)
cfg, err := config.Parse()
if err != nil {
log.Fatalf("配置解析失败: %v", err)
}
svcCfg, err := buildServiceConfig(cfg)
if err != nil {
log.Fatalf("构建服务配置失败: %v", err)
}
prg := &program{cfg: cfg}
svc, err := service.New(prg, svcCfg)
if err != nil {
log.Fatalf("创建系统服务失败: %v", err)
}
if cfg.ServiceAction != "" {
if err := runServiceControl(svc, cfg.ServiceAction); err != nil {
fmt.Fprintf(os.Stderr, "服务操作失败 (%s): %v\n", cfg.ServiceAction, err)
os.Exit(1)
}
return
}
// 交互终端或由服务管理器拉起时均走 Run:
// Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
if err := svc.Run(); err != nil {
log.Fatalf("运行失败: %v", err)
}
}

170
cmd/jiang13/program.go Normal file
View File

@@ -0,0 +1,170 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/kardianos/service"
"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/router"
)
const (
svcName = "jiang13"
svcDisplayName = "姜十三论坛"
svcDescription = "姜十三论坛 Jiang13 Forum — 轻量单二进制论坛服务"
)
// program 实现 kardianos/service.Interface,兼容 Windows Service 与 Linux systemd
type program struct {
cfg *config.Config
server *http.Server
}
func (p *program) Start(s service.Service) error {
if err := p.setup(); err != nil {
return err
}
go func() {
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP 服务异常: %v", err)
}
}()
return nil
}
func (p *program) Stop(s service.Service) error {
log.Println("收到关机信号,正在优雅关闭...")
if p.server == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := p.server.Shutdown(ctx); err != nil {
log.Printf("HTTP 服务关闭异常: %v", err)
return err
}
log.Println("姜十三论坛已安全退出")
return nil
}
func (p *program) setup() error {
cfg := p.cfg
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("打开日志文件失败: %w", err)
}
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
if service.Interactive() {
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
} else {
log.SetOutput(logFile)
}
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Println("========================================")
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
log.Printf(" 版本: %s", version)
log.Println("========================================")
if err := model.InitDB(cfg.DBPath()); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err)
}
if err := 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)
if err != nil {
return fmt.Errorf("路由初始化失败: %w", err)
}
addr := fmt.Sprintf(":%d", cfg.Port)
p.server = &http.Server{
Addr: addr,
Handler: engine,
}
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
if cfg.DevMode {
log.Printf("前端开发服务器: http://localhost:5173 (后台管理: http://localhost:5173/admin/dashboard)")
} else {
log.Printf("前端由 Node 渲染服务器提供(默认 http://localhost:3000),本服务仅提供 API")
}
log.Printf("工作目录: %s", cfg.WorkPath)
log.Printf("配置文件: %s", cfg.ConfigFile)
log.Printf("数据目录: %s", cfg.DataDir)
return nil
}
func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
// 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务
return &service.Config{
Name: svcName,
DisplayName: svcDisplayName,
Description: svcDescription,
WorkingDirectory: cfg.WorkPath,
Arguments: []string{
"--work-path", cfg.WorkPath,
"--config", cfg.ConfigFile,
},
Option: service.KeyValue{
// systemd:异常退出后自动拉起
"Restart": "always",
// Windows:崩溃后重启
"OnFailure": "restart",
},
}, nil
}
func runServiceControl(s service.Service, action string) error {
if action == "status" {
st, err := s.Status()
if err != nil {
return err
}
switch st {
case service.StatusRunning:
fmt.Println("服务状态: 运行中 (running)")
case service.StatusStopped:
fmt.Println("服务状态: 已停止 (stopped)")
default:
fmt.Println("服务状态: 未知 (unknown)")
}
return nil
}
if err := service.Control(s, action); err != nil {
return err
}
switch action {
case "install":
fmt.Println("服务已安装。可用以下命令启动:")
fmt.Printf(" %s --service start\n", os.Args[0])
fmt.Println("或使用系统工具:")
fmt.Println(" Linux: sudo systemctl start jiang13 && sudo systemctl enable jiang13")
fmt.Println(" Windows: Start-Service jiang13")
case "uninstall":
fmt.Println("服务已卸载")
case "start":
fmt.Println("服务已启动")
case "stop":
fmt.Println("服务已停止")
case "restart":
fmt.Println("服务已重启")
}
return 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)
}

268
config/config.go Normal file
View File

@@ -0,0 +1,268 @@
package config
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
const (
StorageTypeLocal = "local"
StorageTypeS3 = "s3"
)
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
type Config struct {
// 工作目录(默认可执行文件所在目录)
WorkPath string
// 配置文件绝对路径
ConfigFile string
// 监听端口
Port int
// 数据目录:SQLite、上传、日志(绝对路径)
DataDir string
// JWT 签名密钥
JWTSecret string
// 日志文件路径
LogFile string
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
ServiceAction string
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
DevMode bool
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
CommunityHub bool
}
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
//
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
func Parse() (*Config, error) {
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)")
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
flag.Parse()
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
if action != "" && !validServiceAction(action) {
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
}
workPathInput := strings.TrimSpace(*workFlag)
if workPathInput == "" {
workPathInput = envOrDefault(envWorkPath)
}
workPath, err := resolveWorkPath(workPathInput)
if err != nil {
return nil, err
}
configInput := strings.TrimSpace(*configFlag)
if configInput == "" {
configInput = envOrDefault(envConfig)
}
configFile, err := resolveConfigPath(workPath, configInput)
if err != nil {
return nil, err
}
fileCfg := defaultFileSettings()
configExists := false
if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
configExists = true
fileCfg, err = loadAppINI(configFile)
if err != nil {
return nil, err
}
}
port := fileCfg.Port
if p := envIntOrZero(envHTTPPort); p > 0 {
port = p
}
if *portFlag > 0 {
port = *portFlag
}
dataInput := fileCfg.DataRel
if v := envOrDefault(envData); v != "" {
dataInput = v
}
if strings.TrimSpace(*dataFlag) != "" {
dataInput = *dataFlag
}
absData, err := absPath(workPath, dataInput)
if err != nil {
return nil, fmt.Errorf("解析数据目录失败: %w", err)
}
jwtSecret := fileCfg.JWTSecret
if v := envOrDefault(envJWTSecret); v != "" {
jwtSecret = v
}
if strings.TrimSpace(*jwtFlag) != "" {
jwtSecret = strings.TrimSpace(*jwtFlag)
}
communityHub := fileCfg.CommunityHub
if v := envBoolOrNil(envCommunityHub); v != nil {
communityHub = *v
}
cfg := &Config{
WorkPath: workPath,
ConfigFile: configFile,
Port: port,
DataDir: absData,
JWTSecret: jwtSecret,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
DevMode: *devFlag,
CommunityHub: communityHub,
}
needDirs := action == "" || action == "install"
if needDirs {
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
if !configExists {
dataRel := resolveDataRelForINI(workPath, absData)
if err := writeAppINI(configFile, fileSettings{
Port: port,
DataRel: dataRel,
}); err != nil {
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
}
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
} else if action == "install" {
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
dataRel := resolveDataRelForINI(workPath, absData)
iniJWT := fileCfg.JWTSecret
if strings.TrimSpace(*jwtFlag) != "" {
iniJWT = jwtSecret
}
if err := writeAppINI(configFile, fileSettings{
Port: port,
DataRel: dataRel,
JWTSecret: iniJWT,
}); err != nil {
return nil, fmt.Errorf("更新配置文件失败: %w", err)
}
}
if err := ensureDataDirs(absData); err != nil {
return nil, err
}
if err := cfg.resolveJWT(); err != nil {
return nil, err
}
}
return cfg, nil
}
func resolveWorkPath(flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
abs, err := filepath.Abs(flagVal)
if err != nil {
return "", fmt.Errorf("解析工作目录失败: %w", err)
}
return filepath.Clean(abs), nil
}
return defaultWorkPath()
}
func resolveConfigPath(workPath, flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
return absPath(workPath, flagVal)
}
return filepath.Join(workPath, defaultConfName), nil
}
func ensureDataDirs(dataDir string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("创建数据目录失败: %w", err)
}
for _, sub := range []string{
filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"),
filepath.Join(dataDir, "uploads", "site"),
filepath.Join(dataDir, "logs", "access"),
} {
if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err)
}
}
return nil
}
func (c *Config) resolveJWT() error {
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
if c.JWTSecret != "" {
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
return nil
}
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
c.JWTSecret = string(data)
return nil
}
c.JWTSecret = generateRandomSecret(32)
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
}
return nil
}
func validServiceAction(action string) bool {
switch action {
case "install", "uninstall", "start", "stop", "restart", "status":
return true
default:
return false
}
}
// DBPath 返回主库 SQLite 路径
func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db")
}
// MonitorDBPath 返回监控独立库路径(page_views)
func (c *Config) MonitorDBPath() string {
return filepath.Join(c.DataDir, "monitor.db")
}
// AvatarUploadDir 返回头像上传目录
func (c *Config) AvatarUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars")
}
// PostImageUploadDir 返回帖子正文图片上传目录
func (c *Config) PostImageUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "posts")
}
// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
func (c *Config) SiteUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "site")
}
// FilterWordsPath 返回敏感词配置文件路径
func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt")
}
func generateRandomSecret(n int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = chars[i%len(chars)]
}
return string(b)
}

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

114
config/ini.go Normal file
View File

@@ -0,0 +1,114 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/ini.v1"
)
const (
defaultPort = 3000
defaultDataRel = "data"
defaultConfName = "app.ini"
)
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
type fileSettings struct {
Port int
DataRel string
JWTSecret string
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
}
func defaultFileSettings() fileSettings {
return fileSettings{
Port: defaultPort,
DataRel: defaultDataRel,
}
}
func loadAppINI(path string) (fileSettings, error) {
out := defaultFileSettings()
cfg, err := ini.LoadSources(ini.LoadOptions{
IgnoreInlineComment: true,
}, path)
if err != nil {
return out, fmt.Errorf("读取配置文件失败: %w", err)
}
if sec, err := cfg.GetSection("server"); err == nil {
if k := sec.Key("HTTP_PORT"); k.String() != "" {
p, err := k.Int()
if err != nil {
return out, fmt.Errorf("server.HTTP_PORT 无效: %w", err)
}
if p <= 0 || p > 65535 {
return out, fmt.Errorf("server.HTTP_PORT 超出范围: %d", p)
}
out.Port = p
}
}
if sec, err := cfg.GetSection("paths"); err == nil {
if v := strings.TrimSpace(sec.Key("DATA").String()); v != "" {
out.DataRel = v
}
}
if sec, err := cfg.GetSection("security"); err == nil {
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
}
// writeAppINI 写入/覆盖 app.ini(安装服务或首次生成时使用)
func writeAppINI(path string, s fileSettings) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
var b strings.Builder
b.WriteString("; 姜十三论坛 Jiang13 Forum — 配置文件(风格类似 Gitea app.ini)\n")
b.WriteString("; 修改后重启进程/服务生效。命令行参数优先级高于本文件。\n")
b.WriteString("; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。\n")
b.WriteString(";\n")
b.WriteString("; 默认位置:程序工作目录下的 app.ini\n")
b.WriteString("; 可用 --config / --work-path 覆盖。\n")
b.WriteString("\n")
b.WriteString("[server]\n")
b.WriteString("HTTP_PORT = ")
b.WriteString(strconv.Itoa(s.Port))
b.WriteString("\n\n")
b.WriteString("[paths]\n")
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
b.WriteString("DATA = ")
b.WriteString(s.DataRel)
b.WriteString("\n\n")
b.WriteString("[security]\n")
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)\n")
b.WriteString("JWT_SECRET = ")
b.WriteString(s.JWTSecret)
b.WriteString("\n")
return os.WriteFile(path, []byte(b.String()), 0644)
}
// resolveDataRelForINI 把绝对数据目录尽量写成相对工作目录的路径,便于 app.ini 可读
func resolveDataRelForINI(workPath, absData string) string {
rel, err := filepath.Rel(workPath, absData)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return absData
}
return filepath.ToSlash(rel)
}

69
config/paths.go Normal file
View File

@@ -0,0 +1,69 @@
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// resolveAppPath 返回可执行文件的绝对路径(解析符号链接)
func resolveAppPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("获取可执行文件路径失败: %w", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return "", fmt.Errorf("解析可执行文件路径失败: %w", err)
}
return exe, nil
}
// defaultWorkPath 类似 Gitea:默认可执行文件所在目录;
// go run 时二进制在临时目录,回退为当前工作目录。
func defaultWorkPath() (string, error) {
exe, err := resolveAppPath()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
if isEphemeralExeDir(dir) {
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("获取当前目录失败: %w", err)
}
return filepath.Clean(wd), nil
}
return dir, nil
}
func isEphemeralExeDir(dir string) bool {
lower := strings.ToLower(filepath.Clean(dir))
sep := string(filepath.Separator)
markers := []string{
sep + "go-build",
sep + "go-run",
}
if runtime.GOOS == "windows" {
markers = append(markers, "\\go-build", "\\go-run")
}
for _, m := range markers {
if strings.Contains(lower, strings.ToLower(m)) {
return true
}
}
return false
}
func absPath(base, p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", fmt.Errorf("路径为空")
}
if filepath.IsAbs(p) {
return filepath.Clean(p), nil
}
return filepath.Abs(filepath.Join(base, p))
}

57
docker-compose.yml Normal file
View File

@@ -0,0 +1,57 @@
# 姜十三论坛 — Docker Compose 双服务部署
# 架构:web(Node :3000 对外:Vike SSR + 静态资源 + API 反代) → api(Go :3001 内部:纯 API + /data 数据卷)
# 启动:docker compose up -d --build
# 或:make compose-up / build.bat -Target compose-up
services:
api:
image: hangzhang714128/jiang13-forum:latest
build:
context: .
dockerfile: Dockerfile
args:
VERSION: dev
command: ["--work-path", "/app", "--data", "/data", "--port", "3001"]
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
expose:
- "3001"
restart: unless-stopped
stop_grace_period: 15s
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
web:
image: hangzhang714128/jiang13-forum:latest
entrypoint: ["node", "server/index.mjs"]
working_dir: /app/frontend
user: "1000:1000"
ports:
- "3000:3000"
environment:
TZ: Asia/Shanghai
PORT: "3000"
GO_API_URL: http://api:3001
depends_on:
api:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
jiang13-data:

16
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
set -e
# api 容器入口:默认运行 Go API(web 容器在 compose 中以 node 用户直接覆盖 entrypoint)。
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
# 而应用以 uid 1000(node 镜像内置用户)运行,需在启动时修正 /data 权限后降权。
DATA_DIR="/data"
if [ "$(id -u)" = '0' ]; then
mkdir -p "$DATA_DIR"
chown -R 1000:1000 "$DATA_DIR"
exec gosu 1000:1000 /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 里假装有两个独立数据根。

158
docs/introduction.md Normal file
View File

@@ -0,0 +1,158 @@
# 姜十三论坛:为小圈子而生的轻量论坛
> 轻量 · 好看 · 单文件部署
> 面向内部交流,不堆功能,把「能聊、好看、好装」做到位。
---
## 它是什么
**姜十三论坛(Jiang13 Forum)** 是一款面向小圈子、团队与兴趣社群的现代化论坛软件。
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:前端 SPA(单页应用)通过 `go:embed` 内嵌,数据库用内置 **SQLite**,拷贝到服务器就能跑——没有复杂的中间件矩阵,也没有「先装一堆依赖再祈祷能起来」的仪式感。
---
## 为什么做它
市面上的论坛方案往往落在两端:
- **重型产品**(Discourse、phpBB 等):功能完整,但部署与运维成本高,对小团队过重。
- **即时通讯 / 文档工具**:适合聊天与协作,却缺少「发帖—回复—沉淀」的社区节奏。
小圈子真正需要的,常常是中间态:
- 有板块、帖子、楼层回复,讨论可追溯;
- 界面清爽、信息密度够高,桌面与手机都好用;
- 部署简单到「一个文件 + 一份配置」;
- 数据在自己手里,备份一眼能懂。
姜十三论坛正是为这个缺口而生。
---
## 谁适合用
| 场景 | 说明 |
|------|------|
| 团队 / 工作室内部论坛 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 开源 / 私有项目配套社区 | 与 Gitea 等工具通过 OIDC(开放身份连接)做 SSO(单点登录) |
| 想自己托管的个人站长 | 单机即可,无需云数据库与一堆微服务 |
如果你需要百万用户级的公网社区、复杂插件生态或企业级工单流,它可能不是最优选;如果你要的是 **小而美、自己能装、自己能管**,它会对胃口。
---
## 用起来是什么感觉
### 浏览:高密度、不臃肿
前台采用熟悉的 **三栏布局**:
- **左栏**:板块导航,可折叠;
- **中间**:帖子 Feed(信息流),支持虚拟滚动,长列表依然流畅;
- **右栏**:热门帖与最新评论,社区动态一目了然。
Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗色主题一键切换,并会记住你的偏好。平板与手机上侧栏会自动收起,触控浏览同样顺手。
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
### 发帖:富文本,够用就好
发帖使用 **TipTap** 富文本编辑器:标题、排版、标签、板块选择、正文图片本地上传,日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**,可做 diff(差异)对比。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
### 讨论:楼层回复,聊得清楚
评论是楼层式的:可回复指定楼层、引用原文、@ 高亮。点赞、收藏、热门帖,把活跃内容自然推到前面。
### 管理:后台也是 SPA
管理后台统一在 `/admin`,与前台同一套 React 体验:
- 仪表盘、板块与帖子管理
- 用户禁言、删帖删评
- 论坛参数、限流、敏感词
- SQLite **一键备份**
权限模型很简单:**普通用户 / 管理员**;站点 **第一个注册用户自动成为管理员**,省去安装向导里的一堆步骤。
---
## 部署:一个文件,真正开箱
这大概是姜十三论坛最「硬核」的卖点之一。
```text
编译 → 得到一个 jiang13(或 jiang13.exe)
放到目录里运行 → 自动生成 app.ini
打开浏览器注册 → 第一个账号就是管理员
```
要点:
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
- **app.ini 配置**:风格类似 Gitea,端口与数据目录一眼能改;业务项在管理后台配置;
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
典型启动后访问 `http://localhost:3000`,注册即可开始。
---
## 技术素描(给好奇的人)
| 层级 | 技术 |
|------|------|
| 后端 | Go · Gin · GORM · SQLite |
| 前端 | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| 构建 | Vite 构建 SPA,再由 `go:embed` 打进二进制 |
| 认证 | bcrypt + JWT Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:前后端分离开发,`dev` 模式下 Vite HMR(热模块替换)可秒级预览前端改动。
---
## 现在走到哪一步
项目仍在积极开发中,核心体验已经可用:
- ✅ 三栏布局、主题切换、虚拟滚动、Feed 排序
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史
- ✅ React 管理后台与论坛参数配置
- ✅ OIDC Provider(可作 Gitea 等站点的登录源)
- ✅ 单二进制部署与系统服务
计划中的方向包括通知动态优化、搜索增强、邮件提醒等——完整列表见仓库 [ROADMAP.md](../ROADMAP.md)。
---
## 一句话总结
**姜十三论坛** = 小圈子论坛该有的功能 × 清新好用的界面 × 真正能单文件带走的部署方式。
如果你正在给团队或同好找一个「自己的小论坛」,不妨编译跑一下:注册第一个账号,从发第一帖开始。
---
## 快速上手
```bash
# Windows
.\build.bat
# Linux / macOS
make build
# 运行
./dist/jiang13 # 或 Windows: .\dist\jiang13.exe
```
浏览器打开 `http://localhost:3000/register`,第一个注册用户即为管理员。
更多细节(配置项、系统服务、前端开发)见项目 [README](../README.md)。欢迎通过 Issue / PR 参与共建。
**许可证:** MIT

92
docs/issue-templates.md Normal file
View File

@@ -0,0 +1,92 @@
# Issue 预填模板
以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。
---
## Issue #1 · 评论回复换行不显示
**标题:** `[Bug] 评论回复换行不显示`
**标签:** `bug` `ui/ux`
**正文:**
### 问题描述
在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。
### 复现步骤
1. 打开任意帖子详情页(如 `/post/2`)
2. 在底部评论框输入:
```
第一行
第二行
第三行
```
3. 点击发送
4. 查看刚发布的评论
### 期望行为
评论正文按输入时的换行分段显示,行与行之间有明显间隔。
### 实际行为
多行内容被渲染成单行连续文字。
### 相关代码
- `frontend/src/components/CommentContent.tsx`
- `frontend/src/utils/content.ts`(`highlightMentions` 中的 `\n` → `<br>` 转换)
- `frontend/src/styles/global.css`(`.floor-body` 的 `white-space: pre-wrap`)
### 可能原因
- 仅处理了 `\n`,未处理 Windows 的 `\r\n`
- `dangerouslySetInnerHTML` 与 `pre-wrap` 样式叠加导致表现异常
- 服务端 `strings.TrimSpace` 或其他处理误删换行(待排查)
### 环境
- 前台:React SPA(`:3000` 嵌入版或 `:5173` 开发版)
- 浏览器:Chrome / Edge 最新版
---
## Issue #2 · 示例:管理能力扩展(模板文案)
**标题:** `[Feature] 管理后台增加某某能力`
**标签:** `enhancement` `ui/ux` `good first issue`
**正文:**
### 要解决的问题
描述管理员在 React SPA 管理后台 / 前台中缺少的操作入口或能力。
### 现状
| 能力 | 状态 |
| --- | --- |
| 数据模型与业务逻辑 | ✅ / ❌ |
| JSON API(如 `POST /api/admin/...`) | ✅ / ❌ |
| React 管理后台入口 | ✅ / ❌ |
| React 前台操作入口(如适用) | ✅ / ❌ |
### 期望方案
1. 在对应页面为管理员增加操作入口(仅 `role === 'admin'` 可见)
2. 调用已有或新增的 `/api/admin/*` JSON API
3. 成功后刷新列表/详情,无需离开当前页面
### 相关代码
- 后端:`service/`、`handler/api.go`、`router/router.go`
- 前端:`frontend/src/pages/admin/`、`frontend/src/api/client.ts`
### 备注
适合作为 `good first issue` 时,优先选择 API 已就绪、只需补 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

46
docs/site-posts/README.md Normal file
View File

@@ -0,0 +1,46 @@
# 站务公告 · 发帖素材
## 板块设置
| 项 | 建议值 |
| --- | --- |
| 名称 | 站务公告 |
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
| 图标 | `megaphone` |
| 色标 | 建议第 0 或 1 槽 |
| 排序 | `0` |
## 主文(必发)
| 文件 | 标题 | 标签 | 操作 |
| --- | --- | --- | --- |
| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) |
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
## 自定义单页(推荐)
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
| 文件 | 标题 | slug | 操作 |
| --- | --- | --- | --- |
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
访问路径:`/page/changelog`。
## 部署指南(置顶帖)
| 文件 | 标题 | 标签 | 操作 |
| --- | --- | --- | --- |
| [deployment-guide.md](./deployment-guide.md) | 从零开始部署姜十三论坛 | `部署` `教程` | **置顶**(可选精华) |
覆盖:服务器选型、宝塔 / 1Panel、Docker / 二进制、域名 HTTPS、备份升级、常见问题。
## 可选短文
若站务区希望不止一篇,见 [optional.md](./optional.md):
- [community-guidelines.md](./community-guidelines.md) — 发帖约定
- [feedback.md](./feedback.md) — 反馈与建议
复制正文时:从各文件第一个 `---` **之后**开始粘贴。

View File

@@ -0,0 +1,185 @@
# 关于本站 · 姜十三论坛
> 建议标题:关于本站 · 姜十三论坛
> 建议标签:`站务` `介绍`
> 建议操作:置顶(可选精华)
---
你好,欢迎加入本站。
这里运行的是开源论坛 **姜十三论坛(Jiang13 Forum)**——面向小圈子、团队与同好社群,目标很明确:
**能聊 · 好看 · 好装**
下文介绍它是什么、怎么上手、有哪些能力,以及为何采用单文件部署。读完即可开始发帖;细节可随时回来查阅。
---
## 一、它是什么
姜十三论坛不是大而全的社区平台,而是一款轻量、现代化的论坛软件。
它做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
- 有板块、帖子、楼层回复,讨论能沉淀、能追溯
- 界面清爽,信息密度适中,桌面与手机都好用
- 单个程序即可部署,备份路径清晰
技术上,它是一个 **Go 单二进制**:前端页面内嵌,数据库用内置 **SQLite**。拷到服务器、启动、注册——就可以用。
### 谁适合用
| 场景 | 说明 |
| --- | --- |
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 项目配套社区 | 可与 Gitea 等做 SSO 联动 |
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
若你需要百万级公网社区、复杂插件生态或企业级工单流,它可能不是最优选。
若你要的是 **小而美、自己能装、自己能管**,就对了。
### 本板块说明
「站务公告」只发站务与介绍类内容。日常聊天、提问、分享,请到其他讨论板块。
---
## 二、三分钟上手
### 注册与登录
1. 打开注册页,填写用户名、邮箱与密码。
2. 若已开启邮件服务,可能需要邮箱验证码。
3. **第一个注册用户自动成为管理员**,无需安装向导。
登录后,右上角会出现头像与私信入口。
### 认识首页
| 区域 | 内容 |
| --- | --- |
| 左侧 | 板块导航、常用入口 |
| 中间 | 帖子列表(Feed) |
| 右侧 | 热门帖、最新评论等 |
列表可按 **最新发帖 / 最新回复 / 热门** 切换。
支持浅色 / 暗色主题,偏好会记住。手机上侧栏会收起,不影响浏览与发帖。
### 发帖与回复
点「发帖」→ 选板块 → 写标题、标签(可选)与正文。
正文支持富文本与图片上传。评论是 **楼层式** 的,可回复、引用、@。
帖子可点赞、收藏;公开主页可展示你的资料与动态。
### 个人入口
头像菜单常见项:个人主页、账号设置、站内私信、我的收藏。
### 管理后台(仅管理员)
访问 `/admin`:板块、帖子、评论、举报、用户、媒体、系统设置,以及备份等。
| 改什么 | 去哪里 |
| --- | --- |
| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) |
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
逛完板块,发第一帖或回一楼——聊起来就对了。
---
## 三、功能速览
功能刻意收在「小圈子真正用得到」的范围内。
### 浏览与发帖
- 三栏布局,桌面 / 移动自适应
- Feed 排序与虚拟滚动
- 浅色 / 暗色主题;板块图标与主题色可配
- TipTap 富文本、标签、正文图片
- 楼层评论:回复、引用、@
- 点赞、收藏;置顶 / 精华
- 编辑修订历史(可对比差异)
- 可配置普通用户的编辑时限
### 社交与个人
- 公开用户主页
- 会话式站内私信
- 昵称、签名、头像等资料
### 管理后台
与前台同一套 React 体验,统一在 `/admin`:
| 模块 | 能力 |
| --- | --- |
| 仪表盘 | 站点概览 |
| 板块 | 创建、编辑、排序、外观 |
| 帖子 | 审核、置顶、精华、锁定、回收站 |
| 评论 / 举报 | 审核与处理 |
| 用户 | 禁言等 |
| 媒体 | 查看与清理上传 |
| 设置 | 品牌、限流、敏感词、邮件、OIDC、存储等 |
| 备份 | SQLite 一键备份 |
### 可选能力
按需在后台开启,不是装完就必须用:
- 邮件验证码注册
- OIDC Provider(可作 Gitea 等登录源)
- Gitea 公开仓库同步到「开源码桶」
- 本地磁盘或 S3 兼容对象存储
### 权限
只有两种角色:**普通用户** / **管理员**。
第一个注册用户即管理员。小圈子够用,不堆角色矩阵。
项目仍在积极开发。完整规划见仓库 `ROADMAP.md`,欢迎反馈与共建。
---
## 四、为什么是单文件部署
最硬核的一点,是部署路径极短——不是「理论上能打包」,而是日常用法就是:
1. 编译得到一个二进制
2. 放到目录里运行
3. 自动生成 `app.ini`
4. 浏览器注册,第一个账号即管理员
### 它补哪块空缺
论坛方案常落在两端:很重(功能全、运维贵),或很碎(聊天 / 文档方便,却缺少发帖沉淀)。
小圈子往往需要中间态:有论坛结构、界面顺手、一个人能装、备份好懂。单文件部署,就是为后两点服务的。
### 具体意味着什么
| 能力 | 说明 |
| --- | --- |
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
| 内置 SQLite | 零外部数据库,数据在本地目录 |
| 精简 `app.ini` | 主要管端口、数据目录、JWT |
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
| 系统服务 | 内置 Linux systemd / Windows Service |
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
进程级项改 `app.ini` 后重启;业务项在管理后台改。既保留「改文件控进程」的可控性,又避免把所有开关塞进配置文件。
---
## 写在最后
**姜十三论坛** = 小圈子该有的论坛能力 × 清新界面 × 真正能单文件带走的部署。
源码与说明见项目仓库。许可证:MIT。
有问题可联系管理员。祝你在这里聊得愉快。

View File

@@ -0,0 +1,193 @@
# 更新日志
> 建议标题:更新日志
> 建议 slug:`changelog`
> 建议路径:`/page/changelog`
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
> 复制正文时:从下方第一个 `---` **之后**开始粘贴(Markdown 模式)
---
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
**当前版本:1.1.8**(2026-09-02)
## 去哪看新版本
| 渠道 | 看什么 | 地址 |
| --- | --- | --- |
| 本页 | 版本号、变更说明、升级注意 | 本站 `/page/changelog` |
| Docker | 镜像标签(`latest` 与 `1.1.x`) | [Docker Hub · Tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags) |
| Windows / Linux / macOS | 预编译单文件(与 Docker 同版本) | [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) |
| 源码 | 提交记录与自行编译 | [Gitea 仓库](https://git.iioio.com/freefire/jiang13-forum) |
**1.1.8 直接下载:**
- Windows x64:[jiang13_v1.1.8_windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_windows-amd64.exe)
- Linux x64:[jiang13_v1.1.8_linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64)
- macOS arm64:[jiang13_v1.1.8_darwin-arm64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_darwin-arm64)
拉取指定版本镜像:
```bash
docker pull hangzhang714128/jiang13-forum:1.1.8
# 或始终跟随最新
docker pull hangzhang714128/jiang13-forum:latest
```
Windows:停掉正在跑的进程或服务,用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`),**不要动**旁边的 `data/` 与 `app.ini`,再启动即可。
Linux:赋予执行权限后放到原目录覆盖,同样保留 `data/` 与 `app.ini`。
## 升级注意
- 一般只需换镜像或换 exe,数据目录向后兼容。
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
- 升级后若页面样式异常,强制刷新浏览器(Ctrl+F5)。程序也会在前端资源失效时自动硬刷新。
---
## 📦 v1.1.8 · 2026-09-02
表情选择与评论富文本展示完善,私信表情不再发成图片附件。
**新增 / 调整**
- 表情选择器在评论富文本中可直接插入,与文章编辑器一致
- 私信页支持表情选择器,点击表情直接插入文本框
**修复**
- 私信中插入的表情改为文本内容发送,不再作为图片附件
- 消息未读数在多端间同步更及时
- 帖子详情快照逻辑修复,避免打开旧缓存页
---
## 📦 v1.1.7 · 2026-09-02
私信与开源展柜体验升级,待审通知的审核态更及时。
**新增 / 调整**
- 私信改为飞书风布局:缩略图表情/图片、按会话草稿、用户搜索发起会话
- 主页「发私信」直达消息页对应会话
- 开源展柜改为目录式 UI,空列表不再粘滞
- 消息页体验重构;待审通知打开后实时回填审核态
**修复**
- 帖子详情右栏默认头像改为深绿底白字,与列表默认头像配色一致
---
## 📦 v1.1.6 · 2026-09-01
编辑器插入体验与首页默认 Feed 排序更合理。
**新增 / 调整**
- 链接对话框支持网址、链接文字与站内帖子/单页搜索;站内搜索收进「高级选项」,点选同步链接文字
- 文章与评论共用统一图片插入选择器(上传 / 链接 / 已上传)
- 链接是否新标签打开交由全站设置,对话框不再单独开关
- 首页默认「最新」按发帖与最后评论的较晚时间混排,零回复新帖不再沉底
- 单页详情右侧栏与帖子共用目录布局;三栏主滚动区统一细绿色滚动条
**修复**
- 帖子目录从正文同步派生,避免预取缓存竞态导致空树
---
## 📦 v1.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 一致,减少闪动
---
## 📦 v1.1.4 · 2026-08-31
网站监控的地理信息与口径更准确。
- 补全 IP2Location(IP 地理位置库)中国城市中文名,同名城市按省消歧
- 对齐监控概览「双通道」口径文案与诊断抽样说明
---
## 📦 v1.1.3 · 2026-08-31
管理后台可看站点访问情况。
- 新增管理端 **网站监控**:浏览量写入独立 `monitor.db`,与主库分开
---
## 📦 v1.1.2 · 2026-08-31
可选接入官方社区展柜。
- 可选社区上报,以及官方精选展柜(自建站可出现在官方社区列表中)
---
## 📦 v1.1.1 · 2026-08-31
- 评论管理操作收进「更多」菜单,管理员界面更干净
---
## 📦 v1.1.0 · 2026-08-30
一批社区功能与界面整理,版本号从 1.0 跨到 1.1。
**新增**
- 友链申请、独立友链页;页脚左右区域可分别开关
- 自定义单页(后台「单页管理」,如本页)
- 投票帖、悬赏帖、抽奖帖
- 侧栏签到;右侧栏「最新注册」
- 搜索重设计:筛选面板、结果页 chips(筛选标签)
- 帖子管理收进右上角菜单;手机端评论输入默认折叠
**优化**
- 首页帖子列表密度与标题样式
- 开源码桶展示加强,去掉 Feed 顶栏统计
- 单页编辑、列表留白与友链申请体验
- 开发与发行版共用 `dist/data` 数据目录,避免两套数据打架
**修复**
- 手机端搜索入口与底部 Sheet(抽屉面板)
- 占位头像对比度;无头像用户不再误用游客灰底
- 页面双指缩放锁死,正文原图与灯箱仍可 pinch(捏合)放大
- 单页正文排版与帖子详情对齐
---
## 📦 v1.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

@@ -0,0 +1,30 @@
# 本站发帖约定
> 建议标题:本站发帖约定
> 建议标签:`站务` `公约`
> 建议操作:普通发布(可选)
---
本站是小圈子论坛,约定尽量少、好遵守。
## 请这样做
- 选对板块再发;不确定就发到综合讨论类板块
- 标题写清楚在说什么,方便后人搜索
- 讨论对事不对人;引用、回复尽量指向具体楼层
- 分享链接时简单说明来源与用途
## 请避免
- 在「站务公告」灌水、闲聊(请到其他板块)
- 无意义刷屏、重复灌帖
- 人身攻击、骚扰、泄露他人隐私
- 明显的广告、引流(除非板块明确允许)
## 管理说明
管理员可能会:移动板块、编辑标题、锁定、删除或禁言。
有争议可私信管理员说明情况。
一句话:**把讨论留给值得留下的内容。**

View File

@@ -0,0 +1,244 @@
# 从零开始部署姜十三论坛
> 建议标题:从零开始部署姜十三论坛
> 建议 slug:`deploy`
> 建议操作:发布后设为「置顶」
> 复制正文时:从下方第一个 `---` **之后**开始粘贴
---
## 为什么选姜十三
单二进制 + 内置 SQLite,**不需要 MySQL、PHP、Redis**。面板(宝塔 / 1Panel)只是帮你更方便地管理,不是必须的。
## 服务器选型
| 部署方式 | CPU | 内存 | 磁盘 |
| --- | --- | --- | --- |
| 纯二进制(无面板) | 1 核 | 512MB | 10GB SSD |
| 二进制 + 宝塔 | 1 核 | 1GB | 20GB SSD |
| Docker + 1Panel | 2 核 | 2GB | 40GB SSD |
> 推荐 Debian 12 / Ubuntu 22.04。建议直接上 2 核 2G。
## 部署方式对比
| | 1Panel + Docker | 宝塔 + 二进制 | 纯命令行 Docker |
| --- | --- | --- | --- |
| 适合 | 新手 / 还要跑其他容器 | 习惯宝塔 / 只跑这一个 | 熟悉 Linux / 资源紧张 |
| 资源占用 | 较高(自带 Docker) | 低 | 最低 |
| HTTPS | 面板一键 | 面板一键 | Caddy / certbot |
| 备份 | 面板计划任务 | 面板计划任务 | `tar` 打包 |
| 升级 | 面板点一下 | 替换二进制 | `docker pull` + 重启 |
## 前置准备
```bash
apt update && apt upgrade -y
apt install -y curl wget ca-certificates
timedatectl set-timezone Asia/Shanghai
```
---
## 方式一:1Panel + Docker
### 1. 安装 1Panel
```bash
curl -sSL https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh \
-o quick_start.sh && bash quick_start.sh
```
- 安装目录:默认 `/opt`
- 是否装 Docker:**y**(必须)
- 面板端口 / 安全入口 / 账号密码自定义,记好
安全组放行设置的端口,然后浏览器打开面板。
### 2. 启动姜十三
1Panel 面板 → 容器 → 创建:
| 字段 | 值 |
| --- | --- |
| 镜像 | `hangzhang714128/jiang13-forum:latest` |
| 端口 | `3000:3000` |
| 数据卷 | 主机目录 → 容器内 **`/data`** |
| 重启策略 | `unless-stopped` |
浏览器访问 `http://IP:3000` → **首个注册用户自动成为管理员**。
> 若容器报 `permission denied`:`chown -R 1000:1000 /数据目录`
---
## 方式二:宝塔 + 二进制
### 1. 安装宝塔
```bash
# 正式版 13.0.0(含 AI,需绑定账号)
if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh KZOqff1mn
# 稳定版 12.0.0(纯净、免绑定)
url=https://download.bt.cn/install/installStable_12.sh;if [ -f /usr/bin/curl ];then curl -sSO $url;else wget -O installStable_12.sh $url;fi;bash installStable_12.sh 5x59vxy56
```
安全组放行随机端口,浏览器打开(自签名证书提示 → 高级 → 继续前往)。
### 2. 下载并注册服务
```bash
mkdir -p /opt/jiang13 && cd /opt/jiang13
# 版本号以更新日志为准:https://bbs.iioio.com/page/update-log.html
wget -O jiang13 https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64
chmod +x jiang13
# 注册为 systemd 服务(install 创建 unit 文件,start 启动,enable 开机自启)
./jiang13 -service install
./jiang13 -service start
systemctl enable jiang13
# 日常管理
./jiang13 -service status # 查看状态
./jiang13 -service restart # 重启
# 验证
curl http://127.0.0.1:3000/health
```
> 只是临时跑一下?直接 `./jiang13` 前台启动即可,Ctrl+C 停止,不用 `install`。
### 2.1 升级二进制
```bash
cd /opt/jiang13
./jiang13 -service stop
wget -O jiang13.new https://git.iioio.com/freefire/jiang13-forum/releases/download/v新版本/jiang13_v新版本_linux-amd64
mv jiang13.new jiang13 && chmod +x jiang13
./jiang13 -service start
```
### 3. 宝塔反代 + SSL
网站 → 添加站点(域名,PHP 选**纯静态**)→ 反向代理(目标 `http://127.0.0.1:3000`)→ SSL → Let's Encrypt 一键签发 → 强制 HTTPS。
---
## 方式三:纯 Docker
```bash
# 安装 Docker
bash <(curl -sSL https://linuxmirrors.cn/docker.sh)
# 启动姜十三
docker run -d \
--name jiang13 \
-p 3000:3000 \
-v /opt/jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
```
或用 docker compose:
```yaml
services:
jiang13:
image: hangzhang714128/jiang13-forum:latest
ports: ["3000:3000"]
volumes: ["./data:/data"]
environment: {TZ: Asia/Shanghai}
restart: unless-stopped
```
```bash
docker compose up -d
```
HTTPS 用 Caddy 最省心:
```
bbs.yourdomain.com {
reverse_proxy 127.0.0.1:3000
}
```
Caddy 自动申请 Let's Encrypt。
---
## 域名 + HTTPS
域名服务商添加 **A 记录**:`bbs` → 服务器公网 IP。
| 方式 | HTTPS 操作 |
| --- | --- |
| 1Panel | 网站 → 创建 → 反向代理 → SSL → Let's Encrypt |
| 宝塔 | 同上 |
| 纯 Docker | Caddy 自动 / Nginx + certbot |
---
## 数据备份
所有数据(SQLite + 上传 + JWT 密钥)都在**一个目录**里,备份 = 复制目录。
| 方式 | 数据目录 |
| --- | --- |
| 1Panel Docker | 你映射的宿主机目录 |
| 宝塔二进制 | 二进制同目录下 `data/` |
| 纯 Docker | `-v` 映射的目录 |
```bash
tar -czf jiang13-backup-$(date +%Y%m%d).tar.gz /数据目录
```
> ⚠️ `data/.jwt_secret` 务必一起备份,丢了所有用户已登录的 cookie 会失效,需要重新输入密码登录(密码本身存 SQLite 里不受影响)。
---
## 版本升级
版本号以 [更新日志](https://bbs.iioio.com/page/update-log.html) 为准。
| 方式 | 升级 |
| --- | --- |
| 1Panel | 容器列表 → 选中 → 镜像更新 |
| docker compose | `docker compose pull && docker compose up -d` |
| 二进制 | 见上方「2.1 升级二进制」 |
升级前先备份:`tar -czf backup.tar.gz /数据目录`
---
## 面板常用命令
| 操作 | 宝塔 | 1Panel |
| --- | --- | --- |
| 菜单 | `bt` | `1pctl` |
| 查入口 / 密码 | `bt default` | `1pctl user-info` |
| 改密码 | `bt 14` | `1pctl update password` |
| 重启面板 | `bt restart` | — |
## 常见问题
**Q:面板 / 网站打不开?** 安全组有没有放行对应端口?`netstat -tlnp | grep 端口` 看服务在不在监听。
**Q:宝塔 vs 1Panel 怎么选?** 只跑姜十三 → 宝塔 + 二进制(省资源);还要跑别的 → 1Panel。
**Q:支持邮件 / SSO 吗?** 邮件 SMTP 和 OIDC SSO 都在**管理后台 → 系统设置**里配置。
**Q:源码编译?** 见项目 README。推荐用预编译包。
## 链接
| 项目 | 地址 |
| --- | --- |
| 在线演示 | https://bbs.iioio.com/ |
| 更新日志 | https://bbs.iioio.com/page/update-log.html |
| Docker Hub | hangzhang714128/jiang13-forum |
| 预编译下载 | https://git.iioio.com/freefire/jiang13-forum/releases |
有问题欢迎本帖留言。

View File

@@ -0,0 +1,26 @@
# 反馈与建议
> 建议标题:反馈与建议
> 建议标签:`站务` `反馈`
> 建议操作:普通发布(可选)
---
本站程序是开源的 **姜十三论坛**,仍在积极开发。欢迎反馈。
## 站内怎么说
- **功能建议 / 体验吐槽**:开新帖,尽量写清场景与期望
- **疑似缺陷**:说明步骤、浏览器、是否可复现;有截图更好
- **站务问题**(注册、权限、内容处理):私信管理员
## 开发仓库(可选)
若你也参与程序本身:
- 查路线图、提 Issue / PR:见项目仓库
- 许可证:MIT
站内讨论与仓库 Issue 不必重复提交;选一处说清楚即可。
感谢一起把小圈子聊好。

View File

@@ -0,0 +1,10 @@
# 可选短文(不与介绍长文重复)
长文 [`about-jiang13.md`](./about-jiang13.md) 置顶后,原先分篇的「上手 / 功能 / 部署」建议 **直接删除**。
若希望「站务公告」里还有几篇配套短帖,可用下面两篇(按需发,不必全发)。
| 文件 | 标题 | 标签 | 说明 |
| --- | --- | --- | --- |
| [community-guidelines.md](./community-guidelines.md) | 本站发帖约定 | `站务` `公约` | 轻量规则,避免灌水 |
| [feedback.md](./feedback.md) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 |

20
frontend/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

7274
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

76
frontend/package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "jiang13-forum-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vike dev",
"build": "vike build",
"preview": "vike preview",
"start": "node server/index.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@svg-maps/china": "^2.0.0",
"@svg-maps/world": "^2.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-placeholder": "^3.26.1",
"@tiptap/extension-table": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compression": "^1.8.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"express": "^5.2.1",
"highlight.js": "^11.11.1",
"http-proxy-middleware": "^4.2.0",
"linkedom": "^0.18.13",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-easy-crop": "^6.0.2",
"react-hook-form": "^7.79.0",
"react-router": "^8.3.1",
"react-streaming": "^0.4.20",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"turndown": "^7.2.4",
"tw-animate-css": "^1.4.0",
"vike": "^0.4.266",
"zod": "^4.4.3",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^25.9.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2",
"vite": "^8.2.2"
},
"allowScripts": {
"esbuild@0.28.2": true
}
}

12
frontend/pages/+config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Config } from 'vike/types';
export default {
// pageProps / data(+data 返回的 boot / snapshot / head)透传给客户端供 hydrate seed
passToClient: ['pageProps', 'data'],
// 自定义集成(无 vike-react):声明 `ssr` 配置供页面 +ssr.ts 使用(SPA 壳页 ssr: false)
meta: {
ssr: {
env: { server: true },
},
},
} satisfies Config;

View File

@@ -0,0 +1,10 @@
import App from '../../src/App';
import type { PageContext } from 'vike/types';
export { Page };
/** 错误页:复用 App 壳层与路由树 —— /post/:id 未找到渲染帖子自己的未找到态,未知路径渲染 catch-all NotFoundPage */
function Page(pageContext: PageContext & { url?: string }) {
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
return <App url={url} />;
}

View File

@@ -0,0 +1,24 @@
import type { PageContextServer } from 'vike/types';
import type { FeedData } from '../../src/ssr/feedSeed';
export { data };
type NotFoundAbortReason = {
head?: FeedData['head'];
boot?: FeedData['boot'];
};
/**
* 错误页取数:feed +data 未找到(帖子/板块)时 throw render(404, { head, boot }),
* 壳层数据经 abortReason 带过来,保证 404 页也渲染完整站点壳与 noindex head。
*/
async function data(pageContext: PageContextServer): Promise<FeedData> {
if (!import.meta.env.SSR) return { kind: 'notfound' };
const reason = pageContext.abortReason as NotFoundAbortReason | undefined;
return {
kind: 'notfound',
head: reason?.head,
boot: reason?.boot,
};
}

View File

@@ -0,0 +1,11 @@
import App from '../../src/App';
import type { PageContext } from 'vike/types';
export { Page };
/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */
function Page(pageContext: PageContext & { url?: string }) {
// onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query)
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
return <App url={url} />;
}

View File

@@ -0,0 +1,9 @@
import type { PageContextServer } from 'vike/types';
import { loadFeedData } from '../../src/ssr/feedData';
export { data };
// SSR 白名单页(/ 、/board/:id 、/post/:id)服务端取数 + head 构建
async function data(pageContext: PageContextServer) {
return await loadFeedData(pageContext);
}

View File

@@ -0,0 +1,11 @@
import type { PageContext } from 'vike/types';
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
export { route };
/** SSR 白名单:`/`、`/board/:id`、`/post/:id`(含 `.html` 伪静态) */
function route(pageContext: PageContext) {
const match = matchFeedSsrPath(pageContext.urlPathname);
if (!match) return false;
return { routeParams: { kind: match.kind, id: match.id } };
}

View File

@@ -0,0 +1,8 @@
import App from '../../src/App';
export { Page };
/** SPA 壳页:客户端整树挂载 react-router(服务端不执行本组件) */
function Page() {
return <App />;
}

View File

@@ -0,0 +1,10 @@
import type { PageContext } from 'vike/types';
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
export { route };
/** catch-all:SSR feed 白名单之外的一切 URL */
function route(pageContext: PageContext) {
if (matchFeedSsrPath(pageContext.urlPathname)) return false;
return { routeParams: {} };
}

View File

@@ -0,0 +1,4 @@
export { ssr };
/** SPA 壳:服务端只出空壳 HTML,客户端整树挂载 react-router */
const ssr = false;

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

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