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(如