84 Commits

Author SHA1 Message Date
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
558 changed files with 75473 additions and 5651 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「快速开始」如需对外暴露再更新

25
.dockerignore Normal file
View File

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

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

8
.gitignore vendored
View File

@@ -1,9 +1,14 @@
# 运行时数据(数据库、JWT 密钥、日志、头像)
/data/
# 本地配置(保留 app.ini.example)
/app.ini
# 前端依赖与构建缓存
/node_modules/
/frontend/node_modules/
/frontend/dist/
/embed_static/static/spa/
# Go 编译产物
/dist/
@@ -18,3 +23,6 @@ tmp-cookie.txt
*.swp
Thumbs.db
.DS_Store
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
frontend/tmp-lucide-paths.json

View File

@@ -0,0 +1,151 @@
# 表情效果调整计划
## 问题分析
当前实现存在 5 个问题,根因如下:
### 1. 插入表情后有选择状态(蓝色高亮)
- **根因**: Tiptap `setImage` 插入图片节点后,节点处于 "node-selected" 状态,显示蓝色选中框
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L155-163
### 2. 输入框表情太大
- **根因**: CSS 选择器 `img[src^="data:image/svg"]` 只匹配旧 SVG data URI,不匹配新的 AVIF URL(`/stickers/tieba/tb_01.avif`),导致表情图片无尺寸约束,以原始大尺寸渲染
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5584-5603
### 3. 插入表情后光标换行
- **根因**: `ArticleImage.configure({ inline: false })` 使图片为块级节点,插入后自动换行
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L92
### 4. 表情栏目太宽松
- **根因**: Grid 仅 6 列,gap 4px,padding 10px
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5502-5510
### 5. 颜文字太小
- **根因**: `.sticker-picker-text` 的 `font-size: 8px`,极小
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5606-5616
## 改动方案
### 文件 1: CommentEditor.tsx
**改动 A — 图片改为内联** (L92):
```typescript
// 旧
ArticleImage.configure({ inline: false, allowBase64: true }),
// 新
ArticleImage.configure({ inline: true, allowBase64: true }),
```
**改动 B — insertSticker 插入后取消选中和换行** (L155-163):
```typescript
const insertSticker = useCallback((sticker: Sticker) => {
if (!editor) return;
if (sticker.type === 'text' && sticker.text) {
editor.chain().focus().insertContent(sticker.text).run();
} else if (sticker.url) {
// 插入内联图片 + 尾随零宽空格,确保光标在图片后面而非选中图片
editor.chain().focus().insertContent([
{ type: 'image', attrs: { src: sticker.url, alt: sticker.name } },
{ type: 'text', text: '\u200b' },
]).run();
}
setShowSticker(false);
}, [editor]);
```
> 用 `insertContent` + 零宽空格替代 `setImage`,避免 node-selected 状态,光标自然落在图片后。
### 文件 2: StickerPicker.tsx
**改动 C — 键盘导航列数匹配新网格** (L44):
```typescript
// 旧
const cols = 6;
// 新
const cols = 8;
```
### 文件 3: global.css
**改动 D — 表情选择器密度提升** (L5502-5510):
```css
.sticker-picker-grid {
display: grid;
grid-template-columns: repeat(8, 1fr); /* 6 → 8 */
gap: 2px; /* 4px → 2px */
padding: 6px; /* 10px → 6px */
overflow-y: auto;
flex: 1;
min-height: 0;
}
```
**改动 E — 选择器项缩小** (L5512-5522):
```css
.sticker-picker-item {
padding: 2px; /* 4px → 2px */
/* 其余不变 */
}
```
**改动 F — 颜文字字体放大** (L5606-5616):
```css
.sticker-picker-text {
font-size: 14px; /* 8px → 14px */
line-height: 1.4; /* 1.2 → 1.4 */
/* 其余不变 */
}
```
**改动 G — 替换旧 SVG 选择器为通用贴纸选择器** (L5584-5603):
删除旧的 `img[src^="data:image/svg"]` 选择器,替换为基于 `/stickers/` 路径的选择器:
```css
/* 编辑器内贴纸 img 尺寸约束 */
.comment-editor .article-prosemirror img[src*="/stickers/"],
.comment-editor .article-editor-content img[src*="/stickers/"] {
display: inline-block;
width: 28px;
height: 28px;
vertical-align: middle;
margin: 0 1px;
border-radius: 4px;
object-fit: contain;
}
/* 评论正文中的贴纸 img */
.floor-body img[src*="/stickers/"],
.comment-body img[src*="/stickers/"] {
display: inline-block;
vertical-align: middle;
width: 28px;
height: 28px;
margin: 0 1px;
background: transparent;
border-radius: 4px;
object-fit: contain;
}
```
**改动 H — 移动端网格也改为 8 列** (L5619-5624):
```css
@media (max-width: 640px) {
.sticker-picker { max-height: 240px; }
.sticker-picker-grid { grid-template-columns: repeat(6, 1fr); } /* 移动端 6 列 */
.sticker-picker-tab { padding: 6px 10px; font-size: 12px; }
.comment-editor .article-tool-btn { width: 30px; height: 30px; }
}
```
> 移动端保持 6 列(屏幕窄),桌面端 8 列。
## 不改动
- `ArticleImageExtension.tsx` — 无需修改,`inline: true` 通过 `configure()` 传入即可
- `kaomoji.ts` / `emojiData.ts` / `hot.ts` — 数据层不变
- `CommentContent.tsx` — 渲染层不变(CSS 覆盖即可)
- PostEditor(帖子编辑器)— 不受影响,仍使用 `inline: false`
## 验证
1. `npm run build` 无报错
2. 浏览器验证:
- 选择表情后插入无蓝色选中框
- 表情在输入框中显示 28px,内联在文字中
- 插入表情后光标紧跟表情后方,不换行
- 表情选择器 8 列密度更高
- 颜文字标签内字体清晰可读

View File

