From 58b94df50c7384175cf1d810ffc3fc8923d155c6 Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 30 Aug 2026 05:10:23 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E4=B8=BB=E9=A2=98=E6=94=B9=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E6=9F=A5=E8=AF=A2=E5=B9=B6=E6=B8=85=E7=90=86=E6=9C=AA?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉 head 防闪脚本;删除论坛 JSON CRUD 与 crawler,仅保留机器入口;用户向文档对齐 SSR。 Co-authored-by: Cursor --- .gitignore | 1 - CONTRIBUTING.md | 30 +- README.md | 30 +- ROADMAP.md | 69 +- docs/introduction.md | 38 +- docs/issue-templates.md | 73 +- docs/rebuild-spec/02-features.md | 2 +- docs/rebuild-spec/04-api.md | 2 +- docs/rebuild-spec/06-pages-ux.md | 7 +- docs/rebuild-spec/09-ssr-progress.md | 13 +- docs/site-posts/about-jiang13.md | 12 +- modules/seo/pagemeta.go | 2 +- public/assets/site.css | 66 ++ public/assets/site.js | 34 +- routers/api/api.go | 1197 -------------------------- routers/api/economy.go | 218 ----- routers/api/friend_link.go | 281 ------ routers/api/handlers.go | 595 +------------ routers/api/media.go | 70 -- routers/api/message.go | 170 ---- routers/api/report.go | 163 ---- routers/api/special.go | 198 ----- routers/setup.go | 19 +- services/crawler.go | 55 -- templates/base/head.tmpl | 14 - web_src/css/site.css | 66 ++ web_src/js/site.js | 34 +- 27 files changed, 277 insertions(+), 3182 deletions(-) delete mode 100644 routers/api/api.go delete mode 100644 routers/api/economy.go delete mode 100644 routers/api/friend_link.go delete mode 100644 routers/api/media.go delete mode 100644 routers/api/message.go delete mode 100644 routers/api/report.go delete mode 100644 routers/api/special.go delete mode 100644 services/crawler.go diff --git a/.gitignore b/.gitignore index c5e2f31..259f6ad 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ # Node / 构建缓存 /node_modules/ /web_src/node_modules/ -.vite/ *.tsbuildinfo # Go 编译产物(统一进 dist/;勿把二进制扔在仓库根目录) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 767ccea..be3cacd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,12 +4,14 @@ ## 开发环境 -**要求:** Go 1.26+、Node.js 18+ +**要求:** Go 1.26+、Node.js 18+(仅构建 `web_src` 静态资源) + +本分支(`rebuild/gitea-ssr`)为 **Go 模板 SSR**;对照 React SPA 请 `git checkout main`。 ```bat -REM Windows:一键启动后端 + 前端热更新(请用 build.bat) +REM Windows:请用 build.bat(内部 Bypass ExecutionPolicy) build.bat -Target dev -REM 浏览器访问 http://localhost:5173 +REM 浏览器访问 http://localhost:3000 ``` ```bash @@ -20,15 +22,14 @@ make dev ## 提交规范 - 一个 PR 只做一件事,保持 diff 小而清晰 -- 前端改动请确认浅色 / 暗色主题下都正常 +- 前端(`web_src` / 模板)改动请确认浅色 / 暗色主题下都正常 - 涉及 UI 变更时,建议在 PR 中附上截图 +- 功能语义以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准 ## 完整构建 -发布单二进制前需先构建前端并 embed: - ```bat -build.bat REM Windows +build.bat REM Windows:先 web_src,再 go build → dist/ ``` ```bash @@ -38,15 +39,6 @@ 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)。 - -## 行为准则 - -请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。 +- 期望行为与实际行为 +- 复现步骤、浏览器 / OS +- 相关模板或 `routers/web` 路径(若已知) diff --git a/README.md b/README.md index 6cc909b..56cecd7 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ 富文本与代码高亮
富文本渲染
- TipTap 排版 · 图片 · 代码高亮 · 目录导航 + Markdown 排版 · 图片 · 代码块 · 目录导航 @@ -96,20 +96,20 @@ | 特性 | 说明 | |------|------| -| **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 | -| **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 | +| **三栏布局** | 左栏板块导航 + 中间帖列表 + 右栏热门 / 标签 / 最新评论 | +| **真 SSR** | 公开页服务端渲染完整 HTML(Go `html/template`) | | **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 | -| **主题切换** | 浅色 / 暗色,跟随系统偏好并本地记忆 | -| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 | +| **主题切换** | 浅色 / 暗色 / 跟随系统(`localStorage`) | +| **响应式** | ≤900px 隐藏右栏;搜索、发帖、登录触手可及 | ### 社区功能 -- 用户注册 / 登录(bcrypt + JWT Cookie);**首个注册用户自动成为管理员** -- 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华 -- 帖子修订历史与 diff(差异)对比;可配置普通用户编辑时限 -- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控 +- 安装向导创建管理员;用户注册 / 登录(bcrypt + 会话 Cookie) +- 板块、发帖、Markdown 工具栏、正文图片上传、标签、置顶 / 精华 +- 帖子修订历史;可配置普通用户编辑时限 +- 楼层式评论:回复、嵌套树、内容门控(登录/回复/积分可见) - 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页 -- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份 +- SSR 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份 - 可选:邮件验证码、OIDC Provider、S3 兼容对象存储(Gitea 仓库同步后置) ### 部署体验 @@ -349,7 +349,7 @@ jiang13-forum/ # 分支 rebuild/gitea-ssr ├── routers/ │ ├── setup.go # 路由总装 │ ├── web/ # HTML SSR -│ └── api/ # JSON API +│ └── api/ # 机器入口(health / OIDC / SEO / thumb) ├── modules/ │ ├── auth/ # JWT / 限流 │ ├── webrender/ # 模板渲染 @@ -387,10 +387,10 @@ data/ | 类型 | 示例 | |------|------| -| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 | -| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 | -| ✅ 管理后台 | JSON API 已就绪;本分支管理 UI 为 SSR 占位(完整后台见 `main` SPA) | -| 📋 计划中 | 通知动态优化、邮件提醒 | +| ✅ 已可用 | 三栏布局、主题、Feed 排序、楼层评论、嵌套树 | +| ✅ 发帖体验 | Markdown 工具栏、图片上传、修订历史、内容门控 | +| ✅ 管理后台 | SSR `/admin/*`(对照 SPA 见 `main`) | +| 📋 计划中 | 见 [ROADMAP.md](ROADMAP.md) / [09-ssr-progress.md](docs/rebuild-spec/09-ssr-progress.md) | --- diff --git a/ROADMAP.md b/ROADMAP.md index 084e6af..cf40ae7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,8 @@ # 路线图 ROADMAP -> 姜十三论坛仍在积极开发中,功能尚未完善。 -> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。 +> 姜十三论坛仍在积极开发中。 +> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。 +> **本仓库默认开发分支**:`rebuild/gitea-ssr`(Gitea 式 SSR)。`main` = React SPA 对照。 **图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷 @@ -11,10 +12,14 @@ | 模块 | 状态 | 说明 | |------|------|------| -| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 | -| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 | -| 评论系统 | ✅ | 换行显示已修复 | -| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(管理后台配置 ROOT_URL 与 OAuth 应用) | +| 公开页 SSR | ✅ | Go `html/template`;首页 / 板块 / 帖详情 / 用户 / 消息等 | +| 管理后台 SSR | ✅ | `/admin/*` 表单;对照 SPA 见 `main` | +| 评论系统 | ✅ | 楼层 + 嵌套树(`ThreadParentID`) | +| Markdown 编辑 | ✅ | 工具栏 + `/compose/preview`(非 TipTap) | +| 主题 | ✅ | 浅色 / 暗色 / 跟随系统 | +| OIDC Provider | ✅ | Discovery / Authorize / Token / UserInfo;Admin 配置面可继续打磨 | + +细节进度见 [`docs/rebuild-spec/09-ssr-progress.md`](docs/rebuild-spec/09-ssr-progress.md)。 --- @@ -28,48 +33,20 @@ _当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_ | 优先级 | 功能 | 说明 | |--------|------|------| -| 中 | 通知动态优化 | 右栏最新评论的展示与交互 | -| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 | +| 中 | OIDC / 存储 Admin 打磨 | SSO 与 S3 热切换运维面 | +| 低 | 编辑器增强 | 表格、表情贴纸(TipTap 不作为本分支默认) | +| 低 | Gitea `/projects` | 仓库列表页(后置) | --- -## 🚧 进行中(In Progress) +## ✅ 已完成(摘,本分支) -_当前无公开认领任务。_ +- [x] Gitea 式目录与 Go 模板 SSR +- [x] 五种帖类型 + 核心闭环(赞/藏/门控/审核…) +- [x] Admin 仪表盘、板块、审核、用户、徽章、媒体、设置等 +- [x] Markdown 发帖/评论编辑器与预览 +- [x] 评论嵌套树、浅色/暗色主题 +- [x] OIDC Provider 机器入口 +- [x] 本分支删除未挂载论坛 JSON API(对照 `main`) ---- - -## ✅ 已完成(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_ +更全清单:[`docs/rebuild-spec/02-features.md`](docs/rebuild-spec/02-features.md)。 diff --git a/docs/introduction.md b/docs/introduction.md index 7fa8d1f..87733c5 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -11,7 +11,7 @@ 它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。 -技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:前端 SPA(单页应用)通过 `go:embed` 内嵌,数据库用内置 **SQLite**,拷贝到服务器就能跑——没有复杂的中间件矩阵,也没有「先装一堆依赖再祈祷能起来」的仪式感。 +技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:公开页用 **Go `html/template` 真 SSR**,`web_src` 做渐进增强 CSS/JS(`go:embed`),数据库默认 **SQLite**,拷贝到服务器就能跑。 --- @@ -60,24 +60,24 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗 整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。 -### 发帖:富文本,够用就好 +### 发帖:Markdown,够用就好 -发帖使用 **TipTap** 富文本编辑器:标题、排版、标签、板块选择、正文图片本地上传,日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**,可做 diff(差异)对比。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。 +发帖使用 **Markdown 工具栏**(标题、粗斜体、列表、代码块、图片上传、内容门控插入)与服务端预览;标签、板块选择日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。 ### 讨论:楼层回复,聊得清楚 -评论是楼层式的:可回复指定楼层、引用原文、@ 高亮。点赞、收藏、热门帖,把活跃内容自然推到前面。 +评论是楼层式的:可回复指定楼层、引用原文;支持嵌套展示。点赞、收藏、热门帖,把活跃内容自然推到前面。 -### 管理:后台也是 SPA +### 管理:SSR 后台 -管理后台统一在 `/admin`,与前台同一套 React 体验: +管理后台统一在 `/admin`,与公开页同一套模板布局: - 仪表盘、板块与帖子管理 - 用户禁言、删帖删评 - 论坛参数、限流、敏感词 - SQLite **一键备份** -权限模型很简单:**普通用户 / 管理员**;站点 **第一个注册用户自动成为管理员**,省去安装向导里的一堆步骤。 +权限模型很简单:**普通用户 / 管理员**;首次通过 **安装向导** 创建管理员账号。 --- @@ -88,7 +88,7 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗 ```text 编译 → 得到一个 jiang13(或 jiang13.exe) 放到目录里运行 → Env / CLI 引导(默认 SQLite) -打开浏览器注册 → 第一个账号就是管理员 +打开浏览器完成安装向导 → 创建管理员 ``` 要点: @@ -99,7 +99,7 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗 - **系统服务**:内置 Linux systemd / Windows Service 安装与启停; - **跨平台**:Windows / Linux / macOS 均可编译与运行。 -典型启动后访问 `http://localhost:3000`,注册即可开始。 +典型启动后访问 `http://localhost:3000`,完成安装后即可开始。 --- @@ -107,26 +107,26 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗 | 层级 | 技术 | |------|------| -| 后端 | Go · Gin · GORM · SQLite | -| 前端 | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual | -| 构建 | Vite 构建 SPA,再由 `go:embed` 打进二进制 | -| 认证 | bcrypt + JWT Cookie;可选 OIDC Provider,对接 Gitea 等 SSO | +| 后端 / SSR | Go · Gin · GORM · SQLite · `html/template` | +| 渐进资源 | `web_src` → `public/assets`(`/ssr-assets/`) | +| 构建 | `web_src` + `go:embed` templates/assets,单二进制 | +| 认证 | bcrypt + 会话 Cookie;可选 OIDC Provider,对接 Gitea 等 SSO | -对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:前后端分离开发,`dev` 模式下 Vite HMR(热模块替换)可秒级预览前端改动。 +对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:改模板/Go 后重启进程;改 `web_src` 后跑 `build.bat -Target web-src`。 --- ## 现在走到哪一步 -项目仍在积极开发中,核心体验已经可用: +项目仍在积极开发中,核心体验已经可用(分支 `rebuild/gitea-ssr`): -- ✅ 三栏布局、主题切换、虚拟滚动、Feed 排序 -- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史 -- ✅ React 管理后台与论坛参数配置 +- ✅ 三栏布局、主题切换、Feed 排序、真 SSR 公开页 +- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史(Markdown 编辑器) +- ✅ SSR 管理后台与论坛参数配置 - ✅ OIDC Provider(可作 Gitea 等站点的登录源) - ✅ 单二进制部署与系统服务 -计划中的方向包括通知动态优化、搜索增强、邮件提醒等——完整列表见仓库 [ROADMAP.md](../ROADMAP.md)。 +计划中的方向见仓库 [ROADMAP.md](../ROADMAP.md) 与 [09-ssr-progress.md](rebuild-spec/09-ssr-progress.md)。 --- diff --git a/docs/issue-templates.md b/docs/issue-templates.md index 31a13f4..535f924 100644 --- a/docs/issue-templates.md +++ b/docs/issue-templates.md @@ -1,12 +1,12 @@ # Issue 预填模板 -以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。 +以下可直接复制到本仓库 Issues 创建。本分支为 **SSR**(`rebuild/gitea-ssr`);对照 SPA 请注明 `main`。 --- -## Issue #1 · 评论回复换行不显示 +## Issue · Bug 模板 -**标题:** `[Bug] 评论回复换行不显示` +**标题:** `[Bug] 简短描述` **标签:** `bug` `ui/ux` @@ -14,79 +14,52 @@ ### 问题描述 -在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。 +(一句话) ### 复现步骤 -1. 打开任意帖子详情页(如 `/post/2`) -2. 在底部评论框输入: - ``` - 第一行 - 第二行 - 第三行 - ``` -3. 点击发送 -4. 查看刚发布的评论 +1. … +2. … -### 期望行为 +### 期望行为 / 实际行为 -评论正文按输入时的换行分段显示,行与行之间有明显间隔。 +### 相关代码(若已知) -### 实际行为 - -多行内容被渲染成单行连续文字。 - -### 相关代码 - -- `frontend/src/components/CommentContent.tsx` -- `frontend/src/utils/content.ts`(`highlightMentions` 中的 `\n` → `
` 转换) -- `frontend/src/styles/global.css`(`.floor-body` 的 `white-space: pre-wrap`) - -### 可能原因 - -- 仅处理了 `\n`,未处理 Windows 的 `\r\n` -- `dangerouslySetInnerHTML` 与 `pre-wrap` 样式叠加导致表现异常 -- 服务端 `strings.TrimSpace` 或其他处理误删换行(待排查) +- 模板:`templates/…` +- 路由:`routers/web/…` +- 样式/脚本:`web_src/…` ### 环境 -- 前台:React SPA(`:3000` 嵌入版或 `:5173` 开发版) -- 浏览器:Chrome / Edge 最新版 +- 分支:`rebuild/gitea-ssr`(或 `main` SPA) +- 浏览器 / OS --- -## Issue #2 · 示例:管理能力扩展(模板文案) +## Issue · 功能增强模板 -**标题:** `[Feature] 管理后台增加某某能力` +**标题:** `[Feature] 简短描述` -**标签:** `enhancement` `ui/ux` `good first issue` +**标签:** `enhancement` **正文:** ### 要解决的问题 -描述管理员在 React SPA 管理后台 / 前台中缺少的操作入口或能力。 - ### 现状 | 能力 | 状态 | | --- | --- | -| 数据模型与业务逻辑 | ✅ / ❌ | -| JSON API(如 `POST /api/admin/...`) | ✅ / ❌ | -| React 管理后台入口 | ✅ / ❌ | -| React 前台操作入口(如适用) | ✅ / ❌ | +| 规格(`docs/rebuild-spec`) | ✅ / ❌ | +| SSR 页面 / 表单 | ✅ / ❌ | +| 业务 `services/` | ✅ / ❌ | ### 期望方案 -1. 在对应页面为管理员增加操作入口(仅 `role === 'admin'` 可见) -2. 调用已有或新增的 `/api/admin/*` JSON API -3. 成功后刷新列表/详情,无需离开当前页面 +1. … +2. … ### 相关代码 -- 后端:`service/`、`handler/api.go`、`router/router.go` -- 前端:`frontend/src/pages/admin/`、`frontend/src/api/client.ts` - -### 备注 - -适合作为 `good first issue` 时,优先选择 API 已就绪、只需补 UI 的小改动。 +- `routers/web/`、`templates/`、`services/` +- 规格:`docs/rebuild-spec/02-features.md` / `06-pages-ux.md` diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 0befb39..999e702 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -12,7 +12,7 @@ ## A. 浏览与布局 - [x] 三栏布局:左导航 / 中 Feed / 右栏小组件 — SSR(桌面;≤900px 隐藏右栏) -- [x] 浅色 / 暗色主题;跟随系统偏好并本地记忆 — `data-theme` + `localStorage`(`j13-theme`);顶栏循环:跟随系统 / 浅色 / 暗色 +- [x] 浅色 / 暗色主题;跟随系统偏好并本地记忆 — CSS `prefers-color-scheme` + `data-theme` 显式覆盖;顶栏循环:跟随系统 / 浅色 / 暗色(`j13-theme`) - [ ] 响应式:平板/手机收起侧栏 - [ ] 长列表虚拟滚动或等价流畅方案 - [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门) diff --git a/docs/rebuild-spec/04-api.md b/docs/rebuild-spec/04-api.md index 244951a..2e7c154 100644 --- a/docs/rebuild-spec/04-api.md +++ b/docs/rebuild-spec/04-api.md @@ -5,7 +5,7 @@ > **源码**:[`routers/setup.go`](../../routers/setup.go)、[`modules/auth/auth.go`](../../modules/auth/auth.go) 本分支(`rebuild/gitea-ssr`)浏览器走 **`routers/web` 模板 + 表单**。 -下列 JSON 合约保留作历史对照与未来机器 API;**当前进程仅注册** health / OIDC / robots / sitemap / media 等机器相关路由,论坛 CRUD 的 `/api/*` 已从路由表移除(handler 源码可删可留,不以 SPA 兼容为目的)。 +**当前进程仅注册** health / OIDC / robots / sitemap / media thumb 等机器相关路由;论坛 CRUD 的 `/api/*` **handler 已从本分支删除**(对照实现见 `main`,合约形状见下文历史章节)。 `main` 分支 SPA 仍完整实现下表;对照请 checkout `main`。 diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 116be5e..9054552 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -105,10 +105,9 @@ ### 2.5 主题 -- 浅色 / 暗色;默认 **跟随系统**;`localStorage` 键 `j13-theme`(`system` | `light` | `dark`) -- ``;`` 内联脚本防首屏闪烁 -- 顶栏按钮循环切换;系统偏好变化时在 `system` 模式下即时更新 -- 兼容旧 SPA 仅存 `light`/`dark` 的值 +- 浅色 / 暗色;默认 **跟随系统**(CSS `@media (prefers-color-scheme)`);`localStorage` 键 `j13-theme`(`system` | `light` | `dark`) +- 显式浅/深时设 ``;`system` 时去掉该属性,交给媒体查询 +- 顶栏按钮循环切换;兼容旧 SPA 仅存 `light`/`dark` 的值 --- diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index 1b5cf5e..ba25741 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -10,15 +10,15 @@ | 项 | 值 | |----|-----| -| **上一刀** | 浅色/暗色主题(跟随系统 + localStorage) | -| **下一刀** | 需点名(OIDC / S3 / Gitea / 表格表情…) | +| **上一刀** | 主题去防闪 + 删未挂载 JSON API / crawler + 文档对齐 | +| **下一刀** | 需点名(OIDC Admin / S3 / Gitea / 表格表情…) | | **工作区** | 应干净 | --- ## 一句话状态 -核心论坛、Admin、Limits/伪静态、嵌套评论、Markdown 编辑器、**主题**已齐。余量多为后置与体验打磨。 +核心论坛、Admin、Limits/伪静态、嵌套评论、Markdown 编辑器、主题已齐;本分支 `routers/api` 仅保留机器入口。余量多为后置与体验打磨。 --- @@ -30,15 +30,16 @@ | Limits / 伪静态 / 品牌图 | 已迁 | | 评论嵌套树 UI | 已迁 | | Markdown 工具栏 + preview | 已迁 | -| 浅色 / 暗色 / 跟随系统 | 已迁 | +| 浅色 / 暗色 / 跟随系统 | 已迁(CSS 媒体查询,无 head 防闪脚本) | +| 未挂载论坛 JSON API | 已删(对照 `main`) | --- ## 未完成 -- P3:TipTip、游客评论、修订 diff、响应式侧栏抽屉 +- P3:游客评论、修订 diff、响应式侧栏抽屉 - 编辑器后置:TipTap、表格、图片组、表情贴纸 -- 后置:OIDC、S3 热切换、Gitea `/projects` +- 后置:OIDC Admin UI、S3 热切换、Gitea `/projects` --- diff --git a/docs/site-posts/about-jiang13.md b/docs/site-posts/about-jiang13.md index e4f1dbf..4a0d34b 100644 --- a/docs/site-posts/about-jiang13.md +++ b/docs/site-posts/about-jiang13.md @@ -98,12 +98,12 @@ ### 浏览与发帖 - 三栏布局,桌面 / 移动自适应 -- Feed 排序与虚拟滚动 -- 浅色 / 暗色主题;板块图标与主题色可配 -- TipTap 富文本、标签、正文图片 -- 楼层评论:回复、引用、@ +- Feed 排序 +- 浅色 / 暗色主题(可跟随系统);板块图标与主题色可配 +- Markdown 编辑器、标签、正文图片、内容门控 +- 楼层评论:回复、引用、嵌套树 - 点赞、收藏;置顶 / 精华 -- 编辑修订历史(可对比差异) +- 编辑修订历史 - 可配置普通用户的编辑时限 ### 社交与个人 @@ -114,7 +114,7 @@ ### 管理后台 -与前台同一套 React 体验,统一在 `/admin`: +SSR 后台统一在 `/admin`: | 模块 | 能力 | | --- | --- | diff --git a/modules/seo/pagemeta.go b/modules/seo/pagemeta.go index fb49bb7..6f92144 100644 --- a/modules/seo/pagemeta.go +++ b/modules/seo/pagemeta.go @@ -1,6 +1,6 @@ package seo -// PageMeta 页面级 SEO / 社交预览元数据(SSR 与爬虫 HTML 共用) +// PageMeta 页面级 SEO / 社交预览元数据(SSR 模板 head) type PageMeta struct { Title string // 完整 Description string diff --git a/public/assets/site.css b/public/assets/site.css index d8e34a7..0aa8bdd 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -32,6 +32,72 @@ --j13-overlay: rgba(255, 255, 255, 0.85); } +/* 跟随系统:无 data-theme 时由媒体查询决定 */ +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --j13-bg: #1c1917; + --j13-surface: #292524; + --j13-text: #f5f5f4; + --j13-muted: #a8a29e; + --j13-accent: #2dd4bf; + --j13-on-accent: #134e4a; + --j13-border: #44403c; + --j13-danger: #f87171; + --j13-ok-bg: #064e3b; + --j13-ok-fg: #a7f3d0; + --j13-warn-bg: #7c2d12; + --j13-warn-fg: #fed7aa; + --j13-warn-border: #9a3412; + --j13-err-bg: #7f1d1d; + --j13-err-fg: #fecaca; + --j13-err-border: #991b1b; + --j13-pin-bg: #78350f; + --j13-pin-fg: #fde68a; + --j13-feat-bg: #4c1d95; + --j13-feat-fg: #ddd6fe; + --j13-neutral-bg: #334155; + --j13-neutral-fg: #e2e8f0; + --j13-self-bg: #1e3a5f; + --j13-self-border: #1d4ed8; + --j13-delta-pos: #4ade80; + --j13-delta-neg: #f87171; + --j13-overlay: rgba(28, 25, 23, 0.85); + } +} + +/* 显式浅色/暗色覆盖系统偏好 */ +html[data-theme="light"] { + color-scheme: light; + --j13-bg: #f6f4ef; + --j13-surface: #fffdf8; + --j13-text: #1c1917; + --j13-muted: #78716c; + --j13-accent: #0f766e; + --j13-on-accent: #fff; + --j13-border: #e7e5e4; + --j13-danger: #b91c1c; + --j13-ok-bg: #ecfdf5; + --j13-ok-fg: #065f46; + --j13-warn-bg: #fff7ed; + --j13-warn-fg: #9a3412; + --j13-warn-border: #fed7aa; + --j13-err-bg: #fef2f2; + --j13-err-fg: #991b1b; + --j13-err-border: #fecaca; + --j13-pin-bg: #fef3c7; + --j13-pin-fg: #92400e; + --j13-feat-bg: #ede9fe; + --j13-feat-fg: #5b21b6; + --j13-neutral-bg: #f8fafc; + --j13-neutral-fg: #475569; + --j13-self-bg: #eff6ff; + --j13-self-border: #bfdbfe; + --j13-delta-pos: #15803d; + --j13-delta-neg: #b91c1c; + --j13-overlay: rgba(255, 255, 255, 0.85); +} + html[data-theme="dark"] { color-scheme: dark; --j13-bg: #1c1917; diff --git a/public/assets/site.js b/public/assets/site.js index 948e40d..db2c642 100644 --- a/public/assets/site.js +++ b/public/assets/site.js @@ -11,22 +11,17 @@ document.documentElement.dataset.j13Ssr = "1"; return "system"; } - function resolve(pref) { - if (pref === "dark") return "dark"; - if (pref === "light") return "light"; - try { - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - } catch (e) { - return "light"; - } - } - function apply(pref) { pref = normalizePref(pref); - var resolved = resolve(pref); - document.documentElement.setAttribute("data-theme", resolved); - document.documentElement.setAttribute("data-theme-pref", pref); - document.documentElement.style.colorScheme = resolved; + var root = document.documentElement; + if (pref === "system") { + root.removeAttribute("data-theme"); + root.style.removeProperty("color-scheme"); + } else { + root.setAttribute("data-theme", pref); + root.style.colorScheme = pref; + } + root.setAttribute("data-theme-pref", pref); try { localStorage.setItem(KEY, pref); } catch (e) {} @@ -40,7 +35,7 @@ document.documentElement.dataset.j13Ssr = "1"; function currentPref() { try { - return normalizePref(localStorage.getItem(KEY) || document.documentElement.getAttribute("data-theme-pref") || "system"); + return normalizePref(localStorage.getItem(KEY) || "system"); } catch (e) { return "system"; } @@ -57,15 +52,6 @@ document.documentElement.dataset.j13Ssr = "1"; apply(next); }); } - - try { - var mq = window.matchMedia("(prefers-color-scheme: dark)"); - var onChange = function () { - if (currentPref() === "system") apply("system"); - }; - if (mq.addEventListener) mq.addEventListener("change", onChange); - else if (mq.addListener) mq.addListener(onChange); - } catch (e) {} })(); (function mdEditors() { diff --git a/routers/api/api.go b/routers/api/api.go deleted file mode 100644 index a6b4e82..0000000 --- a/routers/api/api.go +++ /dev/null @@ -1,1197 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "net/http" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "git.iioio.com/freefire/jiang13-forum/modules/auth" - "git.iioio.com/freefire/jiang13-forum/models" - "git.iioio.com/freefire/jiang13-forum/services" -) - -// APIMe 当前登录用户 -func (h *Handlers) APIMe(c *gin.Context) { - uid := h.currentUserID(c) - if uid == 0 { - c.JSON(http.StatusOK, gin.H{"user": nil}) - return - } - user, err := h.User.GetByID(uid) - if err != nil { - // 账号已删或不存在:清掉失效 cookie,与未登录态一致 - c.SetCookie(auth.CookieName, "", -1, "/", "", false, true) - c.JSON(http.StatusOK, gin.H{"user": nil}) - return - } - if h.Badge != nil { - _ = h.Badge.EvaluateAuto(user.ID) - } - view := user.ToSelf() - if h.Badge != nil { - if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { - view.Badges = services.BadgeViews(badges, 0) - } - } - c.JSON(http.StatusOK, gin.H{ - "user": view, - }) -} - -// APIBoards 板块列表(含帖子数) -func (h *Handlers) APIBoards(c *gin.Context) { - boards, err := h.Board.ListWithStats() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if boards == nil { - boards = []services.BoardWithStats{} - } - c.JSON(http.StatusOK, gin.H{"boards": boards}) -} - -// APIHealth 健康检查(容器探活 / 负载均衡) -func (h *Handlers) APIHealth(c *gin.Context) { - if err := models.PingDB(); err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "status": "unavailable", - "error": err.Error(), - }) - return - } - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// APIStats 论坛概览统计 -func (h *Handlers) APIStats(c *gin.Context) { - var userCount, postCount, boardCount, commentCount int64 - models.DB.Model(&models.User{}).Count(&userCount) - models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&postCount) - models.DB.Model(&models.Board{}).Count(&boardCount) - models.DB.Model(&models.Comment{}).Where("status = ?", models.ContentStatusPublished).Count(&commentCount) - c.JSON(http.StatusOK, gin.H{ - "users": userCount, - "posts": postCount, - "boards": boardCount, - "comments": commentCount, - }) -} - -// APIForumLimits 前台可见的论坛限制配置 -func (h *Handlers) APIForumLimits(c *gin.Context) { - c.JSON(http.StatusOK, h.Settings.PublicLimits()) -} - -// APIAdminCreateBoard 管理员创建板块(JSON) -func (h *Handlers) APIAdminCreateBoard(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Icon string `json:"icon"` - ColorIndex int `json:"color_index"` - SortOrder int `json:"sort_order"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - board, err := h.Board.Create(req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "板块已创建", "board": board}) -} - -// APIAdminUpdateBoard 管理员更新板块 -func (h *Handlers) APIAdminUpdateBoard(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Icon string `json:"icon"` - ColorIndex int `json:"color_index"` - SortOrder int `json:"sort_order"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Board.Update(uint(id), req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - board, _ := h.Board.GetByID(uint(id)) - c.JSON(http.StatusOK, gin.H{"message": "板块已更新", "board": board}) -} - -// APIAdminDeleteBoard 管理员删除板块 -func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Board.Delete(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "板块已删除"}) -} - -// APIAdminDashboard 管理后台概览 -func (h *Handlers) APIAdminDashboard(c *gin.Context) { - var userCount, postCount, boardCount, commentCount int64 - models.DB.Model(&models.User{}).Count(&userCount) - models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&postCount) - models.DB.Model(&models.Board{}).Count(&boardCount) - models.DB.Model(&models.Comment{}).Where("status = ?", models.ContentStatusPublished).Count(&commentCount) - pendingPosts, _ := h.Post.PendingPostCount() - pendingComments, _ := h.Comment.PendingCommentCount() - pendingReports, _ := h.Report.PendingCount() - pendingFriendLinks, _ := h.FriendLinkApply.PendingCount() - recentPosts, _, _ := h.Post.List(services.PostListQuery{ - Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all", - }) - if recentPosts == nil { - recentPosts = []models.Post{} - } - c.JSON(http.StatusOK, gin.H{ - "users": userCount, "posts": postCount, "boards": boardCount, - "comments": commentCount, - "pending_posts": pendingPosts, - "pending_comments": pendingComments, - "pending_reports": pendingReports, - "pending_friend_links": pendingFriendLinks, - "recent_posts": recentPosts, - }) -} - -// APIAdminPosts 管理员帖子列表 -func (h *Handlers) APIAdminPosts(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - keyword := strings.TrimSpace(c.Query("keyword")) - status := strings.TrimSpace(c.DefaultQuery("status", "all")) - posts, total, err := h.Post.ListItems(services.PostListQuery{ - Page: page, Size: size, Keyword: keyword, - ViewerIsAdmin: true, Status: status, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if posts == nil { - posts = []services.PostListItem{} - } - pending, _ := h.Post.PendingPostCount() - c.JSON(http.StatusOK, gin.H{ - "posts": posts, "total": total, "page": page, - "total_pages": calcTotalPages(total, size), - "pending_count": pending, - "status": status, - }) -} - -// APIAdminLockPost 锁定/解锁帖子编辑 -func (h *Handlers) APIAdminLockPost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Locked bool `json:"locked"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Post.SetEditLocked(uint(id), req.Locked); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已解锁编辑" - if req.Locked { - msg = "已锁定编辑" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked}) -} - -// APIAdminCommentsLockPost 锁定/解锁讨论(禁止新评论) -func (h *Handlers) APIAdminCommentsLockPost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Locked bool `json:"locked"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Post.SetCommentsLocked(uint(id), req.Locked); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已开放讨论" - if req.Locked { - msg = "已锁定讨论" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "comments_locked": req.Locked}) -} - -// APIAdminPinPost 全局置顶/取消全局置顶(JSON) -func (h *Handlers) APIAdminPinPost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Pinned bool `json:"pinned"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Post.SetPinned(uint(id), req.Pinned); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已取消全局置顶" - if req.Pinned { - msg = "已全局置顶" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned}) -} - -// APIAdminBoardPinPost 板块内置顶/取消板块内置顶(JSON) -func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - BoardPinned bool `json:"board_pinned"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Post.SetBoardPinned(uint(id), req.BoardPinned); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已取消板块置顶" - if req.BoardPinned { - msg = "已板块置顶" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "board_pinned": req.BoardPinned}) -} - -// APIAdminFeaturePost 设为精华/取消精华(JSON) -func (h *Handlers) APIAdminFeaturePost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Featured bool `json:"featured"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Post.SetFeatured(uint(id), req.Featured); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已取消精华" - if req.Featured { - msg = "已设为精华" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "featured": req.Featured}) -} - -// APIAdminDeletePost 管理员软删除帖子(进入回收站) -func (h *Handlers) APIAdminDeletePost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Post.Delete(0, uint(id), true); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "帖子已移入回收站"}) -} - -// APIAdminTrashPosts 回收站帖子列表 -func (h *Handlers) APIAdminTrashPosts(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - keyword := strings.TrimSpace(c.Query("keyword")) - posts, total, err := h.Post.ListTrash(page, size, keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if posts == nil { - posts = []services.TrashPostItem{} - } - c.JSON(http.StatusOK, gin.H{ - "posts": posts, "total": total, "page": page, - "total_pages": calcTotalPages(total, size), - }) -} - -// APIAdminRestorePost 从回收站恢复帖子 -func (h *Handlers) APIAdminRestorePost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Post.Restore(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "帖子已恢复"}) -} - -// APIAdminPurgePost 永久删除回收站帖子 -func (h *Handlers) APIAdminPurgePost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Post.Purge(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "帖子已永久删除"}) -} - -// APIAdminComments 管理员评论列表 -func (h *Handlers) APIAdminComments(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - status := strings.TrimSpace(c.DefaultQuery("status", "all")) - comments, total, err := h.Comment.ListRecent(page, size, status) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if comments == nil { - comments = []models.Comment{} - } - pending, _ := h.Comment.PendingCommentCount() - c.JSON(http.StatusOK, gin.H{ - "comments": comments, "total": total, "page": page, - "total_pages": calcTotalPages(total, size), - "pending_count": pending, - "status": status, - }) -} - -// APIAdminApproveComment 通过评论审核 -func (h *Handlers) APIAdminApproveComment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if h.Notify != nil { - if comment, err := h.Comment.GetByID(uint(id)); err == nil { - comment.Status = models.ContentStatusPublished - h.Notify.AsyncNotifyCommentPublished(comment) - h.Notify.AsyncNotifyCommentMentions(comment) - } - } - c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": models.ContentStatusPublished}) -} - -// APIAdminRejectComment 拒绝评论并私信通知 -func (h *Handlers) APIAdminRejectComment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Reason string `json:"reason"` - } - _ = c.ShouldBindJSON(&req) - reason := strings.TrimSpace(req.Reason) - if reason == "" { - reason = "不符合社区规范" - } - comment, err := h.Comment.GetByID(uint(id)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.Comment.SetStatus(uint(id), models.ContentStatusRejected); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if comment.UserID > 0 { - title := comment.Post.Title - if title == "" { - title = "未知帖子" - } - pid := comment.PostID - _, _ = h.Message.SendSystem( - comment.UserID, - "评论未通过审核", - services.FormatCommentRejectContent(title, comment.PostID, comment.Floor, reason), - models.MessageKindReject, - &pid, - nil, - ) - } - c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": models.ContentStatusRejected}) -} - -// APIAdminDeleteComment 管理员软删除评论(进入回收站) -func (h *Handlers) APIAdminDeleteComment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Comment.AdminDelete(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"}) -} - -// APIAdminTrashComments 评论回收站列表 -func (h *Handlers) APIAdminTrashComments(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - keyword := strings.TrimSpace(c.Query("keyword")) - comments, total, err := h.Comment.ListTrash(page, size, keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if comments == nil { - comments = []services.TrashCommentItem{} - } - c.JSON(http.StatusOK, gin.H{ - "comments": comments, "total": total, "page": page, - "total_pages": calcTotalPages(total, size), - }) -} - -// APIAdminRestoreComment 从回收站恢复评论 -func (h *Handlers) APIAdminRestoreComment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Comment.Restore(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "评论已恢复"}) -} - -// APIAdminPurgeComment 永久删除回收站评论 -func (h *Handlers) APIAdminPurgeComment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Comment.Purge(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "评论已永久删除"}) -} - -// APIAdminCommentRevisions 管理员查看评论编辑历史 -func (h *Handlers) APIAdminCommentRevisions(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - revs, err := h.Comment.ListRevisions(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"revisions": revs}) -} - -// APIAdminUsers 管理员用户列表 -func (h *Handlers) APIAdminUsers(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - keyword := strings.TrimSpace(c.Query("keyword")) - filter := strings.TrimSpace(c.DefaultQuery("filter", "all")) - users, total, err := h.User.ListUsers(services.UserListQuery{ - Page: page, Size: size, Keyword: keyword, Filter: filter, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if users == nil { - users = []models.User{} - } - c.JSON(http.StatusOK, gin.H{ - "users": models.UsersToAdmin(users), "total": total, "page": page, - "total_pages": calcTotalPages(total, size), - "keyword": keyword, - "filter": filter, - }) -} - -// APIAdminBanUser 禁言/解除禁言(JSON) -func (h *Handlers) APIAdminBanUser(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Banned bool `json:"banned"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.User.BanUser(uint(id), req.Banned); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已解除禁言" - if req.Banned { - msg = "已禁言" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "banned": req.Banned}) -} - -// APIAdminBackup 导出 SQLite 备份 -func (h *Handlers) APIAdminBackup(c *gin.Context) { - path, err := h.Backup.ExportSQLite() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - filename := filepath.Base(path) - c.JSON(http.StatusOK, gin.H{ - "message": "备份成功", - "filename": filename, - "download": "/api/admin/backup/download/" + filename, - }) -} - -// APIAdminDownloadBackup 下载备份文件 -func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) { - name := c.Param("name") - if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的备份文件名"}) - return - } - path := filepath.Join(h.Cfg.DataDir, name) - c.FileAttachment(path, name) -} - -// APIAdminSettings 系统设置信息 -func (h *Handlers) APIAdminSettings(c *gin.Context) { - limits := h.Settings.Limits() - filterContent, _ := services.ReadFilterWordsFile(h.Cfg.FilterWordsPath()) - clients, _ := h.Settings.ListOAuthClients() - c.JSON(http.StatusOK, gin.H{ - "filter_path": h.Cfg.FilterWordsPath(), - "data_dir": h.Cfg.DataDir, - "db_path": h.Cfg.DBPath(), - "port": h.Cfg.Port, - "limits": limits, - "mail": h.Settings.MailConfigPublic(), - "oidc": h.Settings.OIDCConfigPublic(), - "oauth_clients": clients, - "gitea": h.Settings.GiteaSyncConfigPublic(), - "storage": h.Settings.StorageConfigPublic(), - "branding": h.Settings.SiteBranding(), - "filter_words": filterContent, - "filter_word_count": services.CountFilterWords(filterContent), - }) -} - -// APISiteBranding 前台公开的站点品牌配置 -func (h *Handlers) APISiteBranding(c *gin.Context) { - brand := h.Settings.SiteBranding() - brand.SiteURL = h.publicBaseURL(c) - c.JSON(http.StatusOK, brand) -} - -// APIAdminUpdateBranding 更新站点品牌文案 -func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) { - var req services.SiteBranding - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Settings.UpdateSiteBranding(req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "站点品牌已保存", - "branding": h.Settings.SiteBranding(), - }) -} - -// APIAdminUploadBrandingAsset 上传 Logo / Favicon / 默认 OG 图(form: file + kind=logo|favicon|og_image) -func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) { - kind := strings.TrimSpace(c.PostForm("kind")) - if kind != "logo" && kind != "favicon" && kind != "og_image" { - c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo、favicon 或 og_image"}) - return - } - file, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"}) - return - } - const maxBytes = 2 * 1024 * 1024 - if file.Size > maxBytes { - c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"}) - return - } - url, err := services.SaveUploadedImage(h.Store, file, services.UploadCategorySite, kind) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - prev := h.Settings.SiteBranding() - switch kind { - case "logo": - _ = h.Settings.SetSiteLogo(url) - h.Store.DeleteByURL(prev.Logo) - case "favicon": - _ = h.Settings.SetSiteFavicon(url) - h.Store.DeleteByURL(prev.Favicon) - case "og_image": - _ = h.Settings.SetSiteOGImage(url) - h.Store.DeleteByURL(prev.OGImage) - } - c.JSON(http.StatusOK, gin.H{ - "message": "上传成功", - "url": url, - "branding": h.Settings.SiteBranding(), - }) -} - -// APIAdminClearBrandingAsset 清除 Logo / Favicon / 默认 OG 图 -func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) { - var req struct { - Kind string `json:"kind"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - kind := strings.TrimSpace(req.Kind) - brand := h.Settings.SiteBranding() - switch kind { - case "logo": - _ = h.Settings.SetSiteLogo("") - h.Store.DeleteByURL(brand.Logo) - case "favicon": - _ = h.Settings.SetSiteFavicon("") - h.Store.DeleteByURL(brand.Favicon) - case "og_image": - _ = h.Settings.SetSiteOGImage("") - h.Store.DeleteByURL(brand.OGImage) - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo、favicon 或 og_image"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "已清除", - "branding": h.Settings.SiteBranding(), - }) -} - -// APIAdminUpdateForumSettings 更新论坛设置 -func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) { - var req services.ForumLimits - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Settings.UpdateLimits(req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "设置已保存", - "limits": h.Settings.Limits(), - }) -} - -// APIAdminUpdateMailSettings 更新邮件 SMTP 配置 -func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) { - var req services.MailConfig - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Settings.UpdateMailConfig(req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "邮件设置已保存", - "mail": h.Settings.MailConfigPublic(), - }) -} - -// APIAdminUpdateOIDCSettings 更新 OIDC Provider 全局配置 -func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) { - var req services.OIDCConfig - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Settings.UpdateOIDCConfig(req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "OIDC 设置已保存", - "oidc": h.Settings.OIDCConfigPublic(), - }) -} - -// APIProjects 会员公开 Gitea 项目列表(本地缓存) -func (h *Handlers) APIProjects(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30"))) - q := strings.TrimSpace(c.Query("q")) - if page < 1 { - page = 1 - } - if size < 1 { - size = 30 - } - if size > 100 { - size = 100 - } - if h.Gitea == nil { - c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0}) - return - } - list, total, err := h.Gitea.ListPublic(page, size, q) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"}) - return - } - list = services.AttachGiteaOwners(list, h.Badge) - c.JSON(http.StatusOK, gin.H{ - "projects": list, - "total": total, - "page": page, - "total_pages": calcTotalPages(total, size), - }) -} - -// APIAdminUpdateGiteaSettings Gitea 同步已后置 -func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"}) -} - -// APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换 -func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) { - var req services.StorageConfig - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Settings.UpdateStorageConfig(req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.Store.ReloadFromSettings(h.Settings); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "配置已保存,但初始化存储失败:" + err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "存储设置已保存", - "storage": h.Settings.StorageConfigPublic(), - }) -} - -// APIAdminSyncGitea Gitea 同步已后置 -func (h *Handlers) APIAdminSyncGitea(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"}) -} - -// APIAdminListOAuthClients 列出 OAuth 应用 -func (h *Handlers) APIAdminListOAuthClients(c *gin.Context) { - list, err := h.Settings.ListOAuthClients() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"}) - return - } - c.JSON(http.StatusOK, gin.H{"clients": list}) -} - -// APIAdminCreateOAuthClient 创建 OAuth 应用 -func (h *Handlers) APIAdminCreateOAuthClient(c *gin.Context) { - var req services.OAuthClientInput - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - view, err := h.Settings.CreateOAuthClient(req) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "应用已创建,请立即保存客户端密钥(仅显示一次)", - "client": view, - "oidc": h.Settings.OIDCConfigPublic(), - }) -} - -// APIAdminUpdateOAuthClient 更新 OAuth 应用 -func (h *Handlers) APIAdminUpdateOAuthClient(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"}) - return - } - var req services.OAuthClientInput - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - view, err := h.Settings.UpdateOAuthClient(uint(id), req) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "应用已更新" - if view.ClientSecret != "" { - msg = "应用已更新,新密钥仅显示一次,请立即保存" - } - c.JSON(http.StatusOK, gin.H{ - "message": msg, - "client": view, - "oidc": h.Settings.OIDCConfigPublic(), - }) -} - -// APIAdminDeleteOAuthClient 删除 OAuth 应用 -func (h *Handlers) APIAdminDeleteOAuthClient(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"}) - return - } - if err := h.Settings.DeleteOAuthClient(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "应用已删除", - "oidc": h.Settings.OIDCConfigPublic(), - }) -} - -// APIAdminTestMail 发送测试邮件 -func (h *Handlers) APIAdminTestMail(c *gin.Context) { - var req struct { - To string `json:"to" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请填写收件邮箱"}) - return - } - if err := services.ValidateEmail(req.To); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if !h.Settings.MailReady() { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()}) - return - } - siteName := h.Settings.SiteBranding().Name - err := h.Mail.Send(services.NormalizeEmail(req.To), "邮件配置测试", - fmt.Sprintf("这是一封来自%s的测试邮件,说明 SMTP 配置正常。", siteName)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "测试邮件已发送"}) -} - -// APIAdminFilterWords 读取敏感词配置 -func (h *Handlers) APIAdminFilterWords(c *gin.Context) { - content, err := services.ReadFilterWordsFile(h.Cfg.FilterWordsPath()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "content": content, - "word_count": services.CountFilterWords(content), - "path": h.Cfg.FilterWordsPath(), - }) -} - -// APIAdminUpdateFilterWords 更新敏感词配置 -func (h *Handlers) APIAdminUpdateFilterWords(c *gin.Context) { - var req struct { - Content string `json:"content"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := services.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "敏感词已保存并生效", - "word_count": services.CountFilterWords(req.Content), - }) -} - -// APIPosts 帖子列表(分页) -func (h *Handlers) APIPosts(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault()))) - boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64) - userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64) - keyword := c.Query("keyword") - tag := strings.TrimSpace(c.Query("tag")) - author := strings.TrimSpace(c.Query("author")) - titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true") - - q := services.PostListQuery{ - BoardID: uint(boardID), - UserID: uint(userID), - Page: page, - Size: size, - Keyword: keyword, - Tag: tag, - Author: author, - TitleOnly: titleOnly, - Sort: c.DefaultQuery("sort", "latest"), - ViewerID: h.currentUserID(c), - ViewerIsAdmin: h.isAdmin(c), - } - items, total, err := h.Post.ListItems(q) - if err != nil { - if isClientLimitError(err) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if items == nil { - items = []services.PostListItem{} - } - if h.Badge != nil { - users := make([]*models.User, 0, len(items)) - for i := range items { - if items[i].User.ID > 0 { - users = append(users, &items[i].User) - } - } - h.Badge.AttachBadgeSummaries(users, 2) - } - c.JSON(http.StatusOK, gin.H{ - "posts": items, - "total": total, - "page": page, - "size": size, - "has_more": int64(page*size) < total, - }) -} - -// APIPostDetail 帖子详情 -func (h *Handlers) APIPostDetail(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - post, err := h.Post.FindByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - uid := h.currentUserID(c) - isAdmin := h.isAdmin(c) - if !services.CanViewPost(post, uid, isAdmin) { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - if c.Query("skip_view") != "1" && post.Status == models.ContentStatusPublished { - h.Post.RecordView(uint(id)) - } - // 出口再消毒:兼容库内历史脏 HTML(如 <style>),避免旧帖污染整页 - post.Content = services.SanitizePostHTML(post.Content) - hasReplied := uid > 0 && h.Comment.HasUserReplied(uint(id), uid) - if uid == 0 { - post.Content = services.RedactMembersOnlyHTML(post.Content) - post.Content = services.RedactReplyOnlyHTML(post.Content) - } else if !isAdmin && post.UserID != uid && !hasReplied { - // 作者与管理员始终可见;其他用户需已回复 - post.Content = services.RedactReplyOnlyHTML(post.Content) - } - // 积分解锁块:作者/站长全文;其他人按解锁记录 redact - if isAdmin || post.UserID == uid { - post.Content = services.RevealAllPointsOnly(post.Content) - } else { - unlocked, _ := services.ListUnlockedKeys(uid, uint(id)) - post.Content = services.RedactPointsOnlyHTML(post.Content, unlocked) - } - comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c)) - if h.Badge != nil { - if post.User.ID > 0 { - h.Badge.AttachBadgeSummaries([]*models.User{&post.User}, 3) - } - h.Badge.AttachBadgeSummariesOnComments(comments, 2) - } - canEdit := h.Post.CanUserEdit(post, uid, isAdmin) - editReason := "" - if !canEdit && uid > 0 { - editReason = h.Post.UserEditBlockReason(post, uid, isAdmin) - } - isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute - resp := gin.H{ - "post": post, - "comment_count": len(comments), - "liked": h.Post.IsLiked(uid, uint(id)), - "favorited": h.Post.IsFavorited(uid, uint(id)), - "has_replied": hasReplied, - "can_edit": canEdit, - "edit_block_reason": editReason, - "is_edited": isEdited, - "post_edit_window_hours": h.Settings.PostEditWindowHours(), - } - if post.PostType == models.PostTypePoll { - if poll, err := services.GetPollView(uint(id), uid); err == nil { - resp["poll"] = poll - } - } - if post.PostType == models.PostTypeLottery { - if lottery, err := services.GetPostLotteryView(post); err == nil && lottery != nil { - resp["lottery"] = lottery - } - } - if post.PostType == models.PostTypeBounty && post.BountyStatus == models.BountyStatusOpen && post.BountyPoints > 0 { - canRefund, blockReason := services.CanRefundBounty(post, isAdmin) - resp["bounty_can_refund"] = canRefund - resp["bounty_refund_block_reason"] = blockReason - if n, err := services.CountEligibleBountyReplies(models.DB, post.ID, post.UserID); err == nil { - resp["bounty_eligible_reply_count"] = n - } - } - c.JSON(http.StatusOK, resp) -} - -// APIPostComments 楼层列表 -func (h *Handlers) APIPostComments(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - post, err := h.Post.FindByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - uid := h.currentUserID(c) - isAdmin := h.isAdmin(c) - if !services.CanViewPost(post, uid, isAdmin) { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - comments, err := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if comments == nil { - comments = []models.Comment{} - } - if h.Badge != nil { - h.Badge.AttachBadgeSummariesOnComments(comments, 2) - } - c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)}) -} - -// APIHotPosts 近期活跃讨论(近 7 日有回复) -func (h *Handlers) APIHotPosts(c *gin.Context) { - items, err := h.Post.HotPosts(10) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"posts": items}) -} - -// APITags 标签云(按使用次数聚合) -func (h *Handlers) APITags(c *gin.Context) { - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40")) - tags, err := h.Post.PopularTags(limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"tags": tags}) -} - -// APIRecentComments 最新公开评论 -func (h *Handlers) APIRecentComments(c *gin.Context) { - list, err := h.Comment.ListRecentPublic(8) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if list == nil { - list = []services.RecentCommentItem{} - } - c.JSON(http.StatusOK, gin.H{"comments": list}) -} - -// APIRecentUsers 最新注册用户(右栏) -func (h *Handlers) APIRecentUsers(c *gin.Context) { - list, err := h.User.ListRecentRegistered(8) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if list == nil { - list = []services.RecentUserItem{} - } - c.JSON(http.StatusOK, gin.H{"users": list}) -} - -// APIFavorites 我的收藏 -func (h *Handlers) APIFavorites(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - favs, total, err := h.Post.ListFavorites(h.currentUserID(c), page, size) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if favs == nil { - favs = []models.PostFavorite{} - } - c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page}) -} - -// APIPostRevisions 帖子编辑历史列表(作者或管理员) -func (h *Handlers) APIPostRevisions(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - post, err := h.Post.FindByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - uid := h.currentUserID(c) - isAdmin := h.isAdmin(c) - if !isAdmin && post.UserID != uid { - c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"}) - return - } - revs, err := h.Post.ListRevisions(uint(id)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"revisions": revs}) -} - -// APIPostRevisionDetail 查看某个历史版本 -func (h *Handlers) APIPostRevisionDetail(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64) - post, err := h.Post.FindByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - uid := h.currentUserID(c) - isAdmin := h.isAdmin(c) - if !isAdmin && post.UserID != uid { - c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"}) - return - } - rev, err := h.Post.GetRevision(uint(id), uint(revID)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"revision": rev}) -} - -func isClientLimitError(err error) bool { - return errors.Is(err, services.ErrSearchKeywordTooShort) || - errors.Is(err, services.ErrSearchKeywordTooLong) -} diff --git a/routers/api/economy.go b/routers/api/economy.go deleted file mode 100644 index d9acbb0..0000000 --- a/routers/api/economy.go +++ /dev/null @@ -1,218 +0,0 @@ -package api - -import ( - "errors" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "git.iioio.com/freefire/jiang13-forum/models" - "git.iioio.com/freefire/jiang13-forum/services" -) - -// APIMePoints 余额与流水 -func (h *Handlers) APIMePoints(c *gin.Context) { - uid := h.currentUserID(c) - user, err := h.User.GetByID(uid) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) - return - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - rows, total, err := h.Points.ListLedger(uid, page, size) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - checkIn, _ := h.Points.GetCheckInStatus(uid) - lottery, _ := h.Points.GetLotteryStatus(uid) - c.JSON(http.StatusOK, gin.H{ - "points": user.Points, - "creator_income_total": user.CreatorIncomeTotal, - "ledger": rows, - "total": total, - "page": page, - "total_pages": calcTotalPages(total, size), - "check_in": checkIn, - "lottery": lottery, - }) -} - -// APIMeCheckInGet 今日签到状态 -func (h *Handlers) APIMeCheckInGet(c *gin.Context) { - st, err := h.Points.GetCheckInStatus(h.currentUserID(c)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"check_in": st}) -} - -// APIMeCheckIn 每日签到 -func (h *Handlers) APIMeCheckIn(c *gin.Context) { - st, err := h.Points.CheckIn(h.currentUserID(c)) - if err != nil { - if errors.Is(err, services.ErrAlreadyCheckedIn) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - user, _ := h.User.GetByID(h.currentUserID(c)) - pts := 0 - if user != nil { - pts = user.Points - } - c.JSON(http.StatusOK, gin.H{"message": "签到成功", "check_in": st, "points": pts}) -} - -// APIMeLottery GET 状态 / POST 抽奖 -func (h *Handlers) APIMeLotteryGet(c *gin.Context) { - st, err := h.Points.GetLotteryStatus(h.currentUserID(c)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"lottery": st}) -} - -func (h *Handlers) APIMeLotteryDraw(c *gin.Context) { - st, err := h.Points.DrawLottery(h.currentUserID(c)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - user, _ := h.User.GetByID(h.currentUserID(c)) - pts := 0 - if user != nil { - pts = user.Points - } - c.JSON(http.StatusOK, gin.H{"message": "抽奖完成", "lottery": st, "points": pts}) -} - -// APIUnlockPostBlock 积分解锁付费块 -func (h *Handlers) APIUnlockPostBlock(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - BlockKey string `json:"block_key"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.BlockKey == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"}) - return - } - res, err := services.UnlockPointsBlock(h.currentUserID(c), uint(id), req.BlockKey) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "解锁成功", "unlock": res}) -} - -// APIAdminVerifyUser 认证开关 -func (h *Handlers) APIAdminVerifyUser(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Verified bool `json:"verified"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := services.SetVerified(uint(id), req.Verified); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已取消认证" - if req.Verified { - msg = "已认证" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "verified": req.Verified}) -} - -// APIAdminSetUserLevel 设等级 -func (h *Handlers) APIAdminSetUserLevel(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Level int `json:"level"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := services.SetUserLevel(uint(id), req.Level); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "等级已更新", "level": req.Level, "exp": models.ExpForLevel(req.Level)}) -} - -// APIAdminAdjustPoints 调积分 -func (h *Handlers) APIAdminAdjustPoints(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Delta int `json:"delta"` - Note string `json:"note"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - bal, err := h.Points.AdminAdjust(uint(id), req.Delta, req.Note) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "积分已调整", "points": bal}) -} - -// APIAdminListBadges 徽章定义列表 -func (h *Handlers) APIAdminListBadges(c *gin.Context) { - rows, err := h.Badge.ListDefs(true) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"badges": rows}) -} - -// APIAdminUpsertBadge 创建/更新徽章定义 -func (h *Handlers) APIAdminUpsertBadge(c *gin.Context) { - var def models.BadgeDef - if err := c.ShouldBindJSON(&def); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Badge.UpsertDef(&def); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已保存", "badge": def}) -} - -// APIAdminAwardBadge 颁发/收回限定徽章 -func (h *Handlers) APIAdminAwardBadge(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - BadgeID uint `json:"badge_id"` - Revoke bool `json:"revoke"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.BadgeID == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if req.Revoke { - if err := h.Badge.Revoke(uint(id), req.BadgeID); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已收回徽章"}) - return - } - if err := h.Badge.AwardLimited(uint(id), req.BadgeID, h.currentUserID(c)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已颁发徽章"}) -} diff --git a/routers/api/friend_link.go b/routers/api/friend_link.go deleted file mode 100644 index 9766f9d..0000000 --- a/routers/api/friend_link.go +++ /dev/null @@ -1,281 +0,0 @@ -package api - -import ( - "fmt" - "net/http" - "strconv" - "strings" - - "git.iioio.com/freefire/jiang13-forum/services" - "github.com/gin-gonic/gin" -) - -// APIApplyFriendLink 提交友情链接申请 -func (h *Handlers) APIApplyFriendLink(c *gin.Context) { - uid := h.currentUserID(c) - if uid == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) - return - } - var req struct { - Name string `json:"name"` - URL string `json:"url"` - Logo string `json:"logo"` - LinkOnHomepage bool `json:"link_on_homepage"` - ReciprocalPageURL string `json:"reciprocal_page_url"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - result, err := h.FriendLinkApply.Create(services.FriendLinkApplyInput{ - UserID: uid, - Name: req.Name, - URL: req.URL, - Logo: req.Logo, - LinkOnHomepage: req.LinkOnHomepage, - ReciprocalPageURL: req.ReciprocalPageURL, - OurSiteURL: h.publicBaseURL(c), - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - resp := gin.H{ - "message": friendLinkApplySubmittedMessage(h.Settings.FriendLinkReciprocalCheckEnabled(), false), - "apply": result.Apply, - } - c.JSON(http.StatusOK, resp) -} - -// APIUploadFriendLinkLogo 上传友链申请 LOGO -func (h *Handlers) APIUploadFriendLinkLogo(c *gin.Context) { - uid := h.currentUserID(c) - if uid == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) - return - } - file, err := c.FormFile("logo") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 LOGO 图片"}) - return - } - maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024 - if file.Size > maxBytes { - c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"}) - return - } - url, err := services.SaveUploadedImage( - h.Store, - file, - services.UploadCategorySite, - fmt.Sprintf("fl_%d", uid), - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "LOGO 已上传", "url": url}) -} - -// APIAdminFriendLinkApplies 管理员友链申请列表 -func (h *Handlers) APIAdminFriendLinkApplies(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - status := strings.TrimSpace(c.DefaultQuery("status", "pending")) - list, total, err := h.FriendLinkApply.ListAdmin(services.FriendLinkApplyListQuery{ - Page: page, Size: size, Status: status, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - pending, _ := h.FriendLinkApply.PendingCount() - c.JSON(http.StatusOK, gin.H{ - "applies": list, - "total": total, - "page": page, - "pending_count": pending, - "reciprocal_check_enabled": h.Settings.FriendLinkReciprocalCheckEnabled(), - }) -} - -// APIAdminUpdateFriendLinkSettings 更新友链相关开关(回链检测 / 入口展示) -func (h *Handlers) APIAdminUpdateFriendLinkSettings(c *gin.Context) { - var req struct { - ReciprocalCheckEnabled *bool `json:"reciprocal_check_enabled"` - NavShowFriendLinks *bool `json:"nav_show_friend_links"` - FooterShowFriendLinks *bool `json:"footer_show_friend_links"` - AsideShowFriendLinks *bool `json:"aside_show_friend_links"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if req.ReciprocalCheckEnabled == nil && req.NavShowFriendLinks == nil && - req.FooterShowFriendLinks == nil && req.AsideShowFriendLinks == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if req.ReciprocalCheckEnabled != nil { - if err := h.Settings.SetFriendLinkReciprocalCheckEnabled(*req.ReciprocalCheckEnabled); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - if req.NavShowFriendLinks != nil { - if err := h.Settings.SetNavShowFriendLinks(*req.NavShowFriendLinks); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - if req.FooterShowFriendLinks != nil { - if err := h.Settings.SetFooterShowFriendLinks(*req.FooterShowFriendLinks); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - if req.AsideShowFriendLinks != nil { - if err := h.Settings.SetAsideFriendLinksEnabled(*req.AsideShowFriendLinks); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - msg := "设置已保存" - if req.ReciprocalCheckEnabled != nil && req.NavShowFriendLinks == nil && - req.FooterShowFriendLinks == nil && req.AsideShowFriendLinks == nil { - if *req.ReciprocalCheckEnabled { - msg = "已开启回链检测" - } else { - msg = "已关闭回链检测" - } - } - - c.JSON(http.StatusOK, gin.H{ - "message": msg, - "reciprocal_check_enabled": h.Settings.FriendLinkReciprocalCheckEnabled(), - "nav_show_friend_links": h.Settings.NavShowFriendLinks(), - "footer_show_friend_links": h.Settings.FooterShowFriendLinks(), - "aside_show_friend_links": h.Settings.AsideShowFriendLinks(), - }) -} - -// APIAdminApproveFriendLinkApply 通过友链申请 -func (h *Handlers) APIAdminApproveFriendLinkApply(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - apply, err := h.FriendLinkApply.Approve(uint(id)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已通过并加入友情链接", "apply": apply}) -} - -// APIAdminRejectFriendLinkApply 拒绝友链申请 -func (h *Handlers) APIAdminRejectFriendLinkApply(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Note string `json:"note"` - } - _ = c.ShouldBindJSON(&req) - apply, err := h.FriendLinkApply.Reject(uint(id), req.Note) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已拒绝申请", "apply": apply}) -} - -// APIMyFriendLinkApplies 当前用户的友链申请列表 -func (h *Handlers) APIMyFriendLinkApplies(c *gin.Context) { - uid := h.currentUserID(c) - if uid == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) - return - } - list, err := h.FriendLinkApply.ListMine(uid) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"applies": list}) -} - -// APICancelFriendLinkApply 撤销待审友链申请 -func (h *Handlers) APICancelFriendLinkApply(c *gin.Context) { - uid := h.currentUserID(c) - if uid == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) - return - } - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.FriendLinkApply.Cancel(uid, uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已撤销申请"}) -} - -// APIUpdateFriendLinkApply 修改并重新提交友链申请 -func (h *Handlers) APIUpdateFriendLinkApply(c *gin.Context) { - uid := h.currentUserID(c) - if uid == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) - return - } - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Name string `json:"name"` - URL string `json:"url"` - Logo string `json:"logo"` - LinkOnHomepage bool `json:"link_on_homepage"` - ReciprocalPageURL string `json:"reciprocal_page_url"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - result, err := h.FriendLinkApply.Update(uid, uint(id), services.FriendLinkApplyInput{ - UserID: uid, - Name: req.Name, - URL: req.URL, - Logo: req.Logo, - LinkOnHomepage: req.LinkOnHomepage, - ReciprocalPageURL: req.ReciprocalPageURL, - OurSiteURL: h.publicBaseURL(c), - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - resp := gin.H{ - "message": friendLinkApplySubmittedMessage(h.Settings.FriendLinkReciprocalCheckEnabled(), true), - "apply": result.Apply, - } - c.JSON(http.StatusOK, resp) -} - -// APIAdminRecheckFriendLinkApply 管理员重新检测回链 -func (h *Handlers) APIAdminRecheckFriendLinkApply(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - apply, err := h.FriendLinkApply.RecheckReciprocal(uint(id), h.publicBaseURL(c)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已开始重新检测回链", "apply": apply}) -} - -func friendLinkApplySubmittedMessage(checkEnabled, isUpdate bool) string { - if isUpdate { - if checkEnabled { - return "申请已更新,回链检测将在后台进行" - } - return "申请已更新" - } - if checkEnabled { - return "申请已提交,回链检测将在后台进行" - } - return "申请已提交" -} diff --git a/routers/api/handlers.go b/routers/api/handlers.go index 70ada6b..024758a 100644 --- a/routers/api/handlers.go +++ b/routers/api/handlers.go @@ -1,52 +1,22 @@ package api import ( - "encoding/base64" - "errors" - "fmt" "net/http" - "strconv" - "strings" - "github.com/gin-gonic/gin" "git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/modules/auth" - "git.iioio.com/freefire/jiang13-forum/models" "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" ) -// Handlers 聚合所有 HTTP 处理器 +// Handlers 本分支仅挂载机器入口(health / SEO / thumb / OIDC)。 +// 论坛 CRUD JSON 已删除,对照见 main 与 docs/rebuild-spec/04-api.md。 type Handlers struct { - Cfg *config.Config - Store *services.UploadStore - Auth *services.AuthService - User *services.UserService - Board *services.BoardService - Post *services.PostService - Comment *services.CommentService - Message *services.MessageService - Notify *services.NotifyService - Report *services.ReportService - Backup *services.BackupService - Filter *services.SensitiveFilter - Limiter *services.RateLimiter - Settings *services.ForumSettingsService - Captcha *services.CaptchaService - Mail *services.MailService - EmailCode *services.EmailCodeService - OIDC *services.OIDCService - Gitea *services.GiteaService - Points *services.PointsService - Badge *services.BadgeService - SitePage *services.SitePageService - FriendLinkApply *services.FriendLinkApplyService -} - -func (h *Handlers) setAuthCookie(c *gin.Context, sessionID string) { - if sessionID == "" { - return - } - auth.SetSessionCookie(c, sessionID) + Cfg *config.Config + Settings *services.ForumSettingsService + Board *services.BoardService + Post *services.PostService + OIDC *services.OIDCService } func (h *Handlers) currentUserID(c *gin.Context) uint { @@ -56,550 +26,7 @@ func (h *Handlers) currentUserID(c *gin.Context) uint { return 0 } -func (h *Handlers) isAdmin(c *gin.Context) bool { - if v, ok := c.Get(auth.CtxRole); ok { - return v == models.RoleAdmin - } - return false -} - -// loadCurrentUser 加载当前登录用户完整资料(含认证/积分) -func (h *Handlers) loadCurrentUser(c *gin.Context) (*models.User, error) { - uid := h.currentUserID(c) - if uid == 0 { - return nil, errors.New("未登录") - } - return h.User.GetByID(uid) -} - -func (h *Handlers) skipsModeration(c *gin.Context) bool { - u, err := h.loadCurrentUser(c) - if err != nil { - return h.isAdmin(c) - } - return u.SkipsModeration() -} - -func (h *Handlers) parseGuestCommentIDs(c *gin.Context) []uint { - raw := c.Query("my_ids") - if raw == "" { - return nil - } - var ids []uint - for _, part := range strings.Split(raw, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - v, err := strconv.ParseUint(part, 10, 64) - if err == nil && v > 0 { - ids = append(ids, uint(v)) - } - } - return ids -} - -func calcTotalPages(total int64, size int) int { - if total == 0 { - return 1 - } - pages := int(total) / size - if int(total)%size > 0 { - pages++ - } - if pages < 1 { - return 1 - } - return pages -} - -// --- API --- - -func (h *Handlers) APICaptcha(c *gin.Context) { - id, svg, err := h.Captcha.Generate() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "id": id, - "image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)), - }) -} - -// APIRegisterConfig 注册页所需公开配置 -func (h *Handlers) APIRegisterConfig(c *gin.Context) { - mailReady := h.Settings.MailReady() - c.JSON(http.StatusOK, gin.H{ - "is_first_user": false, // 已废弃:管理员仅由 /install 创建 - "mail_ready": mailReady, - "require_email_code": mailReady, - "register_open": mailReady, - "email_code_len": services.EmailCodeLen, - }) -} - -// APISendRegisterEmailCode 发送注册邮箱验证码 -func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) { - var req struct { - Email string `json:"email" form:"email" binding:"required"` - } - if err := c.ShouldBind(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if !h.Settings.MailReady() { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()}) - return - } - if err := h.EmailCode.SendRegisterCode(req.Email); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"}) -} - -// APISendResetEmailCode 发送重置密码验证码 -func (h *Handlers) APISendResetEmailCode(c *gin.Context) { - var req struct { - Email string `json:"email" form:"email" binding:"required"` - } - if err := c.ShouldBind(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if !h.Settings.MailReady() { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()}) - return - } - if err := h.EmailCode.SendResetCode(req.Email); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "若该邮箱已注册,验证码将发送到邮箱"}) -} - -// APIResetPassword 邮箱验证码重置密码 -func (h *Handlers) APIResetPassword(c *gin.Context) { - var req struct { - Email string `json:"email" form:"email" binding:"required"` - EmailCode string `json:"email_code" form:"email_code" binding:"required"` - NewPassword string `json:"new_password" form:"new_password" binding:"required"` - } - if err := c.ShouldBind(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if !h.Settings.MailReady() { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()}) - return - } - if !h.EmailCode.VerifyPurpose(services.EmailCodePurposeReset, req.Email, req.EmailCode) { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrEmailCodeInvalid.Error()}) - return - } - if err := h.User.ResetPasswordByEmail(req.Email, req.NewPassword); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "密码已重置,请使用新密码登录"}) -} - -// APISearchUsers 用户搜索(@补全) -func (h *Handlers) APISearchUsers(c *gin.Context) { - q := strings.TrimSpace(c.Query("q")) - if q == "" { - c.JSON(http.StatusOK, gin.H{"users": []any{}}) - return - } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8")) - users, err := h.User.SearchUsersBrief(q, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - out := make([]gin.H, 0, len(users)) - for _, u := range users { - out = append(out, gin.H{ - "id": u.ID, - "username": u.Username, - "nickname": u.Nickname, - "avatar": u.Avatar, - }) - } - c.JSON(http.StatusOK, gin.H{"users": out}) -} - -func (h *Handlers) APIRegister(c *gin.Context) { - var req struct { - Username string `json:"username" form:"username" binding:"required"` - Password string `json:"password" form:"password" binding:"required"` - Nickname string `json:"nickname" form:"nickname"` - Email string `json:"email" form:"email" binding:"required"` - EmailCode string `json:"email_code" form:"email_code"` - CaptchaID string `json:"captcha_id" form:"captcha_id"` - Captcha string `json:"captcha" form:"captcha"` - } - if err := c.ShouldBind(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if h.Captcha == nil || !h.Captcha.Verify(req.CaptchaID, req.Captcha) { - c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误或已过期"}) - return - } - - userCount := h.Auth.UserCount() - mailReady := h.Settings.MailReady() - if userCount > 0 && !mailReady { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrRegisterClosed.Error()}) - return - } - if mailReady { - if !h.EmailCode.Verify(req.Email, req.EmailCode) { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrEmailCodeInvalid.Error()}) - return - } - } - - user, err := h.Auth.Register(req.Username, req.Password, req.Nickname, req.Email) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) - h.setAuthCookie(c, token) - c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID}) -} - -func (h *Handlers) APILogin(c *gin.Context) { - var req struct { - Username string `json:"username" form:"username" binding:"required"` - Password string `json:"password" form:"password" binding:"required"` - } - if err := c.ShouldBind(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - h.setAuthCookie(c, token) - c.JSON(http.StatusOK, gin.H{"message": "登录成功", "user": gin.H{"id": user.ID, "nickname": user.Nickname}}) -} - -func (h *Handlers) APILogout(c *gin.Context) { - c.SetCookie(auth.CookieName, "", -1, "/", "", false, true) - c.JSON(http.StatusOK, gin.H{"message": "已退出"}) -} - -// APIProfileStats 当前用户活动统计(发帖 / 评论 / 收藏 / 获赞) -func (h *Handlers) APIProfileStats(c *gin.Context) { - st, err := h.User.ActivityStats(h.currentUserID(c)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"stats": st}) -} - -// APIUserPublic 公开用户主页(资料 + 公开统计) -func (h *Handlers) APIUserPublic(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效用户"}) - return - } - user, err := h.User.GetByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) - return - } - st, err := h.User.ActivityStats(user.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - // 收藏数仅本人可见 - viewerID := h.currentUserID(c) - if viewerID != user.ID { - st.FavoriteCount = 0 - } - view := user.ToPublic() - if h.Badge != nil { - _ = h.Badge.EvaluateAuto(user.ID) - if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { - view.Badges = services.BadgeViews(badges, 0) - } - } - c.JSON(http.StatusOK, gin.H{ - "user": view, - "stats": st, - }) -} - -func (h *Handlers) APIUpdateProfile(c *gin.Context) { - nickname := c.PostForm("nickname") - if err := h.User.UpdateNickname(h.currentUserID(c), nickname); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - user, _ := h.User.GetByID(h.currentUserID(c)) - var userView any - if user != nil { - userView = user.ToSelf() - } - c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": userView}) -} - -func (h *Handlers) APIUpdateSignature(c *gin.Context) { - signature := c.PostForm("signature") - if err := h.User.UpdateSignature(h.currentUserID(c), signature); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - user, _ := h.User.GetByID(h.currentUserID(c)) - var userView any - if user != nil { - userView = user.ToSelf() - } - c.JSON(http.StatusOK, gin.H{"message": "签名已更新", "user": userView}) -} - -func (h *Handlers) APIUpdatePassword(c *gin.Context) { - oldPass := c.PostForm("old_password") - newPass := c.PostForm("new_password") - if err := h.User.UpdatePassword(h.currentUserID(c), oldPass, newPass); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "密码已修改"}) -} - -func (h *Handlers) APIUploadAvatar(c *gin.Context) { - file, err := c.FormFile("avatar") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"}) - return - } - maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024 - if file.Size > maxBytes { - c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件过大"}) - return - } - url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Store) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "头像已更新", "avatar": url}) -} - -func (h *Handlers) APIUploadPostImage(c *gin.Context) { - file, err := c.FormFile("image") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"}) - return - } - uid := h.currentUserID(c) - url, err := services.SaveUploadedImage( - h.Store, - file, - services.UploadCategoryPosts, - fmt.Sprintf("%d", uid), - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "图片已上传", "url": url}) -} - -func (h *Handlers) APICreatePost(c *gin.Context) { - boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64) - title := c.PostForm("title") - content := c.PostForm("content") - tags := c.PostForm("tags") - postType := c.PostForm("post_type") - skip := h.skipsModeration(c) - post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, skip) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - extras := services.ParsePostExtrasFromForm( - c.PostForm("poll_options"), - c.PostForm("bounty_points"), - c.PostForm("lottery_winner_count"), - ) - if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty || post.PostType == models.PostTypeLottery { - if err := services.FinalizeSpecialPostCreate(post, h.currentUserID(c), extras); err != nil { - _ = h.Post.Delete(h.currentUserID(c), post.ID, true) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - } - msg := "发帖成功" - if post.Status == models.ContentStatusPending { - msg = "已提交审核,通过后将公开显示" - if h.Notify != nil { - h.Notify.AsyncNotifyPendingPost(post) - } - } - c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": post.ID, "status": post.Status}) -} - -func (h *Handlers) APIUpdatePost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64) - isAdmin := h.isAdmin(c) - skip := h.skipsModeration(c) - err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin, skip, - c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - // 非免审用户修改后重新进入审核 - if !skip && h.Notify != nil { - if post, getErr := h.Post.FindByID(uint(id)); getErr == nil { - h.Notify.AsyncNotifyPendingPost(post) - } - } - c.JSON(http.StatusOK, gin.H{"message": "帖子已更新"}) -} - -func (h *Handlers) APIDeletePost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Post.Delete(h.currentUserID(c), uint(id), h.isAdmin(c)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "帖子已移入回收站"}) -} - -func (h *Handlers) APIToggleLike(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - liked, err := h.Post.ToggleLike(h.currentUserID(c), uint(id)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - var post models.Post - models.DB.First(&post, id) - c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": post.LikeCount}) -} - -func (h *Handlers) APIToggleCommentLike(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - liked, likeCount, err := h.Comment.ToggleLike(h.currentUserID(c), uint(id)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": likeCount}) -} - -func (h *Handlers) APIToggleFavorite(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - faved, err := h.Post.ToggleFavorite(h.currentUserID(c), uint(id)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"favorited": faved}) -} - -// APISetQuestionResolved 标记问答帖已解决 / 未解决 -func (h *Handlers) APISetQuestionResolved(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - resolved := c.PostForm("resolved") == "1" || c.PostForm("resolved") == "true" - if err := h.Post.SetQuestionResolved(h.currentUserID(c), uint(id), h.isAdmin(c), resolved); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已标记为未解决" - if resolved { - msg = "已标记为已解决" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "question_resolved": resolved}) -} - -func (h *Handlers) APICreateComment(c *gin.Context) { - postID, _ := strconv.ParseUint(c.Param("id"), 10, 64) - content := c.PostForm("content") - var replyTo *uint - if rt := c.PostForm("reply_to"); rt != "" { - v, _ := strconv.ParseUint(rt, 10, 64) - u := uint(v) - replyTo = &u - } - isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "true" - uid := h.currentUserID(c) - if uid == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后评论"}) - return - } - - in := services.CommentCreateInput{ - UserID: uid, - PostID: uint(postID), - Content: content, - ReplyTo: replyTo, - IsPrivate: isPrivate, - } - - comment, err := h.Comment.Create(in) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "评论成功" - if h.Notify != nil { - switch comment.Status { - case models.ContentStatusPublished: - h.Notify.AsyncNotifyCommentPublished(comment) - h.Notify.AsyncNotifyCommentMentions(comment) - case models.ContentStatusPending: - msg = "评论已提交,审核通过后公开显示" - h.Notify.AsyncNotifyPendingComment(comment) - } - } else if comment.Status == models.ContentStatusPending { - msg = "评论已提交,审核通过后公开显示" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "floor": comment.Floor, "id": comment.ID, "status": comment.Status}) -} - -func (h *Handlers) APIDeleteComment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Comment.Delete(h.currentUserID(c), uint(id), h.isAdmin(c)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"}) -} - -func (h *Handlers) APIUpdateComment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - content := c.PostForm("content") - skip := h.skipsModeration(c) - saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), skip, content) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "评论已更新" - status := "" - if comment, e := h.Comment.GetByID(uint(id)); e == nil { - status = comment.Status - if status == models.ContentStatusPending && !h.isAdmin(c) { - msg = "评论已更新,审核通过后公开显示" - } - if enteredPending && h.Notify != nil { - h.Notify.AsyncNotifyPendingComment(comment) - } - } - c.JSON(http.StatusOK, gin.H{"message": msg, "content": saved, "status": status}) +// APIHealth 探活 +func (h *Handlers) APIHealth(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) } diff --git a/routers/api/media.go b/routers/api/media.go deleted file mode 100644 index f655d49..0000000 --- a/routers/api/media.go +++ /dev/null @@ -1,70 +0,0 @@ -package api - -import ( - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" -) - -// APIAdminMedia 列出媒体资源 -func (h *Handlers) APIAdminMedia(c *gin.Context) { - if h.Store == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "上传存储未初始化"}) - return - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "24")) - category := c.DefaultQuery("category", "all") - query := c.Query("q") - result, err := h.Store.ListMedia(category, query, page, size) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, result) -} - -// APIAdminDeleteMedia 批量删除媒体 -func (h *Handlers) APIAdminDeleteMedia(c *gin.Context) { - if h.Store == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "上传存储未初始化"}) - return - } - var req struct { - URLs []string `json:"urls"` - URL string `json:"url"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - urls := make([]string, 0, len(req.URLs)+1) - for _, u := range req.URLs { - u = strings.TrimSpace(u) - if u != "" { - urls = append(urls, u) - } - } - if u := strings.TrimSpace(req.URL); u != "" { - urls = append(urls, u) - } - if len(urls) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要删除的文件"}) - return - } - if len(urls) > 100 { - c.JSON(http.StatusBadRequest, gin.H{"error": "单次最多删除 100 个文件"}) - return - } - n, err := h.Store.DeleteMedia(urls) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "已删除 " + strconv.Itoa(n) + " 项媒体", - "deleted": n, - }) -} diff --git a/routers/api/message.go b/routers/api/message.go deleted file mode 100644 index 56549c7..0000000 --- a/routers/api/message.go +++ /dev/null @@ -1,170 +0,0 @@ -package api - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "git.iioio.com/freefire/jiang13-forum/models" - "git.iioio.com/freefire/jiang13-forum/services" -) - -// APIMessageConversations 会话列表(按对方聚合) -func (h *Handlers) APIMessageConversations(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "30")) - list, total, err := h.Message.ListConversations(services.ConversationListQuery{ - UserID: h.currentUserID(c), - Page: page, - Size: size, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "conversations": list, - "total": total, - "page": page, - }) -} - -// APIConversationMessages 某会话内消息 -func (h *Handlers) APIConversationMessages(c *gin.Context) { - peerID, err := strconv.ParseUint(c.Param("peerId"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的会话"}) - return - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "50")) - before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64) - uid := h.currentUserID(c) - - list, total, err := h.Message.ListConversationMessages(services.ConversationMessagesQuery{ - UserID: uid, - PeerID: uint(peerID), - Page: page, - Size: size, - Before: uint(before), - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // 首次打开(非向上翻页)时标已读 - if before == 0 { - _ = h.Message.MarkConversationRead(uid, uint(peerID)) - for i := range list { - if list[i].ToUserID == uid { - list[i].IsRead = true - } - } - } - - var peer *models.User - if peerID > 0 { - var u models.User - if err := models.DB.First(&u, uint(peerID)).Error; err == nil { - peer = &u - } - } - - c.JSON(http.StatusOK, gin.H{ - "messages": list, - "total": total, - "peer_user_id": uint(peerID), - "peer_user": peer, - "is_system": peerID == 0, - }) -} - -// APIMarkConversationRead 将会话标为已读 -func (h *Handlers) APIMarkConversationRead(c *gin.Context) { - peerID, err := strconv.ParseUint(c.Param("peerId"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的会话"}) - return - } - if err := h.Message.MarkConversationRead(h.currentUserID(c), uint(peerID)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已标为已读"}) -} - -// APIMessageUnreadCount 未读私信数(含私信/通知分项) -func (h *Handlers) APIMessageUnreadCount(c *gin.Context) { - total, dm, notify, err := h.Message.UnreadCounts(h.currentUserID(c)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "count": total, - "dm_count": dm, - "notify_count": notify, - }) -} - -// APIMessageNotifications 系统通知列表 -func (h *Handlers) APIMessageNotifications(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "30")) - kind := c.Query("kind") - uid := h.currentUserID(c) - list, total, err := h.Message.ListNotifications(uid, page, size, kind) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "notifications": list, - "total": total, - "page": page, - "kind": kind, - }) -} - -// APIMarkNotificationsRead 系统通知全部已读 -func (h *Handlers) APIMarkNotificationsRead(c *gin.Context) { - if err := h.Message.MarkNotificationsRead(h.currentUserID(c)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "通知已全部标为已读"}) -} - -// APISendMessage 发送私信 -func (h *Handlers) APISendMessage(c *gin.Context) { - var req struct { - ToUserID uint `json:"to_user_id"` - Subject string `json:"subject"` - Content string `json:"content"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - msg, err := h.Message.Send(services.MessageSendInput{ - FromUserID: h.currentUserID(c), - ToUserID: req.ToUserID, - Subject: req.Subject, - Content: req.Content, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": msg}) -} - -// APIMarkAllMessagesRead 全部已读 -func (h *Handlers) APIMarkAllMessagesRead(c *gin.Context) { - if err := h.Message.MarkAllRead(h.currentUserID(c)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "已全部标为已读"}) -} diff --git a/routers/api/report.go b/routers/api/report.go deleted file mode 100644 index 294dbc3..0000000 --- a/routers/api/report.go +++ /dev/null @@ -1,163 +0,0 @@ -package api - -import ( - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "git.iioio.com/freefire/jiang13-forum/models" - "git.iioio.com/freefire/jiang13-forum/services" -) - -// APICreatePostReport 举报帖子 -func (h *Handlers) APICreatePostReport(c *gin.Context) { - postID, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Reason string `json:"reason"` - Detail string `json:"detail"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - rep, err := h.Report.Create(h.currentUserID(c), uint(postID), req.Reason, req.Detail) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep}) -} - -// APICreateCommentReport 举报评论 -func (h *Handlers) APICreateCommentReport(c *gin.Context) { - commentID, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Reason string `json:"reason"` - Detail string `json:"detail"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - rep, err := h.Report.CreateCommentReport(h.currentUserID(c), uint(commentID), req.Reason, req.Detail) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep}) -} - -// APIAdminReports 举报列表 -func (h *Handlers) APIAdminReports(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - status := c.DefaultQuery("status", "pending") - list, total, err := h.Report.ListAdmin(services.ReportListQuery{ - Status: status, - Page: page, - Size: size, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - pending, _ := h.Report.PendingCount() - c.JSON(http.StatusOK, gin.H{ - "reports": list, - "total": total, - "page": page, - "pending_count": pending, - "status": status, - }) -} - -// APIAdminHandleReport 处理举报 -func (h *Handlers) APIAdminHandleReport(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Action string `json:"action"` - HandleNote string `json:"handle_note"` - RejectReason string `json:"reject_reason"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - rep, err := h.Report.Handle(services.HandleReportInput{ - ReportID: uint(id), - HandlerID: h.currentUserID(c), - Action: req.Action, - HandleNote: req.HandleNote, - RejectReason: req.RejectReason, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "处理完成", "report": rep}) -} - -// APIAdminApprovePost 通过帖子审核 -func (h *Handlers) APIAdminApprovePost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "帖子已通过审核", "status": models.ContentStatusPublished}) -} - -// APIAdminRejectPost 拒绝帖子并私信通知作者(标记为 rejected,不进回收站) -func (h *Handlers) APIAdminRejectPost(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req struct { - Reason string `json:"reason"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - reason := strings.TrimSpace(req.Reason) - if reason == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "请填写拒绝原因"}) - return - } - - post, err := h.Post.FindByID(uint(id)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - authorID := post.UserID - title := post.Title - postID := post.ID - - if err := h.Post.SetStatus(postID, models.ContentStatusRejected); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - pid := postID - _, msgErr := h.Message.SendSystem( - authorID, - "帖子《"+title+"》未通过审核", - services.FormatRejectContent(title, postID, reason), - models.MessageKindReject, - &pid, - nil, - ) - if msgErr != nil { - c.JSON(http.StatusOK, gin.H{ - "message": "帖子已拒绝,但私信通知失败:" + msgErr.Error(), - "notified": false, - "status": models.ContentStatusRejected, - }) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "已拒绝该帖并私信通知作者", - "notified": true, - "status": models.ContentStatusRejected, - }) -} diff --git a/routers/api/special.go b/routers/api/special.go deleted file mode 100644 index f944eb3..0000000 --- a/routers/api/special.go +++ /dev/null @@ -1,198 +0,0 @@ -package api - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "git.iioio.com/freefire/jiang13-forum/models" - "git.iioio.com/freefire/jiang13-forum/services" -) - -// APIPages 已发布单页摘要列表 -func (h *Handlers) APIPages(c *gin.Context) { - pages, err := h.SitePage.ListPublished() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if pages == nil { - pages = []services.SitePageSummary{} - } - c.JSON(http.StatusOK, gin.H{"pages": pages}) -} - -// APIPageDetail 单页详情 -func (h *Handlers) APIPageDetail(c *gin.Context) { - slug := c.Param("slug") - allowDraft := h.isAdmin(c) - page, err := h.SitePage.GetBySlug(slug, allowDraft) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) - return - } - c.JSON(http.StatusOK, gin.H{"page": page}) -} - -// APIAdminGetPage 管理端单页详情 -func (h *Handlers) APIAdminGetPage(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的单页 ID"}) - return - } - page, err := h.SitePage.GetByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "单页不存在"}) - return - } - c.JSON(http.StatusOK, gin.H{"page": page}) -} - -// APIAdminPages 管理端单页列表 -func (h *Handlers) APIAdminPages(c *gin.Context) { - pages, err := h.SitePage.ListAll() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if pages == nil { - pages = []models.SitePage{} - } - c.JSON(http.StatusOK, gin.H{"pages": pages}) -} - -// APIAdminCreatePage 创建单页 -func (h *Handlers) APIAdminCreatePage(c *gin.Context) { - var in services.SitePageInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) - return - } - page, err := h.SitePage.Create(in) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "单页已创建", "page": page}) -} - -// APIAdminUpdatePage 更新单页 -func (h *Handlers) APIAdminUpdatePage(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var in services.SitePageInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) - return - } - if err := h.SitePage.Update(uint(id), in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "单页已更新"}) -} - -// APIAdminDeletePage 删除单页 -func (h *Handlers) APIAdminDeletePage(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.SitePage.Delete(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "单页已删除"}) -} - -// APIAdminSetPagePublished 切换单页发布状态 -func (h *Handlers) APIAdminSetPagePublished(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的单页 ID"}) - return - } - var body struct { - Published bool `json:"published"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) - return - } - if err := h.SitePage.SetPublished(uint(id), body.Published); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - msg := "已取消发布" - if body.Published { - msg = "已发布" - } - c.JSON(http.StatusOK, gin.H{"message": msg, "published": body.Published}) -} - -// APIPollVote 投票 -func (h *Handlers) APIPollVote(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var body struct { - OptionIDs []uint `json:"option_ids"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) - return - } - if err := services.VotePoll(uint(id), h.currentUserID(c), body.OptionIDs); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - poll, _ := services.GetPollView(uint(id), h.currentUserID(c)) - c.JSON(http.StatusOK, gin.H{"message": "投票成功", "poll": poll}) -} - -// APIPollClose 结束投票 -func (h *Handlers) APIPollClose(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - post, err := h.Post.FindByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - if err := services.ClosePoll(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - poll, _ := services.GetPollView(uint(id), h.currentUserID(c)) - c.JSON(http.StatusOK, gin.H{"message": "投票已结束", "poll": poll}) -} - -// APIBountyAward 采纳悬赏 -func (h *Handlers) APIBountyAward(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - commentID, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64) - if commentID == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "请选择评论"}) - return - } - if err := services.AwardBounty(uint(id), h.currentUserID(c), h.isAdmin(c), uint(commentID)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "悬赏已发放"}) -} - -// APIBountyRefund 退回悬赏 -func (h *Handlers) APIBountyRefund(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := services.RefundBounty(uint(id), h.currentUserID(c), h.isAdmin(c)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "悬赏已退回"}) -} - -// APILotteryDraw 帖内抽奖开奖 -func (h *Handlers) APILotteryDraw(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - view, err := services.DrawPostLottery(uint(id), h.currentUserID(c), h.isAdmin(c)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "开奖完成", "lottery": view}) -} diff --git a/routers/setup.go b/routers/setup.go index fd3ac46..a1fa12f 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -60,8 +60,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { if err != nil { return nil, err } - // Gitea 仓库同步后置:本阶段不启动后台同步,亦不挂管理入口 - giteaSvc := services.NewGiteaService(settingsSvc) uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc) if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil { @@ -79,15 +77,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { sitePageSvc := services.NewSitePageService(filter) h := &api.Handlers{ - Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc, - Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, - Backup: backupSvc, - Filter: filter, Limiter: limiter, Settings: settingsSvc, - Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, - OIDC: oidcSvc, Gitea: giteaSvc, - Points: services.NewPointsService(), Badge: services.NewBadgeService(), - SitePage: sitePageSvc, - FriendLinkApply: friendLinkApplySvc, + Cfg: cfg, Settings: settingsSvc, + Board: boardSvc, Post: postSvc, + OIDC: oidcSvc, } authMW := auth.NewAuthMiddleware(authSvc) @@ -118,9 +110,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.GET("/health", h.APIHealth) r.GET("/robots.txt", h.RobotsTxt) r.GET("/sitemap.xml", h.SitemapXML) - // 机器注册辅助(与 SSR 注册共用 CaptchaService) - r.GET("/api/captcha", h.APICaptcha) - r.POST("/api/register", h.APIRegister) // OIDC Provider(外部机器 / Gitea SSO) r.GET("/.well-known/openid-configuration", h.OIDCDiscovery) @@ -132,7 +121,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.GET("/oauth/logout", h.OIDCLogout) r.POST("/oauth/logout", h.OIDCLogout) - // 精简机器 API:健康检查已注册;保留只读探测与 OIDC,论坛 UI 不再走 /api + // 机器入口:健康检查 / SEO / OIDC / 缩略图;论坛 UI 仅走 routers/web r.NoRoute(webpages.Deps{ DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret, Settings: settingsSvc, Auth: authSvc, diff --git a/services/crawler.go b/services/crawler.go deleted file mode 100644 index afee307..0000000 --- a/services/crawler.go +++ /dev/null @@ -1,55 +0,0 @@ -package services - -import "strings" - -// 常见搜索引擎 / 社交预览 / SEO 工具的 User-Agent 片段(小写匹配) -var seoCrawlerTokens = []string{ - "googlebot", - "google-inspectiontool", - "bingbot", - "baiduspider", - "yandexbot", - "duckduckbot", - "slurp", // Yahoo - "sogou", - "bytespider", - "petalbot", - "applebot", - "facebookexternalhit", - "facebot", - "twitterbot", - "linkedinbot", - "discordbot", - "telegrambot", - "slackbot", - "whatsapp", - "preview", // 部分通用预览 UA - "embedly", - "quora link preview", - "pinterest", - "vkshare", - "w3c_validator", - "ahrefsbot", - "semrushbot", - "dotbot", - "mj12bot", - "gptbot", - "claudebot", - "anthropic-ai", - "chatgpt-user", - "oai-searchbot", -} - -// IsSEOCrawler 是否为需要服务端 HTML 的爬虫 / 预览 bot(动态渲染) -func IsSEOCrawler(userAgent string) bool { - ua := strings.ToLower(strings.TrimSpace(userAgent)) - if ua == "" { - return false - } - for _, token := range seoCrawlerTokens { - if strings.Contains(ua, token) { - return true - } - } - return false -} diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index c9fdcfb..421b72f 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -10,20 +10,6 @@ <meta property="og:title" content="{{.Title}}"/> {{if .Description}}<meta property="og:description" content="{{.Description}}"/>{{end}} {{if .OGImageURL}}<meta property="og:image" content="{{.OGImageURL}}"/>{{end}} - <script> - (function () { - try { - var key = "j13-theme"; - var pref = localStorage.getItem(key) || "system"; - if (pref !== "light" && pref !== "dark" && pref !== "system") pref = "system"; - var dark = pref === "dark" || (pref === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); - var resolved = dark ? "dark" : "light"; - document.documentElement.setAttribute("data-theme", resolved); - document.documentElement.setAttribute("data-theme-pref", pref); - document.documentElement.style.colorScheme = resolved; - } catch (e) {} - })(); - </script> <link rel="stylesheet" href="/ssr-assets/site.css"/> </head> <body class="j13-body"> diff --git a/web_src/css/site.css b/web_src/css/site.css index d8e34a7..0aa8bdd 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -32,6 +32,72 @@ --j13-overlay: rgba(255, 255, 255, 0.85); } +/* 跟随系统:无 data-theme 时由媒体查询决定 */ +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --j13-bg: #1c1917; + --j13-surface: #292524; + --j13-text: #f5f5f4; + --j13-muted: #a8a29e; + --j13-accent: #2dd4bf; + --j13-on-accent: #134e4a; + --j13-border: #44403c; + --j13-danger: #f87171; + --j13-ok-bg: #064e3b; + --j13-ok-fg: #a7f3d0; + --j13-warn-bg: #7c2d12; + --j13-warn-fg: #fed7aa; + --j13-warn-border: #9a3412; + --j13-err-bg: #7f1d1d; + --j13-err-fg: #fecaca; + --j13-err-border: #991b1b; + --j13-pin-bg: #78350f; + --j13-pin-fg: #fde68a; + --j13-feat-bg: #4c1d95; + --j13-feat-fg: #ddd6fe; + --j13-neutral-bg: #334155; + --j13-neutral-fg: #e2e8f0; + --j13-self-bg: #1e3a5f; + --j13-self-border: #1d4ed8; + --j13-delta-pos: #4ade80; + --j13-delta-neg: #f87171; + --j13-overlay: rgba(28, 25, 23, 0.85); + } +} + +/* 显式浅色/暗色覆盖系统偏好 */ +html[data-theme="light"] { + color-scheme: light; + --j13-bg: #f6f4ef; + --j13-surface: #fffdf8; + --j13-text: #1c1917; + --j13-muted: #78716c; + --j13-accent: #0f766e; + --j13-on-accent: #fff; + --j13-border: #e7e5e4; + --j13-danger: #b91c1c; + --j13-ok-bg: #ecfdf5; + --j13-ok-fg: #065f46; + --j13-warn-bg: #fff7ed; + --j13-warn-fg: #9a3412; + --j13-warn-border: #fed7aa; + --j13-err-bg: #fef2f2; + --j13-err-fg: #991b1b; + --j13-err-border: #fecaca; + --j13-pin-bg: #fef3c7; + --j13-pin-fg: #92400e; + --j13-feat-bg: #ede9fe; + --j13-feat-fg: #5b21b6; + --j13-neutral-bg: #f8fafc; + --j13-neutral-fg: #475569; + --j13-self-bg: #eff6ff; + --j13-self-border: #bfdbfe; + --j13-delta-pos: #15803d; + --j13-delta-neg: #b91c1c; + --j13-overlay: rgba(255, 255, 255, 0.85); +} + html[data-theme="dark"] { color-scheme: dark; --j13-bg: #1c1917; diff --git a/web_src/js/site.js b/web_src/js/site.js index 948e40d..db2c642 100644 --- a/web_src/js/site.js +++ b/web_src/js/site.js @@ -11,22 +11,17 @@ document.documentElement.dataset.j13Ssr = "1"; return "system"; } - function resolve(pref) { - if (pref === "dark") return "dark"; - if (pref === "light") return "light"; - try { - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - } catch (e) { - return "light"; - } - } - function apply(pref) { pref = normalizePref(pref); - var resolved = resolve(pref); - document.documentElement.setAttribute("data-theme", resolved); - document.documentElement.setAttribute("data-theme-pref", pref); - document.documentElement.style.colorScheme = resolved; + var root = document.documentElement; + if (pref === "system") { + root.removeAttribute("data-theme"); + root.style.removeProperty("color-scheme"); + } else { + root.setAttribute("data-theme", pref); + root.style.colorScheme = pref; + } + root.setAttribute("data-theme-pref", pref); try { localStorage.setItem(KEY, pref); } catch (e) {} @@ -40,7 +35,7 @@ document.documentElement.dataset.j13Ssr = "1"; function currentPref() { try { - return normalizePref(localStorage.getItem(KEY) || document.documentElement.getAttribute("data-theme-pref") || "system"); + return normalizePref(localStorage.getItem(KEY) || "system"); } catch (e) { return "system"; } @@ -57,15 +52,6 @@ document.documentElement.dataset.j13Ssr = "1"; apply(next); }); } - - try { - var mq = window.matchMedia("(prefers-color-scheme: dark)"); - var onChange = function () { - if (currentPref() === "system") apply("system"); - }; - if (mq.addEventListener) mq.addEventListener("change", onChange); - else if (mq.addListener) mq.addListener(onChange); - } catch (e) {} })(); (function mdEditors() {