chore: 主题改媒体查询并清理未挂载 API
去掉 head 防闪脚本;删除论坛 JSON CRUD 与 crawler,仅保留机器入口;用户向文档对齐 SSR。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,7 +7,6 @@
|
||||
# Node / 构建缓存
|
||||
/node_modules/
|
||||
/web_src/node_modules/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Go 编译产物(统一进 dist/;勿把二进制扔在仓库根目录)
|
||||
|
||||
@@ -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` 路径(若已知)
|
||||
|
||||
30
README.md
30
README.md
@@ -78,7 +78,7 @@
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
|
||||
<br><b>富文本渲染</b><br>
|
||||
<sub>TipTap 排版 · 图片 · 代码高亮 · 目录导航</sub>
|
||||
<sub>Markdown 排版 · 图片 · 代码块 · 目录导航</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
67
ROADMAP.md
67
ROADMAP.md
@@ -1,7 +1,8 @@
|
||||
# 路线图 ROADMAP
|
||||
|
||||
> 姜十三论坛仍在积极开发中,功能尚未完善。
|
||||
> 姜十三论坛仍在积极开发中。
|
||||
> 演示站:[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)。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` → `<br>` 转换)
|
||||
- `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`
|
||||
|
||||
@@ -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`(热门)
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -105,10 +105,9 @@
|
||||
|
||||
### 2.5 主题
|
||||
|
||||
- 浅色 / 暗色;默认 **跟随系统**;`localStorage` 键 `j13-theme`(`system` | `light` | `dark`)
|
||||
- `<html data-theme="light|dark" data-theme-pref="…">`;`<head>` 内联脚本防首屏闪烁
|
||||
- 顶栏按钮循环切换;系统偏好变化时在 `system` 模式下即时更新
|
||||
- 兼容旧 SPA 仅存 `light`/`dark` 的值
|
||||
- 浅色 / 暗色;默认 **跟随系统**(CSS `@media (prefers-color-scheme)`);`localStorage` 键 `j13-theme`(`system` | `light` | `dark`)
|
||||
- 显式浅/深时设 `<html data-theme="light|dark">`;`system` 时去掉该属性,交给媒体查询
|
||||
- 顶栏按钮循环切换;兼容旧 SPA 仅存 `light`/`dark` 的值
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -98,12 +98,12 @@
|
||||
### 浏览与发帖
|
||||
|
||||
- 三栏布局,桌面 / 移动自适应
|
||||
- Feed 排序与虚拟滚动
|
||||
- 浅色 / 暗色主题;板块图标与主题色可配
|
||||
- TipTap 富文本、标签、正文图片
|
||||
- 楼层评论:回复、引用、@
|
||||
- Feed 排序
|
||||
- 浅色 / 暗色主题(可跟随系统);板块图标与主题色可配
|
||||
- Markdown 编辑器、标签、正文图片、内容门控
|
||||
- 楼层评论:回复、引用、嵌套树
|
||||
- 点赞、收藏;置顶 / 精华
|
||||
- 编辑修订历史(可对比差异)
|
||||
- 编辑修订历史
|
||||
- 可配置普通用户的编辑时限
|
||||
|
||||
### 社交与个人
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
### 管理后台
|
||||
|
||||
与前台同一套 React 体验,统一在 `/admin`:
|
||||
SSR 后台统一在 `/admin`:
|
||||
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package seo
|
||||
|
||||
// PageMeta 页面级 SEO / 社交预览元数据(SSR 与爬虫 HTML 共用)
|
||||
// PageMeta 页面级 SEO / 社交预览元数据(SSR 模板 head)
|
||||
type PageMeta struct {
|
||||
Title string // 完整 <title>
|
||||
Description string
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
1197
routers/api/api.go
1197
routers/api/api.go
File diff suppressed because it is too large
Load Diff
@@ -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": "已颁发徽章"})
|
||||
}
|
||||
@@ -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 "申请已提交"
|
||||
}
|
||||
@@ -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
|
||||
Settings *services.ForumSettingsService
|
||||
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)
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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": "已全部标为已读"})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user