@@ -0,0 +1,597 @@
# 评论表情包改造 + 富文本评论编辑器计划
## 一、摘要
将评论系统从「纯文本 textarea + Unicode Emoji」升级为「Tiptap 富文本编辑器 + 姜十三专属 SVG 贴纸」,实现:
1. 去除所有 Unicode Emoji,替换为自定义 SVG 贴纸(懒加载)
2. 姜十三专属表情包:萌系吉祥物表情 + 中文网络流行语文字气泡(混合风格)
3. 评论复用帖子编辑器核心能力(精简变体),支持代码块、链接、图片、格式化等
---
## 二、当前状态分析
### 2.1 评论输入:纯文本 textarea
[CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) 使用 `<textarea>` 输入评论,功能包括:
- `@` 用户提及(textarea 选区扫描 + API 搜索用户)
- Unicode Emoji 面板([EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx) + [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts))
- 隐私评论开关
- 无富文本格式化能力
### 2.2 评论渲染:纯文本转义
[CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) 通过 [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) 的 `highlightMentions()` 渲染评论:
- `escapeWithBreaks()` 转义 HTML 并将 `\n` 转为 `<br>`
- 正则匹配 `@username` 包裹为可点击 `<span class="mention">`
- 不支持 HTML/Markdown 渲染
### 2.3 评论编辑:纯 textarea
[CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) 编辑模式使用 `<textarea>` 直接修改文本。
### 2.4 帖子编辑器:Tiptap 富文本
[ArticleEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/ArticleEditor.tsx) 是功能完整的 Tiptap 编辑器(900 行),包含:
- 富文本模式 + Markdown 模式切换
- 工具栏:标题/加粗/斜体/下划线/删除线/分割线/引用/列表/代码块/表格/链接/图片/图组
- 门控区块:登录可见/回复可见/积分可见
- 全屏模式
- `forwardRef` 暴露 `getHTML()` / `isEmpty()` / `focus()`
- 使用 `DOMPurify` + `POST_CONTENT_PURIFY_CONFIG` 净化 HTML
### 2.5 后端评论存储
- [models.go](file:///c:/Users/freefire/Documents/jiang13-forum/model/models.go#L143-L164): `Comment.Content` 字段类型为 `text`,存储纯文本
- [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176): `Create()` 仅做 `TrimSpace` + 敏感词过滤,**无 HTML 净化**
- [handlers.go](file:///c:/Users/freefire/Documents/jiang13-forum/handler/handlers.go#L508-L530): `APICreateComment` 从 FormData 取 `content` 字段
- [sanitize_html.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go): `SanitizePostHTML` 已有 HTML 白名单策略(bluemonday),但**仅用于帖子,未用于评论**
### 2.6 表情数据
[emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) 定义约 160 个 Unicode Emoji 字符,无分类、无搜索。
---
## 三、修改方案
### Part A:姜十三专属 SVG 贴纸系统
#### A1. 贴纸数据定义
**新建** `frontend/src/data/stickers.ts`
```typescript
export interface Sticker {
id: string; // 唯一ID,如 "j13-happy"
name: string; // 名称,如 "开心"
category: StickerCategory;
aliases?: string[]; // 搜索别名
svg: string; // 完整 SVG 字符串(含 viewBox 0 0 64 64)
}
export type StickerCategory = '热门' | '姜十三' | '文字气泡';
export const STICKER_CATEGORIES: StickerCategory[] = ['热门', '姜十三', '文字气泡'];
export const STICKERS: Sticker[] = [ /* ... */ ];
```
**贴纸内容设计(约 40 个):**
| 分类 | 数量 | 内容 |
|------|------|------|
| 姜十三 | 16 | 萌系姜色(ginger色 #D4A574)圆脸吉祥物,额头带"13"标记,各种表情:开心/大笑/哭泣/生气/惊讶/思考/点赞/心心眼/睡觉/疑惑/酷/捂脸/送花/鼓掌/加油/拜托 |
| 文字气泡 | 16 | 圆角气泡 + 中文网络流行语:666/大佬/同问/给力/沙发/学习了/已赞/佩服/妙啊/牛批/感谢/收藏了/围观/催更/瑞思拜/芜湖 |
| 热门 | 8 | 从上述两类中精选最常用的 8 个 |
**SVG 设计规范:**
- `viewBox="0 0 64 64"` 统一尺寸
- 所有 fill 使用内联颜色(不依赖 CSS 变量)
- 姜十三吉祥物主色系:`#D4A574`(姜色)/ `#FFF3E0`(浅姜)/ `#E8B87C`(深姜)
- 文字气泡:`#FF6B6B`(红)/ `#4ECDC4`(青)/ `#FFE66D`(黄)/ `#95E1D3`(绿)四色循环
- 线条圆润,stroke-linecap: round
#### A2. SVG 贴纸渲染组件
**新建** `frontend/src/components/emoji/StickerSvg.tsx`
```typescript
interface StickerSvgProps {
id: string;
size?: number; // 默认 28
className?: string;
}
```
- 从 `STICKERS` 查找对应 id,渲染 `dangerouslySetInnerHTML={{ __html: sticker.svg }}`
- SVG 已自包含 fill 颜色,无需额外样式
#### A3. 贴纸选择器(懒加载)
**新建** `frontend/src/components/emoji/StickerPicker.tsx`
```typescript
interface StickerPickerProps {
onSelect: (stickerId: string) => void;
}
```
**UI 结构:**
```
┌──────────────────────────────┐
│ [热门] [姜十三] [文字气泡] │ ← 分类 Tab
├──────────────────────────────┤
│ [🙂] [😂] [❤️] [👍] [🎉] ... │ ← SVG 贴纸网格(6列桌面/4列移动端)
└──────────────────────────────┘
```
**懒加载策略:**
- 贴纸数据按分类拆分为独立 chunk:`data/stickers/j13.ts`、`data/stickers/text.ts`、`data/stickers/hot.ts`
- `StickerPicker` 使用 `React.lazy()` + `Suspense` 按需加载当前分类
- 切换分类时才加载对应 chunk,首次打开只加载"热门"分类
- 每个贴纸 SVG 在组件挂载时渲染(已在数据中内联,无需额外网络请求)
**交互:**
- 分类 Tab 点击切换,带下划线动画
- 贴纸 hover: `transform: scale(1.15)`, 0.1s 过渡
- 点击贴纸触发 `onSelect(sticker.id)`
- 键盘导航:方向键浏览 + Enter 选中
#### A4. 删除旧 Emoji 系统
- **删除** [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts)(`EMOJI_LIST` 导出)
- **删除** [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx)
- **保留** `.emoji-picker*` CSS 类(避免影响其他可能的引用),新增 `.sticker-picker*` 类
---
### Part B:评论富文本编辑器(精简变体)
#### B1. 创建 CommentEditor 组件
**新建** `frontend/src/components/CommentEditor.tsx`
不直接复用 ArticleEditor(900 行,含全屏/Markdown/门控区块等评论不需要的功能),而是创建独立的精简 Tiptap 编辑器,**复用 ArticleEditor 的扩展组件**。
```typescript
export interface CommentEditorHandle {
getHTML: () => string;
isEmpty: () => boolean;
focus: () => void;
}
interface CommentEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
}
```
**使用的 Tiptap 扩展(复用现有):**
- `StarterKit`(含 heading H2-H4、bold/italic/strike、blockquote、bulletList/orderedList、horizontalRule)
- `Underline`(来自 @tiptap/extension-underline,已安装)
- `Link`(来自 @tiptap/extension-link,已安装)
- `ArticleCodeBlock`(来自 [ArticleCodeBlockExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleCodeBlockExtension.tsx),复用)
- `Placeholder`(来自 @tiptap/extension-placeholder,已安装)
- `TabIndent`(来自 [TabIndentExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/TabIndentExtension.tsx),复用)
- `ArticleImage`(来自 [ArticleImageExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleImageExtension.tsx),复用,用于插入图片)
**不使用的扩展(评论场景不需要):**
- ~~TableKit~~(表格)
- ~~ImageGroup~~(图组)
- ~~MembersOnly / ReplyOnly / PointsOnly~~(门控区块)
- ~~ClearFloatParagraph~~(清除浮动段落)
- ~~Markdown 模式~~
- ~~全屏模式~~
**工具栏按钮(精简版):**
```
[H] [B] [I] [U] [S] [引用] [列表] [有序列表] [代码块] [链接] [图片] [贴纸]
```
**贴纸集成:**
- 工具栏增加贴纸按钮(Lucide `Sticker` 图标)
- 点击弹出 `StickerPicker`
- 选中贴纸后,将 SVG 作为 inline `<img>` 插入编辑器:
```typescript
const sticker = STICKERS.find(s => s.id === id);
const dataUri = `data:image/svg+xml,${encodeURIComponent(sticker.svg)}`;
editor.chain().focus().setImage({ src: dataUri, alt: sticker.name }).run();
```
**HTML 净化:**
- 使用 `DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG)` 与 ArticleEditor 一致
- `POST_CONTENT_PURIFY_CONFIG` 来自 [postContent.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/postContent.ts),复用现有配置
#### B2. 改造 CommentBox
**修改** [CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx)
**主要变更:**
1. 用 `CommentEditor` 替换 `<textarea>`
2. 用 `CommentEditorHandle` ref 替代 `textareaRef`
3. `content` 状态存储 HTML 而非纯文本
4. 移除 `EmojiPicker` 导入和使用,贴纸功能已集成到 `CommentEditor`
5. 移除 `owoRef` 和 `showEmoji` 状态
6. `@` 提及功能:暂时保留为文本输入(在编辑器中输入 `@username`,渲染时由 `processCommentHtml` 处理高亮),编辑器内自动补全作为后续增强
7. `insertEmoji` 改为 `insertSticker`,调用 `CommentEditor` ref 方法
8. 隐私评论开关保留
9. 发送时 `content` 为 HTML,直接传给 API
**改动前:**
```tsx
<textarea ref={textareaRef} value={content} onChange={handleChange} ... />
<button ref={owoRef} onClick={() => setShowEmoji(v => !v)}>OwO</button>
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
```
**改动后:**
```tsx
<CommentEditor ref={editorRef} value={content} onChange={setContent} placeholder="说点什么吧…" />
```
#### B3. 改造评论编辑模式
**修改** [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297)
编辑模式从 `<textarea>` 改为 `CommentEditor`:
```tsx
// 改动前
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={3} />
// 改动后
<CommentEditor value={editText} onChange={setEditText} placeholder="编辑评论…" />
```
需要 import `CommentEditor`,并在 `handleSave` 中提交 HTML 内容。
---
### Part C:评论内容渲染
#### C1. 更新 content.ts 支持富文本
**修改** [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts)
当前 `highlightMentions()` 处理纯文本(转义 HTML + 换行 + @高亮)。需要新增 HTML 处理能力。
```typescript
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
/** 判断内容是否为 HTML(包含常见 HTML 标签) */
function isHtmlContent(text: string): boolean {
return /<(?:p|div|span|br|h[1-6]|ul|ol|li|pre|code|blockquote|a|img|table|strong|em|u|s)\b/i.test(text);
}
/** 在 HTML 文本节点中高亮 @ 提及(DOM 遍历,避免破坏标签) */
function processMentionsInHtml(html: string): string {
const div = document.createElement('div');
div.innerHTML = html;
const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
for (const textNode of textNodes) {
const text = textNode.textContent ?? '';
if (!/@[\w\u4e00-\u9fa5_-]/.test(text)) continue;
const frag = document.createDocumentFragment();
const parts = text.split(/(@[\w\u4e00-\u9fa5_-]+)/);
for (const part of parts) {
const m = part.match(/^@([\w\u4e00-\u9fa5_-]+)$/);
if (m) {
const span = document.createElement('span');
span.className = 'mention';
span.setAttribute('data-name', m[1]);
span.setAttribute('role', 'link');
span.setAttribute('tabindex', '0');
span.textContent = part;
frag.appendChild(span);
} else if (part) {
frag.appendChild(document.createTextNode(part));
}
}
textNode.parentNode?.replaceChild(frag, textNode);
}
return div.innerHTML;
}
/** 渲染评论内容:HTML 净化 + @提及高亮,兼容旧版纯文本 */
export function renderCommentContent(content: string): string {
if (isHtmlContent(content)) {
// 新版 HTML 评论
const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG);
return processMentionsInHtml(sanitized);
}
// 旧版纯文本评论(向后兼容)
return escapeWithBreaks(content).replace(
/@([\w\u4e00-\u9fa5_-]+)/g,
'<span class="mention" data-name="$1" role="link" tabindex="0">@$1</span>',
);
}
```
保留原 `highlightMentions()` 函数不删除(可能有其他引用),新增 `renderCommentContent()`。
#### C2. 更新 CommentContent 组件
**修改** [CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx)
```tsx
import { renderCommentContent } from '../utils/content';
// 改动前
dangerouslySetInnerHTML={{ __html: highlightMentions(content) }}
// 改动后
dangerouslySetInnerHTML={{ __html: renderCommentContent(content) }}
```
评论内容中的贴纸 `<img>` 标签会被 `POST_CONTENT_PURIFY_CONFIG` 保留(已允许 `<img>` + `src`),自然渲染为 SVG 图。
---
### Part D:后端评论 HTML 净化
#### D1. 评论创建时净化
**修改** [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176)
```go
func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
content := SanitizePostHTML(strings.TrimSpace(in.Content)) // 新增 HTML 净化
content = s.filter.Filter(content) // 敏感词过滤
// ... 其余不变
}
```
#### D2. 评论更新时净化
**修改** [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376)
```go
func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
// ...
content = SanitizePostHTML(strings.TrimSpace(content)) // 新增
content = s.filter.Filter(content) // 敏感词过滤
// ... 其余不变
}
```
`SanitizePostHTML` 已在 [sanitize_html.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go) 中定义,使用 bluemonday 白名单策略,允许 Tiptap 产出的 HTML 标签和属性,禁止 `<script>`、`<style>` 等。
---
### Part E:CSS 样式
#### E1. 贴纸选择器样式
**修改** [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) 区域
新增样式块(在现有 `.emoji-picker*` 样式之后):
```css
/* 贴纸选择器 */
.sticker-picker {
display: flex;
flex-direction: column;
margin-top: 8px;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
background: var(--j13-bg-surface);
max-height: 280px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.sticker-picker-tabs {
display: flex;
border-bottom: 1px solid var(--j13-border-light);
padding: 0 8px;
}
.sticker-picker-tab {
border: none;
background: none;
padding: 8px 12px;
font-size: 13px;
color: var(--color-text-3);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.sticker-picker-tab.active {
color: var(--j13-green);
border-bottom-color: var(--j13-green);
}
.sticker-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
padding: 10px;
overflow-y: auto;
flex: 1;
}
.sticker-picker-item {
border: none;
background: none;
padding: 4px;
cursor: pointer;
border-radius: 8px;
transition: background 0.1s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.sticker-picker-item:hover {
background: var(--color-fill-2);
transform: scale(1.15);
}
/* 贴纸加载占位 */
.sticker-picker-loading {
grid-column: 1 / -1;
text-align: center;
padding: 24px;
color: var(--color-text-4);
font-size: 13px;
}
/* 移动端 */
@media (max-width: 640px) {
.sticker-picker-grid {
grid-template-columns: repeat(4, 1fr);
}
}
```
#### E2. 评论编辑器样式
新增 `.comment-editor` 相关样式,复用 `.article-editor-bar`、`.article-tool-btn` 等现有类名,仅做覆盖调整:
```css
/* 评论富文本编辑器 */
.comment-editor .article-editor-bar {
padding: 4px 8px;
}
.comment-editor .article-tool-btn {
width: 28px;
height: 28px;
}
.comment-editor .article-editor-content {
min-height: 80px;
max-height: 300px;
overflow-y: auto;
padding: 8px 12px;
font-size: 14px;
}
.comment-editor .article-editor-status {
padding: 4px 10px;
}
/* 评论内贴纸图片 */
.comment-sticker {
display: inline-block;
vertical-align: middle;
width: 28px;
height: 28px;
margin: 0 2px;
}
```
---
## 四、文件变更清单
### 新增文件
| 文件路径 | 用途 |
|----------|------|
| `frontend/src/data/stickers/hot.ts` | 热门贴纸数据(懒加载 chunk) |
| `frontend/src/data/stickers/j13.ts` | 姜十三吉祥物贴纸数据(懒加载 chunk) |
| `frontend/src/data/stickers/text.ts` | 文字气泡贴纸数据(懒加载 chunk) |
| `frontend/src/data/stickers/index.ts` | 贴纸类型定义 + 统一导出 |
| `frontend/src/components/emoji/StickerSvg.tsx` | SVG 贴纸渲染组件 |
| `frontend/src/components/emoji/StickerPicker.tsx` | 贴纸选择器(懒加载) |
| `frontend/src/components/CommentEditor.tsx` | 评论富文本编辑器(精简 Tiptap) |
### 修改文件
| 文件 | 变更 |
|------|------|
| [CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) | textarea → CommentEditor,移除 EmojiPicker,content 改为 HTML |
| [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) | 编辑模式 textarea → CommentEditor |
| [CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) | 使用 `renderCommentContent()` 替代 `highlightMentions()` |
| [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) | 新增 `renderCommentContent()` + `processMentionsInHtml()` |
| [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) | 新增 `.sticker-picker*` 和 `.comment-editor*` 样式 |
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176) | Create() 增加 `SanitizePostHTML` |
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376) | Update() 增加 `SanitizePostHTML` |
### 删除文件
| 文件 | 原因 |
|------|------|
| [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) | Unicode Emoji 数据不再需要 |
| [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx) | 被 StickerPicker 替代 |
---
## 五、假设与决策
### 5.1 编辑器决策
- **不直接复用 ArticleEditor 组件**,而是创建独立的 CommentEditor,原因:
- ArticleEditor 900 行,含全屏/Markdown/门控区块等评论不需要的功能
- 复用 ArticleEditor 的 Tiptap 扩展组件(ArticleCodeBlock、ArticleImage、TabIndent 等),避免代码重复
- CommentEditor 更轻量,每个评论实例加载更快
- **@ 提及**:暂不在编辑器中实现自动补全(需要 Tiptap Mention 扩展或自定义扩展),用户手动输入 `@username`,渲染时由 `processMentionsInHtml` 高亮。后续可增强为 Tiptap Mention 扩展。
### 5.2 贴纸存储格式
- 贴纸以 `data:image/svg+xml` URI 内联在 `<img src="...">` 中
- 每个 SVG 约 300-800 字节,单条评论即使 10 个贴纸也仅 ~8KB
- 后端 `SanitizePostHTML` 白名单已允许 `<img src="...">`,无需额外修改
- 旧评论(纯文本)不受影响,`renderCommentContent` 自动检测并兼容
### 5.3 懒加载策略
- 贴纸数据按分类拆分为 3 个 chunk(hot/j13/text),`React.lazy()` 动态导入
- 首次打开选择器只加载"热门"chunk(8 个贴纸),切换分类时才加载其他 chunk
- 每个 chunk 约 5-10KB,加载延迟 < 100ms
### 5.4 向后兼容
- 旧评论为纯文本,新 `renderCommentContent()` 通过 `isHtmlContent()` 检测自动走旧路径(escapeWithBreaks + 正则高亮)
- 新评论为 HTML,走 DOMPurify 净化 + DOM 遍历高亮路径
- 后端 `SanitizePostHTML` 对纯文本也安全(bluemonday 会保留纯文本,仅过滤危险标签)
---
## 六、实施顺序
1. **Part A**:贴纸数据 + 组件(stickers/*.ts → StickerSvg → StickerPicker)
2. **Part D**:后端评论 HTML 净化(comment.go Create/Update 加 SanitizePostHTML)
3. **Part B**:CommentEditor 组件 → CommentBox 集成 → CommentThreadList 编辑模式
4. **Part C**:content.ts 渲染函数 → CommentContent 更新
5. **Part E**:CSS 样式
6. 删除旧 EmojiPicker / emojis.ts
7. 验证测试
---
## 七、验证步骤
1. **贴纸系统验证**:
- 点击贴纸按钮,选择器弹出,3 个分类 Tab 可切换
- 切换分类时加载对应贴纸(Network 面板确认懒加载)
- 点击贴纸后编辑器中出现对应 SVG 图
2. **评论编辑器验证**:
- 评论框支持加粗/斜体/下划线/删除线/标题/引用/列表/代码块/链接/图片/贴纸
- 代码块支持语法高亮和折叠
- 图片可上传并插入
- 无全屏/Markdown/表格/门控区块按钮
3. **评论渲染验证**:
- 新评论 HTML 正确渲染(格式化、代码块、图片、贴纸)
- `@username` 在 HTML 评论中正确高亮为可点击链接
- 旧评论(纯文本)仍正常渲染
- 贴纸图片在评论中正确显示
4. **后端验证**:
- 发送含 `<script>alert(1)</script>` 的评论,后端净化后移除 script 标签
- 发送正常 HTML 评论,后端存储完整 HTML
- 旧纯文本评论正常存储和渲染
5. **编辑模式验证**:
- 编辑已有评论时,CommentEditor 正确加载 HTML 内容
- 保存后评论更新正确

View File

@@ -0,0 +1,138 @@
# 修复主页右侧栏评论点击定位问题
## 问题描述
用户在主页右侧栏点击评论时,如果该评论不是帖子的第一个评论(`floor > 0`),页面跳转后没有定位到该评论上。
## 根因分析
### 问题 1:`jumpToFloor` 使用 `scrollIntoView` 而非 `pageRef.scrollTo`
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L235-L242)
当前 `jumpToFloor` 函数使用 `el.scrollIntoView()` 来滚动:
```typescript
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// ...
}, []);
```
但是页面使用了自定义滚动容器 `pageRef`(类名为 `.post-detail-page`),且 `useGlobalWheelScroll` 钩子拦截了滚轮事件,将其转换为对 `pageRef.scrollTop` 的直接操作。这导致原生 `scrollIntoView` 可能无法正确触发滚动。
对比 `jumpToHeadingHash` 函数(第 244-262 行),它正确地使用了 `pageRef.scrollTo()`:
```typescript
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
// ...
const root = pageRef.current;
if (root) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
root.scrollTo({ top: Math.max(0, top), behavior });
}
// ...
}, []);
```
### 问题 2:`#floor-N` 定位缺少重试机制
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L264-L273)
当前 `#floor-N` 定位只有一次 80ms 延迟,没有重试机制:
```typescript
useEffect(() => {
if (loading || !post) return;
const m = location.hash.match(/^#floor-(\d+)$/);
if (!m) return;
const floor = Number(m[1]);
if (!floor) return;
const t = window.setTimeout(() => jumpToFloor(floor), 80);
return () => clearTimeout(t);
}, [loading, post, comments, location.hash, jumpToFloor]);
```
对比 `#heading-N` 定位(第 275-299 行),它有完善的重试机制(最多 30 次,每次 50ms)。
## 修改方案
### 修改 1:修改 `jumpToFloor` 函数使用 `pageRef.scrollTo`
将 `jumpToFloor` 函数改为使用与 `jumpToHeadingHash` 相同的滚动方式:
```typescript
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return false;
const root = pageRef.current;
if (root) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setHighlightFloor(floor);
clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
return true;
}, []);
```
### 修改 2:为 `#floor-N` 定位添加重试机制
将 `#floor-N` 定位的 `useEffect` 改为类似 `#heading-N` 的重试机制:
```typescript
useEffect(() => {
if (loading || !post) return;
const m = location.hash.match(/^#floor-(\d+)$/);
if (!m) return;
const floor = Number(m[1]);
if (!floor) return;
let cancelled = false;
let attempts = 0;
let timer = 0;
const tryJump = () => {
if (cancelled) return;
if (jumpToFloor(floor)) return;
attempts += 1;
if (attempts < 30) {
timer = window.setTimeout(tryJump, 50);
}
};
timer = window.setTimeout(tryJump, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [loading, post, comments, location.hash, jumpToFloor]);
```
## 涉及文件
- `frontend/src/pages/PostDetailPage.tsx`:修改 `jumpToFloor` 函数和 `#floor-N` 定位的 `useEffect`
## 风险评估
- **低风险**:修改仅限于评论定位逻辑,不影响其他功能
- **需测试**:需要验证页面加载时评论定位是否正常工作,以及页面内部导航时是否正常
## 测试步骤
1. 进入主页,点击右侧栏的一个非第一条评论
2. 验证页面跳转到帖子详情后,是否正确定位到目标评论
3. 验证评论高亮效果是否正常显示
4. 测试第一条评论(floor=0)的定位是否仍然正常工作
5. 在帖子详情页直接加载带 hash 的 URL(如 `/post/123#floor-5`),验证定位效果

View File

@@ -0,0 +1,117 @@
# 修复:再次进入帖子时滚动位置异常保留
## 问题
浏览帖子滑到下方 → 返回主页 → 再次进入同一帖子,滚动条停在之前阅读的位置,而非顶部。
## 根因
项目未设置 `history.scrollRestoration`(默认 `'auto'`)。浏览器在导航时会对**内部滚动容器**(`overflow: auto` 的 `.page-wrap`)执行滚动位置恢复。虽然 PostDetailPage 重新挂载后 `.page-wrap` 是新 DOM 元素,浏览器仍会在 paint 前将其 `scrollTop` 恢复到上次记录的值。
项目没有 ScrollToTop 机制来覆盖此行为。
## 与刷新恢复的冲突
之前实现的 `useScrollRestoration`(MainLayout mount-only)负责刷新场景的位置恢复。本修复必须与之共存:
| 场景 | 期望行为 | 处理者 |
|---|---|---|
| 刷新帖子页 | 恢复到上次位置 | `useScrollRestoration`(sessionStorage + rAF) |
| SPA 导航进入帖子 | 从顶部开始 | 本修复(重置 scrollTop=0) |
| `/post/123` → `/post/456` | 从顶部开始 | 本修复(重置 scrollTop=0) |
**区分依据**:`pagehide` 时 `saveScrollPositions` 将当前 URL 的滚动记录存入 sessionStorage。刷新后该记录存在;SPA 导航进入时该记录不存在(从未为此 URL 保存过,或已被 `restoreScrollPositions` 消费清除)。因此 PostDetailPage 渲染 `.page-wrap` 时检查 sessionStorage 是否有当前 URL 的记录即可区分两种场景。
**时序保证**:React 的 effect 执行顺序是子组件先于父组件。PostDetailPage 的 `useLayoutEffect` 在 MainLayout 的 `useEffect`(含 `useScrollRestoration`)之前执行。此时 sessionStorage 记录尚未被消费,`hasPendingScrollRestore()` 返回准确值。
## 改动计划
### 1. `frontend/src/utils/scrollRestore.ts` — 新增 `hasPendingScrollRestore`
在现有文件中新增导出函数:
```ts
/** 检查 sessionStorage 中是否有指定 URL 的待恢复滚动记录(不消费/不删除) */
export function hasPendingScrollRestore(url: string = getCurrentUrl()): boolean {
try {
const raw = sessionStorage.getItem(storageKey(url));
if (!raw) return false;
const entry = JSON.parse(raw) as SavedPositions;
return !!entry?.containers && Object.keys(entry.containers).length > 0;
} catch {
return false;
}
}
```
纯读取,不删除记录(`restoreScrollPositions` 的 rAF 循环仍需要它来恢复)。
### 2. 新建 `frontend/src/hooks/useScrollToTopOnMount.ts`
通用 hook,在滚动容器渲染就绪后重置到顶部(刷新场景跳过):
```ts
import { useLayoutEffect, useRef, type RefObject } from 'react';
import { hasPendingScrollRestore } from '../utils/scrollRestore';
/**
* 滚动容器渲染就绪后重置到顶部。
* - 首次就绪:刷新场景(sessionStorage 有记录)跳过,让 useScrollRestoration 恢复;
* SPA 导航(无记录)重置到顶部。
* - deps 变化(如 postId 变化):始终重置到顶部。
* - 同 deps 的 ready 状态变化(如评论刷新导致 loading 短暂为 true):不处理,避免误重置。
*/
export function useScrollToTopOnMount(
scrollRef: RefObject<HTMLElement | null>,
deps: React.DependencyList,
ready: boolean,
): void {
const lastKeyRef = useRef<string | null>(null);
const key = JSON.stringify(deps);
useLayoutEffect(() => {
if (!ready || !scrollRef.current) return;
if (lastKeyRef.current === null) {
lastKeyRef.current = key;
if (!hasPendingScrollRestore()) {
scrollRef.current.scrollTop = 0;
}
return;
}
if (lastKeyRef.current !== key) {
lastKeyRef.current = key;
scrollRef.current.scrollTop = 0;
}
}, [key, ready]); // ready 变化时重新检查
}
```
### 3. `frontend/src/pages/PostDetailPage.tsx` — 调用 hook
在 `pageRef` 定义之后(L108 之后)、`useGlobalWheelScroll` 调用处附近添加:
```ts
import { useScrollToTopOnMount } from '../hooks/useScrollToTopOnMount';
// ...
useScrollToTopOnMount(pageRef, [postId], !loading && !!post);
```
- `deps: [postId]` — `/post/123` → `/post/456` 时重置
- `ready: !loading && !!post` — `.page-wrap` 仅在此时渲染(L446 loading return、L447 !post return),pageRef.current 才有效
### 不需要改动的部分
- **其他页面**(FavoritesPage、MessagesPage、ProfilePage 等):用户仅报告了帖子页问题。这些页面内容较短,滚动保留不明显。如后续报告可复用此 hook。
- **MainLayout / AdminLayout**:`useScrollRestoration` 不变。
- **`history.scrollRestoration`**:不设为 `'manual'`,避免影响 back/forward 时其他页面的浏览器原生恢复。
## 验证步骤
1. **SPA 导航进入帖子**:从首页点击帖子 → 滑到下方 → 返回首页 → 再次点击同一帖子 → 应从顶部开始。
2. **帖子间切换**:`/post/123` 滑到下方 → 直接导航到 `/post/456` → 应从顶部开始。
3. **刷新保持位置**:在帖子页滑到下方 → F5 刷新 → 应恢复到原位置(`useScrollRestoration` 生效,hook 跳过)。
4. **硬刷新保持位置**:同上但用 Ctrl+F5 → 应恢复到原位置。
5. **首次访问**:从未访问过的帖子 → 应从顶部开始(sessionStorage 无记录)。
6. **诊断无错误**:TypeScript 编译通过。

View File

@@ -0,0 +1,55 @@
# 修复生产环境表情图片无法显示 Implementation Plan
## Repository Research
### 问题分析
表情图片在生产部署后无法加载,原因是 **Go 后端缺少 `/stickers/*` 路由的静态文件服务注册**。
当前架构:
1. 前端 emoji 数据中的 URL 为 `/stickers/{platform}/{file}.avif`(在 [emojiData.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/data/stickers/emojiData.ts) 中通过 `toLocalUrl()` 生成)
2. Vite 构建时将 `frontend/public/stickers/` 原样复制到 `embed_static/static/spa/stickers/`
3. Go 的 `//go:embed static/*` 会将 `embed_static/static/` 下所有文件(包括 stickers)打包进二进制
4. 但 [embed.go](file:///c:/Users/freefire/Documents/jiang13-forum/embed_static/embed.go) 的 `SetupEmbed()` **只注册了 `/assets/*filepath` 路由**(用于 JS/CSS chunk),没有注册 `/stickers/*filepath`
5. 同时 `IsSPARoute()` 函数也没有排除 `/stickers` 前缀,导致请求被 SPA NoRoute fallback 处理,返回 index.html 而非图片
开发模式之所以正常,是因为 Vite dev server 自动托管了 `public/stickers/` 下的静态文件。
### 当前代码状态
- `embed.go` 第 13 行: `//go:embed static/*` — 正确嵌入了所有静态资源(含 stickers)
- `embed.go` 第 33-43 行: `SetupEmbed()` — 只处理了 `static/spa/assets`,缺少 stickers 路由
- `embed.go` 第 63-77 行: `IsSPARoute()` — 缺少 `/stickers` 前缀排除
## Files and Modules
- `embed_static/embed.go`: 新增 `/stickers/*filepath` 文件服务路由;在 `IsSPARoute` 中排除 `/stickers` 前缀
## Implementation Steps
1. **在 `SetupEmbed` 中注册 `/stickers` 路由**
- 参照现有 `/assets` 路由的模式,添加对 `static/spa/stickers` 子目录的文件服务
- 使用 `fs.Sub(staticFS, "static/spa/stickers")` 获取 stickers 子文件系统
- 注册 `GET /stickers/*filepath` 路由,直接透传,不设置长期缓存(表情可能更新)
2. **在 `IsSPARoute` 中排除 `/stickers` 前缀**
- 在现有 `strings.HasPrefix(path, "/assets")` 附近添加 `strings.HasPrefix(path, "/stickers")`
- 确保表情请求不会被 SPA fallback 拦截
## Dependencies and Considerations
- stickers 目录下存储的是 `.avif` 图片文件,不需要设置特殊 MIME type(http.FileServer 会根据扩展名自动识别)
- stickers 资源不含哈希指纹,不应设置 `immutable` 缓存头(与 `/assets` 不同),但可以设置短过期缓存
- 需确保 `fs.Sub` 路径与实际构建输出路径一致:`embed_static/static/spa/stickers/`
## Validation
- 重新构建前端:`cd frontend && npm run build`
- 重新构建 Go 二进制:`go build -o jiang13-linux-amd64`
- 部署后访问 `/stickers/tieba/tb_01.avif` 应能直接返回图片内容(HTTP 200)
- 在评论框中打开表情选择器,所有平台的表情应正常显示
## Risks
- **风险**: 如果未来在 `public/` 下新增其他静态资源目录(如 `avatars/`、`flags/` 等),需要同样在 `embed.go` 中注册对应路由
- **应对**: 可考虑后续重构为自动扫描 `public/` 下所有子目录并自动注册路由,或改为统一的 SPA 静态文件服务方案

View File

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

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

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

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)。
## 行为准则
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
#
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
# docker build --build-arg IMAGE_PREFIX= ...
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
ARG VERSION=dev
# ── Stage 1: 前端构建(Vite → embed_static/static/spa)────────────────────
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm config set registry https://registry.npmmirror.com \
&& npm ci
COPY frontend/ ./
RUN npm run build
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)────────────────────────
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
ARG VERSION
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/jiang13 ./cmd/jiang13
# ── Stage 3: 运行镜像 ───────────────────────────────────────────────────────
FROM ${IMAGE_PREFIX}alpine:3.21
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata wget su-exec \
&& adduser -D -u 1000 -h /app jiang13
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=builder /out/jiang13 /app/jiang13
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chown jiang13:jiang13 /app/jiang13 \
&& chmod +x /app/jiang13 /docker-entrypoint.sh
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"]

View File

@@ -4,67 +4,89 @@
APP_NAME := jiang13
MAIN_PKG := ./cmd/jiang13
BUILD_DIR := dist
VERSION := 1.0.0
DEV_DATA_DIR := dist/data
VERSION := 1.1.5
LDFLAGS := -s -w -X main.version=$(VERSION)
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
GO := go
GOFLAGS := -trimpath
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build docker compose-up compose-down
all: build
frontend-build:
cd frontend && npm install && npm run build
## 编译当前平台二进制
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
build: frontend-build
@mkdir -p $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64
build-windows:
## Windows amd64(先打包前端再 embed)
build-windows: frontend-build
@mkdir -p $(BUILD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
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:
## Linux amd64(先打包前端再 embed)
build-linux: frontend-build
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
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:
## macOS arm64 (Apple Silicon)(先打包前端再 embed)
build-darwin: frontend-build
@mkdir -p $(BUILD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
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: build-windows build-linux build-darwin build
## 跨平台全量编译(frontend-build 只跑一次)
build-all: frontend-build
@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
## 本地运行(仅后端,使用已 embed 的前端)
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致)
run:
$(GO) run $(MAIN_PKG) --port 3000 --data ./data
@mkdir -p $(DEV_DATA_DIR)
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR)
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出)
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
dev:
@echo "前端热更新: http://localhost:5173"
@echo "后端 API : http://localhost:3000"
@echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)"
@mkdir -p $(DEV_DATA_DIR)
@trap 'kill 0' INT; \
$(GO) run $(MAIN_PKG) --port 3000 --data ./data & \
$(GO) run $(MAIN_PKG) --dev --data $(DEV_DATA_DIR) & \
cd frontend && (test -d node_modules || npm install) && npm run dev
## 清理编译产物
clean:
rm -rf $(BUILD_DIR)
## 构建 Docker 镜像
docker:
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
## Docker Compose 启动
compose-up:
docker compose up -d --build
## Docker Compose 停止
compose-down:
docker compose down
help:
@echo "姜十三论坛编译命令:"
@echo " make build - 编译当前平台"
@@ -74,3 +96,6 @@ help:
@echo " make build-all - 编译全部平台"
@echo " make run - 仅启动后端(:3000)"
@echo " make dev - 前端热更新开发(:5173 + :3000)"
@echo " make docker - 构建 Docker 镜像"
@echo " make compose-up - Docker Compose 启动"
@echo " make compose-down - Docker Compose 停止"

524
README.md
View File

@@ -1,187 +1,445 @@
<div align="center">
# 姜十三论坛 Jiang13 Forum
轻量自用论坛系统,面向小圈子内部交流。与 Gitea 相同,编译为**单个独立 Go 二进制**,前端静态资源 embed 内嵌,内置 SQLite,开箱即用。
**能聊 · 好看 · 好装**
## 项目目录结构
面向小圈子、团队与同好社群的轻量现代化论坛。
编译为单个 Go 二进制,前端 SPA(单页应用)内嵌,内置 SQLite,拷到服务器即可运行。
```
js13-forum/
├── cmd/jiang13/main.go # 程序入口
├── config/config.go # 命令行参数与配置
├── model/
│ ├── models.go # GORM 模型定义
│ └── db.go # 数据库初始化与自动迁移
├── service/ # 业务逻辑层
│ ├── auth.go # 注册/登录/JWT
│ ├── user.go # 用户资料/头像
│ ├── board.go # 板块 CRUD
│ ├── post.go # 帖子/点赞/收藏
│ ├── comment.go # 楼层评论
│ ├── backup.go # SQLite 备份
│ ├── ratelimit.go # 访问限流
│ └── common.go # 敏感词过滤等
├── handler/
│ ├── handlers.go # 前台页面与 API
│ └── admin.go # 后台管理
├── middleware/auth.go # JWT 鉴权与限流中间件
├── router/router.go # 路由注册
├── embed_static/
│ ├── embed.go # go:embed 声明
│ ├── static/css/style.css # 内嵌 CSS
│ ├── static/js/app.js # 内嵌 JS
│ └── templates/ # 内嵌 HTML 模板
├── go.mod
├── Makefile
└── README.md
```
<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-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json)
[![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
[在线演示](https://bbs.iioio.com/) ·
[快速开始](#-快速开始) ·
[界面预览](#-界面预览) ·
[功能亮点](#-功能亮点) ·
[路线图](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 兼容对象存储
### 部署体验
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
- **跨平台** — Windows / Linux / macOS 一键编译
- **系统服务** — 内置 Linux systemd / Windows Service 注册
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
---
## 快速开始
### 1. 编译
**Windows(推荐,无需 GNU Make):**
**Windows(推荐):**
```powershell
# PowerShell
.\build.ps1
# 或双击 / cmd
```bat
build.bat
```
**Linux / macOS(需 GNU Make):**
> 请通过 `build.bat` 调用(内部已处理 ExecutionPolicy)。不要直接 `.\build.ps1`,也不要在 Windows 上使用系统自带的 Embarcadero `make`。
**Linux / macOS:**
```bash
make build
```
**手动分步(全平台通用):**
**手动分步(全平台):**
```bash
cd frontend && npm install && npm run build
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13.exe ./cmd/jiang13
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
```
> Windows 自带的 `make` 通常是 Embarcadero MAKE,**不能**识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make(`choco install make`)后再用 `make build`。
跨平台编译:
```powershell
.\build.ps1 -Target build-windows
.\build.ps1 -Target build-linux
.\build.ps1 -Target build-all
```bat
build.bat -Target build-windows
build.bat -Target build-linux
build.bat -Target build-all
```
### 2. 启动
### 2. Docker 部署(推荐)
**一键启动(本地构建):**
```bash
docker compose up -d --build
```
```bat
.\build.bat -Target compose-up
```
```bash
make compose-up
```
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
**拉取已构建镜像(Docker Hub):**
```bash
docker pull hangzhang714128/jiang13-forum:latest
docker run -d --name jiang13 \
-p 3000:3000 \
-v jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
```
**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
**可选环境变量(容器编排):**
| 变量 | 说明 |
|------|------|
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
| `JIANG13_CONFIG` | 配置文件路径 |
| `JIANG13_WORK_PATH` | 工作目录 |
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
**发布镜像到 Docker Hub(手动):**
```bash
docker login
.\build.bat -Target docker # Windows
# make docker # Linux/macOS
docker push hangzhang714128/jiang13-forum:1.1.5
docker push hangzhang714128/jiang13-forum:latest
```
或直接构建:
```bash
docker build --build-arg VERSION=1.1.5 -t hangzhang714128/jiang13-forum:1.1.5 -t hangzhang714128/jiang13-forum:latest .
docker push hangzhang714128/jiang13-forum:1.1.5
docker push hangzhang714128/jiang13-forum:latest
```
停止服务:`docker compose down` / `.\build.bat -Target compose-down` / `make compose-down`
**构建失败(无法连接 auth.docker.io)?** Dockerfile 已默认经 DaoCloud 拉取基础镜像。若仍超时,可在 Docker Desktop → Settings → Docker Engine 添加:
```json
{
"registry-mirrors": ["https://docker.m.daocloud.io"]
}
```
保存并重启 Docker 后重试 `.\build.bat -Target docker`。PowerShell 中请用 `.\build.bat`(带 `.\` 前缀)。
**1Panel 部署提示:**
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
2. 端口映射 `3000:3000`
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
### 3. 直接启动(二进制)
把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
```bash
# Windows
.\dist\jiang13.exe --port 3000 --data ./data
.\dist\jiang13.exe
# Linux / macOS
./dist/jiang13 --port 3000 --data ./data
./dist/jiang13
```
### 3. 首次使用
也可先复制示例配置再改端口 / 数据目录:
1. 浏览器打开 http://localhost:3000/register 注册账号
2. **第一个注册的用户自动成为管理员**,无需额外命令
3. 登录后可访问 http://localhost:3000/admin/dashboard 进入后台
```bash
cp app.ini.example /opt/jiang13/app.ini
# 编辑 app.ini 后:
./jiang13
```
## 启动参数
### 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` > 内置默认值。
### 启动参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--port` | 3000 | HTTP 监听端口 |
| `--data` | ./data | 数据目录(SQLite、上传、日志) |
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则自动生成并持久化到 data/.jwt_secret) |
| `--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` 按域名自动为枢纽,见上表)
```
data/
├── jiang13.db # SQLite 主数据库
├── jiang13.log # 运行日志
├── filter_words.txt # 敏感词配置(可编辑)
├── .jwt_secret # JWT 密钥(自动生成)
├── uploads/avatars/ # 用户头像(运行时写入,非 embed)
└── jiang13_backup_*.db # 后台导出的备份文件
### 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
```
## 修改前端并重新打包(Gitea 同款流程)
**Windows(Windows Service,需管理员 PowerShell):**
1. 编辑 `embed_static/templates/` 下的 HTML 模板
2. 编辑 `embed_static/static/css/` 或 `static/js/` 下的静态资源
3. 重新编译:`make build`
4. 替换旧二进制,重启服务
前端资源通过 `//go:embed` 编译进二进制,**运行时不需要单独的前端文件夹**。只有用户上传的头像保存在 `--data` 目录。
```go
// embed_static/embed.go
//go:embed static/*
var staticFS embed.FS
//go:embed templates/*
var templatesFS embed.FS
```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`。
- 用户注册/登录(bcrypt + JWT Cookie)
- 普通用户 / 管理员两级权限
- 板块管理、发帖、富文本 HTML、标签、置顶
- 楼层式评论,支持回复指定楼层
- 点赞、收藏
- 管理员:删帖、删评论、禁言、SQLite 备份
- 内置敏感词过滤、发帖/评论限流
---
## 技术栈
- **后端**:Go 1.26 + Gin + GORM + SQLite
- **前端**:React 18 + Arco Design + TanStack Virtual(虚拟滚动)
- **打包**:Vite 构建 → `go:embed` 内嵌 SPA,与 Gitea 同款单二进制
## 前端开发(热更新 HMR)
日常改前端**不需要**重新 `npm run build` 或 `go build`,用 Vite 开发服务器即可秒级热更新:
```bash
# 一键启动:Go 后端 + Vite 热更新(推荐)
.\build.ps1 -Target dev # Windows
make dev # Linux / macOS(需 GNU Make)
# 浏览器访问 http://localhost:5173
# API 自动代理到 http://localhost:3000
```
也可开两个终端分别启动:
```bash
# 终端 1:仅后端
.\build.ps1 -Target run # 或 make run
# 终端 2:仅前端热更新
cd frontend && npm install && npm run dev
```
**何时才需要完整构建:**
- 改 Go 代码、模板、embed 静态资源 → `go build` / `make build`
- 发布单二进制前 → `npm run build` + `make build`(前端产物写入 `embed_static/static/spa/` 后 embed 进二进制)
> 若直接访问 `:3000`,看到的是**上次 build 嵌入**的前端,不会有热更新。开发时请用 `:5173`。
### 前端特性(高密度 V2EX/NGA 风格)
| 特性 | 实现 |
| 层级 | 技术 |
|------|------|
| 三栏布局 | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
| 虚拟滚动 | `@tanstack/react-virtual` 帖列表 & 楼层回复 |
| 已读/未读 | 未读高亮 + 角标 + 批量标记已读 |
| 主题 | 浅色/暗黑一键切换,平板/手机自动收起侧栏 |
| 交互 | 滚动加载更多、快捷回帖、楼层锚点、引用回复、@高亮 |
| **后端** | Go 1.26 · Gin · GORM · SQLite |
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
---
## 前端开发
日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换):
```bat
build.bat -Target dev
```
```bash
make dev
```
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
**何时需要完整构建:**
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
- 更新 README 界面截图 → 见下方「更新截图」
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
### 更新截图
默认从演示站抓取到 `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 # 多阶段 Docker 构建
├── docker-compose.yml # 单容器 Compose 部署
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限)
├── .dockerignore
├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑
├── handler/ # HTTP 处理器(前台 + 后台)
├── middleware/ # JWT 鉴权等
├── router/ # 路由注册
├── embed_static/ # go:embed 内嵌的 SPA
├── frontend/ # React 源码(Vite 构建)
├── 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 — 自用随意,欢迎修改。
[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

View File

@@ -1,4 +1,4 @@
@echo off
REM 姜十三论坛 Windows 快捷构建(双击或命令行均可)
REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
exit /b %ERRORLEVEL%

View File

@@ -3,7 +3,7 @@
# .\build.ps1 -Target build-windows
param(
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'help')]
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
[string]$Target = 'build'
)
@@ -11,7 +11,9 @@ $ErrorActionPreference = 'Stop'
$AppName = 'jiang13'
$MainPkg = './cmd/jiang13'
$BuildDir = 'dist'
$Version = '1.0.0'
$DevDataDir = 'dist/data'
$Version = '1.1.5'
$RegistryImage = 'hangzhang714128/jiang13-forum'
$Ldlags = "-s -w -X main.version=$Version"
function Ensure-Dir($path) {
@@ -39,16 +41,30 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
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" -ForegroundColor Cyan
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) {
@@ -62,6 +78,9 @@ switch ($Target) {
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean'
Write-Host '.\build.ps1 -Target docker build Docker image'
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build'
Write-Host '.\build.ps1 -Target compose-down docker compose down'
Write-Host ''
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
}
@@ -72,18 +91,21 @@ switch ($Target) {
Write-Host '[ok] cleaned dist' -ForegroundColor Green
}
'run' {
go run $MainPkg --port 3000 --data ./data
Ensure-Dir $DevDataDir
go run $MainPkg --data $DevDataDir
}
'dev' {
$root = (Get-Location).Path
Ensure-Dir $DevDataDir
Write-Host ''
Write-Host '[dev] 前端热更新: http://localhost:5173' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --port 3000 --data ./data"
"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
@@ -103,6 +125,7 @@ switch ($Target) {
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
}
'build-linux' {
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
Build-Frontend
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
}
@@ -117,4 +140,20 @@ switch ($Target) {
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
Write-Host '[ok] all platforms done' -ForegroundColor Green
}
'docker' {
Write-Host "[docker] build $RegistryImage`:$Version and latest" -ForegroundColor Cyan
docker build --build-arg "VERSION=$Version" -t "${RegistryImage}:$Version" -t "${RegistryImage}:latest" .
if ($LASTEXITCODE -ne 0) { throw 'docker build failed' }
Write-Host '[ok] docker image built' -ForegroundColor Green
}
'compose-up' {
docker compose up -d --build
if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' }
Write-Host '[ok] compose started' -ForegroundColor Green
}
'compose-down' {
docker compose down
if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' }
Write-Host '[ok] compose stopped' -ForegroundColor Green
}
}

View File

@@ -1,76 +1,48 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jiang13/forum/config"
"github.com/jiang13/forum/model"
"github.com/jiang13/forum/router"
"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)
}
// 日志同时输出到控制台和文件
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
svcCfg, err := buildServiceConfig(cfg)
if err != nil {
log.Fatalf("打开日志文件失败: %v", err)
}
defer logFile.Close()
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Println("========================================")
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
log.Println("========================================")
// 初始化数据库
if err := model.InitDB(cfg.DBPath()); err != nil {
log.Fatalf("数据库初始化失败: %v", err)
log.Fatalf("构建服务配置失败: %v", err)
}
// 设置路由
engine, err := router.Setup(cfg)
prg := &program{cfg: cfg}
svc, err := service.New(prg, svcCfg)
if err != nil {
log.Fatalf("路由初始化失败: %v", err)
log.Fatalf("创建系统服务失败: %v", err)
}
addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: engine,
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
}
// 优雅关机
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("收到关机信号,正在优雅关闭...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("HTTP 服务关闭异常: %v", err)
// 交互终端或由服务管理器拉起时均走 Run:
// Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
if err := svc.Run(); err != nil {
log.Fatalf("运行失败: %v", err)
}
}()
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
log.Printf("数据目录: %s", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP 服务异常: %v", err)
}
log.Println("姜十三论坛已安全退出")
}

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

@@ -0,0 +1,162 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config"
"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 := 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)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
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)
}

View File

@@ -5,67 +5,254 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Config 应用全局配置,通过命令行参数注入
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
const (
StorageTypeLocal = "local"
StorageTypeS3 = "s3"
)
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
type Config struct {
// 工作目录(默认可执行文件所在目录)
WorkPath string
// 配置文件绝对路径
ConfigFile string
// 监听端口
Port int
// 数据目录:SQLite 数据库、上传头像、日志文件均存放于此
// 数据目录:SQLite、上传、日志(绝对路径)
DataDir string
// JWT 签名密钥
JWTSecret string
// 日志文件路径(相对 DataDir)
// 日志文件路径
LogFile string
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
ServiceAction string
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
DevMode bool
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
CommunityHub bool
}
// Parse 解析命令行参数并初始化目录
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
//
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
func Parse() (*Config, error) {
port := flag.Int("port", 3000, "HTTP 监听端口")
dataDir := flag.String("data", "./data", "数据存储目录(数据库、上传、日志)")
jwtSecret := flag.String("jwt-secret", "", "JWT 签名密钥(留空则自动生成并持久化)")
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()
// 确保数据目录存在
if err := os.MkdirAll(*dataDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err)
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
if action != "" && !validServiceAction(action) {
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
}
uploadDir := filepath.Join(*dataDir, "uploads", "avatars")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %w", err)
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{
Port: *port,
DataDir: *dataDir,
JWTSecret: *jwtSecret,
LogFile: filepath.Join(*dataDir, "jiang13.log"),
WorkPath: workPath,
ConfigFile: configFile,
Port: port,
DataDir: absData,
JWTSecret: jwtSecret,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
DevMode: *devFlag,
CommunityHub: communityHub,
}
// 处理 JWT 密钥持久化
secretFile := filepath.Join(*dataDir, ".jwt_secret")
if cfg.JWTSecret == "" {
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
cfg.JWTSecret = string(data)
} else {
cfg.JWTSecret = generateRandomSecret(32)
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
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
}
// DBPath 返回 SQLite 数据库文件路径
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")
}
// UploadDir 返回头像上传目录
func (c *Config) UploadDir() string {
// 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")

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

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
# 姜十三论坛 — Docker Compose 单服务部署
# 启动:docker compose up -d --build
# 或:make compose-up / build.bat -Target compose-up
services:
jiang13:
image: hangzhang714128/jiang13-forum:latest
build:
context: .
dockerfile: Dockerfile
args:
VERSION: dev
ports:
- "3000:3000"
volumes:
- jiang13-data:/data
# 可选:挂载自定义 app.ini(只读)
# - ./app.ini:/app/app.ini:ro
environment:
TZ: Asia/Shanghai
# 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret)
# JIANG13_JWT_SECRET: your-secret-here
restart: unless-stopped
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
jiang13-data:

15
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -e
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。
DATA_DIR="/data"
if [ "$(id -u)" = '0' ]; then
mkdir -p "$DATA_DIR"
chown -R jiang13:jiang13 "$DATA_DIR"
exec su-exec jiang13 /app/jiang13 "$@"
fi
exec /app/jiang13 "$@"

105
docs/docker-data.md Normal file
View File

@@ -0,0 +1,105 @@
# Docker 数据持久化说明
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
## 挂载对应关系
| 宿主机 | 容器内 |
|--------|--------|
| 任意目录或 named volume(如 `jiang13-data`) | **`/data`** |
**示例:**
```bash
docker run -d --name jiang13 \
-p 3000:3000 \
-v /你的路径/jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
```
或在 `docker-compose.yml` 中:
```yaml
volumes:
- jiang13-data:/data
# 或绑定宿主机目录:
# - ./data:/data
```
## `/data` 目录结构
数据库与附件都在同一个 `/data` 挂载内:
```
/data/
├── jiang13.db # SQLite 主数据库(不含浏览量)
├── monitor.db # 网站监控 page_views
├── uploads/ # 附件根目录
│ ├── avatars/ # 用户头像
│ └── posts/ # 帖子图片等
├── logs/
│ └── access/ # 网站监控请求日志(按日 jsonl)
│ └── YYYY-MM-DD.jsonl
├── IP2LOCATION-LITE-DB3.BIN # 可选:IPv4 城市库(监控)
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选:IPv6 城市库
├── GeoLite2-ASN.mmdb # 可选:ASN/运营商
├── GeoLite2-Country.mmdb # 可选:国家兜底
├── .jwt_secret # JWT 密钥(自动生成)
├── filter_words.txt # 敏感词配置
└── jiang13.log # 运行日志(若启用)
```
| 用途 | 路径 |
|------|------|
| 主数据库 | `/data/jiang13.db` |
| 监控浏览量 | `/data/monitor.db` |
| 附件 | `/data/uploads/` |
| 请求日志(监控) | `/data/logs/access/` |
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN`、`GeoLite2-ASN.mmdb` 等 |
官方镜像不要求拆成两个 volume;挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP(不做脱敏)。
## 设计说明:为何只挂一个 `/data`
对本项目(单容器、SQLite、默认可本地存附件)而言,**一个数据卷是合理默认**:
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
## 后续计划(可选增强,非必须)
按优先级,仅在有明确需求时做:
1. **文档与面板提示(优先)**
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
- 本页保持为权威说明,避免用户误以为漏了一个卷。
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
```yaml
volumes:
# 推荐:父目录一次挂载
- /ssd/jiang13:/data
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
- /ssd/jiang13:/data
- /hdd/jiang13-uploads:/data/uploads
```
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
3. **产品级:可配置上传根目录(中期)**
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
4. **不做的事**
- 不为「看起来像 WordPress」而强制双卷。
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。

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

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

@@ -0,0 +1,28 @@
# 站务公告 · 发帖素材
## 板块设置
| 项 | 建议值 |
| --- | --- |
| 名称 | 站务公告 |
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
| 图标 | `megaphone` |
| 色标 | 建议第 0 或 1 槽 |
| 排序 | `0` |
## 主文(必发)
| 文件 | 标题 | 标签 | 操作 |
| --- | --- | --- | --- |
| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) |
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
## 可选短文
若站务区希望不止一篇,见 [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,30 @@
# 本站发帖约定
> 建议标题:本站发帖约定
> 建议标签:`站务` `公约`
> 建议操作:普通发布(可选)
---
本站是小圈子论坛,约定尽量少、好遵守。
## 请这样做
- 选对板块再发;不确定就发到综合讨论类板块
- 标题写清楚在说什么,方便后人搜索
- 讨论对事不对人;引用、回复尽量指向具体楼层
- 分享链接时简单说明来源与用途
## 请避免
- 在「站务公告」灌水、闲聊(请到其他板块)
- 无意义刷屏、重复灌帖
- 人身攻击、骚扰、泄露他人隐私
- 明显的广告、引流(除非板块明确允许)
## 管理说明
管理员可能会:移动板块、编辑标题、锁定、删除或禁言。
有争议可私信管理员说明情况。
一句话:**把讨论留给值得留下的内容。**

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) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 |

View File

@@ -2,9 +2,9 @@ package embed_static
import (
"embed"
"html/template"
"io/fs"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
@@ -13,61 +13,106 @@ import (
//go:embed static/*
var staticFS embed.FS
//go:embed templates/*
var templatesFS embed.FS
var (
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
spaBrandTitleFn func() string
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
)
// SetupEmbed 配置内嵌资源:SPA 前端 + 后台 HTML 模板
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
func SetSPADocumentTitle(fn func() string) {
spaBrandTitleFn = fn
}
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
func SetSPABrandingJSON(fn func() []byte) {
spaBrandJSONFn = fn
}
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
func SetSPAFaviconURL(fn func() string) {
spaBrandFaviconFn = fn
}
// SetupEmbed 配置内嵌资源:React SPA 静态资源
func SetupEmbed(r *gin.Engine) error {
tmpl, err := LoadTemplates()
if err != nil {
return err
}
r.SetHTMLTemplate(tmpl)
// React SPA 构建产物(Vite)
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
r.GET("/assets/*filepath", gin.WrapH(http.StripPrefix("/assets", http.FileServer(http.FS(sub)))))
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
r.GET("/assets/*filepath", func(c *gin.Context) {
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
c.Writer = w
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
// 后台管理遗留静态资源
if sub, err := fs.Sub(staticFS, "static/legacy"); err == nil {
r.GET("/legacy/*filepath", gin.WrapH(http.StripPrefix("/legacy", http.FileServer(http.FS(sub)))))
if sub, err := fs.Sub(staticFS, "static/spa/stickers"); err == nil {
fileServer := http.StripPrefix("/stickers", http.FileServer(http.FS(sub)))
r.GET("/stickers/*filepath", func(c *gin.Context) {
// stickers 不含哈希指纹,设置适中的缓存
c.Header("Cache-Control", "public, max-age=86400")
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
return nil
}
// ServeSPA 返回 React SPA 入口
func ServeSPA(c *gin.Context) {
data, err := staticFS.ReadFile("static/spa/index.html")
if err != nil {
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
return
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
type cacheOnOKWriter struct {
gin.ResponseWriter
cacheControl string
wroteHeader bool
}
func (w *cacheOnOKWriter) WriteHeader(code int) {
if !w.wroteHeader {
w.wroteHeader = true
if code == http.StatusOK {
w.Header().Set("Cache-Control", w.cacheControl)
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}
w.ResponseWriter.WriteHeader(code)
}
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
func ServeSPA(c *gin.Context) {
ServeSPAWithMeta(c, nil)
}
// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页)
func ServeSPANoIndex(c *gin.Context) {
title := ""
if spaBrandTitleFn != nil {
title = strings.TrimSpace(spaBrandTitleFn())
}
ServeSPAWithMeta(c, &SPAPageMeta{
Title: title,
Robots: "noindex,nofollow",
})
}
// IsSPARoute 判断是否应由 SPA 处理
func IsSPARoute(path string) bool {
if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
return false
}
if strings.HasPrefix(path, "/api") ||
strings.HasPrefix(path, "/admin") ||
strings.HasPrefix(path, "/uploads") ||
strings.HasPrefix(path, "/legacy") ||
strings.HasPrefix(path, "/assets") {
strings.HasPrefix(path, "/media") ||
strings.HasPrefix(path, "/assets") ||
strings.HasPrefix(path, "/stickers") ||
strings.HasPrefix(path, "/oauth") ||
strings.HasPrefix(path, "/.well-known") {
return false
}
return true
}
func LoadTemplates() (*template.Template, error) {
sub, err := fs.Sub(templatesFS, "templates")
if err != nil {
return nil, err
}
tmpl := template.New("").Funcs(template.FuncMap{
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
})
return tmpl.ParseFS(sub, "*.html", "admin/*.html")
}

202
embed_static/spa_meta.go Normal file
View File

@@ -0,0 +1,202 @@
package embed_static
import (
"bytes"
"encoding/json"
"html"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。
type SPAPageMeta struct {
Title string // 完整 <title>
Description string
Keywords string // meta keywords
Canonical string
OGType string // 默认 website
OGImage string
SiteName string // og:site_name
Locale string // og:locale,默认 zh_CN
Robots string // 如 noindex,nofollow
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
Status int // HTTP 状态码,0 视为 200
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
}
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
status := http.StatusOK
if meta != nil && meta.Status != 0 {
status = meta.Status
}
data, err := staticFS.ReadFile("static/spa/index.html")
if err != nil {
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
return
}
data = applySPAPageMeta(data, meta)
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Data(status, "text/html; charset=utf-8", data)
}
func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
if meta == nil {
meta = &SPAPageMeta{}
}
title := strings.TrimSpace(meta.Title)
if title == "" && spaBrandTitleFn != nil {
title = strings.TrimSpace(spaBrandTitleFn())
}
if title != "" {
escaped := html.EscapeString(title)
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
}
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
var seo strings.Builder
writeMeta(&seo, "description", meta.Description)
writeMeta(&seo, "keywords", meta.Keywords)
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
}
if favicon := spaFaviconHref(); favicon != "" {
seo.WriteString(`<link rel="icon" href="` + html.EscapeString(favicon) + `"/>`)
seo.WriteString(`<link rel="shortcut icon" href="` + html.EscapeString(favicon) + `"/>`)
}
robots := strings.TrimSpace(meta.Robots)
if robots != "" {
writeMeta(&seo, "robots", robots)
}
ogType := strings.TrimSpace(meta.OGType)
if ogType == "" {
ogType = "website"
}
locale := strings.TrimSpace(meta.Locale)
if locale == "" {
locale = "zh_CN"
}
writeProp(&seo, "og:type", ogType)
writeProp(&seo, "og:site_name", meta.SiteName)
writeProp(&seo, "og:locale", locale)
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
writeProp(&seo, "og:description", meta.Description)
writeProp(&seo, "og:url", meta.Canonical)
writeProp(&seo, "og:image", meta.OGImage)
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
writeMetaName(&seo, "twitter:description", meta.Description)
writeMetaName(&seo, "twitter:image", meta.OGImage)
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
seo.WriteString(`<script type="application/ld+json">`)
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
seo.WriteString(`</script>`)
}
if seo.Len() > 0 {
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
}
// —— 可执行 boot 脚本仍放在 </head> 前 ——
var boot strings.Builder
if s := spaBrandingBootScript(); s != "" {
boot.WriteString(s)
}
if s := spaHomeBootScript(meta.BootJSON); s != "" {
boot.WriteString(s)
}
if boot.Len() > 0 {
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
}
if root := strings.TrimSpace(meta.RootHTML); root != "" {
data = injectSPARootHTML(data, root)
}
return data
}
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO)
func spaHomeBootScript(raw []byte) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || !json.Valid(raw) {
return ""
}
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
}
// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白)
func injectSPARootHTML(data []byte, rootHTML string) []byte {
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
}
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
func spaBrandingBootScript() string {
if spaBrandJSONFn == nil {
return ""
}
raw := bytes.TrimSpace(spaBrandJSONFn())
if len(raw) == 0 || !json.Valid(raw) {
return ""
}
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
}
func writeMeta(b *strings.Builder, name, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
b.WriteString(`<meta name="` + html.EscapeString(name) + `" content="` + html.EscapeString(content) + `"/>`)
}
func writeMetaName(b *strings.Builder, name, content string) {
writeMeta(b, name, content)
}
func writeProp(b *strings.Builder, prop, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
b.WriteString(`<meta property="` + html.EscapeString(prop) + `" content="` + html.EscapeString(content) + `"/>`)
}
func twitterCard(ogImage string) string {
if strings.TrimSpace(ogImage) != "" {
return "summary_large_image"
}
return "summary"
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
func spaFaviconHref() string {
if spaBrandFaviconFn == nil {
return ""
}
return strings.TrimSpace(spaBrandFaviconFn())
}

View File

@@ -1,80 +0,0 @@
/* 姜十三论坛全局样式 */
:root {
--primary: #2c5530;
--primary-light: #3d7a44;
--accent: #c9a227;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
}
.navbar-brand {
font-weight: 700;
letter-spacing: 0.05em;
}
.site-footer {
margin-top: auto;
background: #212529;
color: #adb5bd;
padding: 1.5rem 0;
font-size: 0.875rem;
}
.post-content {
line-height: 1.8;
word-break: break-word;
}
.post-content img {
max-width: 100%;
height: auto;
}
.comment-floor {
border-left: 3px solid var(--primary);
padding-left: 1rem;
}
.avatar-sm {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 50%;
}
.avatar-md {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 50%;
}
.badge-pin {
background: var(--accent);
}
.admin-sidebar .nav-link.active {
background: var(--primary);
color: #fff;
}
.admin-sidebar .nav-link {
color: #333;
border-radius: 0.375rem;
margin-bottom: 0.25rem;
}
.login-slogan {
font-style: italic;
color: #6c757d;
letter-spacing: 0.1em;
}
@media (max-width: 576px) {
.navbar-brand span.en { display: none; }
}

View File

@@ -1,28 +0,0 @@
// 姜十三论坛前端通用脚本
function apiPost(url, data, isForm) {
const opts = { method: 'POST', credentials: 'same-origin' };
if (isForm) {
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
if (!(data instanceof HTMLFormElement)) {
const fd = new FormData();
for (const k in data) fd.append(k, data[k]);
opts.body = fd;
}
} else {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(data);
}
return fetch(url, opts).then(r => r.json());
}
function showToast(msg, type) {
const el = document.getElementById('toast');
if (!el) { alert(msg); return; }
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
el.querySelector('.toast-body').textContent = msg;
new bootstrap.Toast(el).show();
}
function confirmAction(msg, callback) {
if (confirm(msg)) callback();
}

View File

@@ -1,499 +0,0 @@
/* 姜十三论坛 - 前台 + 管理后台样式 */
:root {
--primary: #1a7f4b;
--primary-dark: #156b3f;
--primary-light: #e8f5ee;
--accent: #c9a227;
--admin-sidebar-w: 220px;
}
body {
min-height: 100vh;
background-color: #f4f6f8;
color: #1d2129;
}
/* ===== 管理后台 ===== */
.admin-body {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f2f3f5;
}
.admin-topbar {
height: 56px;
background: var(--primary);
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,.1);
}
.admin-topbar-brand {
display: flex;
align-items: center;
gap: 12px;
}
.admin-topbar-mark {
width: 34px;
height: 34px;
border-radius: 8px;
background: rgba(255,255,255,.2);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 15px;
flex-shrink: 0;
}
.admin-topbar-title {
display: block;
font-weight: 600;
font-size: 15px;
line-height: 1.3;
}
.admin-topbar-sub {
display: block;
font-size: 12px;
opacity: .75;
font-weight: 400;
}
.admin-topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.admin-topbar-user {
font-size: 13px;
opacity: .9;
padding-right: 4px;
border-right: 1px solid rgba(255,255,255,.25);
margin-right: 4px;
padding-left: 0;
}
.admin-shell {
display: flex;
flex: 1;
min-height: 0;
}
.admin-sidebar {
width: var(--admin-sidebar-w);
flex-shrink: 0;
background: #fff;
border-right: 1px solid #e5e6eb;
padding: 20px 14px;
overflow-y: auto;
}
.admin-sidebar-section {
font-size: 11px;
font-weight: 600;
color: #86909c;
padding: 12px 12px 6px;
letter-spacing: .06em;
}
.admin-sidebar-section:first-child {
padding-top: 4px;
}
.admin-sidebar .nav-link {
display: block;
color: #4e5969;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 2px;
font-size: 14px;
text-decoration: none;
transition: background .15s, color .15s;
border-left: 3px solid transparent;
}
.admin-sidebar .nav-link:hover {
background: #f7f8fa;
color: #1d2129;
}
.admin-sidebar .nav-link.active {
background: var(--primary-light);
color: var(--primary);
font-weight: 600;
border-left-color: var(--primary);
}
.admin-main {
flex: 1;
min-width: 0;
padding: 28px 32px;
overflow-y: auto;
max-width: 1280px;
}
.admin-page-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.admin-page-head h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 6px;
color: #1d2129;
}
.admin-page-head p {
margin: 0;
color: #86909c;
font-size: 14px;
}
.admin-search-bar {
display: flex;
gap: 8px;
align-items: center;
}
.admin-search-bar .form-control {
width: 240px;
border-color: #e5e6eb;
font-size: 13px;
}
.admin-stat-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.admin-stat-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 12px;
padding: 18px 20px;
display: flex;
align-items: center;
gap: 14px;
transition: box-shadow .2s;
}
.admin-stat-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,.06);
}
.admin-stat-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
flex-shrink: 0;
}
.admin-stat-users .admin-stat-icon { background: #e8f3ff; color: #3491fa; }
.admin-stat-posts .admin-stat-icon { background: #e8f5ee; color: var(--primary); }
.admin-stat-boards .admin-stat-icon { background: #fff7e8; color: #ff7d00; }
.admin-stat-comments .admin-stat-icon { background: #f5e8ff; color: #722ed1; }
.admin-stat-online .admin-stat-icon { background: #e8ffea; color: #00b42a; }
.admin-stat-users .value { color: #3491fa; }
.admin-stat-posts .value { color: var(--primary); }
.admin-stat-boards .value { color: #ff7d00; }
.admin-stat-comments .value { color: #722ed1; }
.admin-stat-online .value { color: #00b42a; }
.admin-stat-card .value {
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.admin-stat-card .label {
font-size: 13px;
color: #86909c;
margin-top: 2px;
}
.admin-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.admin-card-head {
padding: 16px 20px;
border-bottom: 1px solid #f2f3f5;
font-weight: 600;
font-size: 15px;
display: flex;
justify-content: space-between;
align-items: center;
color: #1d2129;
}
.admin-card-link {
font-size: 13px;
font-weight: 500;
color: var(--primary);
text-decoration: none;
transition: opacity .15s;
}
.admin-card-link:hover {
opacity: .75;
color: var(--primary-dark);
}
.admin-card-body {
padding: 20px;
}
.admin-table {
margin: 0;
font-size: 13px;
--bs-table-hover-bg: #f7f8fa;
}
.admin-table th {
background: #fafbfc;
font-weight: 600;
white-space: nowrap;
color: #4e5969;
font-size: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e5e6eb;
}
.admin-table td {
vertical-align: middle;
padding: 12px 16px;
border-bottom: 1px solid #f2f3f5;
color: #1d2129;
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.admin-table tbody tr:hover td {
background: #fafbfc;
}
.admin-table .btn-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.admin-table .btn-link:hover {
text-decoration: underline;
}
.text-truncate-cell {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-pin {
background: #ff7d00;
color: #fff;
font-weight: 500;
font-size: 11px;
}
.badge-admin {
background: var(--primary-light);
color: var(--primary);
font-weight: 500;
}
.badge-banned {
background: #ffece8;
color: #f53f3f;
font-weight: 500;
}
.admin-empty {
text-align: center;
padding: 48px 20px;
color: #86909c;
font-size: 14px;
}
.admin-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #f2f3f5;
}
.admin-pagination-info {
font-size: 13px;
color: #86909c;
padding: 0 8px;
}
.admin-body .btn-success {
background: var(--primary);
border-color: var(--primary);
}
.admin-body .btn-success:hover {
background: var(--primary-dark);
border-color: var(--primary-dark);
}
.admin-body .btn-outline-success {
color: var(--primary);
border-color: var(--primary);
}
.admin-body .btn-outline-success:hover {
background: var(--primary);
border-color: var(--primary);
}
.admin-body .form-control:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(26,127,75,.12);
}
.admin-body .form-label {
color: #4e5969;
margin-bottom: 4px;
}
.admin-login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(160deg, #f2f3f5 0%, #e8f5ee 60%, #d4edda 100%);
padding: 24px;
}
.admin-login-card {
width: 100%;
max-width: 400px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 16px;
padding: 36px 32px;
box-shadow: 0 12px 40px rgba(0,0,0,.08);
}
.admin-login-mark {
width: 52px;
height: 52px;
border-radius: 14px;
background: var(--primary);
color: #fff;
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 18px;
box-shadow: 0 4px 12px rgba(26,127,75,.25);
}
.login-slogan {
color: #86909c;
font-size: 13px;
}
.info-row {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f2f3f5;
font-size: 13px;
}
.info-row:last-child { border-bottom: none; }
.info-row .info-label { width: 100px; color: #86909c; flex-shrink: 0; }
.info-row .info-value { flex: 1; word-break: break-all; color: #1d2129; }
#adminToast.toast {
background: #1d2129;
color: #fff;
border: none;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,.15);
}
@media (max-width: 1100px) {
.admin-stat-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.admin-shell { flex-direction: column; }
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e5e6eb;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 10px 12px;
}
.admin-sidebar-section { display: none; }
.admin-sidebar .nav-link {
padding: 7px 12px;
font-size: 13px;
border-left: none;
}
.admin-sidebar .nav-link.active {
border-left: none;
}
.admin-main { padding: 16px; max-width: none; }
.admin-stat-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.admin-stat-card { padding: 14px 16px; }
.admin-stat-card .value { font-size: 22px; }
.admin-topbar-user { display: none; }
.admin-search-bar .form-control { width: 160px; }
}
@media (max-width: 480px) {
.admin-stat-grid {
grid-template-columns: 1fr;
}
}
/* ===== 旧版前台兼容 ===== */
.navbar-brand { font-weight: 700; letter-spacing: 0.05em; }
.post-content { line-height: 1.8; word-break: break-word; }
.post-content img { max-width: 100%; height: auto; }
.avatar-sm { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; }

View File

@@ -1,79 +0,0 @@
// 姜十三论坛 - 管理后台通用脚本
async function adminFetch(url, opts) {
opts = opts || {};
var res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
var data;
try {
data = await res.json();
} catch (e) {
throw new Error('服务器响应异常,请重新登录后再试');
}
if (!res.ok) {
throw new Error(data.error || '请求失败');
}
return data;
}
function showToast(msg, type) {
var el = document.getElementById('adminToast');
if (!el) {
alert(msg);
return;
}
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
el.querySelector('.toast-body').textContent = msg;
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
}
function adminConfirm(msg, fn) {
if (confirm(msg)) fn();
}
function setBtnLoading(btn, loading) {
if (!btn) return;
btn.disabled = loading;
if (loading) {
btn.dataset.originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
} else if (btn.dataset.originalHtml) {
btn.innerHTML = btn.dataset.originalHtml;
}
}
function adminLogout() {
adminFetch('/admin/api/logout', { method: 'POST' })
.then(function () { location.href = '/admin/login'; })
.catch(function (e) { showToast(e.message, 'danger'); });
}
function postForm(url, method, fields) {
var fd = new FormData();
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
return adminFetch(url, { method: method, body: fd });
}
function reloadSoon() {
setTimeout(function () { location.reload(); }, 600);
}
// 兼容旧模板
function apiPost(url, data, isForm) {
var opts = { method: 'POST', credentials: 'same-origin' };
if (isForm) {
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
if (!(data instanceof HTMLFormElement)) {
var fd = new FormData();
for (var k in data) fd.append(k, data[k]);
opts.body = fd;
}
} else {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(data);
}
return fetch(url, opts).then(function (r) { return r.json(); });
}
function confirmAction(msg, callback) {
adminConfirm(msg, callback);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{e as j,r as n,j as s}from"./react-vendor-CgzNJrV9.js";import{u as f,a as g,B as p}from"./index-474mTIgd.js";import{S as N}from"./spinner-DH1syOsQ.js";import{n as v}from"./notify-Zesh5czd.js";import{f as y}from"./content-CKr4Ur51.js";import{A as k}from"./ui-vendor-CdH1UOKW.js";function E(){const t=j(),{user:a,loading:r}=f(),[i,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!r){if(!a){t("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>v.error(e.message)).finally(()=>x(!1))}},[a,r,t]),r||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):a?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(p,{variant:"ghost",className:"mb-3",onClick:()=>t("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",i.length," 篇收藏帖子"]}),i.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(p,{onClick:()=>t("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:i.map(e=>{var o,l,c,d,m;return s.jsx("div",{className:"post-row",onClick:()=>t(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((m=(d=e.post)==null?void 0:d.user)==null?void 0:m.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{E as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{e as f,r as g,j as s,L as b}from"./react-vendor-CgzNJrV9.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,I as i,f as m,o as N,s as d}from"./form-BzHbu9bJ.js";import{u as v,B as L,a as S}from"./index-474mTIgd.js";import{n as u}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=N({username:d().min(1,"请输入用户名"),password:d().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),u.success("登录成功"),x("/",{replace:!0})}catch(o){u.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(i,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(m,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(i,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(m,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{e as f,r as g,j as s,L as w}from"./react-vendor-CgzNJrV9.js";import{u as b,a as F,F as y,b as a,c as n,d as o,e as t,I as c,f as l,o as k,s as i}from"./form-BzHbu9bJ.js";import{u as N,B as S,a as v}from"./index-474mTIgd.js";import{n as x}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=k({username:i().min(1,"请输入用户名"),nickname:i().optional(),password:i().min(6,"密码至少 6 位")});function R(){const u=f(),{refresh:j}=N(),[p,m]=g.useState(!1),r=b({resolver:F(C),defaultValues:{username:"",nickname:"",password:""}}),h=async e=>{m(!0);try{await v.register(e.username,e.password,e.nickname||e.username),await j(),x.success("注册成功"),u("/",{replace:!0})}catch(d){x.error(d instanceof Error?d.message:"注册失败")}finally{m(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"注册账号"}),s.jsx("p",{className:"subtitle",children:"首个注册用户自动成为管理员"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(h),className:"space-y-4",children:[s.jsx(a,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"用户名"}),s.jsx(t,{children:s.jsx(c,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"nickname",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"昵称"}),s.jsx(t,{children:s.jsx(c,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"密码"}),s.jsx(t,{children:s.jsx(c,{type:"password",placeholder:"至少 6 位",autoComplete:"new-password",...e})}),s.jsx(l,{})]})}),s.jsx(S,{type:"submit",className:"w-full",loading:p,children:"注册"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["已有账号?",s.jsx(w,{to:"/login",children:"登录"})]})]})})}export{R as default};

View File

@@ -1 +0,0 @@
import{j as n}from"./react-vendor-CgzNJrV9.js";import{c as a,d as o}from"./index-474mTIgd.js";const s=o("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow",secondary:"border-transparent bg-secondary text-secondary-foreground",destructive:"border-transparent bg-destructive text-destructive-foreground shadow",outline:"text-foreground",green:"border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]",orange:"border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"}},defaultVariants:{variant:"default"}});function g({className:r,variant:e,...t}){return n.jsx("div",{className:a(s({variant:e}),r),...t})}export{g as B};

View File

@@ -1 +0,0 @@
function a(n,t){return n.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(n){const t=new Date(n),e=(new Date().getTime()-t.getTime())/1e3;return e<60?"刚刚":e<3600?`${Math.floor(e/60)}分钟前`:e<86400?`${Math.floor(e/3600)}小时前`:`${t.getMonth()+1}-${t.getDate()} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}export{o as f,a as h};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{t as s}from"./index-474mTIgd.js";const n={success:r=>s.success(r),error:r=>s.error(r),warning:r=>s.warning(r)};export{n};

View File

@@ -1,23 +0,0 @@
import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
<div class="post-members-only__badge">
<span class="post-members-only__badge-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<span>登录可见</span>
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(s){const r=s>0?Math.min(6,Math.max(3,Math.ceil(s/42))):4,n=Array.from({length:r},(l,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=s>0?`约 ${s} 字的`:"一段";return`
<div class="post-members-only__locked-wrap">
<div class="post-members-only__badge post-members-only__badge--locked">
<span class="post-members-only__badge-icon" aria-hidden="true">${a}</span>
<span>登录可见</span>
</div>
<div class="post-members-only__preview" aria-hidden="true">
${n}
</div>
<div class="post-members-only__gate">
<div class="post-members-only__gate-icon" aria-hidden="true">${a}</div>
<p class="post-members-only__gate-title">此处有${e}专属内容</p>
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
</div>
</div>`}function b(s,r){if(!s.trim())return"";const n=new DOMParser().parseFromString(m.sanitize(s,d),"text/html");return n.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!r){const t=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(t);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(t=>!(t instanceof Element&&t.classList.contains("post-members-only__badge"))).map(t=>t instanceof Element?t.outerHTML:t.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),n.body.innerHTML}export{d as P,b as r};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{j as e}from"./react-vendor-CgzNJrV9.js";import{c as s}from"./index-474mTIgd.js";import{p as m}from"./ui-vendor-CdH1UOKW.js";const t={sm:"h-4 w-4",md:"h-5 w-5",lg:"h-8 w-8"};function p({className:r,size:a="md"}){return e.jsx(m,{className:s("animate-spin text-[var(--j13-green)]",t[a],r),"aria-label":"加载中"})}export{p as S};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>姜十三论坛 Jiang13 Forum</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.app-header { height: 56px; flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
<script>
(function () {
var theme = localStorage.getItem('j13-theme') || 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
})();
</script>
<script type="module" crossorigin src="/assets/index-474mTIgd.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CgzNJrV9.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CdH1UOKW.js">
<link rel="stylesheet" crossorigin href="/assets/index-JNN-o0YU.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,95 +0,0 @@
{{define "admin/boards.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_boards"}}
<div class="admin-page-head">
<div>
<h1>板块管理</h1>
<p>创建和维护论坛板块,有帖子的板块无法删除</p>
</div>
</div>
<div class="admin-card">
<div class="admin-card-head">新建板块</div>
<div class="admin-card-body">
<form id="createBoardForm" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">板块名称</label>
<input name="name" class="form-control" placeholder="如:技术交流" required maxlength="64">
</div>
<div class="col-md-4">
<label class="form-label small">简介</label>
<input name="description" class="form-control" placeholder="板块说明(可选)" maxlength="500">
</div>
<div class="col-md-2">
<label class="form-label small">排序</label>
<input name="sort_order" type="number" class="form-control" value="0">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-success w-100" id="createBoardBtn">创建板块</button>
</div>
</form>
</div>
</div>
<div class="admin-card">
<div class="admin-card-head">板块列表 <span class="text-muted fw-normal">共 {{len .Boards}} 个</span></div>
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead><tr><th>ID</th><th>名称</th><th>描述</th><th>排序</th><th>帖子数</th><th width="160">操作</th></tr></thead>
<tbody>
{{range .Boards}}
<tr>
<td>{{.ID}}</td>
<td><input class="form-control form-control-sm" id="name-{{.ID}}" value="{{.Name}}"></td>
<td><input class="form-control form-control-sm" id="desc-{{.ID}}" value="{{.Description}}"></td>
<td><input class="form-control form-control-sm" id="sort-{{.ID}}" type="number" value="{{.SortOrder}}" style="width:72px"></td>
<td><span class="badge bg-secondary">{{.PostCount}}</span></td>
<td>
<button class="btn btn-sm btn-primary" onclick="updateBoard({{.ID}}, this)">保存</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteBoard({{.ID}}, {{.PostCount}})">删除</button>
</td>
</tr>
{{else}}
<tr><td colspan="6" class="admin-empty">还没有板块,请先创建</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{define "admin_scripts_boards"}}
<script>
document.getElementById('createBoardForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('createBoardBtn');
setBtnLoading(btn, true);
adminFetch('/admin/api/boards', { method: 'POST', body: new FormData(this) })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); })
.finally(function() { setBtnLoading(btn, false); });
});
function updateBoard(id, btn) {
setBtnLoading(btn, true);
postForm('/admin/api/boards/' + id, 'PUT', {
name: document.getElementById('name-' + id).value,
description: document.getElementById('desc-' + id).value,
sort_order: document.getElementById('sort-' + id).value
}).then(function(d) { showToast(d.message); })
.catch(function(e) { showToast(e.message, 'danger'); })
.finally(function() { setBtnLoading(btn, false); });
}
function deleteBoard(id, postCount) {
if (postCount > 0) {
showToast('该板块下还有 ' + postCount + ' 篇帖子,无法删除', 'warning');
return;
}
adminConfirm('确定删除该板块?此操作不可恢复。', function() {
adminFetch('/admin/api/boards/' + id, { method: 'DELETE' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -1,58 +0,0 @@
{{define "admin/comments.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_comments"}}
<div class="admin-page-head">
<div>
<h1>评论管理</h1>
<p>查看和删除评论,共 {{.Total}} 条</p>
</div>
</div>
<div class="admin-card">
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead>
<tr><th>ID</th><th>楼层</th><th>帖子</th><th>作者</th><th>回复</th><th>内容</th><th>时间</th><th width="100">操作</th></tr>
</thead>
<tbody>
{{range .Comments}}
<tr>
<td>{{.ID}}</td>
<td>#{{.Floor}}</td>
<td class="text-truncate-cell">
{{if .Post}}
<a href="/post/{{.PostID}}" target="_blank">{{.Post.Title}}</a>
{{else}}帖子 #{{.PostID}}{{end}}
</td>
<td>{{if .UserID}}{{if .User}}{{.User.Nickname}}{{else}}-{{end}}{{else}}{{.GuestNick}}{{end}}</td>
<td>
{{if .ReplyTarget}}
@{{if .ReplyTarget.UserID}}{{if .ReplyTarget.User}}{{.ReplyTarget.User.Nickname}}{{else}}-{{end}}{{else}}{{.ReplyTarget.GuestNick}}{{end}}
{{else}}-{{end}}
</td>
<td class="text-truncate-cell">{{.Content}}</td>
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteComment({{.ID}})">删除</button>
</td>
</tr>
{{else}}
<tr><td colspan="8" class="admin-empty">暂无评论</td></tr>
{{end}}
</tbody>
</table>
</div>
{{template "admin_pagination" .}}
</div>
{{end}}
{{define "admin_scripts_comments"}}
<script>
function deleteComment(id) {
adminConfirm('确定删除该评论?', function() {
adminFetch('/admin/api/comments/' + id, { method: 'DELETE' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -1,76 +0,0 @@
{{define "admin/dashboard.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_dashboard"}}
<div class="admin-page-head">
<div>
<h1>仪表盘</h1>
<p>欢迎回来{{if .CurrentUser}},{{.CurrentUser.Nickname}}{{end}} · 论坛运行概况</p>
</div>
</div>
<div class="admin-stat-grid">
<div class="admin-stat-card admin-stat-users">
<div class="admin-stat-icon">用</div>
<div class="admin-stat-info">
<div class="value">{{.UserCount}}</div>
<div class="label">注册用户</div>
</div>
</div>
<div class="admin-stat-card admin-stat-posts">
<div class="admin-stat-icon">帖</div>
<div class="admin-stat-info">
<div class="value">{{.PostCount}}</div>
<div class="label">帖子总数</div>
</div>
</div>
<div class="admin-stat-card admin-stat-boards">
<div class="admin-stat-icon">板</div>
<div class="admin-stat-info">
<div class="value">{{.BoardCount}}</div>
<div class="label">板块数量</div>
</div>
</div>
<div class="admin-stat-card admin-stat-comments">
<div class="admin-stat-icon">评</div>
<div class="admin-stat-info">
<div class="value">{{.CommentCount}}</div>
<div class="label">评论总数</div>
</div>
</div>
<div class="admin-stat-card admin-stat-online">
<div class="admin-stat-icon">线</div>
<div class="admin-stat-info">
<div class="value">{{.OnlineCount}}</div>
<div class="label">当前浏览</div>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-head">
<span>最近帖子</span>
<a href="/admin/posts" class="admin-card-link">查看全部 →</a>
</div>
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead><tr><th>标题</th><th>板块</th><th>作者</th><th>时间</th><th width="80"></th></tr></thead>
<tbody>
{{range .RecentPosts}}
<tr>
<td class="text-truncate-cell">
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Title}}
</td>
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
<td class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</td>
<td><a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link px-0">查看</a></td>
</tr>
{{else}}
<tr><td colspan="5" class="admin-empty">暂无帖子</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{define "admin_scripts_dashboard"}}{{end}}

View File

@@ -1,88 +0,0 @@
{{define "admin/layout"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - 姜十三论坛管理后台</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/legacy/css/style.css" rel="stylesheet">
</head>
<body class="admin-body">
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index:9999">
<div id="adminToast" class="toast" role="alert">
<div class="d-flex">
<div class="toast-body"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<header class="admin-topbar">
<div class="admin-topbar-brand">
<div class="admin-topbar-mark">姜</div>
<div>
<span class="admin-topbar-title">姜十三论坛</span>
<span class="admin-topbar-sub">管理后台</span>
</div>
</div>
<div class="admin-topbar-actions">
{{if .CurrentUser}}
<span class="admin-topbar-user">{{.CurrentUser.Nickname}}</span>
{{end}}
<a href="/" class="btn btn-outline-light btn-sm">返回前台</a>
<button class="btn btn-light btn-sm" onclick="adminLogout()">退出</button>
</div>
</header>
<div class="admin-shell">
<aside class="admin-sidebar">
<div class="admin-sidebar-section">概览</div>
<a href="/admin/dashboard" class="nav-link {{if eq .ActiveNav "dashboard"}}active{{end}}">仪表盘</a>
<div class="admin-sidebar-section">内容</div>
<a href="/admin/boards" class="nav-link {{if eq .ActiveNav "boards"}}active{{end}}">板块管理</a>
<a href="/admin/posts" class="nav-link {{if eq .ActiveNav "posts"}}active{{end}}">帖子管理</a>
<a href="/admin/comments" class="nav-link {{if eq .ActiveNav "comments"}}active{{end}}">评论管理</a>
<div class="admin-sidebar-section">系统</div>
<a href="/admin/users" class="nav-link {{if eq .ActiveNav "users"}}active{{end}}">用户管理</a>
<a href="/admin/settings" class="nav-link {{if eq .ActiveNav "settings"}}active{{end}}">系统设置</a>
</aside>
<main class="admin-main">
{{if eq .ActiveNav "dashboard"}}{{template "admin_content_dashboard" .}}
{{else if eq .ActiveNav "boards"}}{{template "admin_content_boards" .}}
{{else if eq .ActiveNav "posts"}}{{template "admin_content_posts" .}}
{{else if eq .ActiveNav "comments"}}{{template "admin_content_comments" .}}
{{else if eq .ActiveNav "users"}}{{template "admin_content_users" .}}
{{else if eq .ActiveNav "settings"}}{{template "admin_content_settings" .}}
{{end}}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/legacy/js/app.js"></script>
{{if eq .ActiveNav "boards"}}{{template "admin_scripts_boards" .}}
{{else if eq .ActiveNav "posts"}}{{template "admin_scripts_posts" .}}
{{else if eq .ActiveNav "comments"}}{{template "admin_scripts_comments" .}}
{{else if eq .ActiveNav "users"}}{{template "admin_scripts_users" .}}
{{else if eq .ActiveNav "settings"}}{{template "admin_scripts_settings" .}}
{{end}}
</body>
</html>
{{end}}
{{define "admin_pagination"}}
{{if gt .TotalPages 1}}
<nav class="admin-pagination">
{{if gt .Page 1}}
<a class="btn btn-sm btn-outline-secondary" href="?page={{sub .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">上一页</a>
{{end}}
<span class="admin-pagination-info">第 {{.Page}} / {{.TotalPages}} 页</span>
{{if lt .Page .TotalPages}}
<a class="btn btn-sm btn-outline-secondary" href="?page={{add .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">下一页</a>
{{end}}
</nav>
{{end}}
{{end}}

View File

@@ -1,49 +0,0 @@
{{define "admin/login.html"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>后台登录 - 姜十三论坛</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/legacy/css/style.css" rel="stylesheet">
</head>
<body class="admin-login-page">
<div class="admin-login-card">
<div class="admin-login-mark">姜</div>
<h4 class="text-center mb-1">管理后台登录</h4>
<p class="login-slogan text-center mb-4">姜十三论坛 · 仅管理员可访问</p>
{{if eq .QueryBanned "1"}}
<div class="alert alert-warning small py-2">账号已被禁言,无法登录后台</div>
{{end}}
<form id="adminLoginForm">
<div class="mb-2">
<label class="form-label small">管理员账号</label>
<input type="text" name="username" class="form-control" placeholder="用户名" required autofocus>
</div>
<div class="mb-3">
<label class="form-label small">密码</label>
<input type="password" name="password" class="form-control" placeholder="密码" required>
</div>
<button type="submit" class="btn btn-success w-100" id="loginBtn">登录</button>
</form>
<p class="text-center mt-3 mb-0 small">
<a href="/" class="text-muted">← 返回论坛前台</a>
</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/legacy/js/app.js"></script>
<script>
document.getElementById('adminLoginForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('loginBtn');
setBtnLoading(btn, true);
adminFetch('/admin/api/login', { method: 'POST', body: new FormData(this) })
.then(function() { location.href = '/admin/dashboard'; })
.catch(function(err) { alert(err.message); })
.finally(function() { setBtnLoading(btn, false); });
});
</script>
</body>
</html>
{{end}}

View File

@@ -1,67 +0,0 @@
{{define "admin/posts.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_posts"}}
<div class="admin-page-head">
<div>
<h1>帖子管理</h1>
<p>置顶、删除帖子,共 {{.Total}} 篇</p>
</div>
<form class="admin-search-bar" method="get">
<input name="keyword" class="form-control form-control-sm" placeholder="搜索标题/内容..." value="{{.Keyword}}">
<button class="btn btn-sm btn-success">搜索</button>
{{if .Keyword}}<a href="/admin/posts" class="btn btn-sm btn-outline-secondary">清除</a>{{end}}
</form>
</div>
<div class="admin-card">
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead>
<tr>
<th>ID</th><th>标题</th><th>板块</th><th>作者</th>
<th>置顶</th><th>点赞</th><th>浏览</th><th>时间</th><th width="180">操作</th>
</tr>
</thead>
<tbody>
{{range .Posts}}
<tr id="post-row-{{.ID}}">
<td>{{.ID}}</td>
<td class="text-truncate-cell">{{.Title}}</td>
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
<td>{{if .Pinned}}<span class="badge badge-pin">是</span>{{else}}<span class="text-muted">否</span>{{end}}</td>
<td>{{.LikeCount}}</td>
<td>{{.ViewCount}}</td>
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
<td>
<a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link">查看</a>
<button class="btn btn-sm btn-outline-warning" onclick="togglePin({{.ID}}, {{.Pinned}})">{{if .Pinned}}取消置顶{{else}}置顶{{end}}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePost({{.ID}})">删除</button>
</td>
</tr>
{{else}}
<tr><td colspan="9" class="admin-empty">没有找到帖子</td></tr>
{{end}}
</tbody>
</table>
</div>
{{template "admin_pagination" .}}
</div>
{{end}}
{{define "admin_scripts_posts"}}
<script>
function togglePin(id, pinned) {
postForm('/admin/api/posts/' + id + '/pin', 'POST', { pinned: pinned ? 'false' : 'true' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
}
function deletePost(id) {
adminConfirm('确定删除该帖子?相关评论也将一并删除。', function() {
adminFetch('/admin/api/posts/' + id, { method: 'DELETE' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -1,56 +0,0 @@
{{define "admin/settings.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_settings"}}
<div class="admin-page-head">
<div>
<h1>系统设置</h1>
<p>数据目录、敏感词配置与数据库备份</p>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="admin-card">
<div class="admin-card-head">运行信息</div>
<div class="admin-card-body">
<div class="info-row"><div class="info-label">数据目录</div><div class="info-value"><code>{{.DataDir}}</code></div></div>
<div class="info-row"><div class="info-label">数据库文件</div><div class="info-value"><code>{{.DBPath}}</code></div></div>
<div class="info-row"><div class="info-label">监听端口</div><div class="info-value">{{.Port}}</div></div>
<div class="info-row"><div class="info-label">敏感词配置</div><div class="info-value"><code>{{.FilterPath}}</code></div></div>
<p class="text-muted small mt-3 mb-0">敏感词文件每行一个词,<code>#</code> 开头为注释,修改后需重启服务生效。</p>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="admin-card">
<div class="admin-card-head">数据库备份</div>
<div class="admin-card-body">
<p class="text-muted small">将 SQLite 数据库复制到数据目录,生成带时间戳的备份文件。</p>
<button class="btn btn-success" id="backupBtn" onclick="doBackup()">一键导出备份</button>
<div id="backupResult" class="mt-3"></div>
</div>
</div>
</div>
</div>
{{end}}
{{define "admin_scripts_settings"}}
<script>
function doBackup() {
var btn = document.getElementById('backupBtn');
setBtnLoading(btn, true);
adminFetch('/admin/api/backup', { method: 'POST' })
.then(function(d) {
showToast(d.message);
var html = '<div class="alert alert-success small mb-0">';
html += '<div>备份文件:<code>' + d.filename + '</code></div>';
if (d.download) {
html += '<a href="' + d.download + '" class="btn btn-sm btn-outline-success mt-2">下载备份文件</a>';
}
html += '<div class="text-muted mt-2">存储路径:' + d.path + '</div></div>';
document.getElementById('backupResult').innerHTML = html;
})
.catch(function(e) { showToast(e.message, 'danger'); })
.finally(function() { setBtnLoading(btn, false); });
}
</script>
{{end}}

View File

@@ -1,62 +0,0 @@
{{define "admin/users.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_users"}}
<div class="admin-page-head">
<div>
<h1>用户管理</h1>
<p>禁言违规用户,共 {{.Total}} 位注册用户</p>
</div>
</div>
<div class="admin-card">
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead>
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>角色</th><th>状态</th><th>注册时间</th><th width="120">操作</th></tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.ID}}</td>
<td>{{.Username}}</td>
<td>{{.Nickname}}</td>
<td>
{{if eq .Role "admin"}}
<span class="badge badge-admin">管理员</span>
{{else}}普通用户{{end}}
</td>
<td>
{{if .Banned}}<span class="badge badge-banned">已禁言</span>{{else}}<span class="text-success">正常</span>{{end}}
</td>
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
<td>
{{if eq .Role "admin"}}
<span class="text-muted small">—</span>
{{else if .Banned}}
<button class="btn btn-sm btn-success" onclick="banUser({{.ID}}, false)">解除禁言</button>
{{else}}
<button class="btn btn-sm btn-outline-warning" onclick="banUser({{.ID}}, true)">禁言</button>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="7" class="admin-empty">暂无用户</td></tr>
{{end}}
</tbody>
</table>
</div>
{{template "admin_pagination" .}}
</div>
{{end}}
{{define "admin_scripts_users"}}
<script>
function banUser(id, banned) {
var msg = banned ? '确定禁言该用户?禁言后无法登录和发帖。' : '确定解除该用户的禁言?';
adminConfirm(msg, function() {
postForm('/admin/api/users/' + id + '/ban', 'POST', { banned: banned ? 'true' : 'false' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -1,16 +0,0 @@
{{define "board.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item active">{{.Board.Name}}</li></ol></nav>
<h3>{{.Board.Name}}</h3>
<p class="text-muted">{{.Board.Description}}</p>
{{if .CurrentUser}}<a href="/post/new?board={{.Board.ID}}" class="btn btn-success btn-sm mb-3">在此板块发帖</a>{{end}}
{{range .Posts}}
<div class="card mb-2">
<div class="card-body py-2">
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
<div class="small text-muted mt-1">{{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}}</div>
</div>
</div>
{{else}}<p class="text-muted">该板块暂无帖子</p>{{end}}
{{end}}

View File

@@ -1,7 +0,0 @@
{{define "error.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="text-center py-5">
<h3>{{.Message}}</h3>
<a href="/" class="btn btn-primary mt-3">返回首页</a>
</div>
{{end}}

View File

@@ -1,10 +0,0 @@
{{define "favorites.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<h3>我的收藏</h3>
{{range .Favorites}}
<div class="card mb-2"><div class="card-body py-2">
<a href="/post/{{.Post.ID}}" class="fw-semibold text-decoration-none">{{.Post.Title}}</a>
<div class="small text-muted">{{.Post.Board.Name}} · 收藏于 {{.CreatedAt.Format "2006-01-02"}}</div>
</div></div>
{{else}}<p class="text-muted">暂无收藏</p>{{end}}
{{end}}

View File

@@ -1,34 +0,0 @@
{{define "index.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row">
<div class="col-lg-8">
<h4 class="mb-3">最新帖子</h4>
{{range .Posts}}
<div class="card mb-2">
<div class="card-body py-2">
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
<div class="small text-muted mt-1">
{{.Board.Name}} · {{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}} · 👁 {{.ViewCount}}
</div>
</div>
</div>
{{else}}
<p class="text-muted">暂无帖子,快来发帖吧!</p>
{{end}}
</div>
<div class="col-lg-4">
<h5>板块列表</h5>
<div class="list-group">
{{range .Boards}}
<a href="/board/{{.ID}}" class="list-group-item list-group-item-action">
<strong>{{.Name}}</strong>
<small class="d-block text-muted">{{.Description}}</small>
</a>
{{else}}
<p class="text-muted small">管理员尚未创建板块</p>
{{end}}
</div>
</div>
</div>
{{end}}

View File

@@ -1,56 +0,0 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - 姜十三论坛</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/style.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark" style="background:var(--primary)">
<div class="container">
<a class="navbar-brand" href="/">姜十三论坛 <span class="en fs-6 opacity-75">Jiang13 Forum</span></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="nav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/">首页</a></li>
{{if .CurrentUser}}
<li class="nav-item"><a class="nav-link" href="/post/new">发帖</a></li>
<li class="nav-item"><a class="nav-link" href="/favorites">我的收藏</a></li>
{{end}}
{{if .IsAdmin}}<li class="nav-item"><a class="nav-link" href="/admin/dashboard">管理后台</a></li>{{end}}
</ul>
<ul class="navbar-nav">
{{if .CurrentUser}}
<li class="nav-item"><a class="nav-link" href="/profile">{{.CurrentUser.Nickname}}</a></li>
<li class="nav-item"><a class="nav-link" href="#" onclick="logout()">退出</a></li>
{{else}}
<li class="nav-item"><a class="nav-link" href="/login">登录</a></li>
<li class="nav-item"><a class="nav-link" href="/register">注册</a></li>
{{end}}
</ul>
</div>
</div>
</nav>
<main class="container py-4 flex-grow-1">
{{template "content" .}}
</main>
<footer class="site-footer text-center">
<div class="container">&copy; 2026 姜十三论坛 Jiang13 Forum · 拾三一隅,自在交流</div>
</footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="toast" class="toast" role="alert"><div class="toast-body"></div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
<script>
function logout(){ fetch('/api/logout',{method:'POST',credentials:'same-origin'}).then(()=>location.href='/'); }
</script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@@ -1,36 +0,0 @@
{{define "login.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<h3 class="text-center mb-1">登录</h3>
<p class="text-center login-slogan mb-4">拾三一隅,自在交流</p>
<form id="loginForm">
<div class="mb-3">
<label class="form-label">用户名</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">密码</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-success w-100">登录</button>
</form>
<p class="text-center mt-3 mb-0">还没有账号?<a href="/register">立即注册</a></p>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('loginForm').addEventListener('submit', function(e){
e.preventDefault();
apiPost('/api/login', this, true).then(d=>{
if(d.error){ showToast(d.error,'danger'); return; }
location.href='/';
});
});
</script>
{{end}}

View File

@@ -1,72 +0,0 @@
{{define "post.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item"><a href="/board/{{.Post.BoardID}}">{{.Post.Board.Name}}</a></li><li class="breadcrumb-item active">帖子</li></ol></nav>
<div class="card mb-4">
<div class="card-body">
<h3>{{if .Post.Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Post.Title}}</h3>
<div class="small text-muted mb-3">
<a href="/user/{{.Post.UserID}}">{{.Post.User.Nickname}}</a> · {{.Post.CreatedAt.Format "2006-01-02 15:04"}} · 👁 {{.Post.ViewCount}}
{{if .Post.Tags}}· 标签:{{.Post.Tags}}{{end}}
</div>
<div class="post-content">{{safeHTML .Post.Content}}</div>
<div class="mt-3 d-flex gap-2 flex-wrap">
{{if .CurrentUser}}
<button class="btn btn-outline-primary btn-sm" id="likeBtn" onclick="toggleLike()">{{if .Liked}}已点赞{{else}}点赞{{end}}</button>
<button class="btn btn-outline-warning btn-sm" id="favBtn" onclick="toggleFav()">{{if .Favorited}}已收藏{{else}}收藏{{end}}</button>
{{if or (eq .CurrentUserID .Post.UserID) .IsAdmin}}
<a href="/post/{{.Post.ID}}/edit" class="btn btn-outline-secondary btn-sm">编辑</a>
<button class="btn btn-outline-danger btn-sm" onclick="deletePost()">删除</button>
{{end}}
{{if .IsAdmin}}
<button class="btn btn-outline-dark btn-sm" onclick="pinPost()">{{if .Post.Pinned}}取消置顶{{else}}置顶{{end}}</button>
{{end}}
{{else}}<a href="/login" class="btn btn-outline-primary btn-sm">登录后互动</a>{{end}}
</div>
</div>
</div>
<h5>评论 ({{len .Comments}})</h5>
{{range .Comments}}
<div class="card mb-2 comment-floor">
<div class="card-body py-2">
<div class="d-flex justify-content-between">
<strong>#{{.Floor}} {{.User.Nickname}}</strong>
<small class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</small>
</div>
{{if .ReplyUser}}<div class="small text-muted">回复 #{{.ReplyUser.Floor}} {{.ReplyUser.User.Nickname}}</div>{{end}}
<div class="mt-1">{{.Content}}</div>
{{if $.CurrentUser}}
<button class="btn btn-link btn-sm p-0" onclick="replyTo({{.ID}},{{.Floor}},'{{.User.Nickname}}')">回复</button>
{{if or (eq $.CurrentUserID .UserID) $.IsAdmin}}
<button class="btn btn-link btn-sm p-0 text-danger" onclick="deleteComment({{.ID}})">删除</button>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{if .CurrentUser}}
<div class="card mt-3">
<div class="card-body">
<form id="commentForm">
<div id="replyHint" class="small text-muted mb-2" style="display:none"></div>
<input type="hidden" name="reply_to" id="replyTo">
<textarea name="content" class="form-control mb-2" rows="3" placeholder="写下你的评论..." required></textarea>
<button type="submit" class="btn btn-success btn-sm">发表评论</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelReply()" id="cancelReplyBtn" style="display:none">取消回复</button>
</form>
</div>
</div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
const postId={{.Post.ID}};
function toggleLike(){ fetch('/api/posts/'+postId+'/like',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('likeBtn').textContent=d.liked?'已点赞':'点赞'; }); }
function toggleFav(){ fetch('/api/posts/'+postId+'/favorite',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('favBtn').textContent=d.favorited?'已收藏':'收藏'; }); }
function deletePost(){ confirmAction('确定删除此帖?',()=>{ fetch('/api/posts/'+postId,{method:'DELETE',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.href='/board/{{.Post.BoardID}}'; }); }); }
function pinPost(){ fetch('/admin/api/posts/'+postId+'/pin',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'pinned={{if .Post.Pinned}}false{{else}}true{{end}}'}).then(r=>r.json()).then(()=>location.reload()); }
function replyTo(id,floor,name){ document.getElementById('replyTo').value=id; document.getElementById('replyHint').style.display='block'; document.getElementById('replyHint').textContent='回复 #'+floor+' '+name; document.getElementById('cancelReplyBtn').style.display='inline-block'; }
function cancelReply(){ document.getElementById('replyTo').value=''; document.getElementById('replyHint').style.display='none'; document.getElementById('cancelReplyBtn').style.display='none'; }
function deleteComment(id){ confirmAction('确定删除?',()=>{ fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin'}).then(()=>location.reload()); }); }
document.getElementById('commentForm')?.addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/posts/'+postId+'/comments',this,true).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
</script>
{{end}}

View File

@@ -1,31 +0,0 @@
{{define "post_edit.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<h3>编辑帖子</h3>
<form id="editForm">
<div class="mb-3">
<label class="form-label">标题</label>
<input type="text" name="title" class="form-control" value="{{.Post.Title}}" required>
</div>
<div class="mb-3">
<label class="form-label">标签</label>
<input type="text" name="tags" class="form-control" value="{{.Post.Tags}}">
</div>
<div class="mb-3">
<label class="form-label">内容</label>
<textarea name="content" class="form-control" rows="12" required>{{.Post.Content}}</textarea>
</div>
<button type="submit" class="btn btn-success">保存</button>
<a href="/post/{{.Post.ID}}" class="btn btn-secondary">取消</a>
</form>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('editForm').addEventListener('submit',function(e){
e.preventDefault();
fetch('/api/posts/{{.Post.ID}}',{method:'PUT',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{
if(d.error){showToast(d.error,'danger');return;}
location.href='/post/{{.Post.ID}}';
});
});
</script>
{{end}}

View File

@@ -1,37 +0,0 @@
{{define "post_new.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<h3>发布新帖</h3>
<form id="postForm">
<div class="mb-3">
<label class="form-label">选择板块</label>
<select name="board_id" class="form-select" required>
{{range .Boards}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="mb-3">
<label class="form-label">标题</label>
<input type="text" name="title" class="form-control" required maxlength="256">
</div>
<div class="mb-3">
<label class="form-label">标签(逗号分隔)</label>
<input type="text" name="tags" class="form-control" placeholder="Go,技术,交流">
</div>
<div class="mb-3">
<label class="form-label">内容(支持 HTML 富文本)</label>
<textarea name="content" class="form-control" rows="12" required></textarea>
<div class="form-text">可使用 &lt;b&gt;、&lt;i&gt;、&lt;a&gt;、&lt;img&gt; 等 HTML 标签</div>
</div>
<button type="submit" class="btn btn-success">发布</button>
</form>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('postForm').addEventListener('submit',function(e){
e.preventDefault();
apiPost('/api/posts',this,true).then(d=>{
if(d.error){showToast(d.error,'danger');return;}
location.href='/post/'+d.post_id;
});
});
</script>
{{end}}

View File

@@ -1,34 +0,0 @@
{{define "profile.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row">
<div class="col-md-4 text-center mb-4">
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2" alt="avatar">
<h4>{{.ProfileUser.Nickname}}</h4>
<p class="text-muted">@{{.ProfileUser.Username}}</p>
<form id="avatarForm" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" class="form-control form-control-sm mb-2">
<button type="submit" class="btn btn-outline-primary btn-sm">更换头像</button>
</form>
</div>
<div class="col-md-8">
<div class="card mb-3"><div class="card-header">修改昵称</div><div class="card-body">
<form id="nickForm"><input type="text" name="nickname" class="form-control mb-2" value="{{.ProfileUser.Nickname}}">
<button class="btn btn-success btn-sm">保存昵称</button></form>
</div></div>
<div class="card"><div class="card-header">修改密码</div><div class="card-body">
<form id="pwdForm">
<input type="password" name="old_password" class="form-control mb-2" placeholder="原密码" required>
<input type="password" name="new_password" class="form-control mb-2" placeholder="新密码(至少6位)" required>
<button class="btn btn-success btn-sm">修改密码</button>
</form>
</div></div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('nickForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/nickname',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
document.getElementById('pwdForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/password',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
document.getElementById('avatarForm').addEventListener('submit',function(e){ e.preventDefault(); fetch('/api/profile/avatar',{method:'POST',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
</script>
{{end}}

View File

@@ -1,40 +0,0 @@
{{define "register.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<h3 class="text-center mb-4">注册账号</h3>
<p class="text-center text-muted small mb-3">本站首个注册用户将自动成为管理员</p>
<form id="regForm">
<div class="mb-3">
<label class="form-label">用户名(3-32位字母数字下划线)</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">昵称</label>
<input type="text" name="nickname" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">密码(至少6位)</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-success w-100">注册</button>
</form>
<p class="text-center mt-3 mb-0">已有账号?<a href="/login">去登录</a></p>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('regForm').addEventListener('submit', function(e){
e.preventDefault();
apiPost('/api/register', this, true).then(d=>{
if(d.error){ showToast(d.error,'danger'); return; }
showToast('注册成功'); location.href='/';
});
});
</script>
{{end}}

View File

@@ -1,8 +0,0 @@
{{define "user_profile.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="text-center py-4">
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2">
<h3>{{.ProfileUser.Nickname}}</h3>
<p class="text-muted">@{{.ProfileUser.Username}} · 注册于 {{.ProfileUser.CreatedAt.Format "2006-01-02"}}</p>
</div>
{{end}}

View File

@@ -2,24 +2,10 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>姜十三论坛 Jiang13 Forum</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.app-header { height: 56px; flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="拾三一隅,自在交流" />
<title>姜十三论坛 - 拾三一隅,自在交流</title>
<!-- 样式由 Vite 构建注入外链 CSS,不在此内联 style -->
<script>
(function () {
var theme = localStorage.getItem('j13-theme') || 'light';
@@ -27,6 +13,33 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<script>
/* 锁死手机/平板双指与手势缩放;头像裁剪 / 图片灯箱放行 pinch */
(function () {
function inZoomAllowArea(target) {
return !!(
target &&
target.closest &&
target.closest('.avatar-crop-stage, .image-lightbox')
);
}
function blockGesture(e) {
if (inZoomAllowArea(e.target)) return;
if (e.cancelable) e.preventDefault();
}
function blockMultiTouch(e) {
if (e.touches && e.touches.length > 1) {
if (inZoomAllowArea(e.target)) return;
if (e.cancelable) e.preventDefault();
}
}
document.addEventListener('gesturestart', blockGesture, { passive: false });
document.addEventListener('gesturechange', blockGesture, { passive: false });
document.addEventListener('gestureend', blockGesture, { passive: false });
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
})();
</script>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.16",
@@ -16,29 +19,47 @@
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@svg-maps/china": "^2.0.0",
"@svg-maps/world": "^2.0.0",
"@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1",
"@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",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
"postcss": "^8.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2",
"react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3"
"turndown": "^7.2.4",
"zod": "^4.4.3",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"

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

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