Compare commits
143
Commits
608adac6d4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390c4ac53f | ||
|
|
69ab5676b4 | ||
|
|
af79e5864f | ||
|
|
a992bd841d | ||
|
|
7e16ba1989 | ||
|
|
c539216146 | ||
|
|
e7bb30b4bd | ||
|
|
8e890d9a96 | ||
|
|
e505a170b7 | ||
|
|
77e8ab76ee | ||
|
|
0c8ea58d22 | ||
|
|
cf8bf9eb83 | ||
|
|
ee0086b252 | ||
|
|
4f3a2cf9a8 | ||
|
|
46b4c335dc | ||
|
|
92381b5c78 | ||
|
|
171f1f5893 | ||
|
|
6dfa3dd5c1 | ||
|
|
c8707cf704 | ||
|
|
4c2017cd2c | ||
|
|
13f4a68c06 | ||
|
|
31f7394280 | ||
|
|
1a0dd7e9d1 | ||
|
|
1ba9542479 | ||
|
|
914ad4239b | ||
|
|
299ac3c027 | ||
|
|
38be22700d | ||
|
|
620d931fa6 | ||
|
|
63b89071e3 | ||
|
|
2cf6368abf | ||
|
|
5c220f7ecf | ||
|
|
e097fa260b | ||
|
|
dd2e1f9c84 | ||
|
|
29add7efac | ||
|
|
c7eadec61c | ||
|
|
4325e5aabe | ||
|
|
46bc4bef26 | ||
|
|
f91df3916a | ||
|
|
b301a44caf | ||
|
|
a9726df1ee | ||
|
|
cdd72e29d1 | ||
|
|
6f9691c18e | ||
|
|
bfbcf69e22 | ||
|
|
bc007c165f | ||
|
|
040f9bf2ed | ||
|
|
93c1fb9c7a | ||
|
|
7dbaf2277f | ||
|
|
57851e4daa | ||
|
|
b38dd5c6db | ||
|
|
3cfb9a552c | ||
|
|
2fc18789d8 | ||
|
|
28769cbfa7 | ||
|
|
3a7997521b | ||
|
|
5b6691d2c1 | ||
|
|
4c67bf269a | ||
|
|
ed40a040e3 | ||
|
|
4200826120 | ||
|
|
6ce6438854 | ||
|
|
0def382511 | ||
|
|
636d13407f | ||
|
|
f9abb877e7 | ||
|
|
c947e62364 | ||
|
|
ded6fdb753 | ||
|
|
9c34374b95 | ||
|
|
8a3e213e40 | ||
|
|
2de07710da | ||
|
|
e16130bf8f | ||
|
|
2b748fc48e | ||
|
|
717ebc44f4 | ||
|
|
f2740afe50 | ||
|
|
d9d2ad3f7d | ||
|
|
0ae8815f33 | ||
|
|
9400f76c7e | ||
|
|
03d5c97315 | ||
|
|
2cb9592d56 | ||
|
|
8a08932d78 | ||
|
|
b152a2a9c9 | ||
|
|
4af71185d5 | ||
|
|
6d60b10c14 | ||
|
|
cf4c7d4ca4 | ||
|
|
5acc461e88 | ||
|
|
391cd1bdae | ||
|
|
5b166ef0e2 | ||
|
|
4f89c63b28 | ||
|
|
7bfeffaaa0 | ||
|
|
aff5764b9b | ||
|
|
bb830f779c | ||
|
|
63d329db5f | ||
|
|
e83a4e6ee1 | ||
|
|
0153f770f6 | ||
|
|
789a2d9b33 | ||
|
|
5a878c1aab | ||
|
|
5e4cdf26b9 | ||
|
|
2faace4810 | ||
|
|
45002b809a | ||
|
|
1b62bf99ce | ||
|
|
6fafa6eb00 | ||
|
|
6649eba777 | ||
|
|
6aedc5f632 | ||
|
|
ed82908a0d | ||
|
|
274291f8fe | ||
|
|
588df32306 | ||
|
|
3c98084909 | ||
|
|
1c0339bae1 | ||
|
|
651bbbb22e | ||
|
|
36be795af5 | ||
|
|
632d19f49e | ||
|
|
3cf560f932 | ||
|
|
ad8883cc61 | ||
|
|
f8d495b128 | ||
|
|
c2ca0f2327 | ||
|
|
249ed8cd2c | ||
|
|
a858439e19 | ||
|
|
535e4b8fcd | ||
|
|
75d102d6ee | ||
|
|
f0b642362a | ||
|
|
45f0dc9beb | ||
|
|
b29fbcdcfc | ||
|
|
20deb09dbd | ||
|
|
c71f4f0ed6 | ||
|
|
f7dd4163b2 | ||
|
|
72295ded4d | ||
|
|
f4a5276fb8 | ||
|
|
be6f644154 | ||
|
|
bdd3a068af | ||
|
|
990f6f40d0 | ||
|
|
1bed2dda5f | ||
|
|
b319584012 | ||
|
|
c4c0115ba6 | ||
|
|
f6407a84f7 | ||
|
|
2ac286a4f8 | ||
|
|
91cd009899 | ||
|
|
4d0ad9da68 | ||
|
|
e7f8b3f173 | ||
|
|
a30a628772 | ||
|
|
cd290ff237 | ||
|
|
f7f30af569 | ||
|
|
4465130e60 | ||
|
|
bd0c0442ab | ||
|
|
b80c5a1277 | ||
|
|
73e145df07 | ||
|
|
6ffbf19aff | ||
|
|
2e6eeadfc1 |
@@ -6,3 +6,6 @@ uploads/*
|
||||
.env.json
|
||||
server.pid
|
||||
releases/
|
||||
public/dist/
|
||||
backups/
|
||||
workspace/
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# AGENTS.md
|
||||
|
||||
RainWeb 个人云平台(博客 + 论坛 + 密码管理器 + 管理面板聚合)。Node.js 22+(推荐 22 LTS)、CommonJS、Express 4、better-sqlite3(原生 SQLite)。前端 **React + Vite + React Router**(前台原生样式 Material Design 3,后台 MUI 独立应用)。**无测试、无 lint**——前端改动进 `frontend/src/`,构建产物 `public/dist/`(gitignored)由 server 静态服务,部署必须 `npm run build`。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm start # 生产:node server.js(serve 构建产物 + /api)
|
||||
npm run dev # 开发:vite dev server(5173,/api 与 /uploads 代理到 3101)
|
||||
npm run build # 构建 React 前端 → public/dist/(index.html + admin.html + assets)
|
||||
npm run preview # 预览构建产物
|
||||
node cli.js <cmd> # status | health | db-check | start | stop | restart | port [N] | password | captcha | config | backup | upgrade
|
||||
```
|
||||
|
||||
- 端口:`.env.json`(gitignored)`{"port": N}` 或环境变量 `PORT`,默认 3001;vite 代理目标硬编码 3101(vite.config.js),后端端口改了就同步改。
|
||||
- CLI:`status`、`health`、`db-check`、`config`、`backup list` 支持 `--json`;`health`/`db-check` 检查失败返回非 0。`backup prune` 必须明确 `--keep N`,默认只预览,只有 `--yes` 才删除;`backup restore <file>` 必须服务停止并带 `--yes`,不会默认覆盖。
|
||||
- CLI 的 `start/stop/restart` 只操作经过 cwd 与命令行双重校验、确认属于本项目 `server.js` 的 PID,停止会先优雅等待。`upgrade` 仅供自托管机器本地执行:升级前备份,要求 git 工作区干净,执行 `git pull --ff-only`、`npm install`、`npm run build`,服务原本运行时重启并健康检查;失败返回非 0。
|
||||
- CLI 密码命令默认交互输入;`config` 对密码、secret、token、JWT、私钥和 API key 等敏感配置统一脱敏。验证码参数需使用受支持的类型和值。
|
||||
- 验证方式:后端改动启动后 `curl` 接口;前端改动 `npm run dev` 热更新,或 `npm run build` 后刷新验证(需强刷一次,见下)。
|
||||
- **部署后浏览器必须拿新 HTML**:`serveIndex` 响应带 `Cache-Control: no-store`;构建产物 assets 文件名带 hash,经 `/assets` 挂载长期缓存(immutable)。若用户白屏且 console 报 "MIME type text/html",先查是否 `npm run build` 缺失/旧产物。
|
||||
- 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete` 用 `setup_complete` 门禁,非 adminOnly——新装无 token)。
|
||||
|
||||
## 数据层(db.js)
|
||||
|
||||
- better-sqlite3 是原生 SQLite:`data/rainweb.db` 单文件持久化,写操作经事务落盘。CLI 与 server 并发写库会等待(busy_timeout 5s)而非互相覆盖,但建议不要同时执行写操作。
|
||||
- 建表在 `initTables()`;列迁移用 try/catch 包裹 `ALTER TABLE ... ADD COLUMN`(幂等、静默失败)确保列存在。**新增列必须沿用此模式**,否则旧库会崩。版本化迁移在 `migrateSchema()`:`PRAGMA user_version` 记录 schema 版本,migrations 数组按 version 升序执行(`current < m.version` 才运行并推进版本号)——**新迁移写进 migrations 数组,不要散落 try/catch**。
|
||||
- 站点设置存 `site_settings`(key/value),通过 `db.getSetting` / `setSetting` 读写。
|
||||
- **cli.js 与 server.js 统一使用 db.js 的 `data/rainweb.db`**;旧版 `data.db` 已由 server.js 首次启动时迁移为 `data/rainweb.db`(旧文件改名 `data.db.bak`)。
|
||||
|
||||
## 路由(server.js)
|
||||
|
||||
- 集中挂载所有 `/api/*`(auth、admin-links、announcements、forum、blog、passwords、settings、email、profile、captcha、upload、setup、proxy、import)。**新路由必须在此挂载**——后面有 SPA catch-all:非 `/api/` 一律回 dist/index.html。
|
||||
- **挂载顺序关键**:`serveIndex('/')` → `/assets`(dist/assets,immutable)→ static(public) → `/uploads` → `/api/*` → `/admin*`(dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。
|
||||
- `middleware/auth.js`:Bearer JWT;`SECRET` = `process.env.JWT_SECRET`(**无硬编码回退**——server.js 启动时若 `.env.json` 无 `jwt_secret` 则随机生成写入并设置环境变量);`adminOnly` 会查库复查角色(用户被删/降权立即失效)。
|
||||
- `routes/setup.js`:`/complete` 用 `setup_complete` 门禁('1' 后 403),新密码禁止等于默认 `admin123`。
|
||||
- `routes/proxy.js`:面板嵌入代理——支持 `Authorization` header 或 `?token=` query(iframe 无法带 header);有内网地址拦截(SSRF)。
|
||||
- 验证码:内置 SVG + reCAPTCHA/Turnstile 第三方,服务端统一 `resolveCaptcha` 校验(builtin 走 proof JWT,第三方走 siteverify)。
|
||||
|
||||
## 前端(React + Vite)
|
||||
|
||||
- 源码在 `frontend/`:`src/main.jsx`(前台入口)、`src/App.jsx`(前台布局 Layout + 路由表)、`src/pages/`(前台页面)、`src/components/`(Layout/MarkdownRenderer/CaptchaModal/MusicEmbed/BlogSidebar)、`src/admin/`(**后台独立应用** MUI:main.jsx + AdminLayout + pages/)、`src/api/`(按模块封装的 fetch 层,`client.js` 管 token)、`src/lib/utils.js`、`src/theme.jsx`。
|
||||
- 前后台是**两个独立入口**(Vite 多入口),仅通过整页跳转连接:前台导航「管理后台」=`<a href="/admin">`(后台路由 basename `/admin`)。
|
||||
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html`、`/forum.html` 等),SPA fallback 已支持,链接不用改。
|
||||
- `frontend/index.html` 含 `${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。
|
||||
- 主题:`data-theme="dark"` 属性在 `<html>` 上(`public/css/style.css` 的 `[data-theme="dark"]` 选择器),非 `.dark` 类,非 prefers-color-scheme。切换逻辑在 `src/theme.jsx`。
|
||||
- 主题颜色:`frontend/src/themeTokens.js` 是前后台共享的 Token 派生入口;前台通过 `applyThemeTokens` 写入 CSS 变量,后台 `admin/theme.js` 通过 `buildThemeTokens` 生成 MUI palette。主题设置保存后使用 `rainweb:theme-update` 事件和 localStorage storage 事件同步;颜色、玻璃透明度和主题策略由 `routes/settings.js` 校验。
|
||||
- 构建:`npm run build` → `public/dist/`(gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。
|
||||
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`(marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
|
||||
- 登录态:token 存 localStorage(key `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js` 的 `notifyAuthChange`)。
|
||||
|
||||
## SSR 与 SEO
|
||||
|
||||
- `ssr.js`:`/blog/:id`、`/forum/:id`、`/sitemap.xml`(robots.txt 的域名取自 `site_url` 设置)。SSR 页引用 `/css/style.css`(public/css 保留,勿删)。
|
||||
- 已发布博文(`published=1`)才会 SSR 渲染,未发布返回 404。
|
||||
|
||||
## 版本与更新
|
||||
|
||||
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
|
||||
- **Web 更新接口已移除**(P0 删除 `/api/update/check`、`/api/update/run`,无 RCE 面)。升级只走自托管机器本地的 `cli.js upgrade`:升级前备份、检查干净工作区、`git pull --ff-only`(本地 origin:git.rainnya.asia 镜像)+ npm install + npm run build + 服务重启/健康检查。
|
||||
- 上传附件在 `uploads/`(gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。
|
||||
|
||||
## 约定
|
||||
|
||||
- UI 文案、代码注释、commit message 均为中文,保持一致。
|
||||
- 不要提交:`node_modules/`、`data/*`、`uploads/*`、`.env.json`、`server.pid`、`releases/`、`public/dist/`。
|
||||
@@ -1,21 +1,51 @@
|
||||
MIT License
|
||||
# RainWeb 软件授权协议(定制版)
|
||||
|
||||
Copyright (c) 2024 Xianyunah
|
||||
**版本 1.0 · 2026-08-12**
|
||||
**原作者(Initial Author):Xianyunah(Rainnya 家族)**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
## 一、授权范围
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
本协议授予任何获得本软件副本(含源码、文档、构建产物)的个人或组织(下称"被授权人")以下权利:
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1. **使用**:允许安装、运行、复制本软件,用于个人学习、研究、内部开发。
|
||||
2. **修改**:允许对本软件源码进行修改、扩展、二次开发。
|
||||
3. **分发**:允许分发本软件的原始版本或修改后的衍生版本。
|
||||
|
||||
## 二、禁止事项
|
||||
|
||||
被授权人**不得**:
|
||||
|
||||
1. **商业使用**:不得将本软件或其衍生版本用于任何商业目的,包括但不限于:
|
||||
- 直接销售本软件或其衍生版本
|
||||
- 提供收费的托管服务(SaaS/PaaS)
|
||||
- 将本软件作为商业产品的组成部分
|
||||
- 通过本软件获取直接或间接的商业收益
|
||||
2. **闭源**:本软件及其衍生版本必须保持源代码公开可用,不得以任何形式闭源或隐藏源代码。
|
||||
3. **使用品牌**:不得使用「Rainnya」「雨喵」「RainWeb」名称、logo、视觉标识(含紫色雨夜视觉主题)对本软件的衍生版本进行品牌宣传、背书或暗示官方身份,除非获得原作者书面授权。
|
||||
|
||||
## 三、义务条款(Copyleft)
|
||||
|
||||
1. **衍生版本必须开源**:任何基于本软件的修改、扩展、整合版本,其源代码必须遵循本协议开源发布,且必须包含本 LICENSE 文件。
|
||||
2. **同协议授权**:衍生版本必须使用与本协议相同或更严格(限制更多)的授权条款。
|
||||
3. **署名**:任何二次分发(原始或衍生版本)必须保留本 LICENSE 文件,并显著标明:
|
||||
- 最初作者:**Xianyunah(Rainnya 家族)**
|
||||
- 原始项目名称与原始仓库地址
|
||||
- 若为修改版,须明确标注"基于 RainWeb 修改"并说明修改内容
|
||||
|
||||
## 四、免责声明
|
||||
|
||||
本软件按"现状"(AS IS)提供,不附带任何明示或暗示的担保,包括但不限于适销性、特定用途适用性与非侵权保证。原作者对使用本软件产生的任何损害概不负责,无论基于何种法律理论(合同、侵权或其他)。
|
||||
|
||||
## 五、协议终止
|
||||
|
||||
被授权人违反本协议任何条款,其本协议项下的所有权利自动终止,且须立即停止使用、分发并销毁全部副本。
|
||||
|
||||
## 六、其他
|
||||
|
||||
- 本协议与 GPL-3.0 的精神一致(copyleft),但额外附加了非商业与品牌保留条款,属"源代码可用(source-available)"授权,**非 OSI 认证的开源协议**。
|
||||
- 若本协议任一条款被判定无效,不影响其余条款效力。
|
||||
- 授权事宜咨询:rainnya.asia
|
||||
|
||||
---
|
||||
|
||||
**版权 © 2024 Xianyunah(Rainnya 家族)· 保留所有权利**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# 🌧️ RainWeb - 个人云管理平台
|
||||
|
||||
[](https://github.com/Xianyunah/rainwebblog)
|
||||
[](LICENSE)
|
||||
|
||||
一体化个人云平台,集成博客、论坛、密码管理器、管理后台聚合等功能,Material Design 3 风格,支持深色/浅色切换。
|
||||
@@ -25,7 +24,7 @@
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Node.js** 16.x 或更高版本(推荐 20.x LTS)
|
||||
- **Node.js** 22 或更高版本(推荐 22 LTS)
|
||||
- **npm** 随 Node.js 安装
|
||||
|
||||
### 下载安装
|
||||
@@ -45,14 +44,7 @@
|
||||
unzip rainweb-source.zip -d rainweb
|
||||
cd rainweb
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
**从 GitHub 安装:**
|
||||
```bash
|
||||
git clone https://github.com/Xianyunah/rainwebblog.git
|
||||
cd rainwebblog
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
@@ -65,7 +57,8 @@ npm start
|
||||
- **启动文件**: `server.js`
|
||||
- **端口**: `3001`
|
||||
3. 提交后宝塔自动 `npm install` 并启动
|
||||
4. 如需域名访问,配置 Nginx 反向代理:
|
||||
4. 若未自动构建,手动执行 `npm run build`(构建 React 前端到 `public/dist/`)
|
||||
5. 如需域名访问,配置 Nginx 反向代理:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -90,7 +83,7 @@ server {
|
||||
|
||||
```bash
|
||||
# 使用 Node.js 官方镜像
|
||||
docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:20 bash -c "
|
||||
docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:22 bash -c "
|
||||
cd /app && npm install && node server.js
|
||||
"
|
||||
```
|
||||
@@ -113,21 +106,29 @@ npm run cli -- <command>
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `status` | 查看运行状态 |
|
||||
| `start` | 启动服务器 |
|
||||
| `stop` | 停止服务器 |
|
||||
| `restart` | 重启服务器 |
|
||||
| `status [--json]` | 查看版本、服务、数据库和备份状态 |
|
||||
| `health [--json]` | HTTP 健康检查和 SQLite 完整性检查;不健康时返回非 0 |
|
||||
| `db-check [--json]` | 只读检查数据库完整性和关键表 |
|
||||
| `start` | 校验 PID 后启动服务器,已运行时不会重复启动 |
|
||||
| `stop` | 只停止确认属于本项目的 `server.js`,优雅等待退出 |
|
||||
| `restart` | 优雅停止并等待后启动服务器 |
|
||||
| `port [number]` | 查看/修改端口 |
|
||||
| `password [new-pass]` | 修改管理员密码 |
|
||||
| `captcha` | 交互式配置验证码 |
|
||||
| `config` | 查看所有配置 |
|
||||
| `upgrade` | 一键升级(git pull + npm install + 重启) |
|
||||
| `password` | 交互式修改管理员密码(兼容旧的末尾参数写法) |
|
||||
| `captcha` | 交互式配置验证码;也支持 `--type`、`--login`、`--register`、`--forum`、`--failed`、`--threshold` |
|
||||
| `config [--json]` | 查看配置,敏感值统一脱敏 |
|
||||
| `backup` | 创建数据库备份并立即做完整性检查 |
|
||||
| `backup list [--json]` | 列出备份并检查 SQLite 完整性 |
|
||||
| `backup prune --keep N [--yes]` | 预览清理旧备份;只有显式 `--yes` 才删除 |
|
||||
| `backup restore <file> --yes` | 服务停止后恢复指定备份;显式确认才覆盖,旧库会保留 |
|
||||
| `upgrade` | 升级前备份,检查干净工作区,`git pull --ff-only`、安装、构建并在服务原本运行时重启验活 |
|
||||
|
||||
通用选项:`--yes` 跳过确认,`--json` 输出 JSON,HTTP 检查可用 `--timeout 100-60000` 设置毫秒超时。密码默认交互输入,不建议在命令行中传递明文密码。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Node.js + Express
|
||||
- **数据库**: SQLite (sql.js)
|
||||
- **前端**: 原生 HTML/CSS/JS + Material Design 3
|
||||
- **数据库**: SQLite (better-sqlite3)
|
||||
- **前端**: React + Vite + React Router(前台原生样式 / 后台 MUI)
|
||||
- **加密**: AES-256-GCM, PBKDF2, bcrypt
|
||||
- **验证码**: SVG 扭曲文字 / Google reCAPTCHA V2
|
||||
- **文件上传**: multer
|
||||
@@ -139,6 +140,7 @@ npm run cli -- <command>
|
||||
```
|
||||
rainweb/
|
||||
├── server.js # 主入口
|
||||
├── vite.config.js # Vite 构建配置
|
||||
├── cli.js # CLI 工具
|
||||
├── db.js # 数据库层
|
||||
├── middleware/
|
||||
@@ -156,29 +158,20 @@ rainweb/
|
||||
│ ├── setup.js # 初始化向导
|
||||
│ ├── announcements.js # 公告
|
||||
│ └── profile.js # 个人资料
|
||||
├── frontend/ # React 前端源码(Vite 根目录)
|
||||
│ ├── index.html # 前台入口(含 ${site_name} 占位符)
|
||||
│ ├── admin.html # 后台入口
|
||||
│ └── src/
|
||||
│ ├── main.jsx # 前台入口
|
||||
│ ├── App.jsx # 前台布局 + 路由表
|
||||
│ ├── admin/ # 后台入口(MUI)
|
||||
│ ├── api/ # fetch 封装
|
||||
│ ├── lib/ # 工具函数
|
||||
│ ├── theme.jsx # 主题切换(data-theme)
|
||||
│ └── pages/ # 前台页面
|
||||
├── public/
|
||||
│ ├── index.html # 博客首页
|
||||
│ ├── login.html # 登录
|
||||
│ ├── register.html # 注册
|
||||
│ ├── admin.html # 管理后台
|
||||
│ ├── forum.html # 论坛
|
||||
│ ├── blog.html # 博客页
|
||||
│ ├── passwords.html # 密码管理器
|
||||
│ ├── embed.html # 网页嵌入
|
||||
│ ├── profile.html # 个人中心
|
||||
│ ├── setup.html # 初始化向导
|
||||
│ ├── dist/ # Vite 构建产物(npm run build 生成,serveIndex 读取)
|
||||
│ ├── css/style.css # 全局样式 + 主题
|
||||
│ ├── js/
|
||||
│ │ ├── api.js # API 封装
|
||||
│ │ ├── nav.js # 导航栏 + 主题
|
||||
│ │ ├── captcha.js # 验证码前端
|
||||
│ │ ├── theme.js # 深浅色切换
|
||||
│ │ ├── main.js # 博客首页
|
||||
│ │ ├── admin.js # 管理后台
|
||||
│ │ ├── forum.js # 论坛
|
||||
│ │ ├── blog.js # 博客
|
||||
│ │ ├── passwords.js # 密码管理器
|
||||
│ │ └── ... # 其他页面逻辑
|
||||
│ └── wallpaper/ # 上传的壁纸
|
||||
└── package.json
|
||||
```
|
||||
@@ -211,24 +204,36 @@ rainweb/
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 开发模式启动
|
||||
node server.js
|
||||
# 开发模式(Vite dev server,端口 5173,/api 与 /uploads 代理到本地后端 3101)
|
||||
npm run dev
|
||||
|
||||
# 构建生产产物到 public/dist/
|
||||
npm run build
|
||||
|
||||
# 预览构建产物
|
||||
npm run preview
|
||||
|
||||
# 生产运行(Express 服务 public/ 与构建产物)
|
||||
npm start
|
||||
```
|
||||
|
||||
项目不依赖构建工具,直接修改 `public/` 目录下的文件即可。
|
||||
前端为 React + Vite,开发时前端改动由 Vite 热更新,无需刷新;生产部署必须先在服务器执行 `npm run build` 生成 `public/dist/`。
|
||||
|
||||
## 升级
|
||||
|
||||
```bash
|
||||
# 方法1: 一键升级(推荐)
|
||||
# 方法1:一键升级(推荐;仅限自托管机器本地执行)
|
||||
node cli.js upgrade
|
||||
|
||||
# 方法2: 手动
|
||||
git pull
|
||||
# 方法2:手动
|
||||
git pull --ff-only
|
||||
npm install
|
||||
npm run build
|
||||
node cli.js restart
|
||||
```
|
||||
|
||||
`upgrade` 要求 git 工作区干净,只执行 fast-forward 更新;失败会返回非 0,不会假报成功。`backup prune` 默认只预览不删除,`backup restore` 必须同时满足服务已停止和 `--yes`。
|
||||
|
||||
## 版本
|
||||
|
||||
当前版本记录在项目根目录的 `VERSION` 文件中,导航栏标题右侧会显示当前版本号。
|
||||
|
||||
@@ -1,357 +1,632 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync, spawn } = require('child_process');
|
||||
const { execFileSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const http = require('http');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data.db');
|
||||
const CONFIG_PATH = path.join(__dirname, '.env.json');
|
||||
const PROJECT_ROOT = __dirname;
|
||||
const CONFIG_PATH = path.join(PROJECT_ROOT, '.env.json');
|
||||
const DB_PATH = path.join(PROJECT_ROOT, 'data', 'rainweb.db');
|
||||
const BACKUP_DIR = path.join(PROJECT_ROOT, 'backups');
|
||||
const PID_PATH = path.join(PROJECT_ROOT, 'server.pid');
|
||||
const SERVER_PATH = path.join(PROJECT_ROOT, 'server.js');
|
||||
const PKG = require('./package.json');
|
||||
|
||||
// Lazy-load db
|
||||
let _db = null;
|
||||
async function getDb() {
|
||||
if (_db) return _db;
|
||||
const initSqlJs = require('sql.js');
|
||||
const SQL = await initSqlJs();
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
_db = new SQL.Database(buffer);
|
||||
} else {
|
||||
console.error('data.db not found. Run the server first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
function dbRun(sql, params = []) {
|
||||
if (!_db) return;
|
||||
_db.run(sql, params);
|
||||
const r = _db.exec("SELECT last_insert_rowid()");
|
||||
const data = _db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
return r && r[0] && r[0].values ? r[0].values[0][0] : 0;
|
||||
}
|
||||
|
||||
function dbGet(sql, params = []) {
|
||||
if (!_db) return null;
|
||||
const stmt = _db.prepare(sql); stmt.bind(params);
|
||||
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
|
||||
stmt.free(); return null;
|
||||
}
|
||||
|
||||
function dbAll(sql, params = []) {
|
||||
if (!_db) return [];
|
||||
const stmt = _db.prepare(sql); stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free(); return rows;
|
||||
// 只读命令不加载 db.js,避免检查状态时意外触发数据库迁移或写入。
|
||||
let writableDb = null;
|
||||
function getWritableDb() {
|
||||
if (!writableDb) writableDb = require('./db');
|
||||
return writableDb;
|
||||
}
|
||||
|
||||
const HELP = `
|
||||
RainWeb CLI v${PKG.version}
|
||||
Usage: node cli.js <command> [options]
|
||||
用法:node cli.js <命令> [参数] [选项]
|
||||
|
||||
Commands:
|
||||
status Show server and system status
|
||||
start Start the server
|
||||
restart Restart the server
|
||||
stop Stop the server
|
||||
port [number] Show or change listen port (default: 3001)
|
||||
password [new-pass] Change admin password (leave empty for prompt)
|
||||
captcha Interactive captcha rule configuration
|
||||
config Show all current settings
|
||||
upgrade Git pull + npm install + restart (one-click upgrade)
|
||||
help Show this help
|
||||
命令:
|
||||
status 查看版本、服务、数据库和备份状态
|
||||
health HTTP 健康检查和 SQLite 完整性检查
|
||||
db-check 只读检查 SQLite 完整性和关键表
|
||||
start 启动服务器(防重复启动)
|
||||
stop 优雅停止本项目的 server.js
|
||||
restart 停止后等待并启动服务器
|
||||
port [端口] 查看或修改监听端口
|
||||
password 交互式修改管理员密码
|
||||
captcha 交互式配置验证码规则
|
||||
config 查看配置(敏感值统一脱敏)
|
||||
backup 创建数据库备份
|
||||
backup list 列出并检查备份完整性
|
||||
backup prune --keep <数量> 预览清理旧备份;加 --yes 才会删除
|
||||
backup restore <文件> --yes 服务停止后恢复备份,显式确认才覆盖
|
||||
upgrade 备份、检查工作区、快进升级、构建并验活
|
||||
help 显示帮助
|
||||
|
||||
Examples:
|
||||
node cli.js status
|
||||
node cli.js port 8080
|
||||
node cli.js password MyNewP@ss123
|
||||
node cli.js captcha
|
||||
node cli.js upgrade
|
||||
通用选项:
|
||||
--yes 跳过需要确认的操作
|
||||
--json 以 JSON 输出(status/health/config/backup list 等支持)
|
||||
--timeout <毫秒> 设置 HTTP 检查超时(100-60000)
|
||||
|
||||
说明:
|
||||
password 默认交互输入;为兼容旧脚本仍接受末尾明文参数,但不在帮助示例中推荐。
|
||||
upgrade 仅用于自托管机器上的本地操作,不会修改云端或远程服务。
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const cmd = process.argv[2] || 'help';
|
||||
|
||||
switch (cmd) {
|
||||
case 'status': return cmdStatus();
|
||||
case 'start': return cmdStart();
|
||||
case 'restart': return cmdRestart();
|
||||
case 'stop': return cmdStop();
|
||||
case 'port': return cmdPort();
|
||||
case 'password': return cmdPassword();
|
||||
case 'captcha': return cmdCaptcha();
|
||||
case 'config': return cmdConfig();
|
||||
case 'upgrade': return cmdUpgrade();
|
||||
case 'help':
|
||||
default:
|
||||
console.log(HELP);
|
||||
class CliError extends Error {
|
||||
constructor(message, code = 1) {
|
||||
super(message);
|
||||
this.name = 'CliError';
|
||||
this.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
// === Status ===
|
||||
async function cmdStatus() {
|
||||
console.log(`RainWeb v${PKG.version}`);
|
||||
console.log(`Node.js: ${process.version}`);
|
||||
console.log(`Platform: ${process.platform}`);
|
||||
console.log(`Data DB: ${fs.existsSync(DB_PATH) ? fs.statSync(DB_PATH).size + ' bytes' : 'NOT FOUND'}`);
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
await httpGet('http://localhost:' + (getConfigPort()));
|
||||
console.log('Server: RUNNING');
|
||||
} catch {
|
||||
console.log('Server: STOPPED');
|
||||
}
|
||||
|
||||
// Show admin info
|
||||
try {
|
||||
const db = await getDb();
|
||||
const admin = dbGet("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
|
||||
if (admin) {
|
||||
console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`);
|
||||
function parseArgs(argv) {
|
||||
const positionals = [];
|
||||
const options = { yes: false, json: false };
|
||||
const valueOptions = new Set(['keep', 'timeout', 'type', 'login', 'register', 'forum', 'failed', 'threshold']);
|
||||
const booleanOptions = new Set(['yes', 'json']);
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === '--') {
|
||||
positionals.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
if (!token.startsWith('--')) {
|
||||
positionals.push(token);
|
||||
continue;
|
||||
}
|
||||
const match = token.match(/^--([^=]+)(?:=(.*))?$/);
|
||||
if (!match) throw new CliError(`无法解析选项:${token}`);
|
||||
const name = match[1];
|
||||
let value = match[2];
|
||||
if (booleanOptions.has(name)) {
|
||||
if (value !== undefined) throw new CliError(`选项 --${name} 不接受参数`);
|
||||
options[name] = true;
|
||||
continue;
|
||||
}
|
||||
if (!valueOptions.has(name)) throw new CliError(`未知选项:--${name}`);
|
||||
if (value === undefined) {
|
||||
value = argv[i + 1];
|
||||
if (!value || value.startsWith('--')) throw new CliError(`选项 --${name} 缺少参数`);
|
||||
i += 1;
|
||||
}
|
||||
options[name] = value;
|
||||
}
|
||||
return { positionals, ...options };
|
||||
}
|
||||
|
||||
function readJson(file, fallback = {}) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
||||
}
|
||||
|
||||
// === Port ===
|
||||
function getConfigPort() {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
return config.port || 3001;
|
||||
} catch { return 3001; }
|
||||
const config = readJson(CONFIG_PATH);
|
||||
const value = Number(process.env.PORT || config.port || 3001);
|
||||
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : 3001;
|
||||
}
|
||||
|
||||
function setConfigPort(port) {
|
||||
let config = {};
|
||||
try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {}
|
||||
const config = readJson(CONFIG_PATH);
|
||||
config.port = port;
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function cmdPort() {
|
||||
const arg = process.argv[3];
|
||||
if (arg) {
|
||||
const port = parseInt(arg);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
console.error('Invalid port number. Use 1-65535.');
|
||||
process.exit(1);
|
||||
}
|
||||
setConfigPort(port);
|
||||
console.log(`Port set to ${port}. Restart to apply.`);
|
||||
} else {
|
||||
console.log(`Current port: ${getConfigPort()}`);
|
||||
}
|
||||
function dbExists() { return fs.existsSync(DB_PATH); }
|
||||
|
||||
function openReadonlyDatabase(file = DB_PATH) {
|
||||
if (!fs.existsSync(file)) throw new CliError(`数据库不存在:${file}`);
|
||||
try { return new Database(file, { readonly: true, fileMustExist: true }); }
|
||||
catch (error) { throw new CliError(`无法打开数据库:${error.message}`); }
|
||||
}
|
||||
|
||||
// === Password ===
|
||||
async function cmdPassword() {
|
||||
let newPass = process.argv[3];
|
||||
if (!newPass) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
newPass = await new Promise(resolve => {
|
||||
rl.question('New admin password (min 6 chars): ', resolve);
|
||||
});
|
||||
rl.close();
|
||||
}
|
||||
if (!newPass || newPass.length < 6) {
|
||||
console.error('Password must be at least 6 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await getDb();
|
||||
const admin = dbGet("SELECT id FROM users WHERE role = 'admin'");
|
||||
if (!admin) { console.error('No admin user found.'); process.exit(1); }
|
||||
|
||||
const hash = bcrypt.hashSync(newPass, 10);
|
||||
dbRun('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
||||
console.log('Admin password updated successfully.');
|
||||
function inspectDatabase(file = DB_PATH) {
|
||||
const connection = openReadonlyDatabase(file);
|
||||
try {
|
||||
const quickCheck = connection.pragma('quick_check', { simple: true });
|
||||
const foreignKeyErrors = connection.prepare('PRAGMA foreign_key_check').all();
|
||||
const tables = connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all().map((row) => row.name);
|
||||
const requiredTables = ['users', 'site_settings', 'tickets', 'ticket_messages', 'ticket_events'];
|
||||
const missingTables = requiredTables.filter((name) => !tables.includes(name));
|
||||
return {
|
||||
file,
|
||||
size: fs.statSync(file).size,
|
||||
userVersion: connection.pragma('user_version', { simple: true }),
|
||||
quickCheck,
|
||||
foreignKeyErrors,
|
||||
missingTables,
|
||||
valid: quickCheck === 'ok' && foreignKeyErrors.length === 0 && missingTables.length === 0,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new CliError(`数据库完整性检查失败:${error.message}`);
|
||||
} finally { connection.close(); }
|
||||
}
|
||||
|
||||
// === Captcha ===
|
||||
async function cmdCaptcha() {
|
||||
await getDb();
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const q = (q) => new Promise(resolve => rl.question(q, resolve));
|
||||
|
||||
console.log('=== Captcha Rule Configuration ===\n');
|
||||
console.log('Current settings:');
|
||||
['login','register','forum','failed'].forEach(k => {
|
||||
const v = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
|
||||
console.log(` ${k}: ${v ? v.value : '0'}`);
|
||||
});
|
||||
const type = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_type'");
|
||||
console.log(` type: ${type ? type.value : 'builtin'}\n`);
|
||||
|
||||
const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: ');
|
||||
if (typeAns) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
|
||||
|
||||
for (const scope of ['login', 'register', 'forum']) {
|
||||
const current = dbGet("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'");
|
||||
const ans = await q(`Enable captcha for ${scope}? (y/n) [${current && current.value === '1' ? 'y' : 'n'}]: `);
|
||||
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']);
|
||||
}
|
||||
|
||||
const failAns = await q('Enable captcha after failed attempts? (y/n): ');
|
||||
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
|
||||
if (failAns.toLowerCase() === 'y') {
|
||||
const threshold = await q('Failed attempts threshold (default 5): ');
|
||||
if (threshold) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
console.log('\nCaptcha rules updated.');
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes)) return '未知';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
async function cmdConfig() {
|
||||
await getDb();
|
||||
const rows = dbAll("SELECT key, value FROM site_settings ORDER BY key");
|
||||
console.log('=== Site Configuration ===\n');
|
||||
const secrets = ['smtp_pass', 'recaptcha_secret_key'];
|
||||
rows.forEach(r => {
|
||||
let val = r.value;
|
||||
if (secrets.includes(r.key) && val) val = '****' + val.slice(-4);
|
||||
console.log(` ${r.key}: ${val || '(empty)'}`);
|
||||
});
|
||||
console.log(`\n listen_port: ${getConfigPort()}`);
|
||||
function formatMtime(date) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
// === Start / Stop / Restart ===
|
||||
function findPidFile() { return path.join(__dirname, 'server.pid'); }
|
||||
function print(value, args) {
|
||||
if (args.json) console.log(JSON.stringify(value, null, 2));
|
||||
else console.log(value);
|
||||
}
|
||||
|
||||
function maskValue(key, value) {
|
||||
if (value === undefined || value === null || value === '') return '(空)';
|
||||
const sensitive = /(pass(word)?|secret|token|private|jwt|credential|api[_-]?key|client[_-]?secret|pin[_-]?(hash|salt)|terminal_pin)/i.test(key);
|
||||
if (!sensitive) return String(value);
|
||||
const text = String(value);
|
||||
return text.length <= 4 ? '****' : `****${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
function getPidFromFile() {
|
||||
if (!fs.existsSync(PID_PATH)) return null;
|
||||
const raw = fs.readFileSync(PID_PATH, 'utf8').trim();
|
||||
if (!/^\d+$/.test(raw)) return { invalid: true, raw };
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
function isRunning(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function cmdStop() {
|
||||
const pidFile = findPidFile();
|
||||
if (fs.existsSync(pidFile)) {
|
||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'));
|
||||
if (isRunning(pid)) {
|
||||
try { process.kill(pid); console.log('Server stopped (PID: ' + pid + ')'); } catch { console.log('Could not stop process.'); }
|
||||
} else { console.log('Server not running.'); }
|
||||
fs.unlinkSync(pidFile);
|
||||
} else {
|
||||
// Try to find node process
|
||||
console.log('No PID file found. Try: taskkill /F /IM node.exe (Windows) or pkill node (Linux/Mac)');
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdStart() {
|
||||
const port = getConfigPort();
|
||||
const proc = spawn('node', ['server.js'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
detached: true,
|
||||
});
|
||||
proc.unref();
|
||||
fs.writeFileSync(findPidFile(), String(proc.pid));
|
||||
console.log(`Server starting on port ${port} (PID: ${proc.pid})`);
|
||||
}
|
||||
|
||||
async function cmdRestart() {
|
||||
await cmdStop();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await cmdStart();
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
const REPO_URL = 'https://github.com/Xianyunah/rainwebblog.git';
|
||||
const REPO_ZIP = 'https://github.com/Xianyunah/rainwebblog/archive/refs/heads/main.zip';
|
||||
|
||||
async function cmdUpgrade() {
|
||||
console.log('=== RainWeb Upgrade ===\n');
|
||||
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
||||
|
||||
if (isGitRepo) {
|
||||
console.log('1. Pulling latest code via git...');
|
||||
function readProcessDetails(pid) {
|
||||
if (!isRunning(pid)) return null;
|
||||
let cwd = '';
|
||||
let command = '';
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('Git pull failed. Check for conflicts.');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log('1. Downloading latest release...');
|
||||
try {
|
||||
const tmpDir = path.join(__dirname, '.upgrade-tmp');
|
||||
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
// Download zip using fetch or curl
|
||||
const zipPath = path.join(tmpDir, 'rainweb.zip');
|
||||
try {
|
||||
execSync(`curl -L "${REPO_ZIP}" -o "${zipPath}"`, { stdio: 'pipe' });
|
||||
} catch {
|
||||
// Fallback to wget or node https
|
||||
execSync(`node -e "
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const f = fs.createWriteStream('${zipPath.replace(/\\/g, '/')}');
|
||||
https.get('${REPO_ZIP}', r => r.pipe(f));
|
||||
"`, { stdio: 'pipe', timeout: 60000 });
|
||||
}
|
||||
|
||||
// Extract (requires unzip or 7z)
|
||||
const extractDir = path.join(tmpDir, 'extracted');
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
execSync(`tar -xf "${zipPath}" -C "${extractDir}"`, { stdio: 'pipe' });
|
||||
|
||||
// Find the inner directory (github adds a prefix dir)
|
||||
const inner = fs.readdirSync(extractDir).filter(f => fs.statSync(path.join(extractDir, f)).isDirectory())[0];
|
||||
const srcDir = inner ? path.join(extractDir, inner) : extractDir;
|
||||
|
||||
// Copy files, excluding data.db and node_modules
|
||||
const exclude = ['data.db', 'node_modules', '.env.json', 'server.pid', 'releases'];
|
||||
const cpDir = (src, dest) => {
|
||||
fs.readdirSync(src).forEach(f => {
|
||||
if (exclude.includes(f)) return;
|
||||
const s = path.join(src, f), d = path.join(dest, f);
|
||||
if (fs.statSync(s).isDirectory()) {
|
||||
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
||||
cpDir(s, d);
|
||||
} else {
|
||||
fs.copyFileSync(s, d);
|
||||
}
|
||||
});
|
||||
};
|
||||
cpDir(srcDir, __dirname);
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
console.log(' Download & extract complete.');
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e.message);
|
||||
console.log(' Fallback: manually download from ' + REPO_URL);
|
||||
process.exit(1);
|
||||
}
|
||||
const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ' + $args[0]; if ($p) { $p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress }";
|
||||
const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script, String(pid)], { encoding: 'utf8' }).trim();
|
||||
const processInfo = output ? JSON.parse(output) : null;
|
||||
command = processInfo && processInfo.CommandLine ? processInfo.CommandLine : '';
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.log('\n2. Installing dependencies...');
|
||||
try {
|
||||
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('npm install failed.');
|
||||
process.exit(1);
|
||||
if (process.platform !== 'win32') {
|
||||
cwd = fs.realpathSync(`/proc/${pid}/cwd`);
|
||||
command = fs.readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim();
|
||||
}
|
||||
} catch {}
|
||||
if (!command && process.platform !== 'win32') {
|
||||
try { command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim(); } catch {}
|
||||
}
|
||||
|
||||
console.log('\n3. Restarting server...');
|
||||
await cmdRestart();
|
||||
console.log('\n=== Upgrade complete! ===');
|
||||
const normalizedCommand = command.replace(/\\/g, '/');
|
||||
const normalizedServer = SERVER_PATH.replace(/\\/g, '/');
|
||||
const hasServerScript = normalizedCommand.includes(normalizedServer)
|
||||
|| (cwd === PROJECT_ROOT && /(?:^|\s)server\.js(?:\s|$)/.test(command));
|
||||
const owned = (process.platform === 'win32' ? hasServerScript : cwd === PROJECT_ROOT && hasServerScript)
|
||||
&& !/cli\.js/.test(command);
|
||||
return { pid, cwd, command, owned };
|
||||
}
|
||||
|
||||
// === Helper ===
|
||||
function httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); })
|
||||
.on('error', reject);
|
||||
function listCandidatePids() {
|
||||
try { return fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)).map(Number); }
|
||||
catch {
|
||||
try { return execFileSync('ps', ['-A', '-o', 'pid='], { encoding: 'utf8' }).split(/\s+/).filter(Boolean).map(Number); }
|
||||
catch { return []; }
|
||||
}
|
||||
}
|
||||
|
||||
function findOwnedServers() {
|
||||
return listCandidatePids().filter((pid) => pid !== process.pid).map(readProcessDetails).filter((details) => details && details.owned);
|
||||
}
|
||||
|
||||
function getOwnedServer() {
|
||||
const pidInfo = getPidFromFile();
|
||||
if (pidInfo && pidInfo.invalid) throw new CliError(`PID 文件格式错误:${PID_PATH}`);
|
||||
if (Number.isInteger(pidInfo)) {
|
||||
if (!isRunning(pidInfo)) {
|
||||
try { fs.unlinkSync(PID_PATH); } catch {}
|
||||
} else {
|
||||
const details = readProcessDetails(pidInfo);
|
||||
if (!details || !details.owned) throw new CliError(`拒绝操作:${PID_PATH} 中的 PID ${pidInfo} 不属于本项目的 server.js`);
|
||||
return details;
|
||||
}
|
||||
}
|
||||
const servers = findOwnedServers();
|
||||
if (servers.length > 1) throw new CliError(`发现多个本项目 server.js 进程(${servers.map((item) => item.pid).join(', ')}),请先人工处理`);
|
||||
return servers[0] || null;
|
||||
}
|
||||
|
||||
async function waitForExit(pid, timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isRunning(pid)) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return !isRunning(pid);
|
||||
}
|
||||
|
||||
function probeHttp(port, timeoutMs = 3000) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const request = http.get({ hostname: '127.0.0.1', port, path: '/', timeout: timeoutMs }, (response) => {
|
||||
response.resume();
|
||||
response.on('end', () => resolve({ reachable: true, healthy: response.statusCode >= 200 && response.statusCode < 400, statusCode: response.statusCode, latencyMs: Date.now() - started }));
|
||||
});
|
||||
request.on('timeout', () => request.destroy(new Error('请求超时')));
|
||||
request.on('error', (error) => resolve({ reachable: false, healthy: false, error: error.message, latencyMs: Date.now() - started }));
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
function getTimeout(args) {
|
||||
const value = args.timeout === undefined ? 3000 : Number(args.timeout);
|
||||
if (!Number.isInteger(value) || value < 100 || value > 60000) throw new CliError('--timeout 必须是 100-60000 的整数(毫秒)');
|
||||
return value;
|
||||
}
|
||||
|
||||
async function waitForStartedServer(pid, port, timeoutMs = 12000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last = null;
|
||||
while (Date.now() < deadline) {
|
||||
const details = readProcessDetails(pid);
|
||||
if (!details || !details.owned) throw new CliError(`新启动的 PID ${pid} 未能确认属于本项目 server.js`);
|
||||
last = await probeHttp(port, 1000);
|
||||
if (last.healthy) return last;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new CliError(`服务器启动后健康检查失败:${last && last.error ? last.error : `HTTP ${last && last.statusCode ? last.statusCode : '无响应'}`}`);
|
||||
}
|
||||
|
||||
async function stopServer({ quiet = false } = {}) {
|
||||
const server = getOwnedServer();
|
||||
if (!server) {
|
||||
if (!quiet) console.log('服务器当前未运行。');
|
||||
return { stopped: false, pid: null };
|
||||
}
|
||||
process.kill(server.pid, 'SIGTERM');
|
||||
let exited = await waitForExit(server.pid, 10000);
|
||||
if (!exited) {
|
||||
const stillOwned = readProcessDetails(server.pid);
|
||||
if (!stillOwned || !stillOwned.owned) throw new CliError(`PID ${server.pid} 在等待期间不再确认属于本项目,未强制结束`);
|
||||
process.kill(server.pid, 'SIGKILL');
|
||||
exited = await waitForExit(server.pid, 3000);
|
||||
}
|
||||
if (!exited) throw new CliError(`服务器未能退出(PID: ${server.pid})`);
|
||||
if (fs.existsSync(PID_PATH) && getPidFromFile() === server.pid) fs.unlinkSync(PID_PATH);
|
||||
if (!quiet) console.log(`服务器已停止(PID: ${server.pid})。`);
|
||||
return { stopped: true, pid: server.pid };
|
||||
}
|
||||
|
||||
async function startServer({ quiet = false } = {}) {
|
||||
const existing = getOwnedServer();
|
||||
if (existing) {
|
||||
if (!quiet) console.log(`服务器已经运行(PID: ${existing.pid})。`);
|
||||
return { started: false, alreadyRunning: true, pid: existing.pid, port: getConfigPort() };
|
||||
}
|
||||
const port = getConfigPort();
|
||||
const child = spawn(process.execPath, [SERVER_PATH], { cwd: PROJECT_ROOT, stdio: 'ignore', detached: true, env: { ...process.env, PORT: String(port) } });
|
||||
if (!child.pid) throw new CliError('无法启动服务器进程');
|
||||
fs.writeFileSync(PID_PATH, `${child.pid}\n`, { mode: 0o644 });
|
||||
try { await waitForStartedServer(child.pid, port); }
|
||||
catch (error) {
|
||||
if (isRunning(child.pid)) {
|
||||
const details = readProcessDetails(child.pid);
|
||||
if (details && details.owned) { process.kill(child.pid, 'SIGTERM'); await waitForExit(child.pid, 3000); }
|
||||
}
|
||||
if (fs.existsSync(PID_PATH) && getPidFromFile() === child.pid) fs.unlinkSync(PID_PATH);
|
||||
throw error;
|
||||
}
|
||||
child.unref();
|
||||
if (!quiet) console.log(`服务器已启动(端口: ${port},PID: ${child.pid})。`);
|
||||
return { started: true, alreadyRunning: false, pid: child.pid, port };
|
||||
}
|
||||
|
||||
function listBackupFiles() {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return [];
|
||||
return fs.readdirSync(BACKUP_DIR).filter((file) => /^rainweb-.+\.db$/.test(file)).map((file) => path.join(BACKUP_DIR, file)).filter((file) => fs.statSync(file).isFile()).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
}
|
||||
|
||||
function inspectBackup(file) {
|
||||
try {
|
||||
const result = inspectDatabase(file);
|
||||
const integrityValid = result.quickCheck === 'ok' && result.foreignKeyErrors.length === 0;
|
||||
return { file: path.basename(file), size: result.size, modifiedAt: formatMtime(fs.statSync(file).mtime), valid: integrityValid, schemaReady: result.missingTables.length === 0, quickCheck: result.quickCheck, foreignKeyErrors: result.foreignKeyErrors.length, missingTables: result.missingTables, userVersion: result.userVersion };
|
||||
} catch (error) {
|
||||
return { file: path.basename(file), size: fs.existsSync(file) ? fs.statSync(file).size : 0, modifiedAt: fs.existsSync(file) ? formatMtime(fs.statSync(file).mtime) : '', valid: false, schemaReady: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
if (!dbExists()) throw new CliError(`数据库不存在:${DB_PATH}`);
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
const now = new Date();
|
||||
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}${String(now.getMilliseconds()).padStart(3, '0')}`;
|
||||
const destination = path.join(BACKUP_DIR, `rainweb-${stamp}.db`);
|
||||
const source = new Database(DB_PATH, { readonly: true, fileMustExist: true });
|
||||
try { await source.backup(destination); } finally { source.close(); }
|
||||
const check = inspectBackup(destination);
|
||||
if (!check.valid) { try { fs.unlinkSync(destination); } catch {} throw new CliError(`备份已生成但完整性检查失败:${check.error || check.quickCheck}`); }
|
||||
return { path: destination, ...check };
|
||||
}
|
||||
|
||||
function resolveBackupFile(input) {
|
||||
if (!input) throw new CliError('请指定备份文件名,例如 backup restore rainweb-YYYYMMDD-HHmmss.db');
|
||||
const candidate = path.resolve(BACKUP_DIR, input);
|
||||
if (path.dirname(candidate) !== path.resolve(BACKUP_DIR)) throw new CliError('备份文件必须位于 backups/ 目录内');
|
||||
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) throw new CliError(`备份文件不存在:${candidate}`);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readSettingRows() {
|
||||
if (!dbExists()) return [];
|
||||
const connection = openReadonlyDatabase();
|
||||
try { return connection.prepare('SELECT key, value FROM site_settings ORDER BY key').all(); } finally { connection.close(); }
|
||||
}
|
||||
|
||||
function normalizeSwitch(value, label) {
|
||||
const normalized = String(value || '').toLowerCase();
|
||||
if (['1', 'on', 'yes', 'y', 'true'].includes(normalized)) return '1';
|
||||
if (['0', 'off', 'no', 'n', 'false'].includes(normalized)) return '0';
|
||||
throw new CliError(`${label} 必须是 on/off、yes/no 或 1/0`);
|
||||
}
|
||||
|
||||
function ask(rl, question) { return new Promise((resolve) => rl.question(question, resolve)); }
|
||||
|
||||
async function cmdStatus(args) {
|
||||
const port = getConfigPort();
|
||||
const httpState = await probeHttp(port, getTimeout(args));
|
||||
let server = null;
|
||||
let serverError = null;
|
||||
try { server = getOwnedServer(); } catch (error) { serverError = error.message; }
|
||||
let database = null;
|
||||
if (dbExists()) { try { database = inspectDatabase(); } catch (error) { database = { valid: false, error: error.message }; } }
|
||||
const latest = listBackupFiles()[0];
|
||||
const result = { version: PKG.version, node: process.version, platform: process.platform, port, server: { running: !!server, pid: server ? server.pid : null, error: serverError, http: httpState }, database: database || { valid: false, error: '数据库不存在' }, latestBackup: latest ? inspectBackup(latest) : null };
|
||||
if (args.json) return print(result, args);
|
||||
console.log(`RainWeb v${result.version}`);
|
||||
console.log(`Node.js:${result.node}`);
|
||||
console.log(`平台:${result.platform}`);
|
||||
console.log(`端口:${result.port}`);
|
||||
console.log(`服务:${result.server.running ? `运行中(PID: ${result.server.pid})` : result.server.error || '未运行'}`);
|
||||
console.log(`HTTP:${httpState.healthy ? `正常(${httpState.statusCode},${httpState.latencyMs} ms)` : '不可用'}`);
|
||||
console.log(`数据库:${result.database.valid ? '完整性正常' : result.database.error || '检查失败'}`);
|
||||
console.log(`最近备份:${result.latestBackup ? `${result.latestBackup.file}(${formatBytes(result.latestBackup.size)})` : '无'}`);
|
||||
}
|
||||
|
||||
async function cmdHealth(args) {
|
||||
const port = getConfigPort();
|
||||
const [httpState, database] = await Promise.all([probeHttp(port, getTimeout(args)), Promise.resolve().then(() => (dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在' }))]);
|
||||
const result = { healthy: httpState.healthy && database.valid, port, http: httpState, database };
|
||||
print(args.json ? result : `HTTP:${httpState.healthy ? '正常' : `失败(${httpState.error || httpState.statusCode || '无响应'})`}\n数据库:${database.valid ? '完整性正常' : database.error || '检查失败'}\n总体:${result.healthy ? '健康' : '不健康'}`, args);
|
||||
return result.healthy ? 0 : 2;
|
||||
}
|
||||
|
||||
async function cmdDbCheck(args) {
|
||||
const result = dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在', file: DB_PATH };
|
||||
const foreignKeyCount = Array.isArray(result.foreignKeyErrors) ? result.foreignKeyErrors.length : result.foreignKeyErrors;
|
||||
print(args.json ? result : `数据库:${result.file}\n版本:${result.userVersion ?? '未知'}\nquick_check:${result.quickCheck || '未执行'}\n外键错误:${foreignKeyCount ?? '未知'}\n关键表缺失:${result.missingTables ? result.missingTables.join(', ') || '无' : '未知'}\n结果:${result.valid ? '通过' : result.error || '失败'}`, args);
|
||||
return result.valid ? 0 : 2;
|
||||
}
|
||||
|
||||
async function cmdPort(args) {
|
||||
const value = args.positionals[1];
|
||||
if (value === undefined) return print(args.json ? { port: getConfigPort() } : `当前端口:${getConfigPort()}`, args);
|
||||
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new CliError('端口必须是 1-65535 的整数');
|
||||
setConfigPort(Number(value));
|
||||
print(args.json ? { port: Number(value), message: '端口已保存,重启后生效' } : `端口已设置为 ${value},重启后生效。`, args);
|
||||
}
|
||||
|
||||
async function cmdPassword(args) {
|
||||
await warnIfServerRunning();
|
||||
let newPass = args.positionals[1];
|
||||
if (!newPass) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
newPass = await ask(rl, '请输入新的管理员密码(至少 6 位):');
|
||||
const confirm = await ask(rl, '请再次输入密码:');
|
||||
rl.close();
|
||||
if (newPass !== confirm) throw new CliError('两次输入的密码不一致');
|
||||
}
|
||||
if (!newPass || newPass.length < 6) throw new CliError('密码至少需要 6 位');
|
||||
const db = getWritableDb();
|
||||
db.getDb();
|
||||
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
|
||||
if (!admin) throw new CliError('未找到管理员账号');
|
||||
db.run('UPDATE users SET password = ? WHERE id = ?', [bcrypt.hashSync(newPass, 10), admin.id]);
|
||||
print(args.json ? { message: '管理员密码已更新' } : '管理员密码已更新。', args);
|
||||
}
|
||||
|
||||
async function cmdCaptcha(args) {
|
||||
await warnIfServerRunning();
|
||||
const db = getWritableDb();
|
||||
db.getDb();
|
||||
const scopes = ['login', 'register', 'forum'];
|
||||
const currentType = db.getSetting('captcha_type') || 'builtin';
|
||||
const allowedTypes = new Set(['none', 'builtin', 'recaptcha', 'turnstile', 'both']);
|
||||
const typeArg = args.type;
|
||||
if (typeArg && !allowedTypes.has(typeArg)) throw new CliError('--type 必须是 none、builtin、recaptcha、turnstile 或 both');
|
||||
const hasOptions = typeArg || scopes.some((scope) => args[scope] !== undefined) || args.failed !== undefined || args.threshold !== undefined;
|
||||
const updates = {};
|
||||
if (hasOptions) {
|
||||
updates.captcha_type = typeArg || currentType;
|
||||
for (const scope of scopes) if (args[scope] !== undefined) updates[`captcha_${scope}`] = normalizeSwitch(args[scope], `--${scope}`);
|
||||
if (args.failed !== undefined) updates.captcha_failed = normalizeSwitch(args.failed, '--failed');
|
||||
if (args.threshold !== undefined) {
|
||||
if (!/^\d+$/.test(String(args.threshold)) || Number(args.threshold) < 1) throw new CliError('--threshold 必须是正整数');
|
||||
updates.captcha_failed_threshold = String(Number(args.threshold));
|
||||
}
|
||||
} else {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
console.log('验证码配置(直接回车保留当前值)');
|
||||
const type = await ask(rl, `类型 none/builtin/recaptcha/turnstile/both [${currentType}]:`);
|
||||
const selectedType = type || currentType;
|
||||
if (!allowedTypes.has(selectedType)) { rl.close(); throw new CliError('验证码类型不合法'); }
|
||||
updates.captcha_type = selectedType;
|
||||
for (const scope of scopes) {
|
||||
const current = db.getSetting(`captcha_${scope}`) || '0';
|
||||
const answer = await ask(rl, `启用 ${scope} 验证码?(y/n) [${current === '1' ? 'y' : 'n'}]:`);
|
||||
updates[`captcha_${scope}`] = answer ? normalizeSwitch(answer, scope) : current;
|
||||
}
|
||||
const failed = await ask(rl, `失败尝试后启用验证码?(y/n) [${db.getSetting('captcha_failed') === '1' ? 'y' : 'n'}]:`);
|
||||
updates.captcha_failed = failed ? normalizeSwitch(failed, 'failed') : (db.getSetting('captcha_failed') || '0');
|
||||
if (updates.captcha_failed === '1') {
|
||||
const threshold = await ask(rl, `失败次数阈值 [${db.getSetting('captcha_failed_threshold') || '5'}]:`);
|
||||
if (threshold) {
|
||||
if (!/^\d+$/.test(threshold) || Number(threshold) < 1) { rl.close(); throw new CliError('失败次数阈值必须是正整数'); }
|
||||
updates.captcha_failed_threshold = threshold;
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
}
|
||||
for (const [key, value] of Object.entries(updates)) db.setSetting(key, value);
|
||||
print(args.json ? { message: '验证码规则已更新', settings: updates } : '验证码规则已更新。', args);
|
||||
}
|
||||
|
||||
async function cmdConfig(args) {
|
||||
const settings = Object.fromEntries(readSettingRows().map((row) => [row.key, maskValue(row.key, row.value)]));
|
||||
const envConfig = readJson(CONFIG_PATH);
|
||||
const safeEnv = Object.fromEntries(Object.entries(envConfig).map(([key, value]) => [key, maskValue(key, value)]));
|
||||
const result = { settings, env: safeEnv, listen_port: getConfigPort() };
|
||||
if (args.json) return print(result, args);
|
||||
console.log('=== RainWeb 配置 ===');
|
||||
for (const [key, value] of Object.entries(settings)) console.log(` ${key}:${value}`);
|
||||
for (const [key, value] of Object.entries(safeEnv)) console.log(` env.${key}:${value}`);
|
||||
console.log(` listen_port:${result.listen_port}`);
|
||||
}
|
||||
|
||||
async function cmdBackup(args) {
|
||||
const action = args.positionals[1] || 'create';
|
||||
if (action === 'create') {
|
||||
const result = await createBackup();
|
||||
print(args.json ? result : `备份成功:${result.path}\n完整性:通过(${formatBytes(result.size)})`, args);
|
||||
return;
|
||||
}
|
||||
if (action === 'list') {
|
||||
const result = listBackupFiles().map(inspectBackup);
|
||||
print(args.json ? result : (result.length ? result.map((item) => `${item.valid ? '正常' : '损坏'}${item.valid && !item.schemaReady ? '(待迁移)' : ''} ${item.file} ${formatBytes(item.size)} ${item.modifiedAt}`).join('\n') : '暂无备份。'), args);
|
||||
return;
|
||||
}
|
||||
if (action === 'prune') {
|
||||
if (args.keep === undefined) throw new CliError('prune 必须明确指定 --keep <数量>;未指定时不会删除任何文件');
|
||||
if (!/^\d+$/.test(String(args.keep)) || Number(args.keep) < 1) throw new CliError('--keep 必须是正整数');
|
||||
const keep = Number(args.keep);
|
||||
const remove = listBackupFiles().slice(keep);
|
||||
const result = { keep, candidates: remove.map((file) => path.basename(file)), deleted: [] };
|
||||
if (args.yes) for (const file of remove) { fs.unlinkSync(file); result.deleted.push(path.basename(file)); }
|
||||
print(args.json ? result : (args.yes ? `已删除 ${result.deleted.length} 个旧备份,保留最新 ${keep} 个。` : `预览:将删除 ${remove.length} 个旧备份;加 --yes 才会执行。\n${result.candidates.join('\n')}`), args);
|
||||
return;
|
||||
}
|
||||
if (action === 'restore') {
|
||||
if (!args.yes) throw new CliError('restore 会覆盖当前数据库,必须显式使用 --yes;且服务必须已停止');
|
||||
if (getOwnedServer()) throw new CliError('restore 必须先停止 RainWeb 服务');
|
||||
const httpState = await probeHttp(getConfigPort(), getTimeout(args));
|
||||
if (httpState.reachable) throw new CliError('检测到 HTTP 服务仍在运行,拒绝 restore');
|
||||
const sourcePath = resolveBackupFile(args.positionals[2]);
|
||||
const check = inspectBackup(sourcePath);
|
||||
if (!check.valid) throw new CliError(`备份完整性检查失败,拒绝恢复:${check.error || check.quickCheck}`);
|
||||
if (!dbExists()) fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
const tempPath = `${DB_PATH}.restore-${process.pid}-${Date.now()}`;
|
||||
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
|
||||
try { await source.backup(tempPath); } finally { source.close(); }
|
||||
let oldPath = null;
|
||||
try {
|
||||
if (fs.existsSync(DB_PATH)) { oldPath = `${DB_PATH}.before-restore-${Date.now()}`; fs.renameSync(DB_PATH, oldPath); }
|
||||
fs.renameSync(tempPath, DB_PATH);
|
||||
} catch (error) {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||||
try { if (oldPath && fs.existsSync(oldPath) && !fs.existsSync(DB_PATH)) fs.renameSync(oldPath, DB_PATH); } catch {}
|
||||
throw new CliError(`恢复失败,已尝试保留原数据库:${error.message}`);
|
||||
}
|
||||
print(args.json ? { message: '数据库已恢复', source: sourcePath, previousDatabase: oldPath } : `数据库已恢复:${sourcePath}\n原数据库保留为:${oldPath || '无'}。`, args);
|
||||
return;
|
||||
}
|
||||
throw new CliError(`未知 backup 子命令:${action}`);
|
||||
}
|
||||
|
||||
function runCommand(command, commandArgs, label) {
|
||||
console.log(`${label}:${command} ${commandArgs.join(' ')}`);
|
||||
try { execFileSync(command, commandArgs, { cwd: PROJECT_ROOT, stdio: 'inherit' }); }
|
||||
catch (error) { throw new CliError(`${label}失败(退出码 ${error.status ?? '未知'})`); }
|
||||
}
|
||||
|
||||
function getNpmCommand() {
|
||||
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
async function cmdUpgrade(args) {
|
||||
if (args.json) throw new CliError('upgrade 不支持 --json,请使用普通输出查看升级过程');
|
||||
if (!fs.existsSync(path.join(PROJECT_ROOT, '.git'))) throw new CliError('当前目录不是 git 仓库,无法升级');
|
||||
let status;
|
||||
try { status = execFileSync('git', ['status', '--porcelain'], { cwd: PROJECT_ROOT, encoding: 'utf8' }); }
|
||||
catch (error) { throw new CliError(`无法读取 git 工作区:${error.message}`); }
|
||||
if (status.trim()) throw new CliError('git 工作区不干净,已停止升级;请先处理本地修改或未跟踪文件');
|
||||
const backup = await createBackup();
|
||||
console.log(`升级前备份完成:${backup.file}`);
|
||||
const wasRunning = !!getOwnedServer();
|
||||
if (wasRunning) await stopServer();
|
||||
try {
|
||||
runCommand('git', ['pull', '--ff-only'], 'git 快进更新');
|
||||
runCommand(getNpmCommand(), ['install'], '依赖安装');
|
||||
runCommand(getNpmCommand(), ['run', 'build'], '前端构建');
|
||||
if (wasRunning) {
|
||||
await startServer();
|
||||
const health = await cmdHealth({ json: false });
|
||||
if (health !== 0) throw new CliError('升级后健康检查未通过');
|
||||
}
|
||||
} catch (error) {
|
||||
if (wasRunning) {
|
||||
let running = false;
|
||||
try { running = !!getOwnedServer(); } catch {}
|
||||
if (!running) {
|
||||
try { await startServer(); } catch (restartError) { console.error(`升级失败后恢复启动也失败:${restartError.message}`); }
|
||||
}
|
||||
}
|
||||
throw error instanceof CliError ? error : new CliError(error.message);
|
||||
}
|
||||
console.log('升级完成。');
|
||||
}
|
||||
|
||||
async function cmdPortlessStart() { await startServer(); }
|
||||
async function cmdPortlessStop() { await stopServer(); }
|
||||
async function cmdPortlessRestart() { await stopServer(); await startServer(); }
|
||||
|
||||
async function warnIfServerRunning() {
|
||||
let owned = null;
|
||||
try { owned = getOwnedServer(); } catch {}
|
||||
if (owned) console.log('警告:server 正在运行,并发写库可能等待;建议先停止服务。');
|
||||
}
|
||||
|
||||
async function dispatch(args) {
|
||||
const command = args.positionals[0] || 'help';
|
||||
switch (command) {
|
||||
case 'status': return cmdStatus(args);
|
||||
case 'health': return cmdHealth(args);
|
||||
case 'db-check': return cmdDbCheck(args);
|
||||
case 'start': return cmdPortlessStart(args);
|
||||
case 'stop': return cmdPortlessStop(args);
|
||||
case 'restart': return cmdPortlessRestart(args);
|
||||
case 'port': return cmdPort(args);
|
||||
case 'password': return cmdPassword(args);
|
||||
case 'captcha': return cmdCaptcha(args);
|
||||
case 'config': return cmdConfig(args);
|
||||
case 'backup': return cmdBackup(args);
|
||||
case 'upgrade': return cmdUpgrade(args);
|
||||
case 'help': return console.log(HELP);
|
||||
default: throw new CliError(`未知命令:${command}\n\n${HELP}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const result = await dispatch(args);
|
||||
if (Number.isInteger(result)) process.exitCode = result;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const exitCode = error.exitCode || 1;
|
||||
if (process.argv.includes('--json')) console.error(JSON.stringify({ error: error.message, exitCode }));
|
||||
else console.error(`错误:${error.message}`);
|
||||
process.exitCode = exitCode;
|
||||
});
|
||||
|
||||
@@ -1,103 +1,116 @@
|
||||
const initSqlJs = require('sql.js');
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data', 'rainweb.db');
|
||||
const DATA_DIR = path.dirname(DB_PATH);
|
||||
let db = null;
|
||||
|
||||
async function getDb() {
|
||||
function getDb() {
|
||||
if (db) return db;
|
||||
const SQL = await initSqlJs();
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
db = new SQL.Database(buffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
db = new Database(DB_PATH);
|
||||
initTables();
|
||||
migrateSchema();
|
||||
seedAdmin();
|
||||
seedDefaults();
|
||||
seedSampleData();
|
||||
saveDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
function saveDb() {
|
||||
if (!db) return;
|
||||
const data = db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
}
|
||||
|
||||
function initTables() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
|
||||
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
|
||||
nickname TEXT DEFAULT '', title TEXT DEFAULT '', title_color TEXT DEFAULT '', website TEXT DEFAULT '',
|
||||
bio TEXT DEFAULT '', last_active_at TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS pending_users (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS pending_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
// M2:启动时清理超 15 分钟的 pending 注册记录(验证码过期,防表无限增长 / 验证码长期间可试)
|
||||
db.exec("DELETE FROM pending_users WHERE created_at < datetime('now','-15 minutes')");
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS admin_links (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS admin_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
|
||||
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
|
||||
proxy_headers TEXT DEFAULT '', proxy_skip_tls_verify INTEGER DEFAULT 0,
|
||||
permissions TEXT DEFAULT '', scale REAL DEFAULT 1.0, trusted INTEGER DEFAULT 1,
|
||||
slug TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
// Migration: add columns if missing
|
||||
try { db.run('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.run('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS announcements (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS announcements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '',
|
||||
content TEXT NOT NULL, active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL,
|
||||
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)`);
|
||||
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0,
|
||||
announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`);
|
||||
try { db.exec('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_categories ADD COLUMN sub_categories TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_posts (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL,
|
||||
use_markdown INTEGER DEFAULT 1,
|
||||
use_markdown INTEGER DEFAULT 1, sub_category TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
try { db.run('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blog_comments (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS blog_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL, author_id INTEGER,
|
||||
author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')),
|
||||
author_name TEXT DEFAULT '', parent_id INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'approved',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (post_id) REFERENCES blog_posts(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL, author_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (post_id) REFERENCES forum_posts(id),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||
content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL,
|
||||
published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1,
|
||||
tags TEXT DEFAULT '', views INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS post_likes (
|
||||
post_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (post_id, user_id))`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL,
|
||||
pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS password_entries (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS password_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, username TEXT DEFAULT '',
|
||||
encrypted_password TEXT NOT NULL, url TEXT DEFAULT '', notes TEXT DEFAULT '',
|
||||
@@ -105,7 +118,7 @@ function initTables() {
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS attachments (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL, size INTEGER NOT NULL,
|
||||
mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL,
|
||||
@@ -113,15 +126,288 @@ function initTables() {
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
|
||||
|
||||
// 审计日志表(终端会话/密码失败等安全事件落库)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
user_id INTEGER DEFAULT 0,
|
||||
username TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)');
|
||||
// 审计保留策略:启动时惰性清理 90 天前的记录,防止无限增长
|
||||
//(写入路径上 terminal.js 每 50 条也会触发一次同规则清理)
|
||||
db.exec("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
|
||||
|
||||
// Indexes for performance
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)');
|
||||
|
||||
// Legacy migrations for old database compatibility
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec('ALTER TABLE blog_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
}
|
||||
|
||||
// 版本化迁移框架:PRAGMA user_version 记录当前 schema 版本,
|
||||
// migrations 数组按 version 升序执行,仅当 current < m.version 时运行并推进版本号。
|
||||
// 现有 try/catch ALTER 保留作为"确保列存在"的幂等层,未来新列迁移写入 migrations。
|
||||
function migrateSchema() {
|
||||
const current = db.pragma('user_version', { simple: true });
|
||||
const migrations = [
|
||||
// v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑,
|
||||
// 默认 100000:存量用户保持旧迭代数,旧密文仍可解密)
|
||||
{ version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } },
|
||||
// v2: 博客增强——博文标签/阅读量、评论嵌套与审核、点赞表
|
||||
{ version: 2, up: () => {
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN tags TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN views INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
|
||||
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
|
||||
} },
|
||||
// v3: RainID 单点登录——影子账号绑定键(sub)。部分唯一索引:空串(本地账号)不参与唯一约束
|
||||
{ version: 3, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''"); } catch {}
|
||||
} },
|
||||
// v4: 论坛贴吧升级——置顶/加精/版主/图标
|
||||
{ version: 4, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_pinned INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_essence INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon_color TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_moderators (
|
||||
category_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (category_id, user_id),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_posts_cat ON forum_posts(category_id, is_pinned DESC, created_at DESC)"); } catch {}
|
||||
} },
|
||||
// v5: 论坛版块级禁言
|
||||
{ version: 5, up: () => {
|
||||
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_mutes (
|
||||
category_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
muted_until DATETIME, -- NULL = 永久
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
created_by INTEGER NOT NULL,
|
||||
PRIMARY KEY (category_id, user_id),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_mutes_cat ON forum_mutes(category_id)"); } catch {}
|
||||
} },
|
||||
// v6: RSS 版块级订阅——版块 feed 开关
|
||||
{ version: 6, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN feed_enabled INTEGER DEFAULT 1"); } catch {}
|
||||
} },
|
||||
// v7: 论坛帖子二次编辑计数
|
||||
{ version: 7, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN edit_count INTEGER DEFAULT 0"); } catch {}
|
||||
} },
|
||||
// v8: markdown [lock:] 锁定——博客/论坛帖子存锁定元数据 JSON([{type, hash}],不含块原文)
|
||||
{ version: 8, up: () => {
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
|
||||
} },
|
||||
// v9: 用户 QQ 号字段(头像优先级:自传 > QQ 头像 > RainID 头像)
|
||||
{ version: 9, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN qq TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v10: 公开个人主页——用户个性签名
|
||||
{ version: 10, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v11: 公开个人主页——最后活跃时间(datetime 字符串,跨天更新)
|
||||
{ version: 11, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN last_active_at TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v12: 用户对外昵称(显示名:nickname||username)
|
||||
{ version: 12, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN nickname TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v13: 自定义头衔 + 头衔颜色(帖子/评论作者位展示)
|
||||
{ version: 13, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN title TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN title_color TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v14: 个人博客链接(公开主页展示)
|
||||
{ version: 14, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN website TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v15: 工作台 SPA 代理——面板链接加代理配置列
|
||||
// (proxy_headers=转发自定义头 JSON、proxy_skip_tls_verify、permissions=iframe 委托权限 JSON 数组、
|
||||
// scale=嵌入缩放、trusted=信任模型(1 保留 allow-same-origin)、slug=唯一标识)
|
||||
{ version: 15, up: () => {
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_headers TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_skip_tls_verify INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN permissions TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN scale REAL DEFAULT 1.0"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v16: 站内工单——用户反馈、公开回复、内部备注及业务事件
|
||||
{ version: 16, up: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_no TEXT NOT NULL UNIQUE,
|
||||
requester_id INTEGER,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
source TEXT NOT NULL DEFAULT 'site',
|
||||
source_url TEXT DEFAULT '',
|
||||
source_type TEXT DEFAULT '',
|
||||
source_id INTEGER DEFAULT 0,
|
||||
browser_info TEXT DEFAULT '',
|
||||
assignee_id INTEGER,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
last_reply_at DATETIME DEFAULT NULL,
|
||||
first_response_at DATETIME,
|
||||
resolved_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
author_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
actor_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
field_name TEXT DEFAULT '',
|
||||
old_value TEXT DEFAULT '',
|
||||
new_value TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } catch {}
|
||||
} },
|
||||
// v17: 工单并发版本与回复时间修正;为已存在的部分工单表补齐字段
|
||||
{ version: 17, up: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_no TEXT NOT NULL UNIQUE,
|
||||
requester_id INTEGER,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
source TEXT NOT NULL DEFAULT 'site',
|
||||
source_url TEXT DEFAULT '',
|
||||
source_type TEXT DEFAULT '',
|
||||
source_id INTEGER DEFAULT 0,
|
||||
browser_info TEXT DEFAULT '',
|
||||
assignee_id INTEGER,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
last_reply_at DATETIME DEFAULT NULL,
|
||||
first_response_at DATETIME,
|
||||
resolved_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
author_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
actor_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
field_name TEXT DEFAULT '',
|
||||
old_value TEXT DEFAULT '',
|
||||
new_value TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
const ensureColumns = (table, columns) => {
|
||||
const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
||||
for (const [name, definition] of columns) {
|
||||
if (!existing.has(name)) {
|
||||
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
ensureColumns('tickets', [
|
||||
['ticket_no', "TEXT NOT NULL DEFAULT ''"], ['requester_id', 'INTEGER'],
|
||||
['subject', "TEXT NOT NULL DEFAULT ''"], ['description', "TEXT NOT NULL DEFAULT ''"],
|
||||
['category', "TEXT NOT NULL DEFAULT 'other'"], ['priority', "TEXT NOT NULL DEFAULT 'normal'"],
|
||||
['status', "TEXT NOT NULL DEFAULT 'open'"], ['source', "TEXT NOT NULL DEFAULT 'site'"],
|
||||
['source_url', "TEXT DEFAULT ''"], ['source_type', "TEXT DEFAULT ''"], ['source_id', 'INTEGER DEFAULT 0'],
|
||||
['browser_info', "TEXT DEFAULT ''"], ['assignee_id', 'INTEGER'],
|
||||
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||
['last_reply_at', 'DATETIME DEFAULT NULL'], ['first_response_at', 'DATETIME DEFAULT NULL'],
|
||||
['resolved_at', 'DATETIME DEFAULT NULL'], ['closed_at', 'DATETIME DEFAULT NULL'],
|
||||
['revision', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
]);
|
||||
ensureColumns('ticket_messages', [
|
||||
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['author_id', 'INTEGER'],
|
||||
['content', "TEXT NOT NULL DEFAULT ''"], ['is_internal', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||
]);
|
||||
ensureColumns('ticket_events', [
|
||||
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['actor_id', 'INTEGER'],
|
||||
['event_type', "TEXT NOT NULL DEFAULT ''"], ['field_name', "TEXT DEFAULT ''"],
|
||||
['old_value', "TEXT DEFAULT ''"], ['new_value', "TEXT DEFAULT ''"],
|
||||
['detail', "TEXT DEFAULT ''"], ['created_at', 'DATETIME DEFAULT NULL'],
|
||||
]);
|
||||
db.exec(`UPDATE tickets SET last_reply_at = NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_messages.ticket_id = tickets.id)`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)');
|
||||
} },
|
||||
];
|
||||
for (const m of migrations) {
|
||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin() {
|
||||
@@ -136,9 +422,12 @@ function seedDefaults() {
|
||||
const defaults = {
|
||||
site_name: 'RainWeb',
|
||||
site_description: '个人云平台',
|
||||
site_url: '',
|
||||
primary_color: '#6750a4',
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: '',
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
smtp_host: '',
|
||||
smtp_port: '587',
|
||||
smtp_user: '',
|
||||
@@ -147,6 +436,7 @@ function seedDefaults() {
|
||||
smtp_from_name: 'RainWeb',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: '1',
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
@@ -155,6 +445,33 @@ function seedDefaults() {
|
||||
captcha_register: '0',
|
||||
captcha_forum: '0',
|
||||
captcha_type: 'builtin',
|
||||
rainid_enabled: '0',
|
||||
rainid_client_id: '',
|
||||
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: '0',
|
||||
site_favicon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>',
|
||||
music_embed_enabled: '0',
|
||||
music_embed_code: '',
|
||||
music_embed_position: 'right',
|
||||
music_embed_autohide: '0',
|
||||
music_embed_idle_timeout: '10',
|
||||
footer_style: 'classic',
|
||||
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
|
||||
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
|
||||
footer_desc: '',
|
||||
// 页脚栏目自定义:JSON 字符串 [{title, links:[{label,url}]}];空串时前台回退现有硬编码栏目
|
||||
footer_columns: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
// 评论等次要作者位是否显示 UID 后缀('1'=显示,'0'=隐藏)
|
||||
show_uid_in_comments: '1',
|
||||
// 论坛游客可见开关:'1'=游客可浏览论坛;'0'=游客需登录(读接口 401 + SSR 404,SEO 权衡)
|
||||
forum_guest_visible: '1',
|
||||
// RSS 订阅源:feed_forum_enabled='1' 才对外暴露论坛 feed(默认关,用户没开时不暴露);
|
||||
// feed_show_full='1'=全文 / '0'=摘要;feed_max_items 为每源条数上限
|
||||
feed_forum_enabled: '0',
|
||||
feed_show_full: '0',
|
||||
feed_max_items: '20',
|
||||
};
|
||||
for (const [k, v] of Object.entries(defaults)) {
|
||||
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
|
||||
@@ -164,24 +481,32 @@ function seedDefaults() {
|
||||
}
|
||||
|
||||
function run(sql, params = []) {
|
||||
db.run(sql, params);
|
||||
const r = db.exec("SELECT last_insert_rowid()");
|
||||
const rowid = r && r[0] && r[0].values ? r[0].values[0][0] : 0;
|
||||
saveDb();
|
||||
return rowid;
|
||||
try {
|
||||
const info = db.prepare(sql).run(...params);
|
||||
return Number(info.lastInsertRowid) || 0;
|
||||
} catch (e) {
|
||||
console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function get(sql, params = []) {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
|
||||
stmt.free(); return null;
|
||||
try {
|
||||
const row = db.prepare(sql).get(...params);
|
||||
return row || null;
|
||||
} catch (e) {
|
||||
console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free(); return rows;
|
||||
try {
|
||||
return db.prepare(sql).all(...params);
|
||||
} catch (e) {
|
||||
console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getSetting(key) {
|
||||
@@ -195,6 +520,13 @@ function setSetting(key, value) {
|
||||
else run('INSERT INTO site_settings (key, value) VALUES (?, ?)', [key, value]);
|
||||
}
|
||||
|
||||
// 事务辅助:多步写操作原子执行(better-sqlite3 原生事务;失败自动 ROLLBACK)
|
||||
// 供 L8 改 PIN 重加密、以及任何需要"要么全成要么全不"的批量写场景使用。
|
||||
function transaction(fn) {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
return db.transaction(fn)();
|
||||
}
|
||||
|
||||
function seedSampleData() {
|
||||
const existing = get("SELECT COUNT(*) as c FROM forum_categories");
|
||||
if (existing && existing.c > 0) return;
|
||||
@@ -208,8 +540,8 @@ function seedSampleData() {
|
||||
if (adminUser) {
|
||||
const aid = adminUser.id;
|
||||
// Forum posts
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid]);
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid]);
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid, '分享']);
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid, '讨论']);
|
||||
|
||||
// Blog posts
|
||||
const blogMarkdown = `## 欢迎使用 RainWeb\n\nRainWeb 是一个多功能的个人云平台,集成了 **博客、论坛、密码管理器** 等功能。\n\n- 🎨 Material Design 3 风格\n- 🌓 深色/浅色主题切换\n- 📧 邮箱验证注册\n- 🔒 密码管理器 (AES-256-GCM 加密)\n\n### 快速开始\n\n1. 点击右上角「登录」使用默认账号 \`admin / admin123\`\n2. 在管理后台配置 SMTP 邮件和 reCAPTCHA\n3. 在「面板链接」中添加你的各个管理后台\n4. 发布你的第一篇博客文章!`;
|
||||
@@ -229,4 +561,4 @@ function seedSampleData() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDb, run, get, all, getSetting, setSetting };
|
||||
module.exports = { getDb, run, get, all, getSetting, setSetting, transaction };
|
||||
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
@echo off
|
||||
title RainWeb Deploy
|
||||
|
||||
set REPO_URL=https://github.com/Xianyunah/rainwebblog.git
|
||||
set INSTALL_DIR=rainweb
|
||||
|
||||
echo ====================================
|
||||
echo RainWeb - One-Click Deploy
|
||||
echo Repo: %REPO_URL%
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
REM Check Node.js
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Node.js is not installed. Install from https://nodejs.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Node.js:
|
||||
node -v
|
||||
|
||||
REM Check git
|
||||
where git >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Git is not installed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Clone or pull
|
||||
if exist "%INSTALL_DIR%\.git" (
|
||||
echo [1/4] Updating existing installation...
|
||||
cd "%INSTALL_DIR%"
|
||||
git pull
|
||||
) else (
|
||||
echo [1/4] Cloning repository...
|
||||
git clone "%REPO_URL%" "%INSTALL_DIR%"
|
||||
cd "%INSTALL_DIR%"
|
||||
)
|
||||
|
||||
REM Install dependencies
|
||||
echo.
|
||||
echo [2/4] Installing dependencies...
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] npm install failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Dependencies installed.
|
||||
|
||||
REM Create wallpaper folder
|
||||
if not exist "public\wallpaper" mkdir "public\wallpaper"
|
||||
|
||||
REM Start
|
||||
echo.
|
||||
echo [3/4] Starting server...
|
||||
echo.
|
||||
echo ====================================
|
||||
echo Open http://localhost:3001
|
||||
echo Default admin: admin / admin123
|
||||
echo ====================================
|
||||
echo.
|
||||
start "" http://localhost:3001
|
||||
node server.js
|
||||
|
||||
pause
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
REPO_URL="https://github.com/Xianyunah/rainwebblog.git"
|
||||
INSTALL_DIR="${1:-rainweb}"
|
||||
|
||||
echo "===================================="
|
||||
echo " RainWeb - One-Click Deploy"
|
||||
echo " Repo: $REPO_URL"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Check Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "[ERROR] Node.js is not installed. Install from https://nodejs.org (LTS 20.x+)"
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Node.js: $(node -v)"
|
||||
|
||||
# Check git
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "[ERROR] Git is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clone or pull
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
echo "[1/4] Updating existing installation..."
|
||||
cd "$INSTALL_DIR"
|
||||
git pull
|
||||
else
|
||||
echo "[1/4] Cloning repository..."
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
echo ""
|
||||
echo "[2/4] Installing dependencies..."
|
||||
npm install --production
|
||||
echo "[OK] Dependencies installed."
|
||||
|
||||
# Create wallpaper directory
|
||||
mkdir -p public/wallpaper
|
||||
|
||||
# Start
|
||||
echo ""
|
||||
echo "[3/4] Starting server..."
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo " Open http://localhost:3001"
|
||||
echo " Default admin: admin / admin123"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
if command -v xdg-open &> /dev/null; then
|
||||
xdg-open http://localhost:3001 2>/dev/null || true
|
||||
elif command -v open &> /dev/null; then
|
||||
open http://localhost:3001 2>/dev/null || true
|
||||
fi
|
||||
|
||||
node server.js
|
||||
@@ -0,0 +1,231 @@
|
||||
# RainID OIDC 单点登录接入指南(通用版)
|
||||
|
||||
> **本文档性质**:架构审计视角的通用接入指导。不绑定任何技术栈,面向 RainVideo / 雨测 / 工具箱 / RainnyaAI / 新应用的接入方。
|
||||
> **参考实现**:RainWeb(已完整接入,含踩坑记录)——本文多处标注「RainWeb 案例」。
|
||||
> **核心原则(RainID 侧铁律)**:RainID 只做标准 OIDC/OAuth2,不发明私有协议。各站只需一个 `discovery_url` 即可接入,但**接入质量(验签、绑定、登出、吊销处理)完全由各站负责**——标准协议不替你兜底。
|
||||
|
||||
---
|
||||
|
||||
## 1. 接入前必读
|
||||
|
||||
### 1.1 RainID 的架构定位
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 职责边界 | 只做**身份认证**(认证你是谁):登录、2FA、会话、密码、邮箱验证。**不做授权**(你能干什么):角色/权限/数据归属由各站本地维护 |
|
||||
| 集成面 | 一个 `discovery_url`(`https://rainid.rainnya.asia/oauth`)+ 标准 OIDC 库,自动发现全部端点 |
|
||||
| token 形态 | id_token(RS256 JWT,5 分钟,**只含 `sub`**);access_token(**opaque 不透明**,60 分钟,仅供 userinfo 消费,**不要解析**);refresh_token(30 天,**每次使用即轮换**) |
|
||||
| 绑定键 | `sub`——稳定、永不变更、跨应用唯一的用户标识。**所有绑定以 sub 为准** |
|
||||
| 会话 | RainID 会话(签名 cookie,HttpOnly+SameSite=Lax)**不共享给各站**。各站只持有自己的会话 + refresh token |
|
||||
|
||||
**架构含义**:你的应用不会"成为 RainID 的一部分",而是「信任 RainID 的认证结果 + 自己管理授权数据」。身份数据源在 RainID,业务数据源在你。**边界越清晰,迁移成本越低。**
|
||||
|
||||
### 1.2 client 登记要求(Admin 后台)
|
||||
|
||||
| 字段 | 要求 | 风险点 |
|
||||
|---|---|---|
|
||||
| `confidential` | 必须机密 client(有 secret)。**secret 明文仅创建/轮换时返回一次** | 创建后立即存入服务端配置,绝不进前端代码/日志/Git |
|
||||
| `redirect_uris` | 授权码回调白名单,每行一个,**精确匹配** | 未登记即拒绝(open redirect 防线)——这是 RainID 侧强制,各站回调处理也**不要接受任意 redirect_uri** |
|
||||
| `grant_types` | `authorization_code`+`refresh_token` / `password` / `client_credentials`,按 §2 决策树选 | 最小化:用不到的 grant 不登记 |
|
||||
| `require_pkce` | 默认 true,**建议强制**(防授权码注入) | 机密 client 也建议保留 |
|
||||
| `scope` | 白名单:请求的 OIDC scope 必须 ⊆ 白名单,否则 `invalid_scope` | 只申请需要的 scope |
|
||||
| `post_logout_redirect_uris` | 登出回跳白名单 | 未登记 → 400,登出流程断链 |
|
||||
|
||||
**创建后立即生效(热更新,无需重启 RainID)。**
|
||||
|
||||
### 1.3 必读的 RainID 行为(影响你的架构设计)
|
||||
|
||||
1. **id_token 只含 sub**(OIDC Core 5.4 语义):`name`/`email`/`picture` 必须调 userinfo(`/oauth/me`)获取。**不要假设 id_token 里有人名邮箱**。
|
||||
2. **refresh 轮换 + 复用检测**:旧 refresh 一用即废;已消费的 refresh 被再次使用 → 整条 grant 链吊销(按 grantId,不误伤其他 client)。→ **各站 BFF 必须存最新 refresh,且并发刷新要加锁/串行**(并发用同一 refresh 会触发吊销)。
|
||||
3. **账号事件全链吊销**:改密/换绑/2FA 变更/封禁 → 该用户全部 refresh 立即失效。→ 各站必须把「refresh 返回 400」当成**常态事件**处理:清会话 → 重新走登录。
|
||||
4. **无 front-channel / backchannel logout(oidc-provider v9 限制)**:全局登出由**各站 BFF 各自调 end_session** 承担。→ 单点登出不是自动的,需要各站配合,接入时必须明确这一点(否则用户会投诉"登出一个站别的站还登着")。
|
||||
5. **ROPC 的 2FA 硬限制**:2FA 用户一律拒绝 ROPC。→ 你的登录 UI 必须同时支持「密码直连」与「跳 RainID 登录页」两条路径,ROPC 被拒时引导跳页。
|
||||
|
||||
---
|
||||
|
||||
## 2. 协议选型决策树
|
||||
|
||||
```
|
||||
有浏览器吗?
|
||||
├── 有 → 授权码 + PKCE(唯一正解)
|
||||
│ ├── SSR 传统渲染 → 服务端持有 code_verifier/state/session
|
||||
│ └── SPA → 必须 BFF(后端持 refresh,前端只拿短期 access + 自身 cookie)
|
||||
├── 没有浏览器,但替用户登录(用户在你的登录页输 RainID 账号密码)→ ROPC(仅服务端)
|
||||
└── 没有浏览器,也不替用户(服务间调用)→ client_credentials
|
||||
```
|
||||
|
||||
| 场景 | 协议 | 产物 | 关键约束 |
|
||||
|---|---|---|---|
|
||||
| 有浏览器(RainVideo/雨测/工具箱/新应用主流) | 授权码 + PKCE | access + id_token + refresh | state 防 CSRF、PKCE 强制、SPA 必须 BFF |
|
||||
| 服务端代理登录(沿用自家登录页形态) | ROPC | 同上 | 机密 client + **密码直连白名单** + 非 2FA 用户 |
|
||||
| 服务间授权(RainnyaAI 调各站 API) | client_credentials | access(无 sub,30 分钟) | 机密 client + 业务 scope 需 RainID 侧登记 |
|
||||
|
||||
### 各项目的推荐组合
|
||||
|
||||
| 项目 | 推荐 | 理由 |
|
||||
|---|---|---|
|
||||
| RainVideo | 授权码 + PKCE(SSR/BFF) | 有用户浏览,走标准流,天然支持 2FA |
|
||||
| 雨测 | 授权码 + PKCE | 同上;若登录页形态想保持自家风格可参考 RainWeb 的**双通道**(ROPC + 授权码并存) |
|
||||
| 工具箱 | 授权码 + PKCE | 同上 |
|
||||
| RainnyaAI | 授权码 + PKCE(面向用户)+ client_credentials(服务间) | 两种都要:用户登录走标准流,机器调用走 client_credentials |
|
||||
| 新应用 | 授权码 + PKCE,无脑默认 | 除非有明确的服务端代理需求才加 ROPC |
|
||||
|
||||
**架构决策点**:ROPC 是**妥协方案**(拿不到 2FA、凭据过手服务端、撞库面大),RainID 侧已收紧(白名单 + 共享账号锁 + 统一文案)。新项目能走授权码就**不要**为 ROPC 设计登录页。RainWeb 是历史形态(已有本地登录页)才保留 ROPC 双通道。
|
||||
|
||||
---
|
||||
|
||||
## 3. 账户体系整合模式(重点)
|
||||
|
||||
接入前必须回答的问题:**你的应用现在的用户数据怎么办?** 三个模式,按"本地自主权"从高到低:
|
||||
|
||||
### 模式 A:影子账号(推荐,RainWeb 采用)
|
||||
- **做法**:本地用户表新增一列 `rainid_user_id = sub`(唯一索引)。登录时查 sub → 有则登录,无则**自动创建**影子账号(用户名取 `preferred_username` 或 `rainid_<sub前8>`,密码为**随机不可登录**值,角色默认 user)。
|
||||
- **数据归属**:本地业务数据(帖子、评论、面板配置)挂在本影子账号的本地 id 上。
|
||||
- **优点**:改动最小、现有数据零迁移、角色/权限完全本地自治、可按 sub 重建账号。
|
||||
- **风险**:影子账号与 RainID 账号生命周期解耦(RainID 注销后影子账号还在,需自己定义展示策略);同名冲突(RainWeb 用 sub 前缀+后缀兜底)。
|
||||
- **RainWeb 案例**:`findOrCreateRainidUser` 三步——①按 sub 查;②`email_verified=true` 且本地有同 email 未绑定号 → 条件 UPDATE 自动绑定(**防撞绑闸门**);③新建(首个绑定用户自动 admin)。并发用唯一索引兜底重查。
|
||||
|
||||
### 模式 B:混合绑定(本地账号 + OIDC 绑定)
|
||||
- **做法**:保留本地注册/密码登录,OIDC 登录成功后按 `email_verified=true` 的 email 匹配本地账号并绑定 sub;未匹配则创建影子账号。
|
||||
- **优点**:老用户无感迁移(首次用 RainID 登录即绑定原账号);保留本地逃生通道。
|
||||
- **风险**:**邮箱撞绑**——必须只信任 RainID 已验证的邮箱(`email_verified`),否则攻击者注册同名邮箱即可接管他人账号(RainWeb 的 UPDATE 带 `AND rainid_user_id = ''` 条件 + email_verified 闸门);本地密码与 RainID 密码并存形成**双凭证面**,需明确优先级。
|
||||
- **RainWeb 案例**:auth.js 中**本地 admin 保留 bcrypt 逃生通道**(防 RainID 配置错误锁死后台),影子账号禁止本地密码登入(`rainid_user_id 非空且无 password → 拒绝`)——这是双凭证面治理的范本:**要么本地密码可用(真逃生通道),要么彻底禁用(影子账号),不允许"本地密码残留但功能正常"的中间态**。
|
||||
|
||||
### 模式 C:全托管(完全依赖 IdP)
|
||||
- **做法**:本地不存用户,只存 `sub` 列表或每次从 userinfo 实时取身份,权限也映射自 IdP claims。
|
||||
- **优点**:零账号管理。
|
||||
- **风险**:RainID 不管角色/授权(文档附录 A.8 明说)——你仍要本地映射授权;RainID 不可达时**全站不可登录**(无降级);数据归属必须挂在 sub 上(迁移/审计困难)。**RainID 当前不适合**(无自定义 claims 通道),新应用若选此模式要自行建立 sub→角色映射表——这其实就是模式 A 的退化版,不推荐。
|
||||
|
||||
### 对比与建议
|
||||
|
||||
| 维度 | A 影子账号 | B 混合绑定 | C 全托管 |
|
||||
|---|---|---|---|
|
||||
| 改动量 | 小 | 中 | 小 |
|
||||
| 老数据迁移 | 零(或按 email 一次性绑定) | 平滑 | 需重建 |
|
||||
| 本地自治权 | 高 | 中 | 低 |
|
||||
| 风险面 | 生命周期解耦 | 撞绑 + 双凭证 | 单点故障 + 授权真空 |
|
||||
| 适用 | **新应用、存量小的站** | 存量用户多的老站 | 不推荐 |
|
||||
|
||||
**推荐**:新应用一律模式 A。存量站用模式 B 但必须实现「email_verified 闸门 + 条件绑定 + 双凭证治理」。**任何模式下,角色/权限永远本地维护,绝不从 IdP 直接派生(除非你改 RainID)。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 安全清单(不可遗漏)
|
||||
|
||||
### 4.1 登录链路
|
||||
- [ ] **state 一次性**:发起登录生成随机 state,服务端存 session(限时),回调校验**并删除**(RainWeb 用内存 Map 10 分钟 TTL + delete)
|
||||
- [ ] **PKCE S256 强制**:code_verifier 只存服务端,code_challenge 走 authorize,交换时带 code_verifier
|
||||
- [ ] **redirect_uri 精确匹配**:回调处理用自己构建的完整回调地址(`site_url + /oauth/callback`)校验,不接受请求里的任意 redirect_uri
|
||||
- [ ] **id_token 验签三要素**:签名(RS256 + discovery 的 jwks_uri,多 key 轮换按 kid 自动取)+ **iss**(必须等于 discovery 的 issuer)+ **aud**(必须等于你的 client_id)+ exp。用 openid-client / jose 等标准库,**不要手写 JWT 解析**
|
||||
- [ ] **sub 缺失即拒绝**:id_token/userinfo 无 sub → 中止登录
|
||||
|
||||
### 4.2 凭证与 token 管理
|
||||
- [ ] **client_secret 只存服务端**:环境变量或不可读的后端设置表;明文仅创建时出现过一次
|
||||
- [ ] **token 不进 URL**:回调后不把 token 放 query 跳转前端;用**一次性短 TTL ticket**(RainWeb:30 秒 ticket 换本地 JWT)
|
||||
- [ ] **refresh 只存 BFF**:SPA 纯前端存 refresh = XSS 一键全丢(RainID 文档 §4.3 明令禁止)
|
||||
- [ ] **access_token 是 opaque**:不解析,只调 userinfo
|
||||
- [ ] **refresh 串行刷新**:并发用同一 refresh 会触发复用检测 → 整链吊销(BFF 加刷新锁)
|
||||
- [ ] **refresh 失败即登出**:捕获 `invalid_grant` → 清会话 → 重新走授权码登录(吊销联动是常态,不是异常)
|
||||
|
||||
### 4.3 用户与绑定
|
||||
- [ ] **邮箱撞绑防线**:仅当 `email_verified=true` 才允许按 email 自动绑定;绑定 UPDATE 带「未绑定」条件(`AND rainid_user_id = ''`)
|
||||
- [ ] **影子账号不可本地密码登入**:随机密码 + 登入路径禁止
|
||||
- [ ] **逃生通道**(可选但强烈建议):至少一个本地管理员账号保留独立密码,防 RainID 配置错误/不可达时锁死后台(RainWeb 案例)
|
||||
- [ ] **2FA 处理**:ROPC 被拒(固定文案提示 2FA)→ UI 引导走 RainID 登录页
|
||||
- [ ] **各站自限流**:RainID 有账号锁(5 次/15 分钟)+ IP 限流,但各站登录接口也要自限流(RainWeb:15 分钟 10 次)
|
||||
|
||||
### 4.4 登出与吊销
|
||||
- [ ] **登出联动**:各站登出 = 调 end_session(清 RainID 会话)+ 清本站会话 + 丢弃 refresh;`post_logout_redirect_uri` 先登记;`state` 原样校验
|
||||
- [ ] **主动吊销**(可选加固):登出时调 revocation 吊销本站持有的 refresh(双保险)
|
||||
- [ ] **吊销联动处理**:refresh 400 → 重新登录(覆盖改密/换绑/2FA 变更/封禁/撤销授权/client 轮换全场景)
|
||||
|
||||
---
|
||||
|
||||
## 5. 常见坑清单(RainWeb 实战踩坑汇总)
|
||||
|
||||
| # | 现象 | 根因 | 处理 |
|
||||
|---|---|---|---|
|
||||
| 1 | discovery 抛 `server must be an instance of URL` | openid-client v6 的 `discovery()` 第一个参数必须传 **URL 实例**,传字符串直接抛错 | `discovery(new URL(discovery_url), clientId, clientSecret)` |
|
||||
| 2 | 拿到 id_token 却没有 name/email | RainID `conformIdTokenClaims=true`:**id_token 只含 sub**,profile/email 走 userinfo | 调 `/oauth/me`(Bearer access_token),按 sub 校验 userinfo 与 id_token 一致性 |
|
||||
| 3 | 前端读不到相关设置(白屏/功能缺失) | RainID 侧 `PUBLIC_KEYS` 等配置项漏配,导致 discovery/JWKS 数据不完整 | 接入验收时**系统性核对** client 登记项与 RainID 侧环境变量(PUBLIC_KEYS/OAUTH_CUSTOM_SCOPES/密码直连白名单),不要只测 happy path |
|
||||
| 4 | 登出跳 RainID 后「卡在确认页」 | **预期行为**:登录态存在时 RainID 渲染退出确认页 → 点确认 → 303 回跳 | UI 文案告知用户"将在 RainID 确认退出";回跳地址必须先登记 |
|
||||
| 5 | ROPC 返回 `invalid_grant` 但密码没错 | client 不在 Admin「密码直连白名单」(或非机密 client) | 白名单加 client_id;错误文案与凭据失败一致(防枚举,别想着区分) |
|
||||
| 6 | ROPC 2FA 用户被拒 | 设计决策:2FA 用户一律拒绝 ROPC | UI 固定提示"该账号已开启二次验证,请通过 RainID 登录页登录",引导授权码流 |
|
||||
| 7 | 换不到 refresh | 没请求 `offline_access` 或未走同意页 | scope 加 `offline_access`,首次走完整同意流程 |
|
||||
| 8 | refresh 突然全部 400 | 用户改密/换绑/2FA 变更等**吊销事件** | BFF 捕获 → 清会话 → 重新登录(这是设计,不是 bug) |
|
||||
| 9 | 邮箱绑定被撞 | 未校验 `email_verified` 就按 email 绑定 | 仅验证过的邮箱可绑 + 条件 UPDATE + 唯一索引兜底 |
|
||||
| 10 | 影子账号锁死 / 后台进不去 | RainID 不可达且无本地逃生通道 | 保留本地 admin 逃生账号(RainWeb:admin 保留 bcrypt 密码) |
|
||||
| 11 | client_credentials 无 scope / invalid_client | 用了公共 client,或业务 scope 未在 `OAUTH_CUSTOM_SCOPES` 登记 | 机密 client + RainID 侧登记 scope |
|
||||
| 12 | 授权码回调没带 state 或 state 复用 | 漏传/不校验/不删除 | state 必带、回调校验、**一次性消费** |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收 Checklist(可直接勾选)
|
||||
|
||||
### 6.1 授权码 + PKCE(所有有浏览器的站必过)
|
||||
- [ ] 完整流程:注册(RainID)→ 登录 → 同意 → 回调 → code 换 token → 验签(iss+aud+exp)→ userinfo → 建影子账号
|
||||
- [ ] state:回调不带 state / state 错误 / state 复用 → 全部拒绝
|
||||
- [ ] PKCE:缺 code_challenge → `invalid_request`;code_verifier 错误 → 换 token 失败
|
||||
- [ ] redirect_uri:未登记的 → 400(RainID 拒绝)
|
||||
- [ ] id_token:篡改签名 / 换 iss / 换 aud → 验签失败拒绝登录
|
||||
- [ ] id_token 只含 sub 时,userinfo 正常取到 name/email/email_verified
|
||||
- [ ] refresh 轮换:用一次 refresh → 旧 refresh 再发 → `invalid_grant`,新 refresh 可用
|
||||
- [ ] 2FA 用户:登录两步验证通过
|
||||
- [ ] 错误路径:同意页拒绝(`access_denied`)/ `prompt=none` 未同意(`consent_required`)→ 各站有合理 UI 反馈
|
||||
|
||||
### 6.2 ROPC(仅选用的站)
|
||||
- [ ] 正确凭据 → access + id_token + userinfo
|
||||
- [ ] 错误密码 → 统一文案(不区分账号不存在)
|
||||
- [ ] 2FA 用户 → `invalid_grant` + 固定文案 → UI 引导 RainID 登录页
|
||||
- [ ] 非白名单 client → `invalid_grant`
|
||||
- [ ] 连续失败 → 429(RainID 侧)+ 各站自限流生效
|
||||
|
||||
### 6.3 client_credentials(RainnyaAI 等)
|
||||
- [ ] 机密 client → access + 正确 scope
|
||||
- [ ] 公共 client → `invalid_client`
|
||||
- [ ] 未登记 scope → 拒绝/丢弃
|
||||
- [ ] 服务端校验 scope 后再执行业务操作
|
||||
|
||||
### 6.4 登出
|
||||
- [ ] 登出 → end_session 确认页 → 303 回跳(`post_logout_redirect_uri` + state 透传)
|
||||
- [ ] 未登记回跳 → 400
|
||||
- [ ] 各站自身会话 + refresh 清理
|
||||
- [ ] 「登出一个站,其他站仍登录」——**确认这是预期**(v9 无 front-channel logout,全局登出需各站 BFF 各自调 end_session)
|
||||
|
||||
### 6.5 吊销联动(最容易被漏,但必须过)
|
||||
- [ ] RainID 改密 → 该站旧 refresh 立即失效 → 站内自动重新登录流程触发
|
||||
- [ ] RainID 撤销该应用授权 → 仅本站 refresh 失效(其他 client 不受影响)
|
||||
- [ ] client secret 轮换后 → 旧 secret 的 refresh 全部失效
|
||||
- [ ] RainID 封禁/注销 → 登录入口拒绝
|
||||
|
||||
### 6.6 安全回归
|
||||
- [ ] client_secret 未出现在前端代码 / 日志 / 浏览器网络面板
|
||||
- [ ] token 未出现在 URL
|
||||
- [ ] 影子账号无本地密码可登录
|
||||
- [ ] 邮箱自动绑定仅在 email_verified=true 时发生
|
||||
|
||||
---
|
||||
|
||||
## 附:RainWeb 架构速览(可对照参考)
|
||||
|
||||
```
|
||||
登录入口(auth.js /login)
|
||||
├─ RainID 启用 → admin 逃生(本地 bcrypt)| ROPC(lib/rainid.js → 影子账号)
|
||||
└─ 未启用 → 本地 bcrypt(影子账号禁止)
|
||||
OIDC 流(routes/oidc.js)
|
||||
login(302 授权码+PKCE) → callback(验签→userinfo→影子账号→JWT→30s ticket) → exchange(ticket→本地JWT)
|
||||
logout → end_session 联动
|
||||
共享逻辑(lib/rainid.js)
|
||||
配置 fail-closed(enabled 需 3 项齐全)| discovery 缓存(URL 实例)| 影子账号三查三建 | 错误映射表
|
||||
```
|
||||
|
||||
**关键架构决策提炼(各站可抄)**:
|
||||
1. **fail-closed 配置**:OIDC 配置不全时视为未启用,本地登录不受影响(降级安全)。
|
||||
2. **ticket 换 JWT**:长 token 不进 URL(30 秒一次性 ticket)。
|
||||
3. **影子账号不可本地登入** + **admin 逃生通道**:双凭证面治理。
|
||||
4. **email_verified 撞绑闸门** + 条件 UPDATE + 唯一索引并发兜底。
|
||||
5. **错误映射表集中管理**:`invalid_grant` 区分 2FA 文案与防枚举文案;`invalid_client/scope/request` 视为配置错误(500),不暴露给用户。
|
||||
|
||||
---
|
||||
|
||||
*本文基于 RainID v0.1.4 对接文档 + RainWeb 接入实现撰写。协议细节以 RainID 官方文档为准(端点/token 行为/错误码若有更新,本文相应部分需复核)。*
|
||||
@@ -0,0 +1,629 @@
|
||||
# 任意项目接入 RainID(OIDC)实操指南
|
||||
|
||||
> 视角:**实现 / 落地**。不是协议科普,而是"从 0 到 1 动手做完 + 线上能跑"的操作手册。
|
||||
> 素材来源:RainWeb × RainID 真实接入(`lib/rainid.js`、`routes/oidc.js`、`routes/auth.js`、`routes/settings.js`、前端 `Login.jsx`/`Register.jsx`),已上线并排过障。
|
||||
> 技术栈:Node.js / Express / openid-client v6(函数式 API)。**每个模式旁标注"其他栈同理"**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:一个站点接 RainID 需要什么
|
||||
|
||||
```
|
||||
用户 ──→ 你的前端登录页
|
||||
├─ 方式 A(ROPC):输 RainID 账号密码 → 你的后端 POST rainid /oauth/token (password)
|
||||
│ → 按 sub 建/找影子账号 → 发你的本地 JWT
|
||||
└─ 方式 B(授权码+PKCE):点"使用 RainID 登录" → 你的后端 302 到 RainID
|
||||
→ RainID 登录/同意 → 回跳你的 callback
|
||||
→ 后端换 token + userinfo → 影子账号 → 一次性 ticket
|
||||
→ 前端拿 ticket 换本地 JWT(长 token 不进 URL)
|
||||
登出:你的后端 302 到 RainID end_session → RainID 确认销毁 IdP 会话 → 303 回跳你的页面
|
||||
```
|
||||
|
||||
**核心心智模型**:
|
||||
1. **RainID 只认标准 OIDC/OAuth2**,你的项目只需要一个 `discovery_url`。
|
||||
2. **身份归属 RainID,角色归属你**——影子账号按 `sub` 绑定,admin 等角色在你的本地库维护。
|
||||
3. **access_token 是 opaque,id_token 只有 `sub`**——想要 email/name 必须调 userinfo 端点。
|
||||
4. **你的会话是 JWT/session(本地),RainID 的会话是它的签名 cookie,两者不共享**。登出联动靠 end_session。
|
||||
|
||||
---
|
||||
|
||||
## 1. Step-by-step 接入步骤
|
||||
|
||||
### Step 1:RainID Admin 登记 client(前置,5 分钟)
|
||||
|
||||
RainID Admin → 应用管理 → 新增应用,填:
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `name` | 你的站点名(如 RainWeb) | 展示名 |
|
||||
| `confidential` | ✅ 机密 client | 拿到 `client_secret`;**明文只在创建/轮换时返回一次,立即存好** |
|
||||
| `redirect_uris` | `https://你的域名/api/auth/oidc/callback` | 授权码回跳白名单,**每行一个,未登记直接拒绝** |
|
||||
| `grant_types` | `authorization_code` + `refresh_token`(+ `password` 若要 ROPC) | 按协议选 |
|
||||
| `require_pkce` | ✅ 强制(默认) | 防授权码注入 |
|
||||
| `scope` | `openid profile email offline_access` | 白名单必须覆盖你请求的 scope,否则 `invalid_scope` |
|
||||
| `post_logout_redirect_uris` | `https://你的域名/login.html` | **登出回跳白名单,忘了登记 end_session 就 400** |
|
||||
|
||||
创建后**立即生效**(client 热更新,无需重启 RainID)。
|
||||
|
||||
> 踩过:登出配置好了但 `post_logout_redirect_uri` 没登记 → RainID 返回 400,用户登出卡死。
|
||||
|
||||
### Step 2:装依赖
|
||||
|
||||
```bash
|
||||
npm install openid-client # Node 版;浏览器端等价物见下
|
||||
npm install jose # 仅当你绕过 SDK 手动验签时(一般不需要,SDK 全包)
|
||||
```
|
||||
|
||||
- **Node**:`openid-client` v6(函数式 API,本项目用的就是它)。
|
||||
- **Python**:`authlib`;**Go**:`coreos/go-oidc`;**Java**:`spring-security-oauth2-client`。都是标准 OIDC,照着下面模式映射即可。
|
||||
- **浏览器纯前端(SPA)**:不要 `oidc-client-ts` 全放前端!**SPA 必须 BFF**(后端代理),禁止前端持有 refresh_token。
|
||||
|
||||
### Step 3:配置项(先想清楚再写代码)
|
||||
|
||||
分两档(详见第 3 章):
|
||||
|
||||
- **非敏感(可后台热改、可读)**:`rainid_enabled`、`rainid_client_id`、`rainid_discovery_url`、`rainid_register_redirect`。
|
||||
- **机密(只写不读)**:`rainid_client_secret`——**优先 `.env.json`(gitignored)**,回退后台设置(可写、GET 不返回)。
|
||||
|
||||
### Step 4:后端三个路由(授权码流)/ 一个路由(ROPC)
|
||||
|
||||
统一挂载(RainWeb 挂 `/api/auth/oidc`):
|
||||
|
||||
| 方法 | 路径 | 作用 |
|
||||
|---|---|---|
|
||||
| GET | `/login` | 生成 PKCE+state,302 到 RainID authorize |
|
||||
| GET | `/callback` | 验 state → 换 token → userinfo → 影子账号 → 发一次性 ticket → 302 回前端 |
|
||||
| POST | `/exchange` | 前端用 ticket 换本地 JWT(30s 一次性) |
|
||||
| GET | `/logout` | 302 到 RainID end_session(登出联动) |
|
||||
|
||||
ROPC 不需要新路由——**复用你的现有登录 POST**,在处理器里加一个分支转发 RainID。
|
||||
|
||||
完整代码模式见第 2 章。
|
||||
|
||||
### Step 5:影子账号(数据层)
|
||||
|
||||
用户表加一列 `rainid_user_id`(唯一索引),按 `sub` 查/建。见 2.5。
|
||||
|
||||
### Step 6:前端入口(登录页/注册页)
|
||||
|
||||
- 登录页:`rainid_enabled=1` 时显示「使用 RainID 登录」按钮 → `window.location.href='/api/auth/oidc/login'`。
|
||||
- 回跳收尾:URL 带 `?oidc_ticket=xxx` → POST `/exchange` 换 token → 存 localStorage → 通知登录态变更 → 跳首页;带 `?oidc_error=xxx` → 映射错误文案。
|
||||
- 注册页:`rainid_register_redirect=1` 时整页跳 `https://rainid.rainnya.asia/register`,本地表单不渲染。
|
||||
|
||||
### Step 7:登出联动
|
||||
|
||||
你的登出按钮:`rainid_enabled=1` 时跳 `/api/auth/oidc/logout`(后端 302 到 RainID end_session),否则直接清本地会话。
|
||||
|
||||
### Step 8:验收(对照 RainID 文档 §15 checklist)
|
||||
|
||||
完整走一遍:注册 → RainID 登录 → 同意 → 回跳 → 影子账号 → 刷新页面保持登录 → 登出回跳。**再测一遍 2FA 用户、密码错误、未登记回调**三条错误路径。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键代码模式(可直接抄)
|
||||
|
||||
### 2.1 discovery 缓存(⚠️ v6 第一参数必须是 `new URL()`)
|
||||
|
||||
```js
|
||||
const openidClient = require('openid-client'); // v6 函数式 API
|
||||
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
|
||||
|
||||
// 模块级缓存:discovery 是一次网络请求 + JWKS 拉取,不能每次登录都打
|
||||
let configCache = null;
|
||||
async function getOidcConfig() {
|
||||
if (configCache) return configCache;
|
||||
// ⚠️⚠️ v6 的 discovery() 第一个参数必须是 URL 实例!
|
||||
// 传字符串会抛 "server must be an instance of URL" → 所有登录请求 502(线上踩过的根因,见 §5.1)
|
||||
configCache = await openidClient.discovery(
|
||||
new URL(discoveryUrl), // ← 必须是 new URL(),不是字符串
|
||||
clientId,
|
||||
clientSecret
|
||||
);
|
||||
return configCache;
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
- **缓存副作用:后台改了 discovery_url / client 配置,需重启进程生效**(RainWeb 注释里明确写了这条)。
|
||||
- JWKS 多 key 轮换 SDK 自动处理,你不需要管。
|
||||
- 其他栈同理:authlib 的 `discovery(url)`、go-oidc 的 `NewProvider` 接受 URL 字符串,但 Node 的 v6 就是严格。
|
||||
|
||||
### 2.2 PKCE + state 无 session 框架怎么存:内存 Map + TTL,取用即删
|
||||
|
||||
你的框架若没有 session(RainWeb 是无状态 JWT),PKCE 的 `code_verifier` 和 `state` 不能丢。**内存 Map + 定时清理**即可(单进程够用;多进程部署换 Redis,TTL 语义一样):
|
||||
|
||||
```js
|
||||
const OIDC_STATE_TTL = 10 * 60 * 1000; // 发起登录 → 回调
|
||||
const oidcStates = new Map(); // state -> { verifier, createdAt }
|
||||
|
||||
// 每分钟清扫过期项(防内存泄漏)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of oidcStates) if (now - v.createdAt > OIDC_STATE_TTL) oidcStates.delete(k);
|
||||
}, 60000);
|
||||
|
||||
// ① 发起登录
|
||||
router.get('/login', async (req, res) => {
|
||||
const config = await getOidcConfig();
|
||||
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
||||
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = openidClient.randomState();
|
||||
oidcStates.set(state, { verifier: codeVerifier, createdAt: Date.now() });
|
||||
const url = openidClient.buildAuthorizationUrl(config, {
|
||||
redirect_uri: siteBase(req) + '/api/auth/oidc/callback',
|
||||
scope: 'openid profile email', // 要 refresh 就加 offline_access(需用户同意)
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
});
|
||||
res.redirect(url.href);
|
||||
});
|
||||
```
|
||||
|
||||
要点:
|
||||
- **`state` 一次性,回调里取用即删**——防重放。
|
||||
- **不要手写 PKCE**,用 SDK 标准函数(`randomPKCECodeVerifier` / `calculatePKCECodeChallenge`)。
|
||||
|
||||
### 2.3 回调换 token + 验签 + userinfo(id_token 只含 sub!)
|
||||
|
||||
```js
|
||||
router.get('/callback', async (req, res) => {
|
||||
const state = req.query.state;
|
||||
const stored = state && oidcStates.get(state);
|
||||
if (!stored) return res.status(400).send('登录状态已失效,请重新使用 RainID 登录');
|
||||
oidcStates.delete(state); // 一次性
|
||||
|
||||
const config = await getOidcConfig();
|
||||
const base = siteBase(req);
|
||||
const currentUrl = new URL(req.originalUrl, base); // 回调的完整 URL(含 query)
|
||||
try {
|
||||
// authorizationCodeGrant 自动完成:验 state + PKCE + 换 token
|
||||
// + 自动验 id_token 签名(RS256/JWKS) + iss + aud + exp
|
||||
const tokens = await openidClient.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: stored.verifier,
|
||||
expectedState: state,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
|
||||
const claims = tokens.claims(); // id_token 解码
|
||||
const sub = claims && claims.sub; // ⚠️ 只有 sub!没有 email/name(见 §5.3)
|
||||
if (!sub) throw new Error('id_token 缺少 sub');
|
||||
|
||||
// ⚠️ 必须调 userinfo 拿 email/name/email_verified
|
||||
// fetchUserInfo 第三个参数传 sub = 校验 userinfo 的 sub 与 id_token 一致(防注入)
|
||||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
|
||||
|
||||
const user = findOrCreateRainidUser(sub, userinfo); // 见 2.5
|
||||
const token = jwt.sign({ id: user.id, username: user.username, role: user.role },
|
||||
SECRET, { expiresIn: '7d' }); // ← 你的本地会话
|
||||
|
||||
const ticket = crypto.randomBytes(16).toString('hex');
|
||||
oidcTickets.set(ticket, { jwt: token, username: user.username, role: user.role,
|
||||
email: user.email, email_verified: user.email_verified,
|
||||
createdAt: Date.now() });
|
||||
res.redirect(frontLoginUrl(req, '?oidc_ticket=' + encodeURIComponent(ticket)));
|
||||
} catch (err) {
|
||||
// error 参数:access_denied / consent_required / invalid_grant 等 → 回前端带错误码
|
||||
const code = err && err.error;
|
||||
res.redirect(frontLoginUrl(req, '?oidc_error=' + encodeURIComponent(code || 'server_error')));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
要点:
|
||||
- `sub` 是**稳定、永不变更**的绑定键,跨应用唯一。
|
||||
- 错误码映射表见 §6(前端把 `oidc_error` 码翻译成用户文案)。
|
||||
|
||||
### 2.4 一次性 ticket 换本地 JWT(避免长 token 进 URL)
|
||||
|
||||
JWT 动辄几百字符,塞进 302 URL 会进浏览器历史/反代日志。**用一个 30 秒一次性的随机 ticket 过渡**:
|
||||
|
||||
```js
|
||||
const OIDC_TICKET_TTL = 30 * 1000;
|
||||
const oidcTickets = new Map(); // ticket -> { jwt, ... }
|
||||
|
||||
router.post('/exchange', (req, res) => {
|
||||
const ticket = req.body && req.body.ticket;
|
||||
if (!ticket) return res.status(400).json({ error: '缺少 ticket' });
|
||||
const rec = oidcTickets.get(ticket);
|
||||
if (!rec) return res.status(401).json({ error: '登录已过期,请重新使用 RainID 登录' });
|
||||
oidcTickets.delete(ticket); // 一次性
|
||||
res.json({ token: rec.jwt, username: rec.username, role: rec.role,
|
||||
email: rec.email, email_verified: rec.email_verified });
|
||||
});
|
||||
```
|
||||
|
||||
前端收尾(`Login.jsx` 实测模式):
|
||||
|
||||
```js
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('oidc_ticket');
|
||||
const oidcError = params.get('oidc_error');
|
||||
if (ticket) { handleOidcTicket(ticket); return; } // 先兑换,30s 有效期
|
||||
if (oidcError) { setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败'); clearOidcParams(); return; }
|
||||
...
|
||||
}, []);
|
||||
|
||||
const handleOidcTicket = async (ticket) => {
|
||||
const data = await authApi.oidcExchange(ticket);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
clearOidcParams(); // history.replaceState 清掉 URL 上的 ticket,避免刷新重复兑换
|
||||
navigate(from || '/');
|
||||
};
|
||||
```
|
||||
|
||||
### 2.5 影子账号创建/绑定逻辑(含 email_verified 闸门、并发兜底、首用户 admin)
|
||||
|
||||
```js
|
||||
// ① 按 sub 查 → ② email_verified 且本地同邮箱未绑定 → 绑定 → ③ 否则创建
|
||||
function findOrCreateRainidUser(sub, profile) {
|
||||
if (!sub) throw new Error('RainID userinfo 缺少 sub');
|
||||
const email = String(profile.email || '').trim().toLowerCase();
|
||||
|
||||
// 1) 已绑定:sub 命中直接返回
|
||||
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||||
if (user) return user;
|
||||
|
||||
// 2) 自动绑定:仅当 RainID 已验证该邮箱(email_verified=true)才允许绑
|
||||
// 防撞绑:未验证邮箱的 userinfo 绝不能自动绑到本地已有账号
|
||||
if (email && profile.email_verified) {
|
||||
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (local && !local.rainid_user_id) {
|
||||
// 条件 UPDATE:WHERE 再带 rainid_user_id='',并发下第二个人写不进来
|
||||
db.run('UPDATE users SET rainid_user_id = ? WHERE id = ? AND rainid_user_id = ?', [sub, local.id, '']);
|
||||
const updated = db.get('SELECT * FROM users WHERE id = ?', [local.id]);
|
||||
if (updated && updated.rainid_user_id === sub) return updated;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 创建影子账号
|
||||
let username = profile.preferred_username || ('rainid_' + String(sub).slice(0, 8));
|
||||
if (db.get('SELECT id FROM users WHERE username = ?', [username])) {
|
||||
username = username + '_' + String(sub).slice(0, 4); // 冲突加后缀
|
||||
}
|
||||
// 影子账号给随机不可登录密码 —— 禁止本地密码登入(见 §4)
|
||||
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
|
||||
// 首用户 admin:全站无 admin 时第一个绑定的用户自动成为 admin(RainID 不管角色)
|
||||
const adminCount = db.get("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
|
||||
const role = adminCount && adminCount.c > 0 ? 'user' : 'admin';
|
||||
try {
|
||||
const id = db.run(
|
||||
'INSERT INTO users (username, password, email, email_verified, role, avatar, rainid_user_id) VALUES (?, ?, ?, 1, ?, ?, ?)',
|
||||
[username, randomHash, email, role, profile.picture || '', sub]
|
||||
);
|
||||
return db.get('SELECT * FROM users WHERE id = ?', [id]);
|
||||
} catch (e) {
|
||||
// 并发兜底:唯一索引冲突 → 重查按 sub 返回(谁先建都行)
|
||||
const dup = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||||
if (dup) return dup;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配套 DDL(唯一索引是并发兜底的地基,**必须建**):
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''; -- 幂等补列
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid
|
||||
ON users(rainid_user_id) WHERE rainid_user_id <> ''; -- 部分唯一索引
|
||||
```
|
||||
|
||||
要点:
|
||||
- **email_verified 是自动绑定的闸门**:只有 RainID 验证过的邮箱才能绑本地号,防有人用他人邮箱注册 RainID 来接管本地账号。
|
||||
- 绑定用**条件 UPDATE**(`WHERE rainid_user_id=''`)而非先查后改,并发安全。
|
||||
|
||||
### 2.6 ROPC 密码登录(`grant_type=password` + 2FA 拒绝文案)
|
||||
|
||||
```js
|
||||
// 复用一个 getOidcConfig()。genericGrantRequest 自动带 client 认证(HTTP Basic)
|
||||
async function rainidRopcLogin(username, password) {
|
||||
const s = getOidcSettings();
|
||||
if (!s.enabled) return { ok: false, status: 400, error: 'RainID 登录未启用' };
|
||||
|
||||
let config;
|
||||
try { config = await getOidcConfig(); }
|
||||
catch (e) {
|
||||
if (e && e.code === 'RAINID_NOT_CONFIGURED')
|
||||
return { ok: false, status: 500, error: 'RainID 客户端未配置' };
|
||||
return { ok: false, status: 502, error: 'RainID 服务配置错误:Discovery 失败(检查端点是否为 https)' };
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await openidClient.genericGrantRequest(config, 'password', {
|
||||
username, password, scope: 'openid profile email',
|
||||
});
|
||||
// id_token 验签 SDK 负责;拿 sub 做 userinfo 的 subject 校验
|
||||
let expectedSub = openidClient.skipSubjectCheck;
|
||||
try {
|
||||
const claims = tokens.claims();
|
||||
if (claims && claims.sub) expectedSub = claims.sub;
|
||||
} catch { /* id_token 解析失败不阻断,userinfo 为准 */ }
|
||||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, expectedSub);
|
||||
const user = findOrCreateRainidUser(userinfo.sub, userinfo);
|
||||
return { ok: true, user };
|
||||
} catch (err) {
|
||||
return mapOidcError(err); // 见下
|
||||
}
|
||||
}
|
||||
|
||||
// RainID OAuth 错误 → HTTP 状态 + 文案(防枚举!不区分具体原因)
|
||||
function mapOidcError(err) {
|
||||
const code = err && err.error;
|
||||
const desc = (err && err.error_description) || '';
|
||||
switch (code) {
|
||||
case 'invalid_grant':
|
||||
// ⚠️ 2FA 用户 ROPC 被拒 → 固定文案引导走 RainID 登录页
|
||||
return { ok: false, status: 401, error: desc.includes('二次验证')
|
||||
? '该账号已开启二次验证,请使用 RainID 登录'
|
||||
: '用户名/邮箱或密码错误' };
|
||||
case 'rate_limited':
|
||||
return { ok: false, status: 429, error: '尝试过于频繁,请稍后再试' };
|
||||
case 'invalid_client':
|
||||
case 'invalid_scope':
|
||||
case 'invalid_request':
|
||||
return { ok: false, status: 500, error: 'RainID 配置错误,请联系管理员' };
|
||||
default:
|
||||
return { ok: false, status: 502, error: 'RainID 服务暂不可用,请稍后再试' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
你的登录路由这样接线(`routes/auth.js` 实测模式):
|
||||
|
||||
```js
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
// ...验证码校验略
|
||||
if (db.getSetting('rainid_enabled') === '1') {
|
||||
// ① admin 逃生通道:本地 admin 且有本地密码 → 走本地 bcrypt(见 §4)
|
||||
const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||||
if (localUser && localUser.role === 'admin' && !!localUser.password) {
|
||||
if (!bcrypt.compareSync(password, localUser.password))
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
const token = jwt.sign({...}, SECRET, { expiresIn: '7d' });
|
||||
return res.json({ token, ... });
|
||||
}
|
||||
// ② 其余用户:转发 RainID(ROPC)
|
||||
const r = await rainidRopcLogin(username, password);
|
||||
if (!r.ok) return res.status(r.status).json({ error: r.error });
|
||||
const token = jwt.sign({ id: r.user.id, ... }, SECRET, { expiresIn: '7d' });
|
||||
return res.json({ token, ... });
|
||||
}
|
||||
// ③ RainID 未启用 → 本地 bcrypt 登录(原有逻辑保留)
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
### 2.7 登出联动 end_session
|
||||
|
||||
```js
|
||||
router.get('/logout', async (req, res) => {
|
||||
const s = getOidcSettings();
|
||||
if (!s.enabled) return res.redirect('/');
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
const url = openidClient.buildEndSessionUrl(config, {
|
||||
// ⚠️ 此地址必须在 RainID Admin 的 post_logout_redirect_uris 里登记过,否则 400
|
||||
post_logout_redirect_uri: siteBase(req) + '/login.html',
|
||||
});
|
||||
res.redirect(url.href); // 用户确认后 RainID 303 回跳 login.html
|
||||
} catch (err) {
|
||||
console.error('[RainID] logout error:', err.message);
|
||||
res.redirect('/'); // RainID 故障时至少能回站内
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
前端(`Layout.jsx` 实测):`rainid_enabled=1` 时登出按钮跳 `/api/auth/oidc/logout`(整页跳,让 RainID 处理完再回来),否则直接清本地 token。
|
||||
|
||||
**清本地会话的时点**:RainID 回跳 `login.html` 后,前端 `logout()` 清 localStorage token 即可(你的 JWT 是有时效的;要彻底就再调一次 revocation,非必需)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置项设计:哪些进设置、哪些进机密存储
|
||||
|
||||
**⚠️ 最易踩的坑:凡前端需要读的 key,必须同时加进"公开设置白名单",否则前端 `getPublicSettings()` 拿不到 → 功能静默失效**(RainWeb 踩过 `rainid_register_redirect` 漏 PUBLIC_KEYS,见 §5.4)。
|
||||
|
||||
### 3.1 三个集合(RainWeb 实测结构)
|
||||
|
||||
```js
|
||||
// ① 公开设置白名单:任何未登录用户可读(前端页面依赖)
|
||||
const PUBLIC_KEYS = [
|
||||
'site_name', /* ... */,
|
||||
'rainid_enabled', 'rainid_register_redirect', // ← 前端判断显示 RainID 按钮/注册托管
|
||||
/* ... */
|
||||
];
|
||||
|
||||
// ② 管理端可读(adminOnly GET):包含全部非敏感配置
|
||||
const ALL_KEYS = [
|
||||
/* ... */,
|
||||
'rainid_enabled', 'rainid_client_id', 'rainid_discovery_url', 'rainid_register_redirect',
|
||||
/* ... */
|
||||
];
|
||||
|
||||
// ③ 可写白名单:管理端可写(adminOnly PUT)。机密 key 在此,但绝不在 ②
|
||||
const ALLOWED_SET = [...ALL_KEYS, 'rainid_client_secret']; // ← secret 只写不读!
|
||||
```
|
||||
|
||||
**机密存储原则**:
|
||||
- `rainid_client_secret` **优先 `.env.json`(gitignored)**,server.js 启动时注入环境变量 `RAINID_CLIENT_SECRET`,运行时还有 `.env.json` 直读和后台设置两档回退(见 `lib/rainid.js getClientSecret()`)。
|
||||
- 后台设置通道**可写不可读**:`ALLOWED_SET` 里有它(能写),`ALL_KEYS` 里没有它(GET `/api/settings` 不返回),浏览器和 API 都读不到明文。
|
||||
- **fail-closed**:`rainid_enabled='1'` 但 `client_id`/`client_secret` 任一缺失 → `enabled=false`,RainID 按钮不出现、ROPC 拒绝,**本地登录不受影响**。启动时打 warning 日志。
|
||||
|
||||
### 3.2 配置清单
|
||||
|
||||
| key | 敏感 | 公开可读 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `rainid_enabled` | 否 | ✅ | `'1'`/'0',前端据此显示按钮 |
|
||||
| `rainid_client_id` | 否 | ❌ | 管理端可改 |
|
||||
| `rainid_client_secret` | ✅ | ❌ | 只写不读,双通道 |
|
||||
| `rainid_discovery_url` | 否 | ❌ | 默认 `https://rainid.rainnya.asia/oauth` |
|
||||
| `rainid_register_redirect` | 否 | ✅ | `'1'` 时前端整页跳 RainID 注册页 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin 逃生通道(IdP 故障时的 break-glass)
|
||||
|
||||
**原则:启用 RainID 后,绝不能出现"RainID 挂了管理员也进不去后台"的死局。**
|
||||
|
||||
RainWeb 实践(`routes/auth.js`):
|
||||
|
||||
```js
|
||||
if (db.getSetting('rainid_enabled') === '1') {
|
||||
// 逃生通道:本地 admin 账号(有本地 bcrypt 密码)始终可用本地密码登录
|
||||
const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||||
const isAdminEscape = localUser && localUser.role === 'admin' && !!localUser.password;
|
||||
if (isAdminEscape) {
|
||||
if (!bcrypt.compareSync(password, localUser.password)) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
const token = jwt.sign({ id: localUser.id, username: localUser.username, role: localUser.role },
|
||||
SECRET, { expiresIn: '7d' });
|
||||
return res.json({ token, username: localUser.username, role: localUser.role, ... });
|
||||
}
|
||||
// 其余用户 → ROPC 转发 RainID
|
||||
const r = await rainidRopcLogin(username, password);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
1. **本地 admin + 有本地密码** → 永远走本地 bcrypt,不经 RainID。即使 RainID 完全宕机、discovery 失败,admin 还能登录后台。
|
||||
2. **影子账号(`rainid_user_id` 非空)给随机不可登录密码** → 在本地登录分支里显式禁止(`user.rainid_user_id && !user.password` 直接拒绝),防止影子账号被猜到密码本地登入。
|
||||
3. 配套兜底:**首次接入前先建一个本地 admin 并记住密码**(RainWeb 播种的 `admin/admin123` 即为这个角色),再开 `rainid_enabled`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 踩坑实录(按严重程度排序)
|
||||
|
||||
### 5.1 [致命] discovery 传字符串 → 全部登录 502
|
||||
|
||||
- **现象**:启用 RainID 后,ROPC 登录和授权码登录全部失败;后端日志 `server must be an instance of URL`。
|
||||
- **根因**:openid-client **v6 函数式 API** 的 `discovery()` 第一参数必须是 `new URL()` 实例;v5 类式 API 接受字符串,升级后行为变了,网上旧示例都是字符串。传字符串直接抛 TypeError。
|
||||
- **修复**:
|
||||
```js
|
||||
// ❌ v5 时代:openidClient.Issuer.discover('https://...')
|
||||
// ✅ v6:必须 new URL()
|
||||
configCache = await openidClient.discovery(new URL(discoveryUrl), clientId, clientSecret);
|
||||
```
|
||||
- **预防**:升级 openid-client 大版本后跑一遍登录全流程;读 changelog 的 breaking changes。
|
||||
|
||||
### 5.2 [高] RainID 端点写成 http:// 被 openid-client 拒绝(RFC 9700)
|
||||
|
||||
- **现象**:配置 `rainid_discovery_url=http://rainid...`(或反代配错协议)→ discovery 失败,登录 502。
|
||||
- **根因**:openid-client 遵循 RFC 9700(OAuth 2.0 over HTTPS),**拒绝 http 端点**(localhost 除外)。RainWeb 错误文案就是为此写的:"Discovery 失败(检查端点是否为 https)"。
|
||||
- **修复**:端点统一 `https://rainid.rainnya.asia/oauth`;**确认反代回源协议**——如果你的站点跑在反代后面,回调的 `req.protocol` 会算错,redirect_uri 变成 `http://` 导致 RainID 侧"回调未登记"。处理:优先用 `site_url` 设置拼基址,或用 `app.set('trust proxy', 1)` + 反代带 `X-Forwarded-Proto`。
|
||||
- **预防**:`siteBase(req)` 优先读 `site_url` 设置,回退请求头,且反代必须正确转发协议。
|
||||
|
||||
### 5.3 [高] id_token 无 email/name(`conformIdTokenClaims`)→ 必须 userinfo
|
||||
|
||||
- **现象**:回调里 `tokens.claims()` 只有 `sub`,`email`/`name` 全是 undefined → 影子账号 email 为空、头像昵称丢。
|
||||
- **根因**:RainID 的 access_token 是 opaque,`conformIdTokenClaims=true` 语义下 **id_token 只带 openid scope 的声明(sub)**,`profile`/`email` 数据在 userinfo 端点(OIDC Core 5.4)。拿 id_token 当唯一数据源是普遍错觉。
|
||||
- **修复**:
|
||||
```js
|
||||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
|
||||
// userinfo: { sub, name, preferred_username, picture, email, email_verified }
|
||||
```
|
||||
并顺手用 `sub` 校验 userinfo 与 id_token 一致(防 userinfo 注入)。
|
||||
|
||||
### 5.4 [高] 设置 key 漏 PUBLIC_KEYS → 前端功能静默失效
|
||||
|
||||
- **现象**:后台打开了 `rainid_register_redirect`,注册页却仍显示本地表单;无任何报错。
|
||||
- **根因**:注册页靠 `settingsApi.getPublicSettings()` 读 `rainid_register_redirect`,但该 key 只加了 `ALL_KEYS` 没加 `PUBLIC_KEYS` → 前端拿到 `undefined`,静默走本地注册分支。**公共配置接口返回 undefined 不报错,是最难排查的一类 bug**。
|
||||
- **修复**:`rainid_register_redirect` 加入 `PUBLIC_KEYS`。
|
||||
- **预防**:凡是"前端根据设置决定显示逻辑"的 key,写完务必在 `/api/settings/public` 响应里确认存在;加新前端依赖的 key 时列 checklist。
|
||||
|
||||
### 5.5 [中] end_session 卡确认页不回跳(RainID 侧 issue)
|
||||
|
||||
- **现象**:登出跳 RainID 后停在"确认退出"页,点了确认不回跳你的站点。
|
||||
- **根因**:`post_logout_redirect_uri` 未在 RainID Admin 登记,或登记的与请求的不完全一致(域名大小写/结尾斜杠/协议)。RainID 侧拒绝回跳时只渲染提示页(oidc-provider 行为),没有 303。
|
||||
- **修复**:在 RainID Admin 的 `post_logout_redirect_uris` 精确登记你 `buildEndSessionUrl` 传的完整 URL(含路径 `/login.html`);检查域名规范化。
|
||||
- **预防**:登出回跳地址做成常量与 `siteBase` 拼接,保证与登记一致;登记时复制请求里实际的 URL。
|
||||
|
||||
### 5.6 [中] ROPC 未进"密码直连白名单" → invalid_grant 静默失败
|
||||
|
||||
- **现象**:密码明明正确,ROPC 登录始终 `400 invalid_grant`,文案统一"用户名/邮箱或密码错误"。
|
||||
- **根因**:RainID 对 `password` grant 有 **Admin 站点设置 → 密码直连白名单**(`system_settings.client_password_grant.allowed`)。client 不在白名单 → `invalid_grant`,**文案与凭据失败完全一致**(防枚举设计)→ 让你误以为密码错了。
|
||||
- **修复**:RainID Admin 把该 client_id 加进密码直连白名单(前置条件:client 必须机密 client、grant_types 含 `password`)。
|
||||
- **预防**:接入文档 §5.1 四条前置逐条核对;ROPC 配好第一件事就是 curl 一次 token 端点确认不是白名单问题(见 §6)。
|
||||
|
||||
### 5.7 [低] 测试端口避开 3k-4k(RainID 占 3001)
|
||||
|
||||
- **现象**:本地开发时浏览器明明打开了 RainID 页面,但跳回登录总失败/串数据。
|
||||
- **根因**:RainWeb 开发端口 3001 与 RainID 本地实例端口**冲突**,你在开自己的服务时把 RainID 顶掉了(或反之)。
|
||||
- **修复**:自己的开发端口避开 3k-4k 区间(RainID 用 3001,vite 代理硬编码 3101);或至少确认本地测试时 RainID 实例可用。
|
||||
- **预防**:多项目共存时先在项目文档里登记"占用的端口清单"。
|
||||
|
||||
---
|
||||
|
||||
## 6. 快速排障指南:登录报错按什么顺序查
|
||||
|
||||
**自顶向下,每层 30 秒内能判**。RainWeb 线上排障就是这个顺序:
|
||||
|
||||
### L1 进程层
|
||||
- 后端进程还活着吗?`node cli.js status` / `ps aux | grep server`。
|
||||
- 重启过吗?**改了 discovery_url / client 配置要重启**(discovery 有进程级缓存)。
|
||||
|
||||
### L2 依赖层
|
||||
- `openid-client` 版本?v5→v6 是 breaking(§5.1)。
|
||||
- Node 版本?openid-client v6 需要较新 Node(`require('esm')`),RainWeb 要求 Node ≥23 / 22+。
|
||||
- 启动有没有 require 报错?`node -e "require('openid-client')"` 一把验证。
|
||||
|
||||
### L3 配置层
|
||||
- 后端日志有没有 `RAINID_NOT_CONFIGURED` / "client_id 或 client_secret 未配置"?→ 检查 `.env.json` 的 `rainid_client_secret`、后台 `rainid_client_id`。
|
||||
- `rainid_enabled='1'` 且三项齐全?**fail-closed 下任一缺失按钮都不会出现**,先看前端到底有没有按钮。
|
||||
- **管理端 GET /api/settings 里能看到 `rainid_client_secret` 吗?** 能看到就是泄露(正常不该返回)。
|
||||
- 前端读到的公开设置对不对?`curl /api/settings/public` 确认 `rainid_enabled`/`rainid_register_redirect` 在不在响应里(§5.4)。
|
||||
|
||||
### L4 网络层
|
||||
- `curl -sI https://rainid.rainnya.asia/oauth/.well-known/openid-configuration` 通不通?
|
||||
- `curl -s https://rainid.rainnya.asia/oauth/.well-known/openid-configuration | head` 看是不是 json?
|
||||
- 本地 dev 端口有没有和 RainID 冲突(§5.7)?反代有没有把协议降级成 http(§5.2)?
|
||||
|
||||
### L5 协议层(最细,看 error code)
|
||||
|
||||
用 curl 直接打 token 端点,绕过前端看原始错误:
|
||||
|
||||
```bash
|
||||
# ROPC 直测(秒杀"白名单还是密码错"之争,§5.6)
|
||||
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
|
||||
-d "grant_type=password&username=test&password=xxx&scope=openid%20profile%20email" \
|
||||
https://rainid.rainnya.asia/oauth/token
|
||||
```
|
||||
|
||||
| 返回 | 判定 | 处理 |
|
||||
|---|---|---|
|
||||
| `400 invalid_grant` | ①凭据错 ②账号锁 ③2FA ④**白名单缺** | 换已确认密码重试;看 error_description 是否含"二次验证";查 Admin 白名单 |
|
||||
| `401 invalid_client` | client_id/secret 错或非机密 client | 核对 Admin client 配置 |
|
||||
| `400 invalid_scope` | scope 超白名单 | Admin 扩 scope 白名单 |
|
||||
| `429 rate_limited` | 限流 | 退避;也检查自己有没有重复请求循环 |
|
||||
| `400 redirect_uri not registered` | 回调未登记/拼接不一致 | 检查 `siteBase` 拼出的 redirect_uri 与登记是否逐字符一致 |
|
||||
| `400 post_logout_redirect_uri not registered` | 登出回跳未登记(§5.5) | Admin 登记完整 URL |
|
||||
| 502 / discovery 失败 | §5.1 / §5.2 | 查 URL 实例化与 https |
|
||||
|
||||
### 前端侧速查
|
||||
- 白屏 + console "MIME type text/html" → 不是 OIDC 问题,是**构建产物缺失**(`npm run build` + 强刷)。
|
||||
- 地址栏残留 `?oidc_ticket=...` → 前端没兑换成功,看 network 里 POST `/exchange` 的响应。
|
||||
- `oidc_error=invalid_scope` → 前端按钮正常,后端 scope 配多了,回 L5。
|
||||
|
||||
---
|
||||
|
||||
## 附:RainWeb 关键文件索引(抄作业对照)
|
||||
|
||||
| 需求 | 文件 |
|
||||
|---|---|
|
||||
| discovery 缓存 / secret 双通道 / 影子账号 / ROPC | `lib/rainid.js` |
|
||||
| 授权码三路由 + ticket + 登出联动 | `routes/oidc.js` |
|
||||
| ROPC 接线 + admin 逃生通道 + 影子账号禁本地登入 | `routes/auth.js` |
|
||||
| PUBLIC_KEYS / ALL_KEYS / ALLOWED_SET | `routes/settings.js` |
|
||||
| 前端登录页(oidc_ticket/oidc_error 收尾) | `frontend/src/pages/Login.jsx` |
|
||||
| 前端注册页(注册托管跳转) | `frontend/src/pages/Register.jsx` |
|
||||
| 登出联动按钮 | `frontend/src/components/Layout.jsx` |
|
||||
| 后台设置表单(secret 置空不回显) | `frontend/src/admin/pages/Settings.jsx` |
|
||||
| 唯一索引迁移 | `db.js` migrations |
|
||||
|
||||
---
|
||||
|
||||
*本文基于 RainWeb × RainID 线上接入实践整理。协议细节以 RainID `docs/OIDC对接文档.md` 为准。*
|
||||
@@ -0,0 +1,46 @@
|
||||
# RainID 接入指南(任意项目通用)
|
||||
|
||||
> RainID(`https://rainid.rainnya.asia`)是 Rainnya 家族统一身份认证服务(OIDC Provider)。
|
||||
> 本目录是**任意项目接入 RainID 的完整资料包**,基于 RainWeb 实战接入沉淀(含线上排障)。
|
||||
> RainID 官方协议文档:`/home/miaomiao/Project/rainid/docs/OIDC对接文档.md`
|
||||
|
||||
## 从哪份开始读
|
||||
|
||||
| 你的角色 | 读这份 |
|
||||
|---|---|
|
||||
| **项目负责人 / 架构师**(先定方向再动手) | `01-onboarding-audit.md`(审计视角:协议选型、账户整合模式 A/B/C、安全清单、验收) |
|
||||
| **开发 / 实施**(照着写代码) | `02-implementation-practice.md`(实现视角:Step-by-step、可抄代码模式、配置、踩坑、排障) |
|
||||
| **已接入但在排障** | `02-implementation-practice.md` 第 5 章(踩坑实录)+ 第 6 章(快速排障 L1-L5) |
|
||||
| **只想知道"注意什么"** | `01-onboarding-audit.md` 第 4 章(安全清单)+ 第 5 章(常见坑) |
|
||||
|
||||
## 核心结论(30 秒速览)
|
||||
|
||||
1. **RainID 只做标准 OIDC/OAuth2**——你的项目只需要一个 `discovery_url` = `https://rainid.rainnya.asia/oauth`
|
||||
2. **身份归 RainID,角色归你**——`sub` 是跨应用稳定绑定键,admin/角色/数据归属永远本地维护
|
||||
3. **授权码 + PKCE 是默认正解**(有浏览器);ROPC 仅服务端代理登录用(2FA 用户会被拒);client_credentials 供服务间
|
||||
4. **影子账号模式最推荐**(用户表加 `rainid_user_id` 一列),存量站可做混合绑定但必须过 `email_verified` 撞绑闸门
|
||||
5. **必须留本地 admin 逃生通道**——RainID 故障时管理员仍能登录后台(break-glass)
|
||||
6. **务必登记**:redirect_uris、post_logout_redirect_uris、grant_types、scope 白名单、ROPC 密码直连白名单——漏一个登出就卡死/登录静默失败
|
||||
|
||||
## 目录文件
|
||||
|
||||
- `01-onboarding-audit.md` —— 架构审计视角通用指南(协议选型决策树、账户整合模式对比、安全清单、验收 checklist)
|
||||
- `02-implementation-practice.md` —— 实现落地实操指南(Step-by-step、可复制代码模式、配置设计、admin 逃生通道、踩坑实录、L1-L5 快速排障)
|
||||
|
||||
## RainWeb 参考实现(抄作业对照)
|
||||
|
||||
| 需求 | RainWeb 文件 |
|
||||
|---|---|
|
||||
| discovery 缓存 / secret 双通道 / 影子账号 / ROPC | `lib/rainid.js` |
|
||||
| 授权码三路由 + ticket + 登出联动 | `routes/oidc.js` |
|
||||
| ROPC 接线 + admin 逃生通道 | `routes/auth.js` |
|
||||
| PUBLIC_KEYS / ALL_KEYS / ALLOWED_SET | `routes/settings.js` |
|
||||
| 前端登录页(ticket/error 收尾) | `frontend/src/pages/Login.jsx` |
|
||||
| 前端注册页(注册托管) | `frontend/src/pages/Register.jsx` |
|
||||
| 登出联动按钮 | `frontend/src/components/Layout.jsx` |
|
||||
| 后台设置表单(secret 置空不回显) | `frontend/src/admin/pages/Settings.jsx` |
|
||||
| 唯一索引迁移 | `db.js` migrations |
|
||||
|
||||
---
|
||||
|
||||
*整理:RainWeb 接入实战(orchestrator 编排 + oracle 审计视角 + fixer 实现视角三方沉淀)*
|
||||
@@ -0,0 +1,174 @@
|
||||
# RainWeb 优化重构实施计划(P0 / P1 / P2)
|
||||
|
||||
> 依据:数据库优化师(better-sqlite3 选型调研)、后端架构师(架构评审)、应用安全工程师(安全审计)、前端路由专项深挖 四份报告合并。
|
||||
> 数据库路线已定:**better-sqlite3 + Node 22 LTS**(放弃 sql.js 与 PostgreSQL)。
|
||||
> 前端路由:用户确认问题很大 → 按「方案 A 止血 → 方案 B 协议收编」两步走(见 P1-14 / P2-19)。
|
||||
> 本计划仅执行顺序与细节,**未开始任何代码改动**。
|
||||
|
||||
## 全局前提(每阶段执行前)
|
||||
|
||||
1. 备份:`cp -r data data.bak-<日期>`(`data/rainweb.db` 是唯一数据文件)
|
||||
2. 每阶段一个 git commit,commit message 中文,如 `P0: 删除 Web 更新链路,修复鉴权漏洞`
|
||||
3. 无测试环境,验证方式 = curl 接口 + 浏览器刷新 + `node cli.js` 命令
|
||||
|
||||
---
|
||||
|
||||
## P0 紧急止损(独立项,可并行,约半天)
|
||||
|
||||
### P0-1 删除 Web 更新链路(消除 R6 + .git 覆盖风险)
|
||||
- `server.js`:删除 84-165 行 `/api/update/check` + `/api/update/run`,保留 `/api/version`(81-82 行)
|
||||
- `public/js/admin.js`:删除 checkUpdate/runUpdate 函数及更新按钮 UI(约 692-731 行区域)
|
||||
- `cli.js`:`cmdUpgrade` 保留 git pull 分支(走本地 Gitea origin),删除非 git 仓库的 zip 下载分支(266-346 行区域)
|
||||
- 理由:用户即上游,zip 覆盖式更新是错误范式;`/api/update/run` 无鉴权=RCE 面,且 exclude 未排除 `.git` 会冲掉 git 历史
|
||||
- 风险:低。注意前端引用一并删除,避免 404
|
||||
- 验证:`curl /api/update/check` → 404;admin 面板打开控制台无报错;`node cli.js upgrade` 走 git pull 分支正常
|
||||
|
||||
### P0-2 JWT_SECRET 随机化(消除 R4)
|
||||
- `middleware/auth.js`:改为 `process.env.JWT_SECRET` 必须存在(长度 ≥32),否则启动失败;删除硬编码回退
|
||||
- `server.js` `start()`:首次启动若无 `JWT_SECRET`,用 `crypto.randomBytes(48).toString('hex')` 生成并写入 `.env.json`
|
||||
- 风险:低;已签发旧 token 全部失效(自部署可接受)
|
||||
- 验证:删 `.env.json` 重启 → 自动生成 secret;登录 → `curl /api/auth/me` 200
|
||||
|
||||
### P0-3 setup 接管漏洞(消除 R5)
|
||||
- `routes/setup.js`:`POST /complete` 加 `authMiddleware, adminOnly`;`GET /setup/status` 响应移除 `default_password` 字段(只保留 `setup_complete`)
|
||||
- 风险:低
|
||||
- 验证:未登录 `curl -X POST /api/setup/complete` → 401;登录后正常
|
||||
|
||||
### P0-4 proxy SSRF 最小防护(消除 R3 的 SSRF 部分)
|
||||
- `routes/proxy.js`:`GET /fetch` 加 `authMiddleware, adminOnly`;`new URL(target)` 后校验 hostname 非 `127.0.0.1`/`::1`/`localhost`/`10.`/`192.168.`/`172.16-31.`/`169.254.`/`fc00:`/`fe80:`
|
||||
- 风险:低(本功能只服务于 admin_links 面板嵌入)
|
||||
- 验证:未登录请求 → 401;`/api/proxy/fetch?url=http://127.0.0.1:3001/` → 拒绝
|
||||
|
||||
### P0-5 邮件验证码回显与滥用(消除 R7)
|
||||
- `routes/email.js`:`send-verify` 响应删除 `code` 回显;验证码生成改 `crypto.randomInt(10000000, 100000000)`;补回"删除 pending 后重建"的 bug(或废弃该接口由 register 内联重发)
|
||||
- 风险:低
|
||||
- 验证:调 send-verify → 响应无 code 字段;邮件仍能收到验证码
|
||||
|
||||
---
|
||||
|
||||
## P1 安全加固 + 数据层统一(依赖顺序:11 → 12,其余独立)
|
||||
|
||||
### P1-6 上传白名单 + /uploads 防护头(消除 R1)
|
||||
- `routes/upload.js`:`shaFileUpload` 内校验 `path.extname(...).toLowerCase()` 在 `ALLOWED_EXT = ['.png','.jpg','.jpeg','.gif','.webp','.pdf','.zip','.txt','.md']` 内,否则报"不支持的文件类型";图片额外校验 `file.mimetype` 以 `image/` 开头
|
||||
- `server.js:63`:`/uploads` 静态服务加 `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox; default-src 'none'`;非白名单扩展名强制 `Content-Disposition: attachment`
|
||||
- 风险:低;已有历史上传文件不受影响(仅新上传受限)
|
||||
- 验证:登录后上传 `x.html` → 400;上传 png → 200;访问 `/uploads/<hash>.png` 响应头含 nosniff
|
||||
|
||||
### P1-7 内容净化双端统一(消除 R2)
|
||||
- `ssr.js`:引入 `dompurify + jsdom`(服务端),`renderSSR` 中 `marked.parse` 结果过 `DOMPurify.sanitize`;`ssrPage` 中 `title/siteName/ogDesc` 全部 HTML 转义,JSON-LD 用 `JSON.stringify` 序列化
|
||||
- `public/js/render.js`:引入 DOMPurify(本地化到 public/js/vendor/ 或 CDN + SRI),`marked.parse` 结果同样过 sanitize
|
||||
- 风险:中(渲染路径改动,需走查博客/论坛/SEO 页)
|
||||
- 验证:发帖内容含 `<img src=x onerror=alert(1)>` → SSR 页与 SPA 页均不弹窗、标签被剥;正常 markdown 渲染不变
|
||||
|
||||
### P1-8 登录限流 + 服务端验证码(消除 R8)
|
||||
- 新增 `express-rate-limit` 依赖:`/api/auth/login` 15 分钟 10 次;`/api/email/send-verify` 60s/次
|
||||
- `routes/auth.js`:login/register 按 `captcha_login`/`captcha_register` 设置服务端校验 captcha token(与 captcha.js 的 verify 存储联动,token 与 username 绑定);`routes/forum.js` 发帖同理(`captcha_forum`)
|
||||
- 删除 `verifyCaptchaToken` 桩函数(auth.js:39-52)
|
||||
- 风险:中(验证码链路前后端要同步改)
|
||||
- 验证:连错密码 11 次 → 429;开启 captcha_login 后登录需先过验证码
|
||||
|
||||
### P1-9 密码箱解锁限流 + KDF 加固(消除 M1)
|
||||
- `routes/passwords.js`:`/unlock` 加失败计数(3 次/5 分钟,指数退避);解锁会话滑动过期替换一次性 setTimeout
|
||||
- PBKDF2 迭代 100k → 600k(OWASP 2023 建议);注意同步执行阻塞事件循环,可接受(单用户站)
|
||||
- 风险:低
|
||||
- 验证:连续输错 PIN 3 次 → 锁定提示;正确解锁正常;旧 PIN 密文在 KDF 迭代变更后需重设(set-pin 已存在)
|
||||
|
||||
### P1-10 鉴权与信息收敛(消除 M2-M7)
|
||||
- `middleware/auth.js`:`adminOnly` 增加 `db.get('SELECT role FROM users WHERE id=?')` 复查(M2)
|
||||
- `routes/blog.js`:`?all=1` 加 `authMiddleware, adminOnly`;`GET /posts/:id` 未发布仅 admin/作者可见(M3)
|
||||
- 错误信息收敛:`routes/forum.js:21,35`、`server.js:163`、`routes/email.js:54,93` 对外统一 `{ error: '操作失败' }`,详情仅 console.error(M4)
|
||||
- `routes/email.js:17`、`auth.js:93`、`profile.js:25`、`proxy.js:22` 的 `rejectUnauthorized:false` 收敛为配置项(默认关闭校验仅当 SMTP 自签时开启)(M5)
|
||||
- `public/js/passwords.js:113`、`forum.js:76`:内联 onclick 字符串插值改 `data-id` + `addEventListener` 委托(M6)
|
||||
- **S3-3 escapeHtml 引号问题**:`escapeHtml` 不转义 `'`/`"`,用于属性/JS 字符串上下文可注入断链(forum.js:76、passwords.js:113、admin.js:74/402-404)——新增 `escapeAttr`(全转义)用于属性上下文,或随 M6 的 data-id 改造一并消除;帖子标题/分类为他人可见,属存储型 XSS 面
|
||||
- `routes/profile.js`:avatar 仅接受 `/uploads/avatars/` 站内路径(M7)
|
||||
- 风险:中;验证:逐项 curl 测试(未登录 401/403、草稿对匿名 404、报错无堆栈详情)
|
||||
|
||||
### P1-11 cli.js 统一数据层(ora-2 P1-1)
|
||||
- `cli.js`:删除 9-51 行重复封装(DB_PATH/getDb/dbRun/dbGet/dbAll),改为 `const db = require('./db')`;`cmdStatus/cmdPassword/cmdCaptcha/cmdConfig` 改用 `db.get/db.all`;各命令 `await getDb()`
|
||||
- `data.db` 旧路径彻底退役(server.js 启动迁移逻辑保留,把旧文件搬进 data/)
|
||||
- 风险:中低;验证:备份后依次跑 `node cli.js status/config/password/captcha` 全部成功且落库
|
||||
|
||||
### P1-12 schema_version 迁移框架(ora-2 P1-2,依赖 P1-11)
|
||||
- `db.js`:`initTables()` 收敛——"确保列存在"函数(幂等补列,逻辑同现有 try/catch)+ `PRAGMA user_version` 记录迁移版本;未来迁移写成 `{ version, up() }` 数组顺序执行
|
||||
- 风险:中;验证:旧库启动自动补列无报错;连续启动两次幂等
|
||||
|
||||
### P1-13 deploy 脚本处置(ora-2 P1-3)
|
||||
- 删除 `deploy.sh` + `deploy.bat`(指向 GitHub 旧仓库,对自托管用户无意义);README 相关段落同步清理
|
||||
- 风险:低;验证:`ls` 确认删除;README 无残留引用
|
||||
|
||||
### P1-14 前端路由止血(方案 A,约 0.5 天)
|
||||
> 专项深挖结论:PJAX 只替换 `<main>`、目标页 `<script>` 从不执行,而 5 个 PJAX 壳页各自加载不同共享脚本子集 → 从某些页面进入时目标页依赖缺失(结构性缺陷 S1-3)。已确认 bug:S1-1 首页空白、S1-2 个人中心空白、S1-3 脚本依赖矩阵、S2-1 监听器重复累积、S2-2 论坛详情后退进 SSR 页、S2-4 滚动不恢复、S2-5 登录态/设置变化后导航不刷新、S2-6 login/register 白跳。
|
||||
- **A1 PJAX_PATHS 收敛**:`router.js` 只保留 5 个有 `_pageConfig` 的页面(`/`、`/blog.html`、`/forum.html`、`/admin.html`、`/passwords.html`);移除 `login.html`/`register.html`/`profile.html`(整页导航约定,根治 S1-2、S2-6)
|
||||
- **A2 统一共享脚本集**:5 个 PJAX 壳页 html 全部加载完整共享集(marked + render + captcha + music-embed + theme + api + nav + router),消除 S1-3 全部矩阵缺口
|
||||
- **A3 首页逻辑外部化**:`public/index.html:54-96` 内联 HOMEPAGE 抽为 `public/js/homepage.js` 并注册进 `_pageConfig`(修 S1-1;顺带合并 S3-4 与 blog.js 的 loadSidebar 重复)
|
||||
- **A4 `_loadScript` 判重**:`router.js:91-99` 加已加载检测,修 S2-1 监听器累积
|
||||
- **A5 popstate 分支 + 滚动**:popstate 识别 `e.state.forumPostId` 直接还原论坛详情(修 S2-2 不再 reload 进 SSR 页);navigate 时 `scrollTo(0,0)`(修 S2-4)
|
||||
- **A6 导航重渲染钩子**:loadPage 完成后调 `NAV.render?.()`(修 S2-5)
|
||||
- 风险:低(login/register/profile 当前本就走整页兜底,无回归面);A2 可能触发 S3-2 重复定义,但各文件实现相同无碍
|
||||
- 验证:passwords.html 整页进入 → PJAX 依次进博客(markdown 正常)/论坛(发帖弹验证码、详情正常)/首页;论坛详情后退回 SPA 列表无 reload;循环 PJAX 5 次 forum.js 只加载一次
|
||||
|
||||
---
|
||||
|
||||
## P2 数据库换轨 + 工程卫生(依赖 P1-11/P1-12)
|
||||
|
||||
### P2-15 better-sqlite3 迁移(lib-1 调研结论)
|
||||
- **前置**:Node 升 22 LTS(宝塔 Node 版本管理器 / nvm);README 与 Docker 示例同步(`node:20` → `node:22`)
|
||||
- 依赖:`npm install better-sqlite3`(v13,预编译随包,宝塔 glibc x64 免编译)
|
||||
- `db.js`:`initSqlJs()` 异步初始化 → `new Database(DB_PATH)` 同步;`run/get/all` 重写:
|
||||
- `run` → `db.prepare(sql).run(...params)` + `info.lastInsertRowid`
|
||||
- `get` → `stmt.get(...params)`;`all` → `stmt.all(...params)`
|
||||
- 删除 `saveDb()`/`db.export()`(SQLite 事务原生落盘);返回签名不变,40+ 业务调用点零改动
|
||||
- `initTables()` 的 CREATE TABLE IF NOT EXISTS 与幂等补列原样保留(兼容旧库)
|
||||
- `cli.js`:同套改写(P1-11 统一后只需改 db.js 一处)
|
||||
- `routes/import.js`:上传文件先落临时路径再 `new Database(tmpPath)` 只读打开,`exec` 数组结果改对象结果,用后 close
|
||||
- **数据迁移**:零迁移——现有 `data/rainweb.db` 是标准 SQLite 文件,直接打开
|
||||
- 风险:中;验证:备份 data/ 后启动 → 管理后台 CRUD 走一遍 → `node cli.js status/password` → `sqlite3 data/rainweb.db "PRAGMA integrity_check;"`
|
||||
|
||||
### P2-16 死代码 + 前端重复清理
|
||||
- 删除 `routes/links.js`(引用不存在的 links 表,挂载即崩)、`public/js/main.js`(无引用)、`public/js/passwords.js` 内重复的 `deleteFromDetail`
|
||||
- S3-1:`register.html:85` 与 `:92` 重复调用 `CAPTCHA.checkRequired('register')`,删一行
|
||||
- S3-2:`escapeHtml/closeDialog/openDialog/showSnackbar` 在 nav/render/blog/forum/admin/passwords/index/profile 各定义一份 → 收敛到 nav.js(或公共 util)单份定义;admin.js 同文件内 6 个函数重复定义(setNavStyle/setCardStyle/previewWallpaperUrl/removeWallpaper/loadWallpaperList/selectUploadedWallpaper/uploadWallpaper,admin.js:179/263 等)删后一份
|
||||
- 风险:低;验证:全站走查控制台无 404、无重复定义报错
|
||||
|
||||
### P2-17 版本单源 + 文档同步
|
||||
- `VERSION` 文件为唯一源(server.js:81 已读它);`cli.js` HELP 版本改读 VERSION;`package.json` version 与 VERSION 同步
|
||||
- `README.md`:Node 要求改 `>= 22 LTS`;删除更新/部署章节中 GitHub 引用
|
||||
- `AGENTS.md`:数据层章节重写(better-sqlite3、无 saveDb、单文件直写、cli.js 已统一)
|
||||
- 风险:低;验证:`node cli.js help` 与 `/api/version` 显示一致
|
||||
|
||||
### P2-18 可选加固(H 系列,按需确认)
|
||||
- H1:`helmet()` + 收紧 CSP(CDN 引入 marked 的 5 个页面加 SRI)——建议做
|
||||
- H3:验证码 `Math.random()` → `crypto.randomInt`(auth.js:80、email.js:63、profile.js:68)——建议做,改动极小
|
||||
- H9:bcrypt cost 10 → 12;注册用户名格式校验——建议做
|
||||
- H2:JWT 从 localStorage 改 HttpOnly cookie——改动大(前端 api.js 全量),**默认不做**,列入后续
|
||||
- H4:首启随机管理员密码——与 setup 向导冲突,**默认不做**(P0-3 已封堵接管面)
|
||||
|
||||
### P2-19 前端路由协议收编(方案 B,约 1.5-2 天,依赖 P1-14)
|
||||
> 方案 A 修完当前全部可复现 bug;方案 B 是防复发投资——解决"下一次加页面还会不会踩坑"。
|
||||
- **B1 单一注册表**:`PAGES` 配置对象 `path → { pjax, title, scripts, global, init }`,`PJAX_PATHS`/`_pageConfig`/脚本依赖全部由它派生;命中且 `pjax:true` 才拦截,其余整页导航(无兜底分支)
|
||||
- **B2 页面结构规范**:每个 PJAX 页 `<main data-page="blog">`,router 读 `data-page` 查注册表,不再硬编码路径数组;共享依赖在注册表声明,`_loadScript` 按"已加载集合"增量补齐
|
||||
- **B3 页面逻辑全外部化**:HOMEPAGE(P1-14 A3 已抽)、profile 逻辑(profile.html:43-180)抽为独立 js;内联 `<script>` 仅保留极少量数据初始化;统一 `window[global].init()` 协议
|
||||
- **B4 pushState/popstate 统一由 router 管理**:页面不再自行 pushState 非标准 state;论坛/博客详情作为"子路由"由注册表定义,popstate 由 router 解析还原(根治 S2-2/S2-3)
|
||||
- **B5 生命周期钩子**:注册表条目可选 `destroy()`,音乐嵌入/定时器/事件解绑在此处理(根治 S2-1 结构性累积)
|
||||
- 整页应用(login/register/write/embed/forum-manage/setup)标记 `pjax:false` 不参与 PJAX,保留内联脚本
|
||||
- 风险:中(popstate 语义变化与音乐嵌入销毁/重建是重点回归面;11 个 html 全动,必须全导航路径走查)
|
||||
- 验证:全导航路径走查(每页进出、后退/前进、刷新直链、登录/退出、音乐嵌入显示与自动隐藏);控制台零报错
|
||||
- 明确不做(方案 C):引入构建链/框架——违反无构建约定,对单人无测试项目负收益
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
P0-1..P0-5 (独立,并行) ≈ 半天
|
||||
P1-6..P1-10 (安全加固,独立并行)
|
||||
P1-11 cli统一 → P1-12 schema_version ≈ 1-2 天
|
||||
P1-13 / P1-14(路由止血,独立) ≈ 0.5 天
|
||||
P2-15 better-sqlite3(依赖 P1-11/12)≈ 1 天
|
||||
P2-16 / P2-17 / P2-18(独立)
|
||||
P2-19 路由协议收编(依赖 P1-14) ≈ 1.5-2 天
|
||||
```
|
||||
|
||||
> 若时间有限:P0 + P1 全部完成后项目即处于"安全 + 可正常使用"状态(路由 8 个 bug 全修);P2-19 是防复发投资,可最后做或延后。
|
||||
|
||||
## 交付节奏
|
||||
|
||||
每个 P 阶段结束:跑通验证清单 → git commit → 可随时暂停(数据文件备份在手,sql.js→better-sqlite3 前任何时刻可回滚)。
|
||||
@@ -0,0 +1,58 @@
|
||||
# RainWeb 工单系统后续完善计划
|
||||
|
||||
## 当前定位
|
||||
|
||||
RainWeb 当前已具备登录用户提交和跟踪、管理员队列处理、状态/优先级/负责人、公开回复、内部备注、基础事件时间线、筛选搜索和分页,定位为可用 MVP。
|
||||
|
||||
本计划只覆盖审查后确认的后续增强,不影响当前 Bug 修复批次。
|
||||
|
||||
## 阶段一:运营上线前(P0)
|
||||
|
||||
目标:控制滥用风险,保护工单隐私,保证管理员团队可以稳定协作。
|
||||
|
||||
| 项目 | 主要内容 | 复杂度 | 依赖 |
|
||||
| --- | --- | --- | --- |
|
||||
| 工单反滥用 | 按 IP/用户限流、未关闭工单配额、重复提交幂等键、必要时验证码 | 中 | 现有 tickets、captcha |
|
||||
| 私有附件 | 工单附件关联表、引用对象校验、私有下载鉴权、配额、孤儿清理、下载审计 | 中高 | upload、权限模型 |
|
||||
| 通知闭环 | 创建确认、公开回复、状态/负责人变化通知;投递记录、失败重试和管理员告警 | 中 | email、异步任务 |
|
||||
| 最小权限模型 | 客服、主管、只读审计角色;按队列/分类限制访问范围 | 中高 | 用户角色、工单分类 |
|
||||
| 自动化测试 | 越权访问、内部备注隔离、状态流转、并发更新、限流、附件访问、迁移兼容 | 中 | 以上后端能力 |
|
||||
|
||||
## 阶段二:处理效率(P1)
|
||||
|
||||
目标:让工单处理可衡量、可搜索、可协作。
|
||||
|
||||
- SLA 策略:按优先级/分类设置首次响应和解决目标。
|
||||
- SLA 计时:支持工作时间、节假日,并在“等待用户”时暂停。
|
||||
- 超时处理:临界提醒、违约标记、自动升级和主管队列。
|
||||
- 全文搜索:搜索回复、用户、负责人、来源和日期;数据量增长后接入 SQLite FTS5。
|
||||
- 工单协作:快捷回复模板、标签、合并/关联工单、重复反馈识别。
|
||||
- 用户体验:通知偏好、未读数、满意度评价、前台完整分页。
|
||||
- 完整审计:操作者、IP、请求 ID、前后值、后台查询和导出。
|
||||
|
||||
## 阶段三:规模化与治理(P2)
|
||||
|
||||
目标:支持长期运营、外部渠道和故障恢复。
|
||||
|
||||
- 邮件入站转工单、退信处理和邮件线程关联。
|
||||
- 附件真实 MIME/魔数检查、病毒扫描、图片重编码,必要时迁移对象存储。
|
||||
- 运营报表:积压、响应时长、解决时长、重开率、SLA 达成率和客服负载。
|
||||
- 健康检查、就绪检查、结构化日志、指标、告警和磁盘容量保护。
|
||||
- 数据保留、删除/匿名化、用户数据导出、备份加密、轮换、异地保存和恢复演练。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 先完成阶段一的权限和附件边界,再接入通知,避免通知泄漏内部数据。
|
||||
2. 在限流、分页和审计基础上实现 SLA,避免统计建立在不完整事件数据上。
|
||||
3. 最后实现邮件入站、病毒扫描、对象存储和运营报表等高复杂度能力。
|
||||
|
||||
## 暂不纳入本批次
|
||||
|
||||
本批次只修复审查发现的代码 Bug:前端请求竞态、记录排序、分页/来源地址校验、状态选项和文案一致性,以及后端限流/响应放大、并发更新、用户存在性、时间字段、事件契约和迁移兼容。上述阶段功能不会在 Bug 修复批次中顺手实现。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- SQLite FTS5:https://sqlite.org/fts5.html
|
||||
- OWASP 文件上传安全:https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
|
||||
- OWASP 日志安全:https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||
- Google SRE 监控原则:https://sre.google/sre-book/monitoring/
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# RainWeb v2 全面升级计划
|
||||
|
||||
> v1(原生 HTML/CSS/JS + PJAX)→ v2(React 前后台分离 + 后端改造)。用户确认的选型全部定案,本计划为唯一执行依据。
|
||||
> 旧 `docs/refactor-plan.md` 的后端部分(P0/P1/P2 中安全与数据库项)并入本计划;其前端路由项(P1-14 方案 A、P2-19 方案 B)**作废**——由 React Router 取代。
|
||||
> 本计划未开始任何代码改动,待用户确认后按阶段执行。
|
||||
|
||||
## 0. 选型定案(已确认,不再变更)
|
||||
|
||||
| 项 | 决定 |
|
||||
|---|---|
|
||||
| 前端框架 | **React + Vite**(TS 可选,见 §4.1) |
|
||||
| 前台 UI | **不引 UI 库**,手写组件 + 复用 `public/css/style.css`,**UI 100% 还原** |
|
||||
| 后台 | **独立应用**(独立入口 `/admin`),引入 **MUI** |
|
||||
| 工程组织 | **单 Vite 项目多入口**(前台 + 后台两个 HTML 入口) |
|
||||
| SEO | **保留 ssr.js 双轨**(/blog/:id、/forum/:id、sitemap.xml 不变) |
|
||||
| 数据库 | **better-sqlite3 + Node 22 LTS**(替换 sql.js) |
|
||||
| 后端改造 | 原 P0 止损 + P1 安全加固照旧执行 |
|
||||
|
||||
## 1. 目标架构
|
||||
|
||||
```
|
||||
浏览器
|
||||
├── 前台 SPA(/) React + React Router,构建产物 + style.css,占位符机制保留
|
||||
│ └── 导航「管理后台」按钮 → 整页跳转 /admin(浏览器级导航,非 SPA 路由、非 iframe)
|
||||
├── 后台 SPA(/admin) React + MUI,完全独立入口/独立路由/独立登录态,外部面板嵌入保留
|
||||
└── SEO 直链(/blog/:id 等) ssr.js 服务端渲染(不变)
|
||||
↓
|
||||
Express 4(server.js)
|
||||
├── /api/* 15 个路由模块(契约不变,前端重写以现有 API 为基)
|
||||
├── 静态:构建产物 + uploads/ + public/wallpaper/
|
||||
└── SPA fallback:/ → 前台 index.html;/admin* → 后台 index.html
|
||||
↓
|
||||
better-sqlite3(data/rainweb.db,零迁移直接打开)
|
||||
```
|
||||
|
||||
**前后台关系**:两个独立 React 入口(单 Vite 多入口构建),互相之间仅通过整页跳转连接。前台无后台路由、后台无前台路由;登录态共用(同一 JWT,localStorage)。
|
||||
|
||||
## 2. v1 → v2 保留 / 废弃清单
|
||||
|
||||
**保留**:后端全部(routes/、middleware/、db.js 逻辑、ssr.js、cli.js 改造后)、`public/css/style.css`(搬入前台)、`public/wallpaper/`、`uploads/`、API 契约。
|
||||
|
||||
**废弃**:`public/index.html` 等全部页面 HTML(改 React 组件)、`public/js/` 全部(api/nav/router/theme/captcha/render/music-embed/main/blog/forum/admin/passwords → 拆成 React 模块)、PJAX 机制(router.js)、`public/js/main.js` 等死代码。
|
||||
|
||||
**保留功能点(迁移时必须覆盖)**:博客瀑布流、论坛多分类/子分类/发帖回复、密码管理器(PIN + AES-256-GCM,见 §4.4)、管理面板聚合(iframe 嵌入 + proxy 代理)、壁纸/磨砂玻璃/深浅色主题、音乐嵌入(自动隐藏)、验证码(内置/recaptcha)、邮箱验证注册、公告、附件上传([image:]/[file:] 标签渲染)。
|
||||
|
||||
## 3. 阶段划分(每阶段验证 + commit,可随时暂停)
|
||||
|
||||
### Phase 0 后端止损(≈半天,独立)
|
||||
- P0-1 删除 Web 更新链路(server.js /api/update/check + /api/update/run、admin.js 前端引用、cli.js zip 分支)→ 消除 RCE 面 + `.git` 覆盖风险
|
||||
- P0-2 JWT_SECRET 启动生成随机值写入 `.env.json`(消除 R4 硬编码密钥)
|
||||
- P0-3 /api/setup/complete 加 adminOnly;/setup/status 移除 default_password(消除 R5 接管)
|
||||
- P0-4 /api/proxy/fetch 加 adminOnly + 私网地址过滤(消除 R3 SSRF)
|
||||
- P0-5 邮件验证码不回显 + 修复 pending 重建 bug(消除 R7)
|
||||
- 验证:curl 逐项(未登录 401、内网 URL 拒绝、响应无 code)
|
||||
|
||||
### Phase 1 后端安全加固(≈1-1.5 天)
|
||||
- P1-1 上传白名单 + /uploads 防护头(R1)
|
||||
- P1-2 内容净化:ssr.js + 后端出口统一 DOMPurify/转义(R2 服务端侧;前端侧随 v2 组件实现)
|
||||
- P1-3 登录/发帖限流 + 服务端验证码校验(R8,express-rate-limit)
|
||||
- P1-4 密码箱解锁限流 + PBKDF2 600k(M1)
|
||||
- P1-5 adminOnly 查库复查、草稿权限、错误信息收敛、rejectUnauthorized 收敛、avatar 白名单(M2-M7 中后端项)
|
||||
- P1-6 cli.js 统一到 db.js(消除双封装 + data.db 路径分裂)
|
||||
- P1-7 schema_version 迁移框架(幂等补列 + PRAGMA user_version)
|
||||
- 验证:curl 安全用例 + `node cli.js status/config/password` 正常
|
||||
|
||||
### Phase 2 数据层换轨 better-sqlite3(≈1 天,依赖 P1-6/P1-7)
|
||||
- 前置:Node 升 22 LTS(宝塔 Node 版本管理器);README/Docker 示例同步
|
||||
- db.js:`new Database(DB_PATH)` 同步初始化;run/get/all 重写(prepare + info.lastInsertRowid);删 saveDb/export;initTables 保留幂等补列
|
||||
- routes/import.js:上传文件落临时路径只读打开
|
||||
- 数据零迁移:现有 data/rainweb.db 直接打开;`PRAGMA integrity_check` 预检
|
||||
- 验证:备份 → 启动 → CRUD 走查 → cli 命令 → sqlite3 命令行直查
|
||||
|
||||
### Phase 3 前端工程化搭建(≈1 天)
|
||||
- 项目根新建 `frontend/`(Vite + React + React Router;多入口:`index.html`(前台)+ `admin.html`(后台))
|
||||
- 共享层:api 客户端(封装现有 /api/*,token 仍 localStorage,错误处理统一)、工具(escapeHtml/日期/分页)、主题(深浅色状态管理)
|
||||
- `style.css` 迁入前台入口;`${site_name}` 等占位符保留在构建模板中(serveIndex 运行时替换机制不变)
|
||||
- Vite 配置:构建产物输出到 `public/dist/`;`base: '/'`;多入口 rollupOptions
|
||||
- package.json scripts:`dev`(vite)、`build`(vite build)、`start`(server.js)
|
||||
- 验证:dev server 起得来、两个入口都能打开、HMR 正常
|
||||
|
||||
### Phase 4 前台页面迁移(≈3-4 天,核心工作量)
|
||||
按页面迁移为 React 组件,**视觉逐页对照还原**:
|
||||
1. 布局壳(导航栏/主题切换/壁纸/磨砂玻璃)+ React Router 路由表(/、/blog、/forum、/passwords、/login、/register、/profile、/write、/forum-manage、/embed、/setup);导航栏「管理后台」按钮 = 整页跳转 `/admin`(`<a href="/admin">`,不走 SPA 路由)
|
||||
2. 首页(瀑布流、侧栏头像/简介、最新文章)→ 原 HOMEPAGE 逻辑
|
||||
3. 博客(列表/详情/编辑,markdown + [image:]/[file:] 渲染 → 组件化 DOMPurify 净化)
|
||||
4. 论坛(分类/子分类/发帖/回复/详情)
|
||||
5. 密码管理器(PIN 解锁、加密交互,见 §4.4)
|
||||
6. 登录/注册(验证码、邮箱验证流程)
|
||||
7. 个人中心(头像上传、资料编辑)
|
||||
8. 音乐嵌入、公告、其他全局组件
|
||||
- 验证:逐页对照 v1 截图/行为走查;无控制台报错
|
||||
|
||||
### Phase 5 后台独立应用(≈2 天)
|
||||
- MUI 后台(/admin):完全独立入口与路由;入口方式 = 前台导航「管理后台」按钮整页跳转(Phase 4 已建);后台内可返回前台(跳转 /)
|
||||
- 登录态守卫(未登录跳转 /login,JWT 与前台共用);布局(侧栏导航 + 顶栏)
|
||||
- 页面:仪表盘、站点设置(含主题/壁纸/玻璃参数)、博客管理、论坛管理、用户管理、公告、面板链接(嵌入 + proxy 代理保留)、验证码/邮件配置、上传管理、密码箱(admin 视角)、更新检查入口移除(P0-1 已删)
|
||||
- 原 admin.js 600 行的逻辑按 MUI 组件重写
|
||||
- 验证:后台全功能走查 + 前台联动(改设置 → 前台生效)
|
||||
|
||||
### Phase 6 集成上线(≈1 天)
|
||||
- server.js:静态服务指向构建产物;SPA fallback 分流(/admin* → 后台入口,其余 → 前台入口);serveIndex 占位符对构建产物生效
|
||||
- 旧 public/*.html 与 public/js/ 全部退役删除(ssr.js 引用除外——核对 ssr.js 是否引用 style.css/占位符,保留所需)
|
||||
- SEO 双轨验证:/blog/:id、/forum/:id、sitemap.xml、robots.txt 行为不变
|
||||
- 部署流程更新:宝塔 = npm install → npm run build → npm start
|
||||
- README/AGENTS.md 全量更新(v2 架构、命令、构建链说明)
|
||||
|
||||
## 4. 关键技术决策
|
||||
|
||||
### 4.1 TypeScript?
|
||||
默认**不用 TS**(v1 全 JS,单人无测试,减少迁移摩擦);如用户要 TS 可启用(React + Vite TS 模板),代价是迁移工作量 +20%。→ 计划默认 JS,待确认。
|
||||
|
||||
### 4.2 构建产物与占位符
|
||||
Vite 构建产物 index.html 保留 `${site_name}`/`${site_description}`/`${site_favicon}` 文本,serveIndex 运行时替换机制不变(构建模板中写占位符即可)。后台入口同理。
|
||||
|
||||
### 4.3 路由与 fallback
|
||||
- 前台:React Router(history 模式),server.js `app.get('*')` 回前台入口
|
||||
- 后台:/admin 前缀路由,server.js 增加 `/admin*` → 后台入口(注意在 SPA catch-all 前)
|
||||
- SSR 路由(/blog/:id、/forum/:id)优先级高于 SPA fallback(现状已如此,保持)
|
||||
|
||||
### 4.4 密码管理器加密
|
||||
v1 为"服务端保管密钥"模型(PIN 明文到服务端派生)。v2 前台重写时**默认保持行为不变**(服务端派生 + PBKDF2 600k,Phase 1 已加固)。真正 E2E(WebCrypto 客户端派生)列为 v2 后续可选项,不在本次范围。
|
||||
|
||||
### 4.5 音乐嵌入
|
||||
v1 的 music-embed(自动隐藏、位置、超时)迁移为 React 全局组件,行为参数从 site_settings 读取,逻辑照搬。
|
||||
|
||||
### 4.6 验证码
|
||||
captcha.js 前端逻辑(内置 SVG 验证码 + recaptcha)React 化;服务端校验在 Phase 1 已接通(P1-3)。
|
||||
|
||||
## 5. 验证策略(无测试环境)
|
||||
|
||||
- 每阶段:curl API 用例 + 浏览器手动走查 + `node cli.js` 命令
|
||||
- Phase 4/5:逐页对照 v1 行为清单(§2 保留功能点逐项打勾)
|
||||
- 上线前:完整回归清单(登录/发帖/上传/密码箱/主题/面板嵌入/SEO 直链)
|
||||
- 可选:引入 Vitest + React Testing Library 对共享层(api 客户端、渲染函数)补少量单测——默认不做,待确认
|
||||
|
||||
## 6. 风险与回滚
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 前端重写回归(11 页功能面广) | 阶段化推进、§2 功能清单逐项走查;v1 代码保留到 Phase 6 验收通过再删 |
|
||||
| 密码管理器迁移出错 | 加密逻辑独立模块化迁移,Phase 4 单独验收 |
|
||||
| better-sqlite3 换轨 | 数据零迁移 + 备份 + PRAGMA 预检;失败可随时回滚 db.js(v1 代码在 git 历史) |
|
||||
| 构建链引入部署变化 | Phase 6 单独验收部署流程;dev(vite dev)+ prod(build)双路径文档化 |
|
||||
| SEO 回归 | ssr.js 双轨不变,Phase 6 专门验证直链 |
|
||||
|
||||
## 7. 工时总览
|
||||
|
||||
| 阶段 | 内容 | 工时 |
|
||||
|---|---|---|
|
||||
| Phase 0 | 后端止损 | 0.5 天 |
|
||||
| Phase 1 | 后端安全加固 + cli 统一 + 迁移框架 | 1-1.5 天 |
|
||||
| Phase 2 | better-sqlite3 换轨 | 1 天 |
|
||||
| Phase 3 | 前端工程化搭建 | 1 天 |
|
||||
| Phase 4 | 前台页面迁移 | 3-4 天 |
|
||||
| Phase 5 | 后台独立应用(MUI) | 2 天 |
|
||||
| Phase 6 | 集成上线 + 文档 | 1 天 |
|
||||
| **合计** | | **9.5-11.5 天** |
|
||||
|
||||
每阶段结束:验证清单通过 → git commit → 可暂停验收。
|
||||
@@ -0,0 +1,83 @@
|
||||
# 工作台模块项目计划(tools/workbench)
|
||||
|
||||
> 独立完整项目流程:调研 ✓ → 设计规范 ✓ → 本计划 → 实现 → 审查 → 验证。
|
||||
> 布局调研:lib-1(VSCode 四层 / Dashy Workspace / Vivaldi Web Panels / Arc pinned / Homepage groups + iframe 技术建议)。
|
||||
> 设计规范:frontend-pack/design skill(8px 网格、语义色 token、层次/留白/焦点环/深浅色/加载空错误态)。
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
「面板工作台」:浏览器式多面板聚合界面(左侧侧边栏 + 顶部工具栏 + 右侧 iframe 内容区),叠加密码库快捷取用。仅管理员(adminOnly)可进入。
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
| 项 | 决定 |
|
||||
|---|---|
|
||||
| 风格 | **MUI**(与后台一致,MD3 主题复用 frontend/src/admin/theme.js) |
|
||||
| 路由 | 独立页面 `/workbench`(React Router,挂管理后台路由树外或独立 BrowserRouter 区——用后台同一入口 admin 应用内加路由最简,basename /admin 下 `/admin/workbench`?——**决定:独立入口**(admin.html 已有),路由挂 `/admin/workbench`,与 Dashboard 等并列,顶栏入口按钮跳转;整页全屏布局不带 AdminLayout 侧栏(工作台自带侧边栏) |
|
||||
| 代码位置 | `frontend/src/tools/workbench/`(独立工具区,页面+组件+密码库组件+工具函数全在这,方便后续升级) |
|
||||
| 鉴权 | 入口守卫:无 token → 跳 /login.html;非 admin → 403 提示返回(复用后台守卫模式) |
|
||||
| 数据 | admin_links API(复用 frontend/src/api/adminLinks.js)+ passwords API(复用) |
|
||||
| proxy | `/api/proxy/fetch?url=&token=`(iframe query token,复用 Embed.jsx 的 needsProxy 逻辑) |
|
||||
|
||||
## 3. 文件结构
|
||||
|
||||
```
|
||||
frontend/src/tools/workbench/
|
||||
├── Workbench.jsx # 页面入口(路由挂载点 + 鉴权守卫 + 布局组装)
|
||||
├── components/
|
||||
│ ├── Toolbar.jsx # 顶部工具栏(◀▶↻ 地址栏 搜索 添加 密码库 侧栏开关 返回)
|
||||
│ ├── Sidebar.jsx # 左侧边栏(固定区 + 分组列表 + 折叠 + 拖拽调宽可选)
|
||||
│ ├── PanelFrame.jsx # iframe 内容区(加载态/超时/失败回退/LRU 保留)
|
||||
│ ├── AddPanelDialog.jsx # 添加面板弹窗(URL/命名/分组/图标 + 打开方式)
|
||||
│ └── VaultDrawer.jsx # 密码库抽屉(PIN 解锁/搜索/复制,MUI)
|
||||
├── hooks/
|
||||
│ ├── usePanels.js # 面板数据/打开集合/历史栈/持久化(localStorage)
|
||||
│ └── useVault.js # 密码库解锁状态/条目加载(复用 passwords API)
|
||||
└── index.js # 模块导出(便于未来升级替换)
|
||||
```
|
||||
|
||||
## 4. 功能清单
|
||||
|
||||
### 核心(必须)
|
||||
- [ ] 鉴权守卫(adminOnly)
|
||||
- [ ] 工具栏:◀ 后退 / ▶ 前进(当前面板历史栈)、↻ 刷新、只读地址栏、🔍 搜索(面板名/URL/分组)、+添加、🔑 密码库、侧边栏折叠、返回前台
|
||||
- [ ] 侧边栏:admin_links 按 category 分组 + 固定区(pin),点击切换,当前项左侧 2px primary 指示条 + surface-container 底(MUI 化:ListItemButton selected 态)
|
||||
- [ ] iframe 区:懒加载(首次激活创建)、display:none 切换保留状态、**超时+load 双保险加载态**、失败(超时/空白启发式)显示「在新标签打开」回退按钮
|
||||
- [ ] 打开方式:内嵌(默认)/ 新标签(target=_blank)/ 弹窗(modal)——面板项右键或添加时选择,存面板设置
|
||||
- [ ] 添加面板弹窗:URL + 标题 + 分组 + 图标(favicon 预览)+ 打开方式;保存到 admin_links(复用现有 API)
|
||||
- [ ] localStorage 持久化:打开面板集合、当前面板、历史栈、侧边栏折叠态
|
||||
- [ ] 密码库抽屉:PIN 解锁(复用 /api/passwords/unlock,服务端会话互通)→ 条目列表(标题/用户名)+ 搜索 + 详情(密码明文)+ 一键复制(clipboard);未设 PIN 引导去 /passwords.html;只读取用不编辑
|
||||
- [ ] 空态/加载态/错误态:无面板引导添加、加载骨架、加载失败重试
|
||||
|
||||
### 进阶(尽力)
|
||||
- [ ] LRU iframe 保留(最多 3 个活跃,超出销毁最久未用,记录 URL 重建)
|
||||
- [ ] 每面板定时刷新(可选 30s/5min/30min)
|
||||
- [ ] 侧边栏拖拽调宽 + 记忆
|
||||
- [ ] 分组折叠状态持久化
|
||||
|
||||
## 5. 设计规范(design skill + 调研要点)
|
||||
|
||||
- 8px 网格间距;一屏内不超过 2 种圆角(MUI 默认 8px/20px pill)
|
||||
- 语义色 token(MD3):工具栏/侧边栏 surface-container、选中 primary-container、hover surface-variant
|
||||
- 选中态:左侧 2px primary 指示条(VSCode 模式)或 MUI selected 态,二选一统一
|
||||
- 深色模式默认适配(后台已有 data-theme 机制)
|
||||
- 焦点环(focus-visible)、语义 HTML(nav/main/aside/button)、触控目标 ≥40px
|
||||
- 加载态用骨架/居中 spinner;空态带图标+CTA;错误态带重试
|
||||
- iframe title 属性必须;allow 权限策略按面板
|
||||
- 工具栏高度 48-56px、侧边栏默认 240px(可 180-360 拖拽)
|
||||
|
||||
## 6. 实施与质量流程
|
||||
|
||||
1. 派 designer 按本计划实现(一个会话,MUI + 复用后台主题/api)
|
||||
2. ora 审查(鉴权/安全/iframe 处理/状态管理/无障碍)→ 修复
|
||||
3. 验证:npm run build + 后台入口跳转 + admin 权限拦截 + 面板切换/proxy/密码库全链路冒烟
|
||||
4. commit + 用户验收
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] 非 admin 无法进入(无 token 跳登录、非 admin 403)
|
||||
- [ ] 面板分组/固定/切换/关闭/添加/持久化全部可用
|
||||
- [ ] proxy 面板正常嵌入;X-Frame-Options 拒绝的面板有回退提示
|
||||
- [ ] 密码库解锁→搜索→复制链路通(与服务端会话互通)
|
||||
- [ ] 深浅色、320/768/1440 三档响应式正常
|
||||
- [ ] build 通过、无 console 错误
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${site_name}</title>
|
||||
<meta name="description" content="${site_description}">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="icon" href="${site_favicon}">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from './theme.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import Blog from './pages/Blog.jsx';
|
||||
import BlogDetail from './pages/BlogDetail.jsx';
|
||||
import Tag from './pages/Tag.jsx';
|
||||
import Archive from './pages/Archive.jsx';
|
||||
import Forum from './pages/Forum.jsx';
|
||||
import ForumCategory from './pages/ForumCategory.jsx';
|
||||
import ForumDetail from './pages/ForumDetail.jsx';
|
||||
import ForumManagePanel from './pages/ForumManagePanel.jsx';
|
||||
import ForumManageCategory from './pages/ForumManageCategory.jsx';
|
||||
import UserProfile from './pages/UserProfile.jsx';
|
||||
import Passwords from './pages/Passwords.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Register from './pages/Register.jsx';
|
||||
import Profile from './pages/Profile.jsx';
|
||||
import Write from './pages/Write.jsx';
|
||||
import Embed from './pages/Embed.jsx';
|
||||
import Setup from './pages/Setup.jsx';
|
||||
import Tickets from './pages/Tickets.jsx';
|
||||
import TicketCreate from './pages/TicketCreate.jsx';
|
||||
import TicketDetail from './pages/TicketDetail.jsx';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Routes>
|
||||
{/* 前台布局壳:导航 / 壁纸 / 页脚 / 音乐嵌入 / 验证码弹窗 */}
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/blog.html" element={<Blog />} />
|
||||
<Route path="/blog/:id" element={<BlogDetail />} />
|
||||
<Route path="/tag/:name" element={<Tag />} />
|
||||
<Route path="/archive.html" element={<Archive />} />
|
||||
<Route path="/forum.html" element={<Forum />} />
|
||||
<Route path="/forum/c/:id" element={<ForumCategory />} />
|
||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||
<Route path="/tickets.html" element={<Tickets />} />
|
||||
<Route path="/tickets/new" element={<TicketCreate />} />
|
||||
<Route path="/tickets/:id" element={<TicketDetail />} />
|
||||
<Route path="/forum/manage" element={<ForumManagePanel />} />
|
||||
<Route path="/forum/manage/:id" element={<ForumManageCategory />} />
|
||||
<Route path="/u/:id" element={<UserProfile />} />
|
||||
<Route path="/passwords.html" element={<Passwords />} />
|
||||
<Route path="/login.html" element={<Login />} />
|
||||
<Route path="/register.html" element={<Register />} />
|
||||
<Route path="/profile.html" element={<Profile />} />
|
||||
<Route path="/write.html" element={<Write />} />
|
||||
{/* 前台论坛管理页已退役 → 后台 /admin/forum(服务端另有 302) */}
|
||||
<Route path="/forum-manage.html" element={<Navigate to="/admin/forum" replace />} />
|
||||
<Route path="/embed.html" element={<Embed />} />
|
||||
<Route path="/setup.html" element={<Setup />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTheme as useMuiTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import GridViewIcon from '@mui/icons-material/GridView';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { logout } from '../api/auth.js';
|
||||
import SnackHost, { showSnack } from './snack.jsx';
|
||||
import { NAV_GROUPS, searchNav } from './navConfig.js';
|
||||
|
||||
/** 等元素挂载(跨路由跳转后目标页异步渲染,最多轮询 ~2s) */
|
||||
function waitForId(id, tries = 40) {
|
||||
return document.getElementById(id)
|
||||
? Promise.resolve()
|
||||
: tries > 0
|
||||
? new Promise((res) => setTimeout(() => res(waitForId(id, tries - 1)), 50))
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
/** 顶栏搜索下拉最多展示条数 */
|
||||
const SEARCH_LIMIT = 5;
|
||||
|
||||
/** 后台布局:AppBar(中间设置搜索 + 工作台/返回前台/退出)+ Drawer(分组折叠导航)+ 内容区 Outlet */
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const muiTheme = useMuiTheme();
|
||||
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// 分组折叠状态:默认展开全部(单成员组平铺,不参与折叠)
|
||||
const [openGroups, setOpenGroups] = useState(() => (
|
||||
new Set(NAV_GROUPS.filter((g) => g.items.length > 1).map((g) => g.id))
|
||||
));
|
||||
|
||||
// 设置搜索(顶栏中间;结果最多 SEARCH_LIMIT 条)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
const results = useMemo(() => searchNav(searchQuery, SEARCH_LIMIT), [searchQuery]);
|
||||
|
||||
// 当前项所在组自动展开
|
||||
useEffect(() => {
|
||||
const current = NAV_GROUPS.find((g) => g.items.some((it) => location.pathname.startsWith(it.path)));
|
||||
if (current && current.items.length > 1 && !openGroups.has(current.id)) {
|
||||
setOpenGroups((prev) => new Set(prev).add(current.id));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname]);
|
||||
|
||||
// Esc 关闭搜索下拉
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') setSearchOpen(false); };
|
||||
if (searchOpen) window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [searchOpen]);
|
||||
|
||||
const toggleGroup = (id) => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const goMenu = (path) => {
|
||||
navigate(path);
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const jump = (r) => {
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
if (r.type === 'menu') { goMenu(r.path); return; }
|
||||
// 设置区块:跳转页面后定位 + 高亮对应 Paper
|
||||
const scroll = () => {
|
||||
const el = document.getElementById(r.anchor);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
const c = muiTheme.palette.primary.main;
|
||||
el.style.transition = 'box-shadow 0.5s ease';
|
||||
el.style.boxShadow = `0 0 0 3px ${c}`;
|
||||
window.setTimeout(() => { el.style.boxShadow = 'none'; el.style.transition = ''; }, 1800);
|
||||
};
|
||||
if (location.pathname === r.path) scroll();
|
||||
else { navigate(r.path); waitForId(r.anchor).then(scroll); }
|
||||
};
|
||||
|
||||
const onSearchChange = (v) => {
|
||||
setSearchQuery(v);
|
||||
setSearchOpen(v.trim().length >= 1);
|
||||
};
|
||||
|
||||
const onSearchKeyDown = (e) => {
|
||||
if (e.key === 'Escape') { setSearchOpen(false); return; }
|
||||
if (e.key === 'Enter' && results.length === 1) { jump(results[0]); }
|
||||
};
|
||||
|
||||
/** 顶栏搜索框 + 结果下拉(桌面居中 / 移动端全宽第二行) */
|
||||
const searchField = (
|
||||
<>
|
||||
<OutlinedInput
|
||||
inputRef={searchRef}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="搜索设置…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
onFocus={() => { if (searchQuery.trim()) setSearchOpen(true); }}
|
||||
startAdornment={<InputAdornment position="start"><SearchIcon sx={{ fontSize: 18, color: 'text.secondary' }} /></InputAdornment>}
|
||||
endAdornment={searchQuery ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" edge="end" onClick={() => onSearchChange('')} title="清空">
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null}
|
||||
sx={{
|
||||
height: 38, borderRadius: 3,
|
||||
bgcolor: 'action.hover',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'divider' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' },
|
||||
}}
|
||||
/>
|
||||
{/* 结果下拉:最多 5 条,maxHeight 280 ≈ 5 个列表项卡片高度 */}
|
||||
<Popover
|
||||
open={searchOpen}
|
||||
anchorEl={searchRef.current}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: 320, maxHeight: 280, mt: 0.5, borderRadius: 2, boxShadow: 8,
|
||||
overflow: 'auto', border: '1px solid', borderColor: 'divider',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<List dense disablePadding sx={{ py: 0.5 }}>
|
||||
{results.length === 0 ? (
|
||||
<ListItem>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: 13 }}>没有匹配的设置</Typography>
|
||||
</ListItem>
|
||||
) : results.map((r) => (
|
||||
<MenuItem key={r.type + r.path + (r.anchor || '')} dense onClick={() => jump(r)}>
|
||||
<ListItemIcon sx={{ minWidth: 34 }}>{r.icon ? <r.icon fontSize="small" /> : <SearchIcon fontSize="small" />}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={r.label}
|
||||
secondary={r.group}
|
||||
primaryTypographyProps={{ fontSize: 13.5 }}
|
||||
slotProps={{ secondary: { fontSize: 11.5 } }}
|
||||
/>
|
||||
{r.type === 'section' && (
|
||||
<Box component="span" sx={{ ml: 1, fontSize: 11, color: 'text.disabled', flexShrink: 0 }}>设置项</Box>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
||||
/** 单成员组平铺渲染,多成员组显示组头 + Collapse 折叠 */
|
||||
const renderGroup = (g) => {
|
||||
const single = g.items.length === 1;
|
||||
const open = openGroups.has(g.id);
|
||||
const itemList = (
|
||||
<List component="div" disablePadding>
|
||||
{g.items.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
selected={location.pathname.startsWith(item.path)}
|
||||
onClick={() => { goMenu(item.path); }}
|
||||
sx={{ pl: single ? 2 : 4 }}
|
||||
>
|
||||
<ListItemIcon>{item.icon ? <item.icon fontSize="small" /> : null}</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: 14 }} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
const items = single ? itemList : (
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
{itemList}
|
||||
</Collapse>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box key={g.id}>
|
||||
{single ? null : (
|
||||
<ListSubheader
|
||||
component="div"
|
||||
disableSticky
|
||||
onClick={() => toggleGroup(g.id)}
|
||||
sx={{
|
||||
cursor: 'pointer', userSelect: 'none', display: 'flex', alignItems: 'center',
|
||||
gap: 0.5, fontSize: 12, fontWeight: 700, letterSpacing: 0.06, lineHeight: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>{g.label}</Box>
|
||||
{open ? <ExpandLessIcon sx={{ fontSize: 16, color: 'text.disabled' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'text.disabled' }} />}
|
||||
</ListSubheader>
|
||||
)}
|
||||
{items}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Toolbar />
|
||||
<List dense>{NAV_GROUPS.map(renderGroup)}</List>
|
||||
</>
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
showSnack('已退出登录');
|
||||
setTimeout(() => { window.location.href = '/login.html'; }, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
||||
{/* 顶行:汉堡(移动) + 标题(左) + 搜索(桌面中) + 操作按钮(右) */}
|
||||
<Toolbar sx={{ gap: 1, minHeight: isMobile ? 56 : 64 }}>
|
||||
{isMobile && (
|
||||
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 0.5 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" noWrap sx={{ flexShrink: 0, fontSize: { xs: 18, sm: 22 } }}>管理后台</Typography>
|
||||
{!isMobile && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', minWidth: 0, px: 2 }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 480 }}>{searchField}</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0, sm: 0.5 }, ml: 'auto', flexShrink: 0 }}>
|
||||
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
|
||||
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
|
||||
</Button>
|
||||
<Button color="inherit" href="/" title="返回前台" sx={{ px: { xs: 1, sm: 1.5 } }}>
|
||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>返回前台</Box>
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleLogout} sx={{ px: { xs: 1, sm: 1.5 } }}>
|
||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>退出</Box>
|
||||
</Button>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
{/* 移动:第二行全宽搜索(顶栏空间不足,收窄到独立一行不拥挤) */}
|
||||
{isMobile && (
|
||||
<Toolbar variant="dense" sx={{ pt: 0, pb: 1, gap: 1, minHeight: 48 }}>
|
||||
{searchField}
|
||||
</Toolbar>
|
||||
)}
|
||||
</AppBar>
|
||||
|
||||
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
|
||||
{isMobile ? (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Drawer variant="permanent" sx={{ width: 240, '& .MuiDrawer-paper': { width: 240 } }}>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box component="main" sx={{ flex: '1 1 auto', width: { xs: '100%', md: 'calc(100vw - 240px)' }, maxWidth: { xs: '100%', md: 'calc(100vw - 240px)' }, boxSizing: 'border-box', p: { xs: 2, md: 3 }, minWidth: 0, overflowX: 'hidden' }}>
|
||||
{/* AppBar 占位:桌面 64 / 移动 56 + 搜索行 48 */}
|
||||
<Box sx={{ height: isMobile ? 104 : 64 }} />
|
||||
<Outlet />
|
||||
</Box>
|
||||
<SnackHost />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
/** 通用确认弹窗 */
|
||||
export default function ConfirmDialog({ open, title = '确认操作', message, onClose, onConfirm, confirmText = '确认', danger = true }) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent><Typography variant="body1">{message}</Typography></DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button color={danger ? 'error' : 'primary'} variant="contained" onClick={onConfirm}>{confirmText}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { createMD3Theme } from './theme.js';
|
||||
import { THEME_SETTINGS_EVENT } from '../themeTokens.js';
|
||||
import './theme.css';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getSettings } from '../api/settings.js';
|
||||
import AdminLayout from './AdminLayout.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import CaptchaSettings from './pages/CaptchaSettings.jsx';
|
||||
import ThemeSettings from './pages/ThemeSettings.jsx';
|
||||
import Homepage from './pages/Homepage.jsx';
|
||||
import EmailSettings from './pages/EmailSettings.jsx';
|
||||
import BlogManage from './pages/BlogManage.jsx';
|
||||
import CommentManage from './pages/CommentManage.jsx';
|
||||
import ForumManage from './pages/ForumManage.jsx';
|
||||
import PostManage from './pages/PostManage.jsx';
|
||||
import RssManage from './pages/RssManage.jsx';
|
||||
import Users from './pages/Users.jsx';
|
||||
import Announcements from './pages/Announcements.jsx';
|
||||
import Links from './pages/Links.jsx';
|
||||
import Uploads from './pages/Uploads.jsx';
|
||||
import ImportDb from './pages/ImportDb.jsx';
|
||||
import TicketManage from './pages/TicketManage.jsx';
|
||||
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
||||
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
||||
|
||||
// 生产:/admin/* 由后端 fallback 到 dist/admin.html(Phase 6 需求),basename=/admin;
|
||||
// 开发:vite 多入口直接访问 /admin.html,basename 自适应为空。
|
||||
const pathname = window.location.pathname;
|
||||
const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/admin' : '';
|
||||
|
||||
function AdminApp() {
|
||||
const [status, setStatus] = useState('checking'); // checking | ok
|
||||
const [themeSettings, setThemeSettings] = useState({ primary_color: '#6750a4', glass_opacity: '0.6', theme_force_dark: '0' });
|
||||
|
||||
const preferredTheme = localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
|
||||
const dark = themeSettings.theme_force_dark === '1' || preferredTheme === 'dark';
|
||||
useEffect(() => {
|
||||
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
|
||||
else document.documentElement.removeAttribute('data-theme');
|
||||
}, [dark]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') {
|
||||
window.alert('需要管理员权限');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
setStatus('ok');
|
||||
})
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
getSettings()
|
||||
.then((s) => setThemeSettings((current) => ({ ...current, ...s })))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeUpdate = (event) => {
|
||||
if (event.detail && typeof event.detail === 'object') {
|
||||
setThemeSettings((current) => ({ ...current, ...event.detail }));
|
||||
}
|
||||
};
|
||||
window.addEventListener(THEME_SETTINGS_EVENT, handleThemeUpdate);
|
||||
return () => window.removeEventListener(THEME_SETTINGS_EVENT, handleThemeUpdate);
|
||||
}, []);
|
||||
|
||||
const theme = useMemo(() => createMD3Theme({
|
||||
mode: dark ? 'dark' : 'light',
|
||||
primary: themeSettings.primary_color || '#6750a4',
|
||||
glassOpacity: themeSettings.glass_opacity,
|
||||
}), [dark, themeSettings.primary_color, themeSettings.glass_opacity]);
|
||||
|
||||
if (status !== 'ok') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter basename={basename}>
|
||||
<Routes>
|
||||
{/* 工作台:独立全屏布局,不套 AdminLayout(自带 Toolbar+Sidebar+内容区);懒加载分包 */}
|
||||
<Route
|
||||
path="/workbench"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/captcha" element={<CaptchaSettings />} />
|
||||
<Route path="/theme" element={<ThemeSettings />} />
|
||||
<Route path="/homepage" element={<Homepage />} />
|
||||
<Route path="/email" element={<EmailSettings />} />
|
||||
<Route path="/blog" element={<BlogManage />} />
|
||||
<Route path="/comments" element={<CommentManage />} />
|
||||
<Route path="/forum" element={<ForumManage />} />
|
||||
<Route path="/posts" element={<PostManage />} />
|
||||
<Route path="/rss" element={<RssManage />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/announcements" element={<Announcements />} />
|
||||
<Route path="/links" element={<Links />} />
|
||||
<Route path="/uploads" element={<Uploads />} />
|
||||
<Route path="/import" element={<ImportDb />} />
|
||||
<Route path="/tickets" element={<TicketManage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<AdminApp />);
|
||||
@@ -0,0 +1,191 @@
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
|
||||
import ForumIcon from '@mui/icons-material/Forum';
|
||||
import ListAltIcon from '@mui/icons-material/ListAlt';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import CampaignIcon from '@mui/icons-material/Campaign';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import RssFeedIcon from '@mui/icons-material/RssFeed';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
|
||||
|
||||
/**
|
||||
* 后台侧边栏导航配置(单一数据源):
|
||||
* - NAV_GROUPS 分组结构,供侧边栏渲染
|
||||
* - NAV_SECTIONS 各设置页区块锚点(id 与页面 Paper 的 id 对应),供搜索定位
|
||||
* - NAV_ALIASES 别名映射,把常用叫法/英文指到现有条目
|
||||
* - searchNav() 前端本地过滤(无需后端)
|
||||
*/
|
||||
export const NAV_GROUPS = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: '概览',
|
||||
items: [
|
||||
{ path: '/dashboard', label: '仪表盘', icon: DashboardIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
label: '内容管理',
|
||||
items: [
|
||||
{ path: '/blog', label: '博客管理', icon: ArticleIcon },
|
||||
{ path: '/comments', label: '评论管理', icon: ChatBubbleOutlineOutlinedIcon },
|
||||
{ path: '/announcements', label: '公告管理', icon: CampaignIcon },
|
||||
{ path: '/forum', label: '论坛管理', icon: ForumIcon },
|
||||
{ path: '/posts', label: '帖子管理', icon: ListAltIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
label: '反馈处理',
|
||||
items: [
|
||||
{ path: '/tickets', label: '工单管理', icon: SupportAgentIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: '用户',
|
||||
items: [
|
||||
{ path: '/users', label: '用户管理', icon: PeopleIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
label: '外观',
|
||||
items: [
|
||||
{ path: '/theme', label: '主题', icon: PaletteIcon },
|
||||
{ path: '/homepage', label: '首页设置', icon: HomeIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '系统设置',
|
||||
items: [
|
||||
{ path: '/settings', label: '站点设置', icon: SettingsIcon },
|
||||
{ path: '/captcha', label: '验证码', icon: VerifiedUserIcon },
|
||||
{ path: '/email', label: '邮件配置', icon: EmailIcon },
|
||||
{ path: '/rss', label: 'RSS 订阅', icon: RssFeedIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: '工具',
|
||||
items: [
|
||||
{ path: '/links', label: '面板链接', icon: LinkIcon },
|
||||
{ path: '/uploads', label: '附件管理', icon: AttachFileIcon },
|
||||
{ path: '/import', label: '数据导入', icon: UploadFileIcon },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 各设置页区块:anchor 必须与页面 Paper/Box 的 id 一致;keywords 为搜索词 */
|
||||
export const NAV_SECTIONS = [
|
||||
// 站点设置 /settings
|
||||
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-basic', label: '基本设置', keywords: ['基本信息', '网站名称', '网站描述', '网站域名', 'favicon', '图标'] },
|
||||
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-footer', label: '页脚设置', keywords: ['页脚', 'footer', '版权', '分栏导航', '页脚栏目', 'Powered'] },
|
||||
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-search-verify', label: '搜索引擎验证', keywords: ['SEO', 'Bing', 'Google', 'Yandex', '站长工具', '验证文件'] },
|
||||
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-rainid', label: 'RainID 单点登录', keywords: ['SSO', '单点登录', 'OAuth', 'OIDC', 'client', '注册跳转'] },
|
||||
// 主题 /theme
|
||||
{ page: '/theme', pageLabel: '主题', anchor: 'sec-primary-color', label: '主题色', keywords: ['颜色', '主色调', '配色', 'primary'] },
|
||||
{ page: '/theme', pageLabel: '主题', anchor: 'sec-wallpaper', label: '壁纸背景', keywords: ['wallpaper', '背景图', '背景'] },
|
||||
{ page: '/theme', pageLabel: '主题', anchor: 'sec-styles', label: '样式设置', keywords: ['导航栏', '卡片', 'nav', '磨砂'] },
|
||||
{ page: '/theme', pageLabel: '主题', anchor: 'sec-glass', label: '玻璃效果', keywords: ['磨砂玻璃', '模糊', '透明度', 'glass', 'blur'] },
|
||||
{ page: '/theme', pageLabel: '主题', anchor: 'sec-dark', label: '深色模式', keywords: ['暗色', 'dark', '夜间模式'] },
|
||||
// 首页设置 /homepage
|
||||
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-profile', label: '个人信息', keywords: ['头像', '简介', 'bio', '联系链接'] },
|
||||
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-home-content', label: '主页内容', keywords: ['正文', 'markdown', '内容'] },
|
||||
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-music', label: '音乐嵌入', keywords: ['播放器', '网易云', 'music', 'embed'] },
|
||||
// 邮件配置 /email
|
||||
{ page: '/email', pageLabel: '邮件配置', anchor: 'sec-smtp', label: 'SMTP 服务器', keywords: ['smtp', '主机', '端口', '服务器'] },
|
||||
{ page: '/email', pageLabel: '邮件配置', anchor: 'sec-sender', label: '发件人信息', keywords: ['发件人', 'from', '邮箱'] },
|
||||
// 验证码 /captcha
|
||||
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-type', label: '验证码类型', keywords: ['captcha', '类型', '内置'] },
|
||||
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-scope', label: '验证场景', keywords: ['登录验证', '注册验证', '发帖验证'] },
|
||||
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-recaptcha', label: 'reCAPTCHA 配置', keywords: ['Google', 'recaptcha', 'site key'] },
|
||||
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-turnstile', label: 'Turnstile 配置', keywords: ['Cloudflare', 'turnstile', 'site key'] },
|
||||
// RSS /rss
|
||||
{ page: '/rss', pageLabel: 'RSS 订阅', anchor: 'sec-rss-sources', label: '订阅源', keywords: ['源', 'feed', '订阅'] },
|
||||
{ page: '/rss', pageLabel: 'RSS 订阅', anchor: 'sec-rss-content', label: '内容设置', keywords: ['全文', '摘要', '条目数'] },
|
||||
// 博客管理 /blog
|
||||
{ page: '/blog', pageLabel: '博客管理', anchor: 'sec-blog-sidebar', label: '博客侧栏', keywords: ['侧边栏', '头像', '简介', '显示'] },
|
||||
{ page: '/blog', pageLabel: '博客管理', anchor: 'sec-comments', label: '评论设置', keywords: ['评论审核', '评论通知', '审核', '通知', 'moderate', 'UID', '#id', '编号'] },
|
||||
// 论坛管理 /forum
|
||||
{ page: '/forum', pageLabel: '论坛管理', anchor: 'sec-forum-settings', label: '论坛设置', keywords: ['访客可见', '游客', '私密', 'guest', '权限'] },
|
||||
// 面板链接 /links
|
||||
{ page: '/links', pageLabel: '面板链接', anchor: 'sec-proxy', label: '面板代理', keywords: ['代理', 'proxy', '内网', '白名单', 'SSRF', 'iframe'] },
|
||||
];
|
||||
|
||||
/** 别名映射:把常见叫法指到已有菜单项或设置区块(path + 可选 anchor) */
|
||||
export const NAV_ALIASES = [
|
||||
{ keywords: ['工单', '反馈', 'bug', '问题', 'ticket'], path: '/tickets' },
|
||||
{ keywords: ['邮件', 'email', 'smtp'], path: '/email' },
|
||||
{ keywords: ['验证码', 'captcha', 'reCAPTCHA', 'turnstile'], path: '/captcha' },
|
||||
{ keywords: ['rss', '订阅', 'feed'], path: '/rss' },
|
||||
{ keywords: ['favicon', '图标'], path: '/settings', anchor: 'sec-basic' },
|
||||
{ keywords: ['单点登录', 'sso', 'rainid'], path: '/settings', anchor: 'sec-rainid' },
|
||||
{ keywords: ['壁纸', 'wallpaper'], path: '/theme', anchor: 'sec-wallpaper' },
|
||||
{ keywords: ['评论', '审核'], path: '/blog', anchor: 'sec-comments' },
|
||||
{ keywords: ['代理', '内网'], path: '/links', anchor: 'sec-proxy' },
|
||||
];
|
||||
|
||||
/** 搜索索引:菜单项 + 设置区块 + 别名(模块加载时构建一次) */
|
||||
function buildIndex() {
|
||||
const index = [];
|
||||
NAV_GROUPS.forEach((g) => {
|
||||
g.items.forEach((item) => {
|
||||
index.push({
|
||||
type: 'menu',
|
||||
label: item.label,
|
||||
group: g.label,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
keywords: [item.label],
|
||||
});
|
||||
});
|
||||
});
|
||||
NAV_SECTIONS.forEach((sec) => {
|
||||
const pageItem = index.find((e) => e.type === 'menu' && e.path === sec.page);
|
||||
index.push({
|
||||
type: 'section',
|
||||
label: sec.label,
|
||||
group: sec.pageLabel,
|
||||
path: sec.page,
|
||||
anchor: sec.anchor,
|
||||
icon: pageItem ? pageItem.icon : null,
|
||||
keywords: [sec.label, ...(sec.keywords || [])],
|
||||
});
|
||||
});
|
||||
NAV_ALIASES.forEach((a) => {
|
||||
const target = index.find((e) =>
|
||||
e.path === a.path && (a.anchor ? e.anchor === a.anchor : !e.anchor));
|
||||
if (target) target.keywords.push(...a.keywords);
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
const SEARCH_INDEX = buildIndex();
|
||||
|
||||
/**
|
||||
* 前端本地过滤:输入 ≥1 字符返回结果(大小写不敏感、子串匹配)。
|
||||
* limit 为可选参数:限制返回条数(顶栏搜索传 5,最多显示 5 条)。
|
||||
* 相关性排序:命中 label 的条目排在只命中 keywords 的前面
|
||||
* (同相关度保持构建顺序:菜单项 > 设置区块 > 别名,Array.sort 稳定)。
|
||||
*/
|
||||
export function searchNav(query, limit) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const matched = SEARCH_INDEX.filter((e) => e.keywords.some((k) => String(k).toLowerCase().includes(q)));
|
||||
const ranked = matched.sort((a, b) => {
|
||||
const aLabel = String(a.label).toLowerCase().includes(q) ? 0 : 1;
|
||||
const bLabel = String(b.label).toLowerCase().includes(q) ? 0 : 1;
|
||||
return aLabel - bLabel;
|
||||
});
|
||||
return typeof limit === 'number' && limit > 0 ? ranked.slice(0, limit) : ranked;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { listAll, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = { title: '', content: '', active: true };
|
||||
|
||||
/** 公告管理:公告 CRUD(v2 新增模块,v1 无) */
|
||||
export default function Announcements() {
|
||||
const [list, setList] = useState(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAll()
|
||||
.then((rows) => setList(rows || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (a) => {
|
||||
setEditingId(a.id);
|
||||
setForm({ title: a.title || '', content: a.content, active: !!a.active });
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.content.trim()) { showSnack('内容不能为空', 'error'); return; }
|
||||
const data = { title: form.title.trim(), content: form.content, active: form.active };
|
||||
try {
|
||||
if (editingId) await updateAnnouncement(editingId, data);
|
||||
else await createAnnouncement(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAnnouncement(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">公告管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加公告</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>内容</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list === null ? (
|
||||
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5}>暂无公告</TableCell></TableRow>
|
||||
) : list.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{a.title || '(无标题)'}</Box></TableCell>
|
||||
<TableCell sx={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'text.secondary' }}>{a.content}</TableCell>
|
||||
<TableCell><Chip size="small" label={a.active ? '启用' : '停用'} color={a.active ? 'primary' : 'default'} variant={a.active ? 'filled' : 'outlined'} /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{a.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(a)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: a.id, label: a.title || a.content })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑公告' : '添加公告'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="标题" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="内容 *" multiline rows={4} value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} margin="normal" />
|
||||
<FormControlLabel control={<Switch checked={form.active} onChange={(e) => setForm((p) => ({ ...p, active: e.target.checked }))} />} label="启用" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listPosts, updatePost, deletePost } from '../../api/blog.js';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 博客管理:博客侧栏开关 + 评论设置 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
|
||||
export default function BlogManage() {
|
||||
const [posts, setPosts] = useState(null);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [commentModerate, setCommentModerate] = useState('0');
|
||||
const [commentNotify, setCommentNotify] = useState('0');
|
||||
const [showUidComments, setShowUidComments] = useState('1');
|
||||
const [confirm, setConfirm] = useState(null); // { id, title }
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listPosts(true)
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
getSettings().then((s) => {
|
||||
setShowSidebar(s.blog_show_sidebar !== '0');
|
||||
setCommentModerate(s.comment_moderate === '1' ? '1' : '0');
|
||||
setCommentNotify(s.comment_notify === '1' ? '1' : '0');
|
||||
setShowUidComments(s.show_uid_in_comments !== '0' ? '1' : '0');
|
||||
}).catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
const saveSidebar = async () => {
|
||||
try {
|
||||
await saveSettings({ blog_show_sidebar: showSidebar ? '1' : '0' });
|
||||
showSnack('已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveCommentSettings = async () => {
|
||||
try {
|
||||
await saveSettings({
|
||||
comment_moderate: commentModerate,
|
||||
comment_notify: commentNotify,
|
||||
show_uid_in_comments: showUidComments,
|
||||
});
|
||||
showSnack('评论设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublish = async (p) => {
|
||||
try {
|
||||
await updatePost(p.id, {
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
excerpt: p.excerpt || '',
|
||||
published: p.published ? 0 : 1,
|
||||
use_markdown: p.use_markdown,
|
||||
});
|
||||
showSnack(p.published ? '已转为草稿' : '已发布');
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePost(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h5">博客管理</Typography>
|
||||
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
|
||||
</Box>
|
||||
|
||||
<Paper id="sec-blog-sidebar" sx={{ p: 2, mb: 2, maxWidth: 640 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
|
||||
label="博客页面左侧显示头像和简介"
|
||||
/>
|
||||
<Button variant="outlined" size="small" onClick={saveSidebar}>保存</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-comments" sx={{ p: 2, mb: 2, maxWidth: 640 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>评论设置</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={commentModerate === '1'} onChange={(e) => setCommentModerate(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论审核模式</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后新评论需后台审核通过才显示</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={commentNotify === '1'} onChange={(e) => setCommentNotify(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showUidComments === '1'} onChange={(e) => setShowUidComments(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论显示 UID 编号(#id)</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>在评论作者名旁显示 UID 后缀,关闭后仅帖子/楼主等主作者位显示</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button variant="outlined" size="small" onClick={saveCommentSettings}>保存评论设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>格式</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{posts === null ? (
|
||||
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
|
||||
) : posts.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5}>暂无文章</TableCell></TableRow>
|
||||
) : posts.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{p.title}</Box></TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={p.published ? '已发布' : '草稿'}
|
||||
color={p.published ? 'primary' : 'default'}
|
||||
onClick={() => togglePublish(p)}
|
||||
title="点击切换发布状态"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><Chip size="small" label={p.use_markdown ? 'Markdown' : '纯文本'} variant="outlined" /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{p.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" component="a" href={`/write.html?edit=${p.id}`} target="_blank" title="编辑(前台)"><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: p.id, title: p.title })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.title : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText={deleting ? '删除中...' : '确认删除'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 验证码设置:类型/登录注册发帖开关/reCAPTCHA+Turnstile keys(支持 builtin/recaptcha/turnstile/both) */
|
||||
export default function CaptchaSettings() {
|
||||
const [form, setForm] = useState({
|
||||
captcha_type: 'none',
|
||||
captcha_login: false,
|
||||
captcha_register: false,
|
||||
captcha_forum: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: '',
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
captcha_type: s.captcha_type || 'none',
|
||||
captcha_login: s.captcha_login === '1',
|
||||
captcha_register: s.captcha_register === '1',
|
||||
captcha_forum: s.captcha_forum === '1',
|
||||
recaptcha_site_key: s.recaptcha_site_key || '',
|
||||
recaptcha_secret_key: s.recaptcha_secret_key || '',
|
||||
turnstile_site_key: s.turnstile_site_key || '',
|
||||
turnstile_secret_key: s.turnstile_secret_key || '',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
captcha_type: form.captcha_type,
|
||||
captcha_login: form.captcha_login ? '1' : '0',
|
||||
captcha_register: form.captcha_register ? '1' : '0',
|
||||
captcha_forum: form.captcha_forum ? '1' : '0',
|
||||
recaptcha_site_key: form.recaptcha_site_key.trim(),
|
||||
recaptcha_secret_key: form.recaptcha_secret_key.trim(),
|
||||
turnstile_site_key: form.turnstile_site_key.trim(),
|
||||
turnstile_secret_key: form.turnstile_secret_key.trim(),
|
||||
});
|
||||
showSnack('验证码设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const showScope = form.captcha_type !== 'none';
|
||||
const showRecaptcha = form.captcha_type === 'recaptcha' || form.captcha_type === 'both';
|
||||
const showTurnstile = form.captcha_type === 'turnstile' || form.captcha_type === 'both';
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
|
||||
<Paper id="sec-captcha-type" sx={{ p: 3 }}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>验证码类型</InputLabel>
|
||||
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
|
||||
<MenuItem value="none">不使用验证码</MenuItem>
|
||||
<MenuItem value="builtin">内置验证码(扭曲文字)</MenuItem>
|
||||
<MenuItem value="recaptcha">Google reCAPTCHA V2</MenuItem>
|
||||
<MenuItem value="turnstile">Cloudflare Turnstile</MenuItem>
|
||||
<MenuItem value="both">两者都启用(任一通过即可)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{showScope && (
|
||||
<FormGroup id="sec-captcha-scope">
|
||||
<FormControlLabel control={<Switch checked={form.captcha_login} onChange={setSwitch('captcha_login')} />} label="登录验证" />
|
||||
<FormControlLabel control={<Switch checked={form.captcha_register} onChange={setSwitch('captcha_register')} />} label="注册验证" />
|
||||
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{showRecaptcha && (
|
||||
<Box id="sec-captcha-recaptcha" sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Google reCAPTCHA V2 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.recaptcha_site_key} onChange={set('recaptcha_site_key')} margin="normal" placeholder="6L..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.recaptcha_secret_key} onChange={set('recaptcha_secret_key')} margin="normal" placeholder="6L..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showTurnstile && (
|
||||
<Box id="sec-captcha-turnstile" sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Cloudflare Turnstile 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.turnstile_site_key} onChange={set('turnstile_site_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.turnstile_secret_key} onChange={set('turnstile_secret_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存验证码设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listPendingComments, listAllComments, approveComment, rejectComment, deleteComment } from '../../api/blog.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 时间格式化:yyyy-mm-dd hh:mm */
|
||||
function fmtTime(t) {
|
||||
if (!t) return '';
|
||||
return String(t).replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
/** 内容截断(单行预览):换行折叠 + 超长省略 */
|
||||
function clip(content, max = 60) {
|
||||
const s = String(content || '').replace(/\s+/g, ' ').trim();
|
||||
return s.length > max ? s.slice(0, max) + '…' : s;
|
||||
}
|
||||
|
||||
/** 状态 chip */
|
||||
function StatusChip({ status }) {
|
||||
if (status === 'approved') return <Chip size="small" label="已通过" color="success" />;
|
||||
if (status === 'rejected') return <Chip size="small" label="已拒绝" color="error" variant="outlined" />;
|
||||
return <Chip size="small" label="待审核" color="warning" />;
|
||||
}
|
||||
|
||||
/** 评论管理:待审核队列 + 全部评论分页列表(status=all) */
|
||||
export default function CommentManage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [pending, setPending] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// 全部评论(分页)
|
||||
const [allList, setAllList] = useState([]);
|
||||
const [allPage, setAllPage] = useState(1);
|
||||
const [allTotal, setAllTotal] = useState(0);
|
||||
const [allTotalPages, setAllTotalPages] = useState(0);
|
||||
const [allLoading, setAllLoading] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setBusy(true);
|
||||
listPendingComments()
|
||||
.then((list) => setPending(list || []))
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setBusy(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const loadAll = useCallback((page) => {
|
||||
setAllLoading(true);
|
||||
listAllComments({ status: 'all', page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setAllList((res && res.list) || []);
|
||||
setAllTotal(res ? res.total || 0 : 0);
|
||||
setAllTotalPages(res ? res.totalPages || 0 : 0);
|
||||
setAllPage(page);
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setAllLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 1) loadAll(1);
|
||||
}, [tab, loadAll]);
|
||||
|
||||
const act = async (id, fn, okMsg) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn(id);
|
||||
setPending((list) => list.filter((c) => c.id !== id));
|
||||
showSnack(okMsg);
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const actAll = async (id, fn, okMsg, reloadPage = allPage) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn(id);
|
||||
showSnack(okMsg);
|
||||
// 同步待审核徽标 + 刷新当前页;若删除后本页空且非第一页则回退一页
|
||||
setPending((list) => list.filter((c) => c.id !== id));
|
||||
const remaining = allList.filter((c) => c.id !== id).length;
|
||||
loadAll(remaining === 0 && reloadPage > 1 ? reloadPage - 1 : reloadPage);
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, allTotalPages);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="h5">评论管理</Typography>
|
||||
<IconButton onClick={() => (tab === 0 ? load() : loadAll(allPage))} title="刷新" disabled={busy || allLoading}><RefreshIcon /></IconButton>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
|
||||
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
|
||||
<Tab label={`全部评论${allTotal ? ` (${allTotal})` : ''}`} />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{tab === 0 ? (
|
||||
pending.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" sx={{ mb: 0.5 }}>暂无待审核评论</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
开启「评论审核模式」后,新评论会先进这里等待审核
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper>
|
||||
<List disablePadding>
|
||||
{pending.map((c) => (
|
||||
<ListItem
|
||||
key={c.id}
|
||||
divider
|
||||
alignItems="flex-start"
|
||||
sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, py: 2, px: { xs: 2, md: 3 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{c.author_name || '匿名'}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={`文章:${c.post_title || `#${c.post_id}`}`} />
|
||||
<Typography variant="caption" color="text.secondary">{fmtTime(c.created_at)}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'text.primary', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{c.content}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<CheckIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => act(c.id, approveComment, '已通过审核')}
|
||||
>通过</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<CloseIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => act(c.id, rejectComment, '已拒绝')}
|
||||
>拒绝</Button>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>作者</TableCell>
|
||||
<TableCell>文章</TableCell>
|
||||
<TableCell>内容</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{allLoading && allList.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6}>加载中...</TableCell></TableRow>
|
||||
) : allList.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6}>暂无评论</TableCell></TableRow>
|
||||
) : allList.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap', fontWeight: 600 }}>
|
||||
{c.author_name || '匿名'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{c.post_title || `#${c.post_id}`}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 320, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{clip(c.content)}
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={c.status} /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13, whiteSpace: 'nowrap' }}>{fmtTime(c.created_at)}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{c.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" startIcon={<CheckIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, approveComment, '已通过审核')}>通过</Button>
|
||||
<Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, rejectComment, '已拒绝')}>拒绝</Button>
|
||||
</>
|
||||
)}
|
||||
<IconButton size="small" color="error" title="删除评论" disabled={busy} onClick={() => actAll(c.id, deleteComment, '已删除')}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mt: 2 }}>
|
||||
<Button size="small" variant="outlined" disabled={allPage <= 1 || allLoading} onClick={() => loadAll(allPage - 1)}>上一页</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
第 {allPage} / {totalPages} 页 · 共 {allTotal} 条
|
||||
</Typography>
|
||||
<Button size="small" variant="outlined" disabled={allPage >= totalPages || allLoading} onClick={() => loadAll(allPage + 1)}>下一页</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { listAdminLinks } from '../../api/adminLinks.js';
|
||||
import { listPosts as listBlogPosts } from '../../api/blog.js';
|
||||
import { listPosts as listForumPosts } from '../../api/forum.js';
|
||||
import { listUsers } from '../../api/auth.js';
|
||||
import { listAttachments } from '../../api/upload.js';
|
||||
|
||||
/** 仪表盘:版本 + 状态概览 + 面板链接(分组卡片,迁移自 v1 loadPanels) */
|
||||
export default function Dashboard() {
|
||||
const [version, setVersion] = useState('');
|
||||
const [stats, setStats] = useState({ blog: 0, forum: 0, users: 0, attachments: 0 });
|
||||
const [links, setLinks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/version')
|
||||
.then((r) => r.json())
|
||||
.then((v) => { if (v && v.version) setVersion(v.version); })
|
||||
.catch(() => {});
|
||||
Promise.allSettled([
|
||||
listAdminLinks(), listBlogPosts(true), listForumPosts(), listUsers(), listAttachments(),
|
||||
]).then(([l, b, f, u, a]) => {
|
||||
setLinks((l.value) || []);
|
||||
setStats({
|
||||
blog: (b.value || []).length,
|
||||
forum: (f.value || []).length,
|
||||
users: (u.value || []).length,
|
||||
attachments: (a.value || []).length,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const statCards = [
|
||||
{ label: '博客文章', value: stats.blog },
|
||||
{ label: '论坛帖子', value: stats.forum },
|
||||
{ label: '用户', value: stats.users },
|
||||
{ label: '附件', value: stats.attachments },
|
||||
];
|
||||
|
||||
// 面板链接按分类分组
|
||||
const cats = {};
|
||||
links.forEach((l) => { (cats[l.category || '默认'] = cats[l.category || '默认'] || []).push(l); });
|
||||
const sortedCats = Object.keys(cats).sort();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5">仪表盘</Typography>
|
||||
<Chip label={`RainWeb v${version}`} size="small" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{statCards.map((s) => (
|
||||
<Grid item xs={6} sm={3} key={s.label}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600 }}>{s.value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{s.label}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>面板</Typography>
|
||||
{links.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">暂无面板,请先在"面板链接"中添加</Typography>
|
||||
) : (
|
||||
sortedCats.map((cat) => (
|
||||
<Box key={cat} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600, mb: 1 }}>{cat}</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{cats[cat].map((l) => {
|
||||
const embedUrl = l.embed_url || l.url;
|
||||
const proxyParam = l.use_proxy ? '&proxy=1' : '';
|
||||
const href = embedUrl
|
||||
? `/embed.html?url=${encodeURIComponent(embedUrl)}&title=${encodeURIComponent(l.title)}${proxyParam}`
|
||||
: l.url;
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={l.id}>
|
||||
<Card>
|
||||
<CardActionArea component="a" href={href} target={embedUrl ? undefined : '_blank'} rel="noopener">
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>{l.icon ? l.icon.charAt(0).toUpperCase() : '🔗'}</Avatar>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" noWrap>
|
||||
{l.title}
|
||||
{l.version ? <Typography component="span" variant="caption" color="text.secondary"> {l.version}</Typography> : null}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>{l.description || l.url}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { testSmtp } from '../../api/email.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 邮件配置:SMTP + 发件人信息 + 测试发送(迁移自 v1 邮件卡片) */
|
||||
export default function EmailSettings() {
|
||||
const [form, setForm] = useState({
|
||||
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '',
|
||||
smtp_from_email: '', smtp_from_name: 'RainWeb',
|
||||
});
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
smtp_host: s.smtp_host || '',
|
||||
smtp_port: s.smtp_port || '587',
|
||||
smtp_user: s.smtp_user || '',
|
||||
smtp_pass: s.smtp_pass || '',
|
||||
smtp_from_email: s.smtp_from_email || '',
|
||||
smtp_from_name: s.smtp_from_name || 'RainWeb',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
smtp_host: form.smtp_host.trim(),
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_user: form.smtp_user.trim(),
|
||||
smtp_pass: form.smtp_pass,
|
||||
smtp_from_email: form.smtp_from_email.trim(),
|
||||
smtp_from_name: form.smtp_from_name.trim(),
|
||||
});
|
||||
showSnack('邮件配置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!testEmail.trim()) { showSnack('请输入测试邮箱地址', 'error'); return; }
|
||||
setTesting(true);
|
||||
try {
|
||||
await testSmtp(testEmail.trim());
|
||||
showSnack('测试邮件已发送至 ' + testEmail.trim());
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
|
||||
<Paper id="sec-smtp" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>SMTP 服务器</Typography>
|
||||
<TextField fullWidth label="SMTP 主机" value={form.smtp_host} onChange={set('smtp_host')} margin="normal" placeholder="smtp.example.com" />
|
||||
<TextField fullWidth label="端口" type="number" value={form.smtp_port} onChange={set('smtp_port')} margin="normal" placeholder="587" />
|
||||
<TextField fullWidth label="用户名" value={form.smtp_user} onChange={set('smtp_user')} margin="normal" />
|
||||
<TextField fullWidth label="密码" type="password" value={form.smtp_pass} onChange={set('smtp_pass')} margin="normal" />
|
||||
</Paper>
|
||||
<Paper id="sec-sender" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>发件人信息</Typography>
|
||||
<TextField fullWidth label="发件人邮箱" type="email" value={form.smtp_from_email} onChange={set('smtp_from_email')} margin="normal" placeholder="noreply@example.com" />
|
||||
<TextField fullWidth label="发件人名称" value={form.smtp_from_name} onChange={set('smtp_from_name')} margin="normal" placeholder="RainWeb" />
|
||||
</Paper>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存邮件配置</Button>
|
||||
<TextField size="small" label="测试接收邮箱" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} placeholder="your@email.com" />
|
||||
<Button variant="outlined" onClick={test} disabled={testing}>发送测试邮件</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import CampaignIcon from '@mui/icons-material/Campaign';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import { listCategories, createCategory, updateCategory, deleteCategory, updateAnnouncement, updateModerators } from '../../api/forum.js';
|
||||
import { listUsers } from '../../api/auth.js';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0', icon: '', icon_color: '' };
|
||||
|
||||
// 版块图标色板(MD3 常用色相,供快捷选择)
|
||||
const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e',
|
||||
'#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8'];
|
||||
|
||||
/** 版主名提取:兼容数组 / 逗号字符串 */
|
||||
function moderatorNames(cat) {
|
||||
const m = cat && cat.moderators;
|
||||
if (Array.isArray(m)) {
|
||||
return m.map((x) => (typeof x === 'string' ? x : (x && x.username) || '')).filter(Boolean);
|
||||
}
|
||||
if (typeof m === 'string') return m.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 论坛管理(MUI):板块卡片(聚合帖子数)+ 添加/编辑弹窗(icon/icon_color)+
|
||||
* 版主指派弹窗(多选用户)+ 公告快捷编辑 + 删除。
|
||||
* 计数来自 GET /forum/categories 聚合(不再全量 listPosts)。
|
||||
*/
|
||||
export default function ForumManage() {
|
||||
const [cats, setCats] = useState([]);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 版主指派弹窗
|
||||
const [modDialog, setModDialog] = useState(null); // { id, name, ids: [] }
|
||||
const [users, setUsers] = useState([]);
|
||||
const [modSaving, setModSaving] = useState(false);
|
||||
|
||||
// 公告编辑弹窗
|
||||
const [annDialog, setAnnDialog] = useState(null); // { id, name, text }
|
||||
const [annSaving, setAnnSaving] = useState(false);
|
||||
|
||||
// 论坛设置(forum_guest_visible):'1'=游客可见(默认)
|
||||
const [guestVisible, setGuestVisible] = useState(true);
|
||||
const [settingSaving, setSettingSaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listCategories()
|
||||
.then((cs) => setCats(cs || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
getSettings().then((s) => setGuestVisible(s.forum_guest_visible !== '0')).catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
const saveForumSettings = async () => {
|
||||
setSettingSaving(true);
|
||||
try {
|
||||
await saveSettings({ forum_guest_visible: guestVisible ? '1' : '0' });
|
||||
showSnack('论坛设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSettingSaving(false);
|
||||
};
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (c) => {
|
||||
setEditingId(c.id);
|
||||
setForm({
|
||||
name: c.name,
|
||||
description: c.description || '',
|
||||
announcement: c.announcement || '',
|
||||
sub_categories: c.sub_categories || '',
|
||||
sort_order: String(c.sort_order || 0),
|
||||
icon: c.icon || '',
|
||||
icon_color: c.icon_color || '',
|
||||
});
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name.trim()) { showSnack('名称不能为空', 'error'); return; }
|
||||
setSaving(true);
|
||||
const data = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
announcement: form.announcement.trim(),
|
||||
sub_categories: form.sub_categories.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
icon: form.icon.trim(),
|
||||
icon_color: form.icon_color.trim(),
|
||||
};
|
||||
try {
|
||||
if (editingId) await updateCategory(editingId, data);
|
||||
else await createCategory(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteCategory(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ── 版主指派 ──
|
||||
// 分类聚合里的 moderators 是用户名逗号串,打开弹窗时按用户名匹配用户 id 作预选
|
||||
const openMods = async (c) => {
|
||||
const usernames = moderatorNames(c);
|
||||
setModDialog({ id: c.id, name: c.name, usernames, ids: [] });
|
||||
try {
|
||||
const us = await listUsers();
|
||||
const list = (us && us.users) || us || [];
|
||||
setUsers(list);
|
||||
setModDialog((p) => (p ? { ...p, ids: list.filter((u) => usernames.includes(u.username)).map((u) => u.id) } : p));
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
const saveMods = async () => {
|
||||
if (!modDialog) return;
|
||||
setModSaving(true);
|
||||
try {
|
||||
await updateModerators(modDialog.id, modDialog.ids);
|
||||
showSnack('版主已更新');
|
||||
setModDialog(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setModSaving(false);
|
||||
};
|
||||
|
||||
// ── 公告快捷编辑 ──
|
||||
const openAnn = (c) => setAnnDialog({ id: c.id, name: c.name, text: c.announcement || '' });
|
||||
const saveAnn = async () => {
|
||||
if (!annDialog) return;
|
||||
setAnnSaving(true);
|
||||
try {
|
||||
await updateAnnouncement(annDialog.id, annDialog.text.trim());
|
||||
showSnack('公告已更新');
|
||||
setAnnDialog(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setAnnSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">论坛管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
|
||||
</Box>
|
||||
|
||||
<Paper id="sec-forum-settings" sx={{ p: 2.5, mb: 2, maxWidth: 640 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>论坛设置</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={guestVisible} onChange={(e) => setGuestVisible(e.target.checked)} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>访客可见</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后未登录访客可浏览论坛;关闭则需登录(私密模式,SEO 同时隐藏)</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button variant="outlined" size="small" onClick={saveForumSettings} disabled={settingSaving}>保存论坛设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">暂无板块,点击"添加板块"创建</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{cats.map((c) => {
|
||||
const mods = moderatorNames(c);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={c.id}>
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36, height: 36, borderRadius: 3, flexShrink: 0, fontWeight: 700, fontSize: 17,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
||||
background: c.icon_color || 'rgba(0,0,0,0.08)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{c.icon ? (
|
||||
/^https?:\/\//i.test(c.icon)
|
||||
? <Box component="img" src={c.icon} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: c.icon
|
||||
) : (c.name || '?').charAt(0).toUpperCase()}
|
||||
</Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</Typography>
|
||||
<Chip size="small" label={`排序 ${c.sort_order}`} variant="outlined" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, mb: 1 }}>{c.description || '无描述'}</Typography>
|
||||
{c.announcement && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>📢 {c.announcement}</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{typeof c.posts_count === 'number' ? `${c.posts_count} 个帖子` : '—'}
|
||||
{c.today_count ? ` · 今日 ${c.today_count}` : ''}
|
||||
</Typography>
|
||||
{mods.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>版主:{mods.join('、')}</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
|
||||
<IconButton size="small" onClick={() => openMods(c)} title="指派版主"><GroupIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={() => openAnn(c)} title="编辑公告"><CampaignIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={() => openEdit(c)} title="编辑"><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: c.id, name: c.name })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 添加 / 编辑板块 */}
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="名称 *" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="板块公告" multiline rows={2} value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="帖子分类(逗号分隔)" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} margin="normal" placeholder="例: 求助,分享,讨论,建议" />
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField label="图标(emoji 或图片 URL)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="例: 💬 或 https://…/logo.png" sx={{ flex: 1, minWidth: 200 }} />
|
||||
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 120 }} />
|
||||
</Box>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>图标底色(留空按名称自动配色)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{COLOR_PALETTE.map((col) => (
|
||||
<Box
|
||||
key={col}
|
||||
onClick={() => setForm((p) => ({ ...p, icon_color: col === form.icon_color ? '' : col }))}
|
||||
sx={{
|
||||
width: 28, height: 28, borderRadius: 2, cursor: 'pointer', background: col,
|
||||
border: col === form.icon_color ? '2px solid #000' : '2px solid transparent',
|
||||
outline: col === form.icon_color ? '2px solid #fff' : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<TextField
|
||||
size="small"
|
||||
label="自定义"
|
||||
value={form.icon_color}
|
||||
onChange={(e) => setForm((p) => ({ ...p, icon_color: e.target.value }))}
|
||||
sx={{ width: 110 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 版主指派 */}
|
||||
<Dialog open={!!modDialog} onClose={() => setModDialog(null)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>指派版主</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
为版块「{modDialog ? modDialog.name : ''}」选择版主(管理员始终可管理全部版块)
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>版主</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={modDialog ? modDialog.ids : []}
|
||||
onChange={(e) => setModDialog((p) => (p ? { ...p, ids: e.target.value } : p))}
|
||||
label="版主"
|
||||
renderValue={(selected) => {
|
||||
const names = users
|
||||
.filter((u) => selected.includes(u.id))
|
||||
.map((u) => u.username)
|
||||
.join('、');
|
||||
return names || '未选择';
|
||||
}}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<MenuItem key={u.id} value={u.id} disabled={u.role === 'admin'}>
|
||||
<Checkbox checked={(modDialog ? modDialog.ids : []).includes(u.id)} />
|
||||
<ListItemText primary={u.username} secondary={u.role === 'admin' ? '管理员' : ''} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setModDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={saveMods} disabled={modSaving}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 公告快捷编辑 */}
|
||||
<Dialog open={!!annDialog} onClose={() => setAnnDialog(null)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>编辑公告</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
版块「{annDialog ? annDialog.name : ''}」顶部公告
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="公告内容(留空清除)"
|
||||
value={annDialog ? annDialog.text : ''}
|
||||
onChange={(e) => setAnnDialog((p) => (p ? { ...p, text: e.target.value } : p))}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAnnDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={saveAnn} disabled={annSaving}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadFile } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 首页设置:个人信息(头像/简介/联系链接)/主页内容(Markdown)/音乐嵌入(迁移自 v1 首页卡片) */
|
||||
export default function Homepage() {
|
||||
const [form, setForm] = useState({
|
||||
homepage_avatar: '',
|
||||
homepage_bio: '',
|
||||
homepage_content: '',
|
||||
music_embed_enabled: false,
|
||||
music_embed_code: '',
|
||||
music_embed_position: 'right',
|
||||
music_embed_autohide: false,
|
||||
music_embed_idle_timeout: '10',
|
||||
});
|
||||
const [contacts, setContacts] = useState([{ icon: 'link', url: '', title: '' }]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => {
|
||||
let parsed = [];
|
||||
try { parsed = JSON.parse(s.homepage_contacts || '[]'); } catch { parsed = []; }
|
||||
setForm({
|
||||
homepage_avatar: s.homepage_avatar || '',
|
||||
homepage_bio: s.homepage_bio || '',
|
||||
homepage_content: s.homepage_content || '',
|
||||
music_embed_enabled: s.music_embed_enabled === '1',
|
||||
music_embed_code: s.music_embed_code || '',
|
||||
music_embed_position: s.music_embed_position || 'right',
|
||||
music_embed_autohide: s.music_embed_autohide === '1',
|
||||
music_embed_idle_timeout: s.music_embed_idle_timeout || '10',
|
||||
});
|
||||
setContacts(parsed.length > 0 ? parsed : [{ icon: 'link', url: '', title: '' }]);
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
|
||||
|
||||
const updateContact = (i, k) => (e) => {
|
||||
const next = [...contacts];
|
||||
next[i] = { ...next[i], [k]: e.target.value };
|
||||
setContacts(next);
|
||||
};
|
||||
const addContact = () => {
|
||||
if (contacts.length >= 5) { showSnack('最多 5 个联系链接', 'error'); return; }
|
||||
setContacts([...contacts, { icon: 'link', url: '', title: '' }]);
|
||||
};
|
||||
const removeContact = (i) => setContacts(contacts.filter((_, idx) => idx !== i));
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await uploadFile(file);
|
||||
setForm((p) => ({ ...p, homepage_content: p.homepage_content + '\n' + data.tag + '\n' }));
|
||||
setUploadStatus('已插入: ' + data.tag);
|
||||
} catch (err) {
|
||||
showSnack(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const validContacts = contacts.filter((c) => c.url.trim());
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
homepage_avatar: form.homepage_avatar.trim(),
|
||||
homepage_bio: form.homepage_bio,
|
||||
homepage_content: form.homepage_content,
|
||||
homepage_contacts: JSON.stringify(validContacts),
|
||||
music_embed_enabled: form.music_embed_enabled ? '1' : '0',
|
||||
music_embed_code: form.music_embed_code.trim(),
|
||||
music_embed_position: form.music_embed_position,
|
||||
music_embed_autohide: form.music_embed_autohide ? '1' : '0',
|
||||
music_embed_idle_timeout: form.music_embed_idle_timeout,
|
||||
});
|
||||
showSnack('首页设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
|
||||
|
||||
<Paper id="sec-profile" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>个人信息</Typography>
|
||||
<TextField fullWidth label="头像 URL" type="url" value={form.homepage_avatar} onChange={set('homepage_avatar')} margin="normal" placeholder="https://example.com/avatar.jpg" />
|
||||
<TextField fullWidth label="个人简介" multiline rows={2} value={form.homepage_bio} onChange={set('homepage_bio')} margin="normal" placeholder="一段简短的自我介绍" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, mb: 1 }}>联系链接(最多5个)</Typography>
|
||||
{contacts.map((c, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="图标名"
|
||||
value={c.icon}
|
||||
onChange={updateContact(i, 'icon')}
|
||||
sx={{ width: 150 }}
|
||||
helperText="品牌图标 qq/bilibili/telegram/github/gitea/wechat,或 Material Icons 名(如 email/link/rss_feed)"
|
||||
/>
|
||||
<TextField size="small" label="URL" value={c.url} onChange={updateContact(i, 'url')} sx={{ flex: 1 }} placeholder="https://..." />
|
||||
<TextField size="small" label="标题(选填)" value={c.title} onChange={updateContact(i, 'title')} sx={{ width: 120 }} />
|
||||
<IconButton size="small" color="error" onClick={() => removeContact(i)} title="删除"><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-home-content" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主页内容</Typography>
|
||||
<TextField fullWidth label="正文 (Markdown)" multiline rows={10} value={form.homepage_content} onChange={set('homepage_content')} margin="normal" placeholder="支持 Markdown 语法和 [image:filename] 标签" />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button variant="outlined" size="small" onClick={() => fileRef.current && fileRef.current.click()}>
|
||||
上传附件
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
{uploadStatus && <Typography variant="caption" color="text.secondary">{uploadStatus}</Typography>}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-music" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
|
||||
<FormControlLabel control={<Switch checked={form.music_embed_enabled} onChange={setSwitch('music_embed_enabled')} />} label="所有页面显示音乐播放器" />
|
||||
<TextField fullWidth label="嵌入代码" multiline rows={3} value={form.music_embed_code} onChange={set('music_embed_code')} margin="normal" placeholder="粘贴网易云音乐 iframe 代码" />
|
||||
<TextField select label="显示位置" value={form.music_embed_position} onChange={set('music_embed_position')} margin="normal" sx={{ maxWidth: 200 }}>
|
||||
<option value="right">右下角</option>
|
||||
<option value="left">左下角</option>
|
||||
</TextField>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControlLabel control={<Switch checked={form.music_embed_autohide} onChange={setSwitch('music_embed_autohide')} />} label="无操作后缩小为图标" />
|
||||
<TextField size="small" label="空闲超时(秒)" type="number" inputProps={{ min: 3, max: 120 }} value={form.music_embed_idle_timeout} onChange={set('music_embed_idle_timeout')} sx={{ width: 140 }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存首页设置</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import { getToken } from '../../api/client.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 数据导入:上传旧版 data.db,POST /api/import/database(FormData + Bearer) */
|
||||
export default function ImportDb() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState(null); // { total, details, errors } | { error }
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const doImport = async () => {
|
||||
const file = fileRef.current && fileRef.current.files && fileRef.current.files[0];
|
||||
if (!file) { showSnack('请选择 data.db 文件', 'error'); return; }
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch('/api/import/database', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '导入失败');
|
||||
setResult(data);
|
||||
showSnack('导入完成,共 ' + data.total + ' 条记录');
|
||||
} catch (e) {
|
||||
setResult({ error: e.message });
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>数据导入</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
上传旧版 RainWeb 的 <code>data.db</code> 文件,将数据导入当前数据库(重复数据自动跳过)。
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button variant="contained" component="label">
|
||||
选择文件
|
||||
<input type="file" hidden accept=".db" ref={fileRef} onChange={() => setResult(null)} />
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{fileRef.current && fileRef.current.files && fileRef.current.files[0] ? fileRef.current.files[0].name : '未选择文件'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="outlined" onClick={doImport} disabled={busy}>
|
||||
{busy ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||
导入
|
||||
</Button>
|
||||
</Box>
|
||||
{result && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{result.error ? (
|
||||
<Alert severity="error">导入失败: {result.error}</Alert>
|
||||
) : (
|
||||
<Alert severity="success">
|
||||
<Box>导入完成,共 {result.total} 条记录</Box>
|
||||
{Object.entries(result.details || {}).map(([table, count]) => (
|
||||
<Box key={table} component="div" sx={{ fontSize: 13 }}>{table}: {count} 条</Box>
|
||||
))}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<Box component="div" sx={{ mt: 1, color: 'warning.main', fontSize: 13 }}>
|
||||
警告:<br />
|
||||
{result.errors.slice(0, 5).map((e, i) => <Box key={i}>{e}</Box>)}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = {
|
||||
title: '', url: '', embed_url: '', use_proxy: false, description: '',
|
||||
icon: '', category: '默认', version: '', sort_order: '0',
|
||||
slug: '', trusted: true, permissions: [], scale: '1',
|
||||
};
|
||||
|
||||
/* 面板代理配置字段的展示与编辑选项(与工作台 PanelFrame 的委托权限一致) */
|
||||
const PERMISSION_OPTIONS = [
|
||||
{ value: 'camera', label: '摄像头' },
|
||||
{ value: 'microphone', label: '麦克风' },
|
||||
{ value: 'geolocation', label: '定位' },
|
||||
{ value: 'clipboard-read', label: '剪贴板读取' },
|
||||
{ value: 'clipboard-write', label: '剪贴板写入' },
|
||||
{ value: 'payment', label: '支付' },
|
||||
{ value: 'usb', label: 'USB' },
|
||||
{ value: 'serial', label: '串口' },
|
||||
{ value: 'notifications', label: '通知' },
|
||||
];
|
||||
|
||||
/* permissions 字段:后端存 JSON 数组字符串,兼容已解析数组 */
|
||||
function parsePermissions(raw) {
|
||||
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 面板链接管理:CRUD + 代理嵌入开关 + 信任模式/缩放/权限 + 面板代理白名单 */
|
||||
export default function Links() {
|
||||
const [links, setLinks] = useState(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [proxyHosts, setProxyHosts] = useState('');
|
||||
const [proxySaving, setProxySaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAdminLinks()
|
||||
.then((ls) => setLinks(ls || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
getSettings().then((s) => setProxyHosts(s.proxy_allowed_hosts || '')).catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (l) => {
|
||||
setEditingId(l.id);
|
||||
setForm({
|
||||
title: l.title,
|
||||
url: l.url,
|
||||
embed_url: l.embed_url || '',
|
||||
use_proxy: !!l.use_proxy,
|
||||
slug: l.slug || '',
|
||||
trusted: !(l.trusted === 0 || l.trusted === '0'),
|
||||
permissions: parsePermissions(l.permissions),
|
||||
scale: String(parseFloat(l.scale) || 1),
|
||||
description: l.description || '',
|
||||
icon: l.icon || '',
|
||||
category: l.category || '默认',
|
||||
version: l.version || '',
|
||||
sort_order: String(l.sort_order || 0),
|
||||
});
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.title.trim() || !form.url.trim()) { showSnack('标题和链接不能为空', 'error'); return; }
|
||||
const data = {
|
||||
title: form.title.trim(),
|
||||
url: form.url.trim(),
|
||||
embed_url: form.embed_url.trim(),
|
||||
use_proxy: form.use_proxy ? 1 : 0,
|
||||
slug: form.slug.trim(),
|
||||
trusted: form.trusted ? 1 : 0,
|
||||
permissions: JSON.stringify(form.permissions),
|
||||
scale: parseFloat(form.scale) || 1,
|
||||
description: form.description.trim(),
|
||||
icon: form.icon.trim(),
|
||||
category: form.category.trim() || '默认',
|
||||
version: form.version.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await updateAdminLink(editingId, data);
|
||||
else await createAdminLink(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAdminLink(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveProxy = async () => {
|
||||
setProxySaving(true);
|
||||
try {
|
||||
await saveSettings({ proxy_allowed_hosts: proxyHosts });
|
||||
showSnack('面板代理设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setProxySaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">面板链接管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加面板</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>排序</TableCell>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>版本</TableCell>
|
||||
<TableCell>URL</TableCell>
|
||||
<TableCell>嵌入URL</TableCell>
|
||||
<TableCell>代理配置</TableCell>
|
||||
<TableCell>分类</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{links === null ? (
|
||||
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
|
||||
) : links.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8}>暂无面板</TableCell></TableRow>
|
||||
) : links.map((l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>{l.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ fontWeight: 600 }}>{l.title}</Box>
|
||||
{l.slug && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', fontSize: 12, fontFamily: 'monospace' }}>/{l.slug}/</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{l.version || '-'}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 180 }}>
|
||||
<Box component="a" href={l.url} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{l.url}</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 140, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.embed_url || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{l.use_proxy ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={l.trusted === 0 || l.trusted === '0' ? '代理·安全' : '代理·可信'}
|
||||
color={l.trusted === 0 || l.trusted === '0' ? 'default' : 'success'}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : <Chip size="small" label="直嵌" variant="outlined" />}
|
||||
{parseFloat(l.scale) > 0 && parseFloat(l.scale) !== 1 && (
|
||||
<Box component="span" sx={{ ml: 0.75, color: 'text.secondary', fontSize: 12 }}>缩放 {parseFloat(l.scale)}x</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Chip size="small" label={l.category} variant="outlined" /></TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: l.id, label: l.title })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Paper id="sec-proxy" sx={{ p: 2.5, mt: 2, maxWidth: 720 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: 16, mb: 0.5 }}>面板代理</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5, fontSize: 13 }}>
|
||||
通过面板代理嵌入面板(绕过 X-Frame-Options 限制)时,把可信内网地址/网段加入白名单
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="代理允许的内网地址(白名单)"
|
||||
value={proxyHosts}
|
||||
onChange={(e) => setProxyHosts(e.target.value)}
|
||||
margin="normal"
|
||||
multiline
|
||||
minRows={3}
|
||||
placeholder={'每行或逗号分隔一个地址/网段,例如:\n192.168.0.0/16\n10.0.0.0/8\n192.168.3.1:8080'}
|
||||
helperText="https 页面无法嵌入 http 内网面板,把可信内网地址/网段加入白名单后可经面板代理放行。支持单 IP、IPv4 CIDR(如 192.168.0.0/16)与主机名;默认拦截所有内网地址,请谨慎配置。"
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button variant="contained" onClick={saveProxy} disabled={proxySaving}>保存面板代理设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="标题 *" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="URL *" type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} margin="normal" placeholder="https://..." />
|
||||
<TextField fullWidth label="嵌入 URL(iframe嵌入用)" type="url" value={form.embed_url} onChange={(e) => setForm((p) => ({ ...p, embed_url: e.target.value }))} margin="normal" placeholder="留空则新标签页打开" />
|
||||
<FormControlLabel control={<Switch checked={form.use_proxy} onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))} />} label="通过代理嵌入(绕过 X-Frame-Options 限制)" />
|
||||
{form.use_proxy && (
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="代理路径标识 slug"
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
|
||||
margin="dense"
|
||||
placeholder="留空按标题自动生成"
|
||||
helperText="仅小写字母/数字/连字符,≤32 字符;iframe 走 /proxy/{slug}/"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.trusted} onChange={(e) => setForm((p) => ({ ...p, trusted: e.target.checked }))} />}
|
||||
label="可信模式(保留 allow-same-origin,目标站 cookie 登录态可用)"
|
||||
/>
|
||||
<TextField
|
||||
label="缩放(0.1–3)"
|
||||
type="number"
|
||||
value={form.scale}
|
||||
onChange={(e) => setForm((p) => ({ ...p, scale: e.target.value }))}
|
||||
margin="dense"
|
||||
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
|
||||
sx={{ maxWidth: 160 }}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
fullWidth
|
||||
size="small"
|
||||
options={PERMISSION_OPTIONS}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
|
||||
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="委托权限(可选,iframe allow 属性)" margin="dense" placeholder="摄像头 / 麦克风 / 定位等" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="图标 (Material图标名)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="settings, dashboard, ..." />
|
||||
<TextField fullWidth label="分类" value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
|
||||
<TextField fullWidth label="版本号(可选)" value={form.version} onChange={(e) => setForm((p) => ({ ...p, version: e.target.value }))} margin="normal" placeholder="v2.1.0" />
|
||||
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import { listPosts, listCategories, setPinned, setEssence, deletePost } from '../../api/forum.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 帖子管理(admin,路由 /admin/posts):
|
||||
* 跨版块搜索(?q=)+ 版块筛选 + 表格(标题/版块/作者/时间/置顶/精华)+ 置顶/加精/删除 + 分页。
|
||||
*/
|
||||
export default function PostManage() {
|
||||
const [cats, setCats] = useState([]);
|
||||
const [qInput, setQInput] = useState('');
|
||||
const [catFilter, setCatFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState(null); // { list, total, page, pageSize }
|
||||
const [confirm, setConfirm] = useState(null); // { id, title }
|
||||
const [busy, setBusy] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setBusy(true);
|
||||
listPosts({ categoryId: catFilter || undefined, page, q: qInput.trim() || undefined })
|
||||
.then((d) => setData(Array.isArray(d) ? { list: d, total: d.length, page: 1, pageSize: PAGE_SIZE } : d))
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setBusy(false));
|
||||
}, [catFilter, page, qInput]);
|
||||
|
||||
useEffect(() => {
|
||||
listCategories()
|
||||
.then((cs) => setCats(cs || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const search = () => setPage(1);
|
||||
|
||||
const changeCat = (v) => {
|
||||
setCatFilter(v);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const togglePin = async (p) => {
|
||||
try {
|
||||
await setPinned(p.id, !p.is_pinned);
|
||||
showSnack(p.is_pinned ? '已取消置顶' : '已置顶');
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEssence = async (p) => {
|
||||
try {
|
||||
await setEssence(p.id, !p.is_essence);
|
||||
showSnack(p.is_essence ? '已取消加精' : '已加精');
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deletePost(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const posts = (data && data.list) || [];
|
||||
const total = data ? data.total : 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const catNameOf = (cid) => (cats.find((c) => c.id === cid) || {}).name;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h5">帖子管理</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2, display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="搜索标题/内容"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') search(); }}
|
||||
sx={{ flex: 1, minWidth: 220, maxWidth: 360 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>版块</InputLabel>
|
||||
<Select value={catFilter} onChange={(e) => changeCat(e.target.value)} label="版块">
|
||||
<MenuItem value="">全部版块</MenuItem>
|
||||
{cats.map((c) => <MenuItem key={c.id} value={String(c.id)}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="contained" onClick={search}>搜索</Button>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>版块</TableCell>
|
||||
<TableCell>作者</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="center">📌</TableCell>
|
||||
<TableCell align="center">⭐</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{posts.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>{busy ? '加载中...' : '暂无帖子'}</TableCell></TableRow>
|
||||
) : posts.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>
|
||||
<Box sx={{ fontWeight: 600, maxWidth: 360, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.title}</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={catNameOf(p.category_id) || `#${p.category_id}`} variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary' }}>{p.author_name || '匿名'}</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{p.created_at}</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton size="small" color={p.is_pinned ? 'primary' : 'default'} onClick={() => togglePin(p)} title={p.is_pinned ? '取消置顶' : '置顶'}>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton size="small" color={p.is_essence ? 'warning' : 'default'} onClick={() => toggleEssence(p)} title={p.is_essence ? '取消加精' : '加精'}>
|
||||
<StarIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: p.id, title: p.title })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1.5, mt: 2 }}>
|
||||
<Button size="small" variant="outlined" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>上一页</Button>
|
||||
<Typography variant="body2" color="text.secondary">第 {page} / {totalPages} 页(共 {total} 帖)</Typography>
|
||||
<Button size="small" variant="outlined" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>下一页</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除帖子 "${confirm ? confirm.title : ''}" 吗?回复将一并删除`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import RssFeedIcon from '@mui/icons-material/RssFeed';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { listCategories, updateCategoryProfile } from '../../api/forum.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 完整订阅 URL(运行时拼 origin,不硬编码域名) */
|
||||
function feedUrl(path) {
|
||||
return window.location.origin + path;
|
||||
}
|
||||
|
||||
/** 版块图标小方块:icon 非 http 渲染 emoji/首字 */
|
||||
function CatIcon({ c }) {
|
||||
const isImg = /^https?:\/\//i.test(c.icon || '');
|
||||
const letter = isImg ? (c.name || '?').charAt(0) : (c.icon || (c.name || '?').charAt(0));
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 30, height: 30, borderRadius: 2, flexShrink: 0, fontWeight: 700, fontSize: 15,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
||||
background: c.icon_color || 'rgba(0,0,0,0.08)', color: '#fff',
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RSS 订阅管理(admin,路由 /admin/rss):
|
||||
* 1) 源管理:博客(常开只读)/ 论坛全站(feed_forum_enabled)/ 各版块(feed_enabled,即时保存)
|
||||
* 2) 内容设置:feed_show_full(全文/摘要)+ feed_max_items(1-100)→ saveSettings
|
||||
*/
|
||||
export default function RssManage() {
|
||||
const [cats, setCats] = useState([]);
|
||||
const [forumFeed, setForumFeed] = useState(true);
|
||||
const [showFull, setShowFull] = useState('0');
|
||||
const [maxItems, setMaxItems] = useState('20');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getSettings()
|
||||
.then((s) => {
|
||||
setForumFeed(s.feed_forum_enabled !== '0'); // 默认开启
|
||||
setShowFull(s.feed_show_full === '1' ? '1' : '0');
|
||||
setMaxItems(s.feed_max_items || '20');
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
listCategories()
|
||||
.then((cs) => setCats(cs || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const copyUrl = async (url) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showSnack('链接已复制');
|
||||
} catch {
|
||||
showSnack('复制失败,请手动复制', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 论坛全站开关:即时保存
|
||||
const toggleForumFeed = async (next) => {
|
||||
setForumFeed(next);
|
||||
try {
|
||||
await saveSettings({ feed_forum_enabled: next ? '1' : '0' });
|
||||
showSnack(next ? '已开启论坛订阅' : '已关闭论坛订阅');
|
||||
} catch (e) {
|
||||
setForumFeed(!next);
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 版块级开关:即时保存(feed_enabled 已进 profile 白名单)
|
||||
const toggleCatFeed = async (cat, next) => {
|
||||
const prev = cat.feed_enabled !== 0 && cat.feed_enabled !== '0';
|
||||
setCats((cs) => cs.map((c) => (c.id === cat.id ? { ...c, feed_enabled: next ? 1 : 0 } : c)));
|
||||
try {
|
||||
await updateCategoryProfile(cat.id, { feed_enabled: next ? '1' : '0' });
|
||||
showSnack(next ? `已开启「${cat.name}」订阅` : `已关闭「${cat.name}」订阅`);
|
||||
} catch (e) {
|
||||
setCats((cs) => cs.map((c) => (c.id === cat.id ? { ...c, feed_enabled: prev ? 1 : 0 } : c)));
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveContent = async () => {
|
||||
const n = parseInt(maxItems, 10);
|
||||
if (isNaN(n) || n < 1 || n > 100) { showSnack('条目数需为 1-100 的数字', 'error'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({ feed_show_full: showFull, feed_max_items: String(n) });
|
||||
showSnack('内容设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
/** 源行:名称/描述 + URL(复制)+ 开关 */
|
||||
const SourceRow = ({ name, desc, path, onToggle, checked, disabled, badge }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.25, borderBottom: '1px solid', borderColor: 'divider', '&:last-of-type': { borderBottom: 'none' } }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 14 }}>{name}</Typography>
|
||||
{badge}
|
||||
</Box>
|
||||
{desc && <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{desc}</Typography>}
|
||||
<Box component="code" sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5,
|
||||
fontSize: 12, color: 'text.secondary', fontFamily: 'monospace',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{feedUrl(path)}
|
||||
<IconButton size="small" onClick={() => copyUrl(feedUrl(path))} title="复制链接" sx={{ flexShrink: 0 }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
{disabled ? (
|
||||
<Chip size="small" label="始终启用" variant="outlined" sx={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={checked} onChange={(e) => onToggle(e.target.checked)} />}
|
||||
label=""
|
||||
sx={{ m: 0, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h5" sx={{ mb: 0.5 }}>RSS 订阅</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, fontSize: 13 }}>
|
||||
管理各位置的订阅源:订阅器(如 Feedly / Inoreader)可关注这些链接获取最新内容
|
||||
</Typography>
|
||||
|
||||
{/* 源管理 */}
|
||||
<Paper id="sec-rss-sources" sx={{ p: 2.5, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<RssFeedIcon fontSize="small" sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontSize: 16 }}>订阅源</Typography>
|
||||
</Box>
|
||||
|
||||
<SourceRow
|
||||
name="博客"
|
||||
desc="已发布文章(全文/摘要按下方内容设置)"
|
||||
path="/feed.xml"
|
||||
disabled
|
||||
/>
|
||||
<SourceRow
|
||||
name="论坛(全站)"
|
||||
desc="全部版块的最新帖子"
|
||||
path="/feed/forum.xml"
|
||||
checked={forumFeed}
|
||||
onToggle={toggleForumFeed}
|
||||
/>
|
||||
|
||||
{cats.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
版块级订阅(关闭后该版块不进入全站源,其独立源返回 404)
|
||||
</Typography>
|
||||
{cats.map((c) => {
|
||||
const on = c.feed_enabled !== 0 && c.feed_enabled !== '0';
|
||||
return (
|
||||
<Box key={c.id} sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<CatIcon c={c} />
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 14 }}>{c.name}</Typography>
|
||||
<Box component="code" sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: 12, color: 'text.secondary', fontFamily: 'monospace',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{feedUrl(`/feed/forum/c/${c.id}.xml`)}
|
||||
<IconButton size="small" onClick={() => copyUrl(feedUrl(`/feed/forum/c/${c.id}.xml`))} title="复制链接" sx={{ flexShrink: 0 }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={on} onChange={(e) => toggleCatFeed(c, e.target.checked)} />}
|
||||
label=""
|
||||
sx={{ m: 0, flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* 内容设置 */}
|
||||
<Paper id="sec-rss-content" sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1, fontSize: 16 }}>内容设置</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showFull === '1'} onChange={(e) => setShowFull(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>输出全文</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>关闭则仅输出摘要(博客用文章摘要,论坛用内容截断)</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="每条源最大条目数"
|
||||
type="number"
|
||||
value={maxItems}
|
||||
onChange={(e) => setMaxItems(e.target.value)}
|
||||
inputProps={{ min: 1, max: 100 }}
|
||||
helperText="1-100,默认 20"
|
||||
sx={{ mt: 1.5, maxWidth: 220 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={saveContent} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存内容设置'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadVerifyFile, listVerifyFiles, deleteVerifyFile } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import { parseFooterColumns } from '../../lib/outlink.js';
|
||||
|
||||
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
|
||||
const FOOTER_STYLES = [
|
||||
{ value: 'classic', label: '经典左右', desc: '左版权右版本,信息密度低,一眼扫过' },
|
||||
{ value: 'columns', label: '分栏导航', desc: '品牌 + 固定导航栏目 + 底部版权条,内容型站点首选' },
|
||||
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||
];
|
||||
|
||||
/** 基本设置 + 页脚设置 + 搜索引擎验证 + RainID 单点登录(v2) */
|
||||
export default function Settings() {
|
||||
const [form, setForm] = useState({
|
||||
site_name: '',
|
||||
site_description: '',
|
||||
site_url: '',
|
||||
site_favicon: '',
|
||||
footer_style: 'classic',
|
||||
footer_copyright: '',
|
||||
footer_powered: '',
|
||||
footer_desc: '',
|
||||
rainid_enabled: '0',
|
||||
rainid_client_id: '',
|
||||
rainid_client_secret: '',
|
||||
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: '0',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
// 页脚栏目(footer_columns JSON):独立 state 维护,保存时序列化进 form
|
||||
const [columns, setColumns] = useState([]);
|
||||
|
||||
// 搜索引擎验证文件:列表 / 待上传文件 / 上传状态 / 上传成功结果
|
||||
const [verifyFiles, setVerifyFiles] = useState([]);
|
||||
const [verifyFile, setVerifyFile] = useState(null);
|
||||
const [verifyUploading, setVerifyUploading] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState(null);
|
||||
|
||||
const loadVerifyFiles = () => {
|
||||
listVerifyFiles().then(setVerifyFiles).catch((e) => showSnack(e.message, 'error'));
|
||||
};
|
||||
|
||||
const handleVerifyFileChange = (e) => {
|
||||
setVerifyFile((e.target.files && e.target.files[0]) || null);
|
||||
setVerifyResult(null);
|
||||
e.target.value = ''; // 清空 input 允许重复选择同一文件
|
||||
};
|
||||
|
||||
const handleVerifyUpload = async () => {
|
||||
if (!verifyFile) { showSnack('请先选择验证文件', 'error'); return; }
|
||||
setVerifyUploading(true);
|
||||
try {
|
||||
const r = await uploadVerifyFile(verifyFile);
|
||||
setVerifyResult(r);
|
||||
setVerifyFile(null);
|
||||
loadVerifyFiles();
|
||||
showSnack('上传成功');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setVerifyUploading(false);
|
||||
};
|
||||
|
||||
const handleVerifyDelete = async (name) => {
|
||||
if (!window.confirm(`确认删除验证文件 ${name}?`)) return;
|
||||
try {
|
||||
await deleteVerifyFile(name);
|
||||
loadVerifyFiles();
|
||||
showSnack('已删除');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => {
|
||||
setForm({
|
||||
site_name: s.site_name || '',
|
||||
site_description: s.site_description || '',
|
||||
site_url: s.site_url || '',
|
||||
site_favicon: s.site_favicon || '',
|
||||
footer_style: s.footer_style || 'classic',
|
||||
footer_copyright: s.footer_copyright || '',
|
||||
footer_powered: s.footer_powered || '',
|
||||
footer_desc: s.footer_desc || '',
|
||||
rainid_enabled: s.rainid_enabled === '1' ? '1' : '0',
|
||||
rainid_client_id: s.rainid_client_id || '',
|
||||
// secret 后端不回读(ALLOWED_SET 可写不可读),始终为空,留空提交=不修改
|
||||
rainid_client_secret: '',
|
||||
rainid_discovery_url: s.rainid_discovery_url || 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: s.rainid_register_redirect === '1' ? '1' : '0',
|
||||
});
|
||||
setColumns(parseFooterColumns(s.footer_columns)); // 页脚栏目(同一次响应)
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
loadVerifyFiles(); // 加载已上传的验证文件
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const toggle = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked ? '1' : '0' }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { ...form, footer_columns: JSON.stringify(columns) };
|
||||
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
|
||||
if (!body.rainid_client_secret) delete body.rainid_client_secret;
|
||||
await saveSettings(body);
|
||||
showSnack('设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// ── 页脚栏目编辑器(简单版:无拖拽排序;最多 3 栏并行) ──
|
||||
const MAX_COLUMNS = 3;
|
||||
const addColumn = () => {
|
||||
if (columns.length >= MAX_COLUMNS) { showSnack(`最多添加 ${MAX_COLUMNS} 栏`, 'error'); return; }
|
||||
setColumns((cs) => [...cs, { title: '', links: [{ label: '', url: '' }] }]);
|
||||
};
|
||||
const removeColumn = (i) => setColumns((cs) => cs.filter((_, idx) => idx !== i));
|
||||
const setColumnTitle = (i, v) => setColumns((cs) => cs.map((c, idx) => (idx === i ? { ...c, title: v } : c)));
|
||||
const addLink = (ci) => setColumns((cs) => cs.map((c, idx) => (idx === ci ? { ...c, links: [...c.links, { label: '', url: '' }] } : c)));
|
||||
const removeLink = (ci, li) => setColumns((cs) => cs.map((c, idx) => (idx === ci ? { ...c, links: c.links.filter((_, lIdx) => lIdx !== li) } : c)));
|
||||
const setLink = (ci, li, k, v) => setColumns((cs) => cs.map((c, idx) => (idx === ci
|
||||
? { ...c, links: c.links.map((l, lIdx) => (lIdx === li ? { ...l, [k]: v } : l)) }
|
||||
: c)));
|
||||
|
||||
const footStyle = form.footer_style;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
|
||||
|
||||
<Paper id="sec-basic" sx={{ p: 3 }}>
|
||||
<TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" />
|
||||
<TextField fullWidth label="网站描述(SEO)" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" />
|
||||
<TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" />
|
||||
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-footer" sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
|
||||
|
||||
<FormControl component="fieldset" sx={{ mb: 1 }}>
|
||||
<RadioGroup value={footStyle} onChange={set('footer_style')}>
|
||||
{FOOTER_STYLES.map((o) => (
|
||||
<FormControlLabel
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
control={<Radio />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>{o.label}</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>{o.desc}</Box>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{footStyle === 'columns' ? (
|
||||
<>
|
||||
<TextField fullWidth label="网站简介(页脚品牌区)" value={form.footer_desc} onChange={set('footer_desc')} margin="normal" placeholder="留空则自动使用网站描述" multiline minRows={2} />
|
||||
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
|
||||
<FormHelperText sx={{ mt: 1 }}>
|
||||
分栏导航默认显示固定栏目(首页 / 博客 / 论坛 / 管理后台 / 个人中心),可在下方自定义
|
||||
</FormHelperText>
|
||||
|
||||
{/* 页脚栏目编辑器(简单版) */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>页脚栏目</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
链接以 / 开头为站内页,http(s) 开头为外链(经跳转确认页);留空使用默认栏目
|
||||
</Typography>
|
||||
{columns.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>暂无自定义栏目,点击「添加栏目」创建</Typography>
|
||||
)}
|
||||
{columns.map((col, ci) => (
|
||||
<Box key={ci} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<TextField size="small" fullWidth label={`栏目 ${ci + 1} 名称`} value={col.title} onChange={(e) => setColumnTitle(ci, e.target.value)} />
|
||||
<IconButton size="small" color="error" onClick={() => removeColumn(ci)} aria-label={`删除栏目 ${ci + 1}`}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{(col.links || []).map((l, li) => (
|
||||
<Box key={li} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<TextField size="small" label="链接文字" value={l.label} onChange={(e) => setLink(ci, li, 'label', e.target.value)} sx={{ flex: 1, minWidth: 100 }} />
|
||||
<TextField size="small" label="链接地址" value={l.url} onChange={(e) => setLink(ci, li, 'url', e.target.value)} placeholder="/ 开头站内 或 https://" sx={{ flex: 1.4, minWidth: 160 }} />
|
||||
<IconButton size="small" color="error" onClick={() => removeLink(ci, li)} aria-label="删除链接">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => addLink(ci)}>添加链接</Button>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={addColumn}
|
||||
disabled={columns.length >= MAX_COLUMNS}
|
||||
>
|
||||
添加栏目({columns.length}/{MAX_COLUMNS})
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
|
||||
<TextField fullWidth label="Powered by 文本" value={form.footer_powered} onChange={set('footer_powered')} margin="normal" placeholder="Powered by RainnyaWeb" />
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-search-verify" sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
|
||||
Bing / Google / Yandex 站长工具要求把验证文件上传到网站根目录,上传后即可经根路径访问
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<Button component="label" variant="outlined" startIcon={<FileUploadIcon />} disabled={verifyUploading}>
|
||||
选择文件
|
||||
<input type="file" hidden accept=".xml,.html,.txt" onChange={handleVerifyFileChange} />
|
||||
</Button>
|
||||
{verifyFile && (
|
||||
<Typography variant="body2" sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{verifyFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
<Button variant="contained" size="small" onClick={handleVerifyUpload} disabled={!verifyFile || verifyUploading}>
|
||||
{verifyUploading ? '上传中…' : '上传'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{verifyResult && (
|
||||
<Box sx={{ mb: 1, fontSize: 13, color: 'success.main' }}>
|
||||
上传成功,可访问:
|
||||
<a href={verifyResult.url} target="_blank" rel="noopener noreferrer" style={{ marginLeft: 4, color: 'inherit', textDecoration: 'underline' }}>
|
||||
{window.location.origin}{verifyResult.url}
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{verifyFiles.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>已上传的验证文件</Typography>
|
||||
{verifyFiles.map((f) => (
|
||||
<Box key={f.name} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
<a
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{f.name}
|
||||
</a>
|
||||
<IconButton size="small" color="error" onClick={() => handleVerifyDelete(f.name)} aria-label={`删除 ${f.name}`}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-rainid" sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
|
||||
通过 RainID 统一身份认证,支持 ROPC 密码登录与授权码 SSO
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.rainid_enabled === '1'} onChange={toggle('rainid_enabled')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>启用 RainID 登录</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>登录页显示「使用 RainID 登录」,密码登录自动转发 ROPC 校验</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.rainid_register_redirect === '1'} onChange={toggle('rainid_register_redirect')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>注册跳转 RainID</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后注册页直接跳转 RainID 注册,不再显示本地注册表单</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField fullWidth label="Client ID" value={form.rainid_client_id} onChange={set('rainid_client_id')} margin="normal" placeholder="RainID 应用 Client ID(非机密)" />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Client Secret(机密)"
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={form.rainid_client_secret}
|
||||
onChange={set('rainid_client_secret')}
|
||||
margin="normal"
|
||||
placeholder="••••••••••••"
|
||||
autoComplete="new-password"
|
||||
helperText="保存在服务端且不回读;留空提交则保持原值不变"
|
||||
slotProps={{
|
||||
htmlInput: { 'aria-label': 'RainID Client Secret' },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label={showSecret ? '隐藏 Secret' : '显示 Secret'}
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
>
|
||||
{showSecret ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField fullWidth label="Discovery URL" type="url" value={form.rainid_discovery_url} onChange={set('rainid_discovery_url')} margin="normal" placeholder="https://rainid.rainnya.asia/oauth" helperText="RainID OIDC 配置地址,默认即可" />
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadWallpaper } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
||||
|
||||
function validateThemeForm(form) {
|
||||
if (!HEX_COLOR_RE.test(String(form.primary_color).trim())) return '主题色必须是有效的 HEX 色值(如 #6750a4)';
|
||||
const opacity = Number(form.glass_opacity);
|
||||
if (!Number.isFinite(opacity) || opacity < 0.1 || opacity > 0.95) return '玻璃透明度必须在 0.1 到 0.95 之间';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */
|
||||
export default function ThemeSettings() {
|
||||
const [form, setForm] = useState({
|
||||
primary_color: '#6750a4',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: true,
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
glass_opacity: '0.6',
|
||||
theme_force_dark: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
primary_color: s.primary_color || '#6750a4',
|
||||
theme_wallpaper: s.theme_wallpaper || '',
|
||||
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
|
||||
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
|
||||
nav_style: s.nav_style || 'default',
|
||||
card_style: s.card_style || 'default',
|
||||
glass_blur: s.glass_blur || '20',
|
||||
glass_opacity: s.glass_opacity || '0.6',
|
||||
theme_force_dark: s.theme_force_dark === '1',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const handleWallpaperUpload = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await uploadWallpaper(file);
|
||||
setForm((p) => ({ ...p, theme_wallpaper: data.url }));
|
||||
showSnack('壁纸已上传');
|
||||
} catch (err) {
|
||||
showSnack(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const validationError = validateThemeForm(form);
|
||||
if (validationError) {
|
||||
showSnack(validationError, 'error');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const settings = {
|
||||
primary_color: form.primary_color.trim().toLowerCase(),
|
||||
theme_wallpaper: form.theme_wallpaper.trim(),
|
||||
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
||||
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
|
||||
theme_force_dark: form.theme_force_dark ? '1' : '0',
|
||||
nav_style: form.nav_style,
|
||||
card_style: form.card_style,
|
||||
glass_blur: form.glass_blur,
|
||||
glass_opacity: String(Number(form.glass_opacity)),
|
||||
};
|
||||
await saveSettings(settings);
|
||||
window.dispatchEvent(new CustomEvent('rainweb:theme-update', { detail: settings }));
|
||||
showSnack('主题设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
|
||||
|
||||
<Paper id="sec-primary-color" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="color" value={form.primary_color} onChange={set('primary_color')} style={{ width: 48, height: 48, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||
<TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" error={form.primary_color !== '' && !HEX_COLOR_RE.test(form.primary_color.trim())} helperText="支持 3 位或 6 位 HEX 色值" sx={{ width: 190 }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-wallpaper" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
||||
label="启用壁纸背景"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
|
||||
<Typography variant="caption" color="text.secondary">上传后自动填入 URL</Typography>
|
||||
</Box>
|
||||
<TextField fullWidth label="图片 URL" type="url" value={form.theme_wallpaper} onChange={set('theme_wallpaper')} margin="normal" placeholder="https://example.com/wallpaper.jpg" />
|
||||
{form.theme_wallpaper && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<img src={form.theme_wallpaper} alt="wallpaper" style={{ width: '100%', maxHeight: 120, objectFit: 'cover', borderRadius: 8 }} />
|
||||
</Box>
|
||||
)}
|
||||
<TextField fullWidth label="缩放方式" select value={form.theme_wallpaper_scale} onChange={set('theme_wallpaper_scale')} margin="normal" sx={{ maxWidth: 280 }}>
|
||||
<option value="cover">cover - 覆盖填充</option>
|
||||
<option value="contain">contain - 完整显示</option>
|
||||
<option value="repeat">repeat - 平铺重复</option>
|
||||
<option value="stretch">stretch - 拉伸填充</option>
|
||||
</TextField>
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-styles" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>样式设置</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>导航栏样式</Typography>
|
||||
<ToggleButtonGroup exclusive value={form.nav_style} onChange={(e, v) => v && setForm((p) => ({ ...p, nav_style: v }))} size="small" sx={{ mb: 2 }}>
|
||||
<ToggleButton value="default">默认</ToggleButton>
|
||||
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
|
||||
<ToggleButton value="capsule">胶囊</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>卡片样式</Typography>
|
||||
<ToggleButtonGroup exclusive value={form.card_style} onChange={(e, v) => v && setForm((p) => ({ ...p, card_style: v }))} size="small">
|
||||
<ToggleButton value="default">默认</ToggleButton>
|
||||
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-glass" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>玻璃效果</Typography>
|
||||
<Typography variant="body2" color="text.secondary">模糊强度: {form.glass_blur}px</Typography>
|
||||
<Slider min={5} max={40} value={parseInt(form.glass_blur, 10) || 20} onChange={(e, v) => setForm((p) => ({ ...p, glass_blur: String(v) }))} sx={{ maxWidth: 400 }} />
|
||||
<Typography variant="body2" color="text.secondary">透明度: {form.glass_opacity}</Typography>
|
||||
<Slider min={0.1} max={0.95} step={0.05} value={parseFloat(form.glass_opacity) || 0.6} onChange={(e, v) => setForm((p) => ({ ...p, glass_opacity: String(v) }))} sx={{ maxWidth: 400 }} />
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-dark" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
|
||||
label="强制深色模式(启用后用户无法切换为浅色)"
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存主题设置</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import { normalizeTicketListResponse } from '../../api/tickets.js';
|
||||
import { request } from '../../api/client.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
const STATUS = {
|
||||
open: { label: '待处理', color: 'warning' },
|
||||
processing: { label: '处理中', color: 'info' },
|
||||
waiting: { label: '等待用户', color: 'secondary' },
|
||||
resolved: { label: '已解决', color: 'success' },
|
||||
closed: { label: '已关闭', color: 'default' },
|
||||
};
|
||||
const STATUS_TRANSITIONS = {
|
||||
open: ['processing', 'closed'],
|
||||
processing: ['waiting', 'resolved', 'closed'],
|
||||
waiting: ['processing', 'closed'],
|
||||
resolved: ['closed', 'processing'],
|
||||
closed: ['processing'],
|
||||
};
|
||||
const PRIORITY = {
|
||||
low: { label: '低', color: 'default' },
|
||||
normal: { label: '普通', color: 'info' },
|
||||
high: { label: '高', color: 'warning' },
|
||||
urgent: { label: '紧急', color: 'error' },
|
||||
};
|
||||
const CATEGORY = {
|
||||
forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '功能建议',
|
||||
account: '账号问题', other: '其他',
|
||||
};
|
||||
|
||||
function fmtTime(value) {
|
||||
return value ? String(value).replace('T', ' ').slice(0, 16) : '—';
|
||||
}
|
||||
function clip(value, size = 80) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
return text.length > size ? `${text.slice(0, size)}…` : text;
|
||||
}
|
||||
function StatusChip({ value }) {
|
||||
const item = STATUS[value] || { label: value || '未知', color: 'default' };
|
||||
return <Chip size="small" label={item.label} color={item.color} variant={value === 'closed' ? 'outlined' : 'filled'} />;
|
||||
}
|
||||
function PriorityChip({ value }) {
|
||||
const item = PRIORITY[value] || { label: value || '普通', color: 'default' };
|
||||
return <Chip size="small" label={item.label} color={item.color} variant="outlined" />;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, tone }) {
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, minWidth: 105, flex: '1 1 130px' }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 0.25, fontWeight: 700, color: tone ? `${tone}.main` : 'text.primary' }}>{value}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TicketManage() {
|
||||
const [filters, setFilters] = useState({ q: '', status: '', priority: '', category: '' });
|
||||
const [page, setPage] = useState(1);
|
||||
const [list, setList] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [stats, setStats] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reply, setReply] = useState('');
|
||||
const [internal, setInternal] = useState('');
|
||||
const [assignees, setAssignees] = useState([]);
|
||||
const [saveFeedback, setSaveFeedback] = useState({ type: '', text: '' });
|
||||
const listRequestRef = useRef(0);
|
||||
const statsRequestRef = useRef(0);
|
||||
const detailRequestRef = useRef(0);
|
||||
const selectedIdRef = useRef(null);
|
||||
|
||||
const loadStats = useCallback(() => {
|
||||
const requestId = ++statsRequestRef.current;
|
||||
request('/tickets/admin/stats').then((data) => {
|
||||
if (requestId === statsRequestRef.current) setStats(data || {});
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
const requestId = ++listRequestRef.current;
|
||||
setLoading(true); setError('');
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) });
|
||||
Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value); });
|
||||
request(`/tickets/admin?${params.toString()}`)
|
||||
.then((data) => {
|
||||
if (requestId !== listRequestRef.current) return;
|
||||
const normalized = normalizeTicketListResponse(data, PAGE_SIZE);
|
||||
setList(normalized.tickets); setTotal(normalized.total);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (requestId === listRequestRef.current) setError(e.message || '工单加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === listRequestRef.current) setLoading(false);
|
||||
});
|
||||
}, [filters, page]);
|
||||
|
||||
const loadDetail = useCallback((id) => {
|
||||
if (!id) return;
|
||||
const changed = String(selectedIdRef.current) !== String(id);
|
||||
selectedIdRef.current = id;
|
||||
const requestId = ++detailRequestRef.current;
|
||||
setSelectedId(id); setDetailLoading(true);
|
||||
setDetail(null);
|
||||
if (changed) {
|
||||
setReply(''); setInternal('');
|
||||
setSaveFeedback({ type: '', text: '' });
|
||||
}
|
||||
request(`/tickets/${encodeURIComponent(id)}`)
|
||||
.then((data) => {
|
||||
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) {
|
||||
setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] });
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) showSnack(e.message || '详情加载失败', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) setDetailLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
useEffect(() => {
|
||||
request('/tickets/admin/assignees').then((data) => setAssignees(Array.isArray(data) ? data : (data.users || data.list || []))).catch((e) => setSaveFeedback({ type: 'error', text: e.message || '负责人列表加载失败' }));
|
||||
}, []);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const selectedTicket = detail && detail.ticket;
|
||||
const normalizedAssignees = useMemo(() => {
|
||||
const values = assignees
|
||||
.filter((user) => user && (user.role === 'admin' || user.role === undefined))
|
||||
.map((user) => ({
|
||||
...user,
|
||||
id: String(user.id),
|
||||
displayName: user.nickname || user.username || user.name || `管理员 #${user.id}`,
|
||||
}));
|
||||
if (selectedTicket?.assignee_id != null && !values.some((user) => user.id === String(selectedTicket.assignee_id))) {
|
||||
values.push({
|
||||
id: String(selectedTicket.assignee_id),
|
||||
displayName: selectedTicket.assignee_name || selectedTicket.assignee_username || `负责人 #${selectedTicket.assignee_id}`,
|
||||
});
|
||||
}
|
||||
return values;
|
||||
}, [assignees, selectedTicket]);
|
||||
const updateFilter = (key, value) => { setPage(1); setFilters((old) => ({ ...old, [key]: value })); };
|
||||
const refresh = () => { loadList(); loadStats(); if (selectedId) loadDetail(selectedId); };
|
||||
|
||||
const updateTicket = async (path, body, message, ticketId) => {
|
||||
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
|
||||
setSaving(true);
|
||||
setSaveFeedback({ type: 'saving', text: '正在保存…' });
|
||||
try {
|
||||
await request(path, { method: 'PUT', body });
|
||||
if (String(selectedIdRef.current) !== String(ticketId)) return;
|
||||
setSaveFeedback({ type: 'success', text: '已保存' });
|
||||
showSnack(message); loadDetail(ticketId); loadList(); loadStats();
|
||||
} catch (e) {
|
||||
if (String(selectedIdRef.current) === String(ticketId)) {
|
||||
setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' });
|
||||
showSnack(e.message || '保存失败', 'error');
|
||||
}
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
const sendMessage = async (internalMessage, ticketId) => {
|
||||
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
|
||||
const content = (internalMessage ? internal : reply).trim();
|
||||
if (!content) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await request(`/tickets/${ticketId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } });
|
||||
if (String(selectedIdRef.current) !== String(ticketId)) return;
|
||||
if (internalMessage) setInternal(''); else setReply('');
|
||||
showSnack(internalMessage ? '内部备注已添加' : '公开回复已发送');
|
||||
loadDetail(ticketId); loadList(); loadStats();
|
||||
} catch (e) {
|
||||
if (String(selectedIdRef.current) === String(ticketId)) showSnack(e.message || '发送失败', 'error');
|
||||
}
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const messages = detail?.messages || [];
|
||||
const events = detail?.events || [];
|
||||
const timeline = useMemo(() => [
|
||||
...messages.map((item) => ({ type: 'message', item })),
|
||||
...events.map((item) => ({ type: 'event', item })),
|
||||
].sort((a, b) => {
|
||||
const timeA = Date.parse(String(a.item.created_at || '').replace(' ', 'T'));
|
||||
const timeB = Date.parse(String(b.item.created_at || '').replace(' ', 'T'));
|
||||
const timeDiff = (Number.isNaN(timeA) ? Number.MAX_SAFE_INTEGER : timeA)
|
||||
- (Number.isNaN(timeB) ? Number.MAX_SAFE_INTEGER : timeB);
|
||||
if (timeDiff) return timeDiff;
|
||||
const createdDiff = String(a.item.created_at || '').localeCompare(String(b.item.created_at || ''));
|
||||
if (createdDiff) return createdDiff;
|
||||
const idA = Number(a.item.id);
|
||||
const idB = Number(b.item.id);
|
||||
if (Number.isFinite(idA) && Number.isFinite(idB) && idA !== idB) return idA - idB;
|
||||
return a.type === b.type ? 0 : a.type === 'event' ? 1 : -1;
|
||||
}), [messages, events]);
|
||||
const currentStatus = selectedTicket?.status || 'open';
|
||||
const statusOptions = [currentStatus, ...(STATUS_TRANSITIONS[currentStatus] || [])]
|
||||
.filter((value, index, values) => STATUS[value] && values.indexOf(value) === index);
|
||||
const statItems = useMemo(() => [
|
||||
['全部', stats.total || total, null], ['待处理', stats.open || 0, 'warning'],
|
||||
['处理中', stats.processing || 0, 'info'], ['等待用户', stats.waiting || 0, 'secondary'],
|
||||
['已解决', stats.resolved || 0, 'success'],
|
||||
], [stats, total]);
|
||||
|
||||
return (
|
||||
<Box component="main" className="ticket-workspace" aria-labelledby="ticket-page-title">
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Box><Typography id="ticket-page-title" variant="h5" component="h1">工单管理</Typography><Typography variant="body2" color="text.secondary">集中处理论坛和站内问题反馈</Typography></Box>
|
||||
<IconButton aria-label="刷新工单" title="刷新" onClick={refresh} disabled={loading || saving}><RefreshIcon /></IconButton>
|
||||
</Stack>
|
||||
|
||||
<Stack className="ticket-stats" direction="row" spacing={1.25} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}>
|
||||
{statItems.map(([label, value, tone]) => <StatCard key={label} label={label} value={value} tone={tone} />)}
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); if (page === 1) loadList(); else setPage(1); }}>
|
||||
<Box className="ticket-filter-grid">
|
||||
<TextField className="ticket-filter-search" fullWidth size="small" label="搜索工单" placeholder="编号、标题或内容" value={filters.q} onChange={(e) => updateFilter('q', e.target.value)} />
|
||||
<TextField fullWidth select size="small" label="状态" value={filters.status} onChange={(e) => updateFilter('status', e.target.value)}><MenuItem value="">全部状态</MenuItem>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
|
||||
<TextField fullWidth select size="small" label="优先级" value={filters.priority} onChange={(e) => updateFilter('priority', e.target.value)}><MenuItem value="">全部优先级</MenuItem>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
|
||||
<TextField fullWidth select size="small" label="类型" value={filters.category} onChange={(e) => updateFilter('category', e.target.value)}><MenuItem value="">全部类型</MenuItem>{Object.entries(CATEGORY).map(([key, label]) => <MenuItem key={key} value={key}>{label}</MenuItem>)}</TextField>
|
||||
<Button fullWidth type="submit" variant="contained" sx={{ minHeight: 40 }}>搜索</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" role="alert" action={<Button color="inherit" size="small" onClick={loadList}>重试</Button>} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Box className="ticket-work-area">
|
||||
<Box className="ticket-list-pane">
|
||||
<Paper className="ticket-list-card" variant="outlined" sx={{ height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><Typography variant="subtitle1" fontWeight={700}>最近工单</Typography><Typography variant="caption" color="text.secondary">共 {total} 条</Typography></Box>
|
||||
<Divider />
|
||||
{loading ? <Box role="status" aria-label="正在加载工单" sx={{ p: 4, textAlign: 'center' }}><CircularProgress size={28} /></Box> : list.length === 0 ? <Box sx={{ p: 4, textAlign: 'center' }}><Typography color="text.secondary">暂无匹配工单</Typography></Box> : (
|
||||
<Stack component="ul" className="ticket-list-scroll" aria-label="工单列表" sx={{ listStyle: 'none', m: 0, p: 0 }}>
|
||||
{list.map((ticket) => {
|
||||
const subject = ticket.subject || ticket.title || '无标题工单';
|
||||
const requester = `${ticket.requester_name || ticket.requester_username || '未知用户'} · ${fmtTime(ticket.updated_at || ticket.created_at)}`;
|
||||
return <Box component="li" key={ticket.id}><Button fullWidth onClick={() => loadDetail(ticket.id)} aria-pressed={String(selectedId) === String(ticket.id)} sx={{ p: 1.25, textAlign: 'left', textTransform: 'none', justifyContent: 'flex-start', borderRadius: 0, borderBottom: '1px solid', borderColor: 'divider', bgcolor: String(selectedId) === String(ticket.id) ? 'action.selected' : 'transparent' }}><Box className="ticket-list-row" title={`${ticket.ticket_no || `#${ticket.id}`} · ${subject} · ${requester}`}><Typography className="ticket-list-number" variant="caption" color="text.secondary">{ticket.ticket_no || `#${ticket.id}`}</Typography><Typography className="ticket-list-subject" variant="body2" fontWeight={600}>{subject}</Typography><Typography className="ticket-list-requester" variant="caption" color="text.secondary">{requester}</Typography><StatusChip value={ticket.status} /><PriorityChip value={ticket.priority} /></Box></Button></Box>;
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" spacing={1} sx={{ p: 1.25 }}><IconButton aria-label="上一页" size="small" disabled={page <= 1 || loading} onClick={() => setPage(page - 1)}><NavigateBeforeIcon /></IconButton><Typography variant="caption">第 {page} / {totalPages} 页</Typography><IconButton aria-label="下一页" size="small" disabled={page >= totalPages || loading} onClick={() => setPage(page + 1)}><NavigateNextIcon /></IconButton></Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box className="ticket-detail-pane">
|
||||
<Paper className="ticket-detail-card" variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, minHeight: 420 }}>
|
||||
{!selectedId ? <Box sx={{ py: 10, textAlign: 'center' }}><Typography variant="h6" color="text.secondary">选择一个工单开始处理</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>工单详情、回复和状态操作会显示在这里</Typography></Box> : detailLoading ? <Box role="status" aria-label="正在加载工单详情" sx={{ py: 10, textAlign: 'center' }}><CircularProgress /></Box> : selectedTicket ? (
|
||||
<Stack spacing={2}>
|
||||
<Box><Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap><Typography variant="h6" component="h2" sx={{ overflowWrap: 'anywhere' }}>{selectedTicket.subject || selectedTicket.title}</Typography><StatusChip value={selectedTicket.status} /><PriorityChip value={selectedTicket.priority} /></Stack><Typography variant="caption" color="text.secondary">{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)}</Typography></Box>
|
||||
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/status`, { status: e.target.value }, '状态已更新', selectedTicket.id)}>{statusOptions.map((key) => <MenuItem key={key} value={key}>{STATUS[key].label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/priority`, { priority: e.target.value }, '优先级已更新', selectedTicket.id)}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新', selectedTicket.id)}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover' }}><Typography variant="subtitle2" gutterBottom>问题描述</Typography><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{selectedTicket.description || '暂无描述'}</Typography>{selectedTicket.source_url && <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, overflowWrap: 'anywhere' }}>来源:{selectedTicket.source_url}</Typography>}</Paper>
|
||||
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{timeline.map((entry) => { if (entry.type === 'message') { const message = entry.item; return <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>; } const event = entry.item; return <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>; })}</Stack></Box>
|
||||
<Divider />
|
||||
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false, selectedTicket.id)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true, selectedTicket.id)}>添加内部备注</Button></Grid></Grid>
|
||||
</Stack>
|
||||
) : <Alert severity="error">工单详情不存在或加载失败。</Alert>}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listAttachments, deleteAttachment } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 附件管理:列表/删除(迁移自 v1 附件卡片) */
|
||||
export default function Uploads() {
|
||||
const [list, setList] = useState(null);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAttachments()
|
||||
.then((rows) => setList(rows || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAttachment(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>附件管理</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>文件名</TableCell>
|
||||
<TableCell>原始名</TableCell>
|
||||
<TableCell>大小</TableCell>
|
||||
<TableCell>类型</TableCell>
|
||||
<TableCell>上传者</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list === null ? (
|
||||
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8}>暂无附件</TableCell></TableRow>
|
||||
) : list.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell>{f.id}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 160 }}>
|
||||
<Box component="a" href={`/uploads/${f.filename}`} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{f.filename}</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</TableCell>
|
||||
<TableCell>{((f.size || 0) / 1024).toFixed(0)} KB</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.mime_type || '-'}</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary' }}>UID {f.user_id}</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.created_at}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: f.id, label: f.filename })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listUsers, deleteUser, resetUserPassword, registerByAdmin, updateUser, me } from '../../api/auth.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 头衔色板(MD3 常用色相,与论坛板块图标色板一致) */
|
||||
const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e',
|
||||
'#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8'];
|
||||
|
||||
const EMPTY_EDIT = { nickname: '', title: '', title_color: '', website: '', email: '', role: 'user' };
|
||||
|
||||
/** 用户管理:列表 / 添加 / 编辑大弹窗(昵称·头衔·权限·改密·删除危险区) */
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState(null);
|
||||
const [selfId, setSelfId] = useState(null); // 当前管理员自己的 id(自我保护:禁止改权/删除自己)
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
|
||||
|
||||
// 编辑大弹窗
|
||||
const [editUser, setEditUser] = useState(null); // 行数据 { id, username, ... }
|
||||
const [editForm, setEditForm] = useState(EMPTY_EDIT);
|
||||
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null); // { id, username }
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listUsers()
|
||||
.then((us) => setUsers(us || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 自我 id(禁止对自己改权/删除)
|
||||
useEffect(() => {
|
||||
me().then((u) => setSelfId(u && u.id)).catch(() => setSelfId(null));
|
||||
}, []);
|
||||
|
||||
const addUser = async () => {
|
||||
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await registerByAdmin(addForm.username.trim(), addForm.password, addForm.role);
|
||||
showSnack('用户已创建');
|
||||
setAddOpen(false);
|
||||
setAddForm({ username: '', password: '', role: 'user' });
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const openEdit = (u) => {
|
||||
setEditUser(u);
|
||||
setEditForm({
|
||||
nickname: u.nickname || '',
|
||||
title: u.title || '',
|
||||
title_color: u.title_color || '',
|
||||
website: u.website || '',
|
||||
email: u.email || '',
|
||||
role: u.role || 'user',
|
||||
});
|
||||
setPwForm({ p1: '', p2: '' });
|
||||
};
|
||||
|
||||
const closeEdit = () => setEditUser(null);
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!editUser) return;
|
||||
if (editForm.nickname.length > 20) { showSnack('昵称最多 20 字', 'error'); return; }
|
||||
if (editForm.title.length > 20) { showSnack('头衔最多 20 字', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateUser(editUser.id, {
|
||||
nickname: editForm.nickname.trim(),
|
||||
title: editForm.title.trim(),
|
||||
title_color: editForm.title_color.trim(),
|
||||
website: editForm.website.trim(),
|
||||
email: editForm.email.trim(),
|
||||
role: editForm.role,
|
||||
});
|
||||
showSnack('资料已保存');
|
||||
closeEdit();
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const resetPw = async () => {
|
||||
if (!editUser) return;
|
||||
if (!pwForm.p1 || pwForm.p1.length < 6) { showSnack('密码至少6位', 'error'); return; }
|
||||
if (pwForm.p1 !== pwForm.p2) { showSnack('两次密码不一致', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await resetUserPassword(editUser.id, pwForm.p1);
|
||||
showSnack('密码已重置');
|
||||
setPwForm({ p1: '', p2: '' });
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteUser(deleteTarget.id);
|
||||
showSnack('已删除');
|
||||
setDeleteTarget(null);
|
||||
setEditUser(null); // 删除后关闭编辑弹窗
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const isSelf = !!editUser && editUser.id === selfId;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">用户管理</Typography>
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)}>添加用户</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>用户</TableCell>
|
||||
<TableCell>邮箱</TableCell>
|
||||
<TableCell>验证</TableCell>
|
||||
<TableCell>角色</TableCell>
|
||||
<TableCell>注册时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users === null ? (
|
||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>暂无用户</TableCell></TableRow>
|
||||
) : users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>{u.id}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ fontWeight: 600 }}>{u.nickname || u.username}</Box>
|
||||
{u.nickname && u.nickname !== u.username ? (
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>@{u.username}</Box>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary' }}>{u.email || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.email_verified ? '已验证' : '未验证'} color={u.email_verified ? 'primary' : 'default'} variant={u.email_verified ? 'filled' : 'outlined'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.role === 'admin' ? '管理员' : '用户'} color={u.role === 'admin' ? 'secondary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Button size="small" startIcon={<EditIcon fontSize="small" />} onClick={() => openEdit(u)}>编辑</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>添加用户</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="用户名 *" value={addForm.username} onChange={(e) => setAddForm((p) => ({ ...p, username: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="密码 *" type="password" value={addForm.password} onChange={(e) => setAddForm((p) => ({ ...p, password: e.target.value }))} margin="normal" />
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>角色</InputLabel>
|
||||
<Select value={addForm.role} onChange={(e) => setAddForm((p) => ({ ...p, role: e.target.value }))} label="角色">
|
||||
<MenuItem value="user">用户</MenuItem>
|
||||
<MenuItem value="admin">管理员</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={addUser} disabled={busy}>创建</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 编辑用户大弹窗 */}
|
||||
<Dialog open={!!editUser} onClose={closeEdit} fullWidth maxWidth="sm">
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
<DialogContent>
|
||||
{editUser && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
|
||||
{/* 基本信息 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>基本信息</Typography>
|
||||
<TextField fullWidth label="用户名(不可修改)" value={editUser.username} margin="normal" disabled />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="对外昵称"
|
||||
value={editForm.nickname}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, nickname: e.target.value }))}
|
||||
margin="normal"
|
||||
inputProps={{ maxLength: 20 }}
|
||||
helperText="显示在帖子 / 评论 / 个人主页的作者名,留空则显示用户名"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="邮箱"
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, email: e.target.value }))}
|
||||
margin="normal"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<Box sx={{ mt: 3.4, flexShrink: 0 }}>
|
||||
<Chip size="small" label={editUser.email_verified ? '已验证' : '未验证'} color={editUser.email_verified ? 'primary' : 'default'} variant={editUser.email_verified ? 'filled' : 'outlined'} />
|
||||
</Box>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="个人博客"
|
||||
type="url"
|
||||
value={editForm.website}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, website: e.target.value }))}
|
||||
margin="normal"
|
||||
placeholder="https://example.com"
|
||||
helperText="显示在个人主页(外链跳确认页)"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 头衔 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>头衔</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="自定义头衔"
|
||||
value={editForm.title}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, title: e.target.value }))}
|
||||
margin="normal"
|
||||
inputProps={{ maxLength: 20 }}
|
||||
placeholder="如:技术宅 / 站长 / 摸鱼大师"
|
||||
/>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>头衔颜色(点击选择,或输入自定义 hex)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{COLOR_PALETTE.map((col) => (
|
||||
<Box
|
||||
key={col}
|
||||
onClick={() => setEditForm((p) => ({ ...p, title_color: col === p.title_color ? '' : col }))}
|
||||
sx={{
|
||||
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer', background: col,
|
||||
border: col === editForm.title_color ? '2px solid #fff' : '2px solid transparent',
|
||||
outline: col === editForm.title_color ? '2px solid var(--md-sys-color-outline, rgba(0,0,0,0.38))' : 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
title={col}
|
||||
/>
|
||||
))}
|
||||
<TextField
|
||||
size="small"
|
||||
label="自定义"
|
||||
value={editForm.title_color}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, title_color: e.target.value }))}
|
||||
placeholder="#6750a4"
|
||||
sx={{ width: 130 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 权限 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>权限</Typography>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>角色</InputLabel>
|
||||
<Select value={editForm.role} onChange={(e) => setEditForm((p) => ({ ...p, role: e.target.value }))} label="角色" disabled={isSelf}>
|
||||
<MenuItem value="user">普通用户</MenuItem>
|
||||
<MenuItem value="admin">管理员</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{isSelf && <Typography variant="caption" color="text.secondary">不能修改自己的角色</Typography>}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 账号安全(危险区) */}
|
||||
<Box sx={{ border: '1px solid', borderColor: 'error.main', borderRadius: 2, p: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'error.main' }}>账号安全</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-end' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="新密码(至少6位)"
|
||||
type="password"
|
||||
value={pwForm.p1}
|
||||
onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))}
|
||||
margin="normal"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="确认新密码"
|
||||
type="password"
|
||||
value={pwForm.p2}
|
||||
onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))}
|
||||
margin="normal"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="outlined" size="small" onClick={resetPw} disabled={busy} sx={{ mb: 0.5 }}>重置密码</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: 13 }}>
|
||||
删除后将无法恢复,帖子 / 评论 / 附件一并处理
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<DeleteIcon />}
|
||||
disabled={isSelf || busy}
|
||||
onClick={() => setDeleteTarget({ id: editUser.id, username: editUser.username })}
|
||||
title={isSelf ? '不能删除自己' : ''}
|
||||
>
|
||||
删除用户
|
||||
</Button>
|
||||
</Box>
|
||||
{isSelf && <Typography variant="caption" color="text.secondary">不能删除自己的账号</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeEdit}>取消</Button>
|
||||
<Button variant="contained" onClick={saveProfile} disabled={busy}>保存资料</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
message={`确定要删除用户 "${deleteTarget ? deleteTarget.username : ''}" 吗?此操作不可恢复`}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
// 模块级单例:admin 任意处调用 showSnack,由 AdminLayout 内的 SnackHost 消费
|
||||
let showFn = null;
|
||||
|
||||
export function showSnack(msg, severity = 'success') {
|
||||
if (showFn) showFn(msg, severity);
|
||||
else window.alert(msg);
|
||||
}
|
||||
|
||||
export default function SnackHost() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [severity, setSeverity] = useState('success');
|
||||
|
||||
showFn = (m, s) => { setMsg(m); setSeverity(s || 'success'); setOpen(true); };
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={severity} variant="filled" onClose={() => setOpen(false)}>{msg}</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/* ============================================================
|
||||
* 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节
|
||||
* 颜色、焦点色和玻璃透明度由后台 MUI Theme 注入 --admin-* 语义变量。
|
||||
* ============================================================ */
|
||||
|
||||
/* 内容区入场动效(对应前台 contentFadeIn) */
|
||||
main {
|
||||
animation: md3ContentFadeIn 0.28s ease;
|
||||
}
|
||||
|
||||
@keyframes md3ContentFadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* 明暗切换过渡 */
|
||||
body {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 设置区块锚点(搜索跳转定位):预留固定 AppBar 高度,避免被遮挡 */
|
||||
[id^="sec-"] {
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
|
||||
/* 滚动条(对应前台细滚动条) */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--admin-outline-variant, #cac4d0); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--admin-outline, #79747e); }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--admin-primary, #6750a4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 工单工作区:占满 AdminLayout 内容区,桌面保持列表/详情主次比例。 */
|
||||
.ticket-workspace {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
min-height: calc(100vh - 112px);
|
||||
}
|
||||
|
||||
.ticket-workspace .ticket-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticket-work-area {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.55fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: max(520px, calc(100vh - 280px));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ticket-list-pane,
|
||||
.ticket-detail-pane {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticket-list-pane > .MuiPaper-root,
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ticket-list-pane > .MuiPaper-root {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ticket-list-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ticket-list-card > .MuiDivider-root,
|
||||
.ticket-list-card > .MuiStack-root:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ticket-list-scroll > li {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ticket-list-scroll > li,
|
||||
.ticket-list-scroll > li > .MuiButton-root {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ticket-list-scroll > li > .MuiButton-root {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.ticket-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(120px, 1.4fr) minmax(120px, 1fr) max-content max-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.ticket-list-row {
|
||||
min-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket-list-row > .MuiTypography-root,
|
||||
.ticket-list-row > .MuiChip-root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-list-row > .MuiTypography-root {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-list-row > .MuiChip-root {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ticket-list-scroll > li > .MuiButton-root > .MuiBox-root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-list-scroll .MuiTypography-root {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ticket-list-scroll .MuiChip-root {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ticket-list-number {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ticket-detail-card {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ticket-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)) minmax(96px, 0.85fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-filter-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-filter-grid .MuiInputBase-root,
|
||||
.ticket-filter-grid .MuiButton-root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-select-value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-save-feedback {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ticket-save-saving { color: var(--mui-palette-text-secondary, inherit); }
|
||||
.ticket-save-success { color: var(--admin-success, #2e7d32); }
|
||||
.ticket-save-error { color: var(--admin-error, #d32f2f); }
|
||||
|
||||
@media (max-width: 1199px) and (min-width: 700px) {
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: minmax(220px, 1.6fr) repeat(3, minmax(118px, 1fr));
|
||||
}
|
||||
|
||||
.ticket-filter-grid .ticket-filter-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ticket-filter-grid > .MuiButton-root {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) and (min-width: 700px) {
|
||||
.ticket-work-area {
|
||||
grid-template-columns: minmax(250px, 0.62fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 699px) {
|
||||
.ticket-workspace {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ticket-work-area {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ticket-list-pane > .MuiPaper-root,
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ticket-list-card {
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
flex: 0 1 auto;
|
||||
max-height: 350px;
|
||||
}
|
||||
|
||||
.ticket-list-row {
|
||||
grid-template-columns: max-content minmax(140px, 1.2fr) minmax(120px, 1fr) max-content max-content;
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ticket-filter-grid .ticket-filter-search,
|
||||
.ticket-filter-grid > .MuiButton-root {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 459px) {
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 减弱动效偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { createTheme, alpha } from '@mui/material/styles';
|
||||
import { buildThemeTokens, DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY } from '../themeTokens.js';
|
||||
|
||||
/* ============================================================
|
||||
* 后台 Material Design 3 主题
|
||||
*
|
||||
* - 与前台 public/css/style.css 的 MD3 token 保持同源:
|
||||
* 默认主色 #6750a4 时直接复用前台 token 值(浅/深两套),
|
||||
* 自定义主色时用 tonal ramp 近似生成整套 token。
|
||||
* - 组件形态按 MD3 规范覆盖:pill 按钮、大圆角卡片/弹窗、
|
||||
* surface 色阶、弱边框 + 低阴影(surface tint)。
|
||||
* ============================================================ */
|
||||
|
||||
const DEFAULT_SEED = DEFAULT_PRIMARY_COLOR;
|
||||
|
||||
/* ---------- 颜色工具 ---------- */
|
||||
|
||||
function clamp(v) { return Math.min(255, Math.max(0, Math.round(v))); }
|
||||
|
||||
function hexToRgb(hex) {
|
||||
let h = String(hex).replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
const n = parseInt(h, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
const to = (v) => clamp(v).toString(16).padStart(2, '0');
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
function mix(a, b, w) {
|
||||
const ca = hexToRgb(a);
|
||||
const cb = hexToRgb(b);
|
||||
return rgbToHex(
|
||||
ca[0] + (cb[0] - ca[0]) * w,
|
||||
ca[1] + (cb[1] - ca[1]) * w,
|
||||
ca[2] + (cb[2] - ca[2]) * w
|
||||
);
|
||||
}
|
||||
|
||||
function hexToHsl(hex) {
|
||||
const [r, g, b] = hexToRgb(hex).map((v) => v / 255);
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
}
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let rgb;
|
||||
if (h < 60) rgb = [c, x, 0];
|
||||
else if (h < 120) rgb = [x, c, 0];
|
||||
else if (h < 180) rgb = [0, c, x];
|
||||
else if (h < 240) rgb = [0, x, c];
|
||||
else if (h < 300) rgb = [x, 0, c];
|
||||
else rgb = [c, 0, x];
|
||||
return rgbToHex((rgb[0] + m) * 255, (rgb[1] + m) * 255, (rgb[2] + m) * 255);
|
||||
}
|
||||
|
||||
function grayscale(hex) {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
const v = clamp(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
return rgbToHex(v, v, v);
|
||||
}
|
||||
|
||||
/* MD3 tonal ramp 近似:tone t = 向白/黑按比例混合(t40 即 seed 本身) */
|
||||
function tone(seed, t) {
|
||||
if (t >= 40) return mix(seed, '#ffffff', (t - 40) / 60);
|
||||
return mix(seed, '#000000', (40 - t) / 40);
|
||||
}
|
||||
|
||||
/* 派生色:可旋转色相 / 降低饱和度后再套 tone */
|
||||
function tonal(seed, t, { hue = 0, sat = 1 } = {}) {
|
||||
let base = seed;
|
||||
if (hue) {
|
||||
const [h, s, l] = hexToHsl(base);
|
||||
base = hslToHex(h + hue, s, l);
|
||||
}
|
||||
if (sat < 1) base = mix(base, grayscale(base), 1 - sat);
|
||||
return tone(base, t);
|
||||
}
|
||||
|
||||
/* ---------- 前台同源 token(默认主色 #6750a4,取自 public/css/style.css) ---------- */
|
||||
|
||||
const LIGHT_TOKENS = {
|
||||
primary: '#6750a4',
|
||||
onPrimary: '#ffffff',
|
||||
primaryContainer: '#eadfff',
|
||||
onPrimaryContainer: '#21005d',
|
||||
secondary: '#625b71',
|
||||
onSecondary: '#ffffff',
|
||||
secondaryContainer: '#e8def8',
|
||||
onSecondaryContainer: '#1d192b',
|
||||
tertiary: '#7d5260',
|
||||
onTertiary: '#ffffff',
|
||||
tertiaryContainer: '#ffd8e4',
|
||||
onTertiaryContainer: '#31111d',
|
||||
surface: '#fffbfe',
|
||||
onSurface: '#1c1b1f',
|
||||
surfaceVariant: '#e7e0ec',
|
||||
onSurfaceVariant: '#49454f',
|
||||
surfaceContainerLow: '#f7f2fa',
|
||||
surfaceContainer: '#f3edf7',
|
||||
surfaceContainerHigh: '#ece6f0',
|
||||
outline: '#79747e',
|
||||
outlineVariant: '#cac4d0',
|
||||
error: '#b3261e',
|
||||
onError: '#ffffff',
|
||||
inverseSurface: '#313033',
|
||||
inverseOnSurface: '#f4eff4',
|
||||
};
|
||||
|
||||
const DARK_TOKENS = {
|
||||
primary: '#d0bcff',
|
||||
onPrimary: '#381e72',
|
||||
primaryContainer: '#4f378b',
|
||||
onPrimaryContainer: '#eadfff',
|
||||
secondary: '#ccc2dc',
|
||||
onSecondary: '#332d41',
|
||||
secondaryContainer: '#4a4458',
|
||||
onSecondaryContainer: '#e8def8',
|
||||
tertiary: '#efb8c8',
|
||||
onTertiary: '#492532',
|
||||
tertiaryContainer: '#61343f',
|
||||
onTertiaryContainer: '#ffd8e4',
|
||||
surface: '#1c1b1f',
|
||||
onSurface: '#e6e1e5',
|
||||
surfaceVariant: '#49454f',
|
||||
onSurfaceVariant: '#cac4d0',
|
||||
surfaceContainerLow: '#211f26',
|
||||
surfaceContainer: '#25232a',
|
||||
surfaceContainerHigh: '#2b2930',
|
||||
outline: '#938f99',
|
||||
outlineVariant: '#49454f',
|
||||
error: '#f2b8b5',
|
||||
onError: '#601410',
|
||||
inverseSurface: '#e6e1e5',
|
||||
inverseOnSurface: '#313033',
|
||||
};
|
||||
|
||||
function buildPalette(seed, dark) {
|
||||
if (String(seed).toLowerCase() === DEFAULT_SEED) return dark ? DARK_TOKENS : LIGHT_TOKENS;
|
||||
// 自定义主色:surface 色阶保持 MD3 基线,色相相关 token 由 tonal ramp 派生
|
||||
const sat = 0.55; // secondary 适度去饱和,贴近 MD3 参考值
|
||||
const hue = 32; // tertiary 色相偏移
|
||||
if (dark) {
|
||||
return {
|
||||
primary: tone(seed, 80),
|
||||
onPrimary: tone(seed, 20),
|
||||
primaryContainer: tone(seed, 30),
|
||||
onPrimaryContainer: tone(seed, 90),
|
||||
secondary: tonal(seed, 80, { sat }),
|
||||
onSecondary: tonal(seed, 20, { sat }),
|
||||
secondaryContainer: tonal(seed, 30, { sat }),
|
||||
onSecondaryContainer: tonal(seed, 90, { sat }),
|
||||
tertiary: tonal(seed, 80, { hue }),
|
||||
onTertiary: tonal(seed, 20, { hue }),
|
||||
tertiaryContainer: tonal(seed, 30, { hue }),
|
||||
onTertiaryContainer: tonal(seed, 90, { hue }),
|
||||
...DARK_TOKENS_DERIVED,
|
||||
};
|
||||
}
|
||||
return {
|
||||
primary: tone(seed, 40),
|
||||
onPrimary: '#ffffff',
|
||||
primaryContainer: tone(seed, 90),
|
||||
onPrimaryContainer: tone(seed, 10),
|
||||
secondary: tonal(seed, 40, { sat }),
|
||||
onSecondary: '#ffffff',
|
||||
secondaryContainer: tonal(seed, 90, { sat }),
|
||||
onSecondaryContainer: tonal(seed, 10, { sat }),
|
||||
tertiary: tonal(seed, 40, { hue }),
|
||||
onTertiary: '#ffffff',
|
||||
tertiaryContainer: tonal(seed, 90, { hue }),
|
||||
onTertiaryContainer: tonal(seed, 10, { hue }),
|
||||
...LIGHT_TOKENS_DERIVED,
|
||||
};
|
||||
}
|
||||
|
||||
// 派生模式下 surface 相关与 error 沿用 MD3 基线
|
||||
const LIGHT_TOKENS_DERIVED = {
|
||||
surface: '#fffbfe',
|
||||
onSurface: '#1c1b1f',
|
||||
surfaceVariant: '#e7e0ec',
|
||||
onSurfaceVariant: '#49454f',
|
||||
surfaceContainerLow: '#f7f2fa',
|
||||
surfaceContainer: '#f3edf7',
|
||||
surfaceContainerHigh: '#ece6f0',
|
||||
outline: '#79747e',
|
||||
outlineVariant: '#cac4d0',
|
||||
error: '#b3261e',
|
||||
onError: '#ffffff',
|
||||
inverseSurface: '#313033',
|
||||
inverseOnSurface: '#f4eff4',
|
||||
};
|
||||
|
||||
const DARK_TOKENS_DERIVED = {
|
||||
surface: '#1c1b1f',
|
||||
onSurface: '#e6e1e5',
|
||||
surfaceVariant: '#49454f',
|
||||
onSurfaceVariant: '#cac4d0',
|
||||
surfaceContainerLow: '#211f26',
|
||||
surfaceContainer: '#25232a',
|
||||
surfaceContainerHigh: '#2b2930',
|
||||
outline: '#938f99',
|
||||
outlineVariant: '#49454f',
|
||||
error: '#f2b8b5',
|
||||
onError: '#601410',
|
||||
inverseSurface: '#e6e1e5',
|
||||
inverseOnSurface: '#313033',
|
||||
};
|
||||
|
||||
/* ---------- MD3 柔和阴影(surface tint 而非硬投影) ---------- */
|
||||
|
||||
function md3Shadows() {
|
||||
const shadows = ['none'];
|
||||
for (let i = 1; i <= 24; i += 1) {
|
||||
const a = Math.min(0.26, 0.05 + i * 0.008).toFixed(3);
|
||||
const b = Math.min(0.15, 0.03 + i * 0.005).toFixed(3);
|
||||
const y1 = Math.min(3, 1 + Math.floor(i / 8));
|
||||
const y2 = Math.min(2, 1 + Math.floor(i / 12));
|
||||
const b1 = Math.min(8, 2 + Math.floor(i / 3));
|
||||
const b2 = Math.min(6, 1 + Math.floor(i / 5));
|
||||
shadows.push(`0px ${y1}px ${b1}px rgba(0,0,0,${a}), 0px ${y2}px ${b2}px rgba(0,0,0,${b})`);
|
||||
}
|
||||
return shadows;
|
||||
}
|
||||
|
||||
/* ---------- 主题工厂 ---------- */
|
||||
|
||||
export function createMD3Theme({ mode = 'light', primary = DEFAULT_PRIMARY_COLOR, glassOpacity = DEFAULT_GLASS_OPACITY } = {}) {
|
||||
const p = buildThemeTokens({ mode, primary_color: primary, glass_opacity: glassOpacity });
|
||||
const safeGlassOpacity = p.glassOpacity;
|
||||
const dark = p.mode === 'dark';
|
||||
const shadows = md3Shadows();
|
||||
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
|
||||
|
||||
// filled / tonal 按钮的 hover 色(MD3 hover = 相邻 tone)
|
||||
const hoverPrimary = dark ? tone(p.primary, 90) : tone(p.primary, 35);
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: p.primary,
|
||||
contrastText: p.onPrimary,
|
||||
container: p.primaryContainer,
|
||||
onContainer: p.onPrimaryContainer,
|
||||
},
|
||||
secondary: {
|
||||
main: p.secondary,
|
||||
contrastText: p.onSecondary,
|
||||
container: p.secondaryContainer,
|
||||
onContainer: p.onSecondaryContainer,
|
||||
},
|
||||
tertiary: {
|
||||
main: p.tertiary,
|
||||
contrastText: p.onTertiary,
|
||||
container: p.tertiaryContainer,
|
||||
onContainer: p.onTertiaryContainer,
|
||||
},
|
||||
error: { main: p.error, contrastText: p.onError },
|
||||
background: { default: p.surface, paper: p.surfaceContainerLow },
|
||||
text: {
|
||||
primary: p.onSurface,
|
||||
secondary: p.onSurfaceVariant,
|
||||
disabled: alpha(p.onSurface, 0.38),
|
||||
},
|
||||
divider: p.outlineVariant,
|
||||
action: {
|
||||
hover: alpha(p.onSurface, 0.08),
|
||||
selected: alpha(p.onSurface, 0.08),
|
||||
disabled: alpha(p.onSurface, 0.38),
|
||||
disabledBackground: alpha(p.onSurface, 0.12),
|
||||
},
|
||||
},
|
||||
shape: { borderRadius: 8 },
|
||||
typography: {
|
||||
fontFamily,
|
||||
h4: { fontSize: 28, fontWeight: 500, letterSpacing: 0 },
|
||||
h5: { fontSize: 24, fontWeight: 500, letterSpacing: 0 },
|
||||
h6: { fontSize: 22, fontWeight: 500, letterSpacing: 0.15 },
|
||||
subtitle1: { fontSize: 16, fontWeight: 500, letterSpacing: 0.15 },
|
||||
body1: { fontSize: 16, lineHeight: 1.5 },
|
||||
body2: { fontSize: 14, lineHeight: 1.5 },
|
||||
button: { fontSize: 14, fontWeight: 500, letterSpacing: 0.1 },
|
||||
caption: { fontSize: 12, fontWeight: 400, letterSpacing: 0.4 },
|
||||
},
|
||||
shadows,
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
':root': {
|
||||
'--admin-primary': p.primary,
|
||||
'--admin-on-primary': p.onPrimary,
|
||||
'--admin-surface': p.surface,
|
||||
'--admin-surface-container': p.surfaceContainer,
|
||||
'--admin-surface-container-low': p.surfaceContainerLow,
|
||||
'--admin-surface-container-high': p.surfaceContainerHigh,
|
||||
'--admin-on-surface': p.onSurface,
|
||||
'--admin-on-surface-variant': p.onSurfaceVariant,
|
||||
'--admin-outline': p.outline,
|
||||
'--admin-outline-variant': p.outlineVariant,
|
||||
'--admin-error': p.error,
|
||||
'--admin-success': p.success,
|
||||
'--admin-glass-opacity': safeGlassOpacity,
|
||||
},
|
||||
},
|
||||
},
|
||||
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
|
||||
MuiAppBar: {
|
||||
defaultProps: { color: 'inherit', elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: p.surfaceContainer,
|
||||
color: p.onSurface,
|
||||
boxShadow: 'none',
|
||||
borderBottom: `1px solid ${p.outlineVariant}`,
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 导航抽屉:surfaceContainerLow + 右侧细边框 */
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
borderRight: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 导航项:胶囊选中态(secondaryContainer) */
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 24,
|
||||
margin: '4px 10px',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
'& .MuiListItemIcon-root': { color: p.onSurfaceVariant, minWidth: 40 },
|
||||
'& .MuiListItemText-primary': { fontWeight: 500, fontSize: 14 },
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
'&:hover': { backgroundColor: p.secondaryContainer },
|
||||
'& .MuiListItemIcon-root': { color: p.onSecondaryContainer },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 按钮:pill 圆角、无阴影、文字不转大写 */
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.1px',
|
||||
height: 40,
|
||||
padding: '0 24px',
|
||||
},
|
||||
sizeSmall: { height: 32, padding: '0 16px', fontSize: 13 },
|
||||
sizeLarge: { height: 48, padding: '0 28px', fontSize: 15 },
|
||||
containedPrimary: {
|
||||
backgroundColor: p.primary,
|
||||
color: p.onPrimary,
|
||||
'&:hover': { backgroundColor: hoverPrimary, boxShadow: shadows[1] },
|
||||
},
|
||||
containedError: { boxShadow: 'none' },
|
||||
outlined: { borderColor: p.outline },
|
||||
outlinedPrimary: {
|
||||
borderColor: p.outline,
|
||||
'&:hover': { borderColor: p.onSurface, backgroundColor: alpha(p.primary, 0.08) },
|
||||
},
|
||||
outlinedError: { color: p.error, borderColor: p.outline, '&:hover': { backgroundColor: alpha(p.error, 0.08) } },
|
||||
textPrimary: { color: p.primary, '&:hover': { backgroundColor: alpha(p.primary, 0.08) } },
|
||||
textError: { color: p.error },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 卡片:surfaceContainerLow + 弱边框 + 12px 圆角 */
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* 设置页的普通 Paper(默认 elevation=1)同样 MD3 化 */
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
rounded: { borderRadius: 12 },
|
||||
elevation1: {
|
||||
backgroundColor: alpha(p.surfaceContainerLow, safeGlassOpacity),
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
outlined: { borderColor: p.outlineVariant },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 弹窗:28px 大圆角 + surfaceContainerHigh */
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRadius: 28,
|
||||
backgroundColor: p.surfaceContainerHigh,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: shadows[4],
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialogTitle: {
|
||||
styleOverrides: { root: { fontSize: 22, fontWeight: 500, padding: '24px 24px 8px' } },
|
||||
},
|
||||
MuiDialogContent: {
|
||||
styleOverrides: { root: { padding: '8px 24px', color: p.onSurface } },
|
||||
},
|
||||
MuiDialogActions: {
|
||||
styleOverrides: { root: { padding: '16px 24px 24px', gap: 8 } },
|
||||
},
|
||||
|
||||
/* MD3 输入框:4px 圆角 + 中性 outline 边框 */
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 4 },
|
||||
notchedOutline: { borderColor: p.outline },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: p.onSurface },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 Chip:8px 圆角 + outline 边框 */
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 8, fontWeight: 500 },
|
||||
outlined: { borderColor: p.outline },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 表格:outline-variant 分隔线、紧凑密度 */
|
||||
MuiTableCell: {
|
||||
styleOverrides: {
|
||||
root: { borderBottom: `1px solid ${p.outlineVariant}`, padding: '14px 16px', fontSize: 14 },
|
||||
head: { color: p.onSurfaceVariant, fontWeight: 600, fontSize: 13 },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 开关:开 = primary 轨道 + 白 thumb,关 = surfaceContainerHigh 轨道 */
|
||||
MuiSwitch: {
|
||||
styleOverrides: {
|
||||
root: {},
|
||||
switchBase: {
|
||||
color: p.outline,
|
||||
'&.Mui-checked': { color: p.primary },
|
||||
'&.Mui-checked + .MuiSwitch-track': { backgroundColor: p.primary, opacity: 1 },
|
||||
},
|
||||
track: { backgroundColor: p.surfaceContainerHigh, opacity: 1 },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 分段按钮:secondaryContainer 选中态 */
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
textTransform: 'none',
|
||||
borderColor: p.outline,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
borderColor: p.outline,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 头像:primaryContainer 底 + onPrimaryContainer 字 */
|
||||
MuiAvatar: {
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: p.primaryContainer, color: p.onPrimaryContainer },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 提示:inverseSurface 底 */
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
backgroundColor: p.inverseSurface,
|
||||
color: p.inverseOnSurface,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
padding: '6px 8px',
|
||||
},
|
||||
arrow: { color: p.inverseSurface },
|
||||
},
|
||||
},
|
||||
|
||||
/* 图标按钮去底色圆角适配 */
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createMD3Theme;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// 面板链接(routes/admin-links.js)
|
||||
export function listAdminLinks() {
|
||||
return request('/admin-links');
|
||||
}
|
||||
/** data = { title, url, embed_url?, description?, icon?, category?, sort_order?, use_proxy?, version? } */
|
||||
export function createAdminLink(data) {
|
||||
return request('/admin-links', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateAdminLink(id, data) {
|
||||
return request('/admin-links/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteAdminLink(id) {
|
||||
return request('/admin-links/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 启用的公告列表(active=1) */
|
||||
export function listActive() {
|
||||
return request('/announcements');
|
||||
}
|
||||
|
||||
// ── 管理员管理 ──────────────────────────────────
|
||||
export function listAll() {
|
||||
return request('/announcements?all=1');
|
||||
}
|
||||
export function createAnnouncement(data) {
|
||||
return request('/announcements', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateAnnouncement(id, data) {
|
||||
return request('/announcements/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteAnnouncement(id) {
|
||||
return request('/announcements/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { request, setToken, notifyAuthChange } from './client.js';
|
||||
import { applyCaptchaResult } from './captcha.js';
|
||||
|
||||
/** 登录;captcha 为 showCaptcha 的结果({type,value},可选):proof → captcha_proof,recaptcha/turnstile → 对应 token */
|
||||
export function login(username, password, captcha) {
|
||||
const body = { username, password };
|
||||
applyCaptchaResult(body, captcha);
|
||||
return request('/auth/login', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 注册;data = { username, password, email, captcha? }(captcha 同 login) */
|
||||
export function register(data) {
|
||||
const body = { username: data.username, password: data.password, email: data.email };
|
||||
applyCaptchaResult(body, data.captcha);
|
||||
return request('/auth/register', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息(需登录) */
|
||||
export function me() {
|
||||
return request('/auth/me');
|
||||
}
|
||||
|
||||
// ── RainID 单点登录(OIDC) ──────────────────────────────
|
||||
/** 发起授权码登录:整页跳转到后端 /api/auth/oidc/login(后端 302 至 RainID) */
|
||||
export function oidcLoginRedirect() {
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
}
|
||||
|
||||
/** RainID 回跳:用一次性过渡码(30s)换取会话 token → {token, username, role} */
|
||||
export function oidcExchange(ticket) {
|
||||
return request('/auth/oidc/exchange', { method: 'POST', body: { ticket } });
|
||||
}
|
||||
|
||||
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
|
||||
export function logout() {
|
||||
setToken(null);
|
||||
notifyAuthChange();
|
||||
}
|
||||
|
||||
// ── 管理员用户管理 ──────────────────────────────
|
||||
export function registerByAdmin(username, password, role) {
|
||||
return request('/auth/register-by-admin', { method: 'POST', body: { username, password, role } });
|
||||
}
|
||||
export function listUsers() {
|
||||
return request('/auth/users');
|
||||
}
|
||||
export function deleteUser(id) {
|
||||
return request('/auth/users/' + id, { method: 'DELETE' });
|
||||
}
|
||||
export function resetUserPassword(id, newPassword) {
|
||||
return request('/auth/users/' + id + '/password', { method: 'PUT', body: { newPassword } });
|
||||
}
|
||||
export function setUserRole(id, role) {
|
||||
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { role } });
|
||||
}
|
||||
/** 编辑用户资料(管理员):nickname/title/title_color/website/email/role */
|
||||
export function updateUser(id, data) {
|
||||
return request('/auth/users/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 文章列表;all=true 返回全部(含草稿,仅管理员) */
|
||||
export function listPosts(all) {
|
||||
return request('/blog/posts' + (all ? '?all=1' : ''));
|
||||
}
|
||||
export function getPost(id) {
|
||||
return request('/blog/posts/' + id);
|
||||
}
|
||||
/** data = { title, content, excerpt?, published?, use_markdown? }(仅管理员) */
|
||||
export function createPost(data) {
|
||||
return request('/blog/posts', { method: 'POST', body: data });
|
||||
}
|
||||
export function updatePost(id, data) {
|
||||
return request('/blog/posts/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
/** 解锁 markdown 锁定块(password 类):{ password? } → { ok, content }(内容仅前端内存态) */
|
||||
export function unlockLock(postId, index, password) {
|
||||
return request('/blog/posts/' + postId + '/locks/' + index + '/unlock', {
|
||||
method: 'POST',
|
||||
body: password !== undefined && password !== null ? { password } : {},
|
||||
});
|
||||
}
|
||||
export function deletePost(id) {
|
||||
return request('/blog/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 搜索 / 标签 / 归档 / 上下篇 / 点赞(博客增强 B2)──────────
|
||||
export function searchPosts(q) {
|
||||
return request('/blog/search?q=' + encodeURIComponent(q || ''));
|
||||
}
|
||||
export function getTags() {
|
||||
return request('/blog/tags');
|
||||
}
|
||||
export function getTagPosts(name) {
|
||||
return request('/blog/tag/' + encodeURIComponent(name));
|
||||
}
|
||||
export function getArchive() {
|
||||
return request('/blog/archive');
|
||||
}
|
||||
export function getPrevNext(id) {
|
||||
return request('/blog/posts/' + id + '/prevnext');
|
||||
}
|
||||
export function getLikeState(id) {
|
||||
return request('/blog/posts/' + id + '/like');
|
||||
}
|
||||
export function likePost(id) {
|
||||
return request('/blog/posts/' + id + '/like', { method: 'POST' });
|
||||
}
|
||||
export function unlikePost(id) {
|
||||
return request('/blog/posts/' + id + '/like', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 评论 ────────────────────────────────────────
|
||||
export function listComments(postId) {
|
||||
return request('/blog/comments/' + postId);
|
||||
}
|
||||
/** content 评论正文;parentId>0 表示回复某条评论(嵌套) */
|
||||
export function createComment(postId, content, parentId = 0) {
|
||||
return request('/blog/comments/' + postId, { method: 'POST', body: { content, parent_id: parentId || 0 } });
|
||||
}
|
||||
export function deleteComment(id) {
|
||||
return request('/blog/comments/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 评论审核(管理员)──────────────────────────
|
||||
export function listPendingComments() {
|
||||
return request('/blog/comments/pending');
|
||||
}
|
||||
export function approveComment(id) {
|
||||
return request('/blog/comments/' + id + '/approve', { method: 'POST' });
|
||||
}
|
||||
export function rejectComment(id) {
|
||||
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** 全量评论列表(仅管理员):status=all|pending|approved|rejected,分页 → { list, total, page, pageSize, totalPages } */
|
||||
export function listAllComments({ status = 'all', page = 1, pageSize = 20 } = {}) {
|
||||
return request(`/blog/comments?status=${encodeURIComponent(status)}&page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 获取内置 SVG 验证码:返回 { token, svg, expires_in } */
|
||||
export function getImage() {
|
||||
return request('/captcha/image');
|
||||
}
|
||||
|
||||
/** 校验答案:返回 { success, proof?, error? },成功时 proof 为一次性验证码证明令牌 */
|
||||
export function verify(token, answer) {
|
||||
return request('/captcha/verify', { method: 'POST', body: { token, answer } });
|
||||
}
|
||||
|
||||
/** 检查某个动作(login/register/forum)是否需要验证码:返回 { required, type } */
|
||||
export function required(action) {
|
||||
return request('/captcha/required', { method: 'POST', body: { action } });
|
||||
}
|
||||
|
||||
// ── Proof of Work(智能验证,内置验证码的防刷前置) ──
|
||||
export function powChallenge() {
|
||||
return request('/captcha/pow-challenge');
|
||||
}
|
||||
export function powVerify(token, nonce) {
|
||||
return request('/captcha/pow-verify', { method: 'POST', body: { token, nonce: String(nonce) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 showCaptcha 的结果按 type 写入请求体:
|
||||
* - proof → captcha_proof(内置验证码一次性证明令牌)
|
||||
* - recaptcha → recaptcha_token(reCAPTCHA siteverify token)
|
||||
* - turnstile → turnstile_token(Turnstile siteverify token)
|
||||
*/
|
||||
export function applyCaptchaResult(body, result) {
|
||||
if (!body || !result || !result.type || !result.value) return body;
|
||||
if (result.type === 'proof') body.captcha_proof = result.value;
|
||||
else if (result.type === 'recaptcha') body.recaptcha_token = result.value;
|
||||
else if (result.type === 'turnstile') body.turnstile_token = result.value;
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(t) {
|
||||
t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** 登录态变更通知:登录/退出/头像更新后触发,Layout 监听后重新拉取当前用户 */
|
||||
export function notifyAuthChange() {
|
||||
window.dispatchEvent(new Event('authchange'));
|
||||
}
|
||||
|
||||
export async function request(path, { method = 'GET', body, auth = true } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (auth && getToken()) headers.Authorization = 'Bearer ' + getToken();
|
||||
const res = await fetch('/api' + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { const err = new Error(data.error || '请求失败'); err.status = res.status; throw err; }
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 重新发送注册邮箱验证码(8 位数字,存入 pending_users) */
|
||||
export function sendVerify(email, username) {
|
||||
return request('/email/send-verify', { method: 'POST', body: { email, username } });
|
||||
}
|
||||
|
||||
/** 输入 8 位验证码完成注册:{ code, username, password } */
|
||||
export function completeRegister(code, username, password) {
|
||||
return request('/email/complete-register', { method: 'POST', body: { code, username, password } });
|
||||
}
|
||||
|
||||
/** 发送 SMTP 测试邮件(仅管理员) */
|
||||
export function testSmtp(email) {
|
||||
return request('/email/test', { method: 'POST', body: { email } });
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 分类 ────────────────────────────────────────
|
||||
/** 版块列表(含聚合:posts_count / today_count / moderators / icon / icon_color) */
|
||||
export function listCategories() {
|
||||
return request('/forum/categories');
|
||||
}
|
||||
/** 版块详情(含聚合与公告) */
|
||||
export function getCategory(id) {
|
||||
return request('/forum/categories/' + id);
|
||||
}
|
||||
export function createCategory(data) {
|
||||
return request('/forum/categories', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateCategory(id, data) {
|
||||
return request('/forum/categories/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteCategory(id) {
|
||||
return request('/forum/categories/' + id, { method: 'DELETE' });
|
||||
}
|
||||
/** 更新版块公告(版主 / 管理员) */
|
||||
export function updateAnnouncement(id, announcement) {
|
||||
return request('/forum/categories/' + id + '/announcement', { method: 'PUT', body: { announcement } });
|
||||
}
|
||||
/** 版块资料更新(版主 / 管理员):部分更新,只提交有改动的字段。
|
||||
* fields = { name?, description?, icon?, icon_color? };icon_color 空串表示清除 → 按名称哈希配色 */
|
||||
export function updateCategoryProfile(id, fields) {
|
||||
return request('/forum/categories/' + id + '/profile', { method: 'PUT', body: fields });
|
||||
}
|
||||
/** 指派版主(管理员);userIds 为用户 id 数组(后端 body 字段 user_ids) */
|
||||
export function updateModerators(id, userIds) {
|
||||
return request('/forum/categories/' + id + '/moderators', { method: 'PUT', body: { user_ids: userIds } });
|
||||
}
|
||||
|
||||
// ── 帖子 ────────────────────────────────────────
|
||||
/** 帖子列表;opts = { categoryId, page, subCategory, q },返回 { posts, total, page, pageSize }(兼容裸数组) */
|
||||
export function listPosts(opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.categoryId) params.set('category_id', opts.categoryId);
|
||||
if (opts.page && opts.page > 1) params.set('page', String(opts.page));
|
||||
if (opts.subCategory) params.set('sub_category', opts.subCategory);
|
||||
if (opts.q) params.set('q', opts.q);
|
||||
const qs = params.toString();
|
||||
return request('/forum/posts' + (qs ? '?' + qs : ''));
|
||||
}
|
||||
export function getPost(id) {
|
||||
return request('/forum/posts/' + id);
|
||||
}
|
||||
/** data = { category_id, title, content, use_markdown?, sub_category?, captcha_proof? }(需登录) */
|
||||
export function createPost(data) {
|
||||
return request('/forum/posts', { method: 'POST', body: data });
|
||||
}
|
||||
export function reply(postId, content) {
|
||||
return request('/forum/posts/' + postId + '/replies', { method: 'POST', body: { content } });
|
||||
}
|
||||
export function deletePost(id) {
|
||||
return request('/forum/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
/** 编辑帖子(作者/版主/admin):body 部分更新 { title?, content?, sub_category?, use_markdown? } → edit_count+1 */
|
||||
export function updatePost(id, fields) {
|
||||
return request('/forum/posts/' + id, { method: 'PUT', body: fields });
|
||||
}
|
||||
/** 解锁 markdown 锁定块(password 类):{ password? } → { ok, content }(内容仅前端内存态) */
|
||||
export function unlockLock(postId, index, password) {
|
||||
return request('/forum/posts/' + postId + '/locks/' + index + '/unlock', {
|
||||
method: 'POST',
|
||||
body: password !== undefined && password !== null ? { password } : {},
|
||||
});
|
||||
}
|
||||
export function deleteReply(id) {
|
||||
return request('/forum/replies/' + id, { method: 'DELETE' });
|
||||
}
|
||||
/** 置顶 / 取消置顶(管理员或版主);后端 body 字段 pinned */
|
||||
export function setPinned(id, value) {
|
||||
return request('/forum/posts/' + id + '/pin', { method: 'PUT', body: { pinned: value ? 1 : 0 } });
|
||||
}
|
||||
/** 加精 / 取消加精(管理员或版主);后端 body 字段 essence */
|
||||
export function setEssence(id, value) {
|
||||
return request('/forum/posts/' + id + '/essence', { method: 'PUT', body: { essence: value ? 1 : 0 } });
|
||||
}
|
||||
/** 当前用户管理的版块 id 数组(admin 返回全部版块 id;版主返回其管理的) */
|
||||
export function listModerated() {
|
||||
return request('/forum/moderated').then((d) => {
|
||||
const raw = d && (d.category_ids || d.ids);
|
||||
if (Array.isArray(raw)) return raw.map((x) => Number(x)).filter((n) => !isNaN(n));
|
||||
const cats = d && d.categories;
|
||||
if (Array.isArray(cats)) {
|
||||
return cats.map((c) => (typeof c === 'object' ? c.id : Number(c))).filter((n) => !isNaN(n));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** 我管理的版块详情数组(admin 全量 / 版主其管辖),供管理台首页用。
|
||||
* 兼容三种后端形态:直接返回版块详情数组;{ categories: 详情数组, category_ids };旧 { category_ids } 按 id 过滤全量分类 */
|
||||
export async function listModeratedCategories(user) {
|
||||
const d = await request('/forum/moderated');
|
||||
if (Array.isArray(d)) return d; // 形态1:直接是版块详情数组
|
||||
if (Array.isArray(d && d.categories)) return d.categories; // 形态2:categories 已是详情数组
|
||||
const ids = (d && (d.category_ids || d.ids)) || []; // 形态3:仅 id 数组
|
||||
const cats = (await listCategories().catch(() => [])) || [];
|
||||
if (user && user.role === 'admin') return cats; // admin 的 category_ids 为空 = 全版块可管
|
||||
const idSet = new Set(ids.map(Number));
|
||||
return cats.filter((c) => idSet.has(c.id));
|
||||
}
|
||||
|
||||
// ── 禁言(版主/管理员) ──────────────────────────
|
||||
/** 版块禁言列表;返回 [{ user_id, username, permanent, muted_until, created_at }] */
|
||||
export function listMutes(categoryId) {
|
||||
return request('/forum/mutes?category_id=' + encodeURIComponent(categoryId));
|
||||
}
|
||||
/** 添加禁言:username 为被禁用户名(后端解析为 user_id),duration 为天数(1/7/30)或 'forever'(永久) */
|
||||
export function createMute(categoryId, username, duration) {
|
||||
return request('/forum/mutes', { method: 'PUT', body: { category_id: categoryId, username, duration } });
|
||||
}
|
||||
/** 解除禁言:按 (category_id, user_id) 定位(无独立 id) */
|
||||
export function deleteMute(categoryId, userId) {
|
||||
return request('/forum/mutes', { method: 'DELETE', body: { category_id: categoryId, user_id: userId } });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 工作台「记事本」(routes/notes.js,仅管理员) ────────────
|
||||
/** 文件列表:[{ name, size, mtime }],按修改时间倒序 */
|
||||
export function listNotes() {
|
||||
return request('/notes');
|
||||
}
|
||||
/** 读取内容:{ name, content, size, mtime } */
|
||||
export function getNote(filename) {
|
||||
return request('/notes/' + encodeURIComponent(filename));
|
||||
}
|
||||
/** 创建:{ filename, content },同名返回 409 */
|
||||
export function createNote(filename, content) {
|
||||
return request('/notes', { method: 'POST', body: { filename, content } });
|
||||
}
|
||||
/** 更新内容:{ content } */
|
||||
export function updateNote(filename, content) {
|
||||
return request('/notes/' + encodeURIComponent(filename), { method: 'PUT', body: { content } });
|
||||
}
|
||||
/** 删除 */
|
||||
export function deleteNote(filename) {
|
||||
return request('/notes/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── PIN / 解锁 ──────────────────────────────────
|
||||
export function pinStatus() {
|
||||
return request('/passwords/pin-status');
|
||||
}
|
||||
export function setPin(pin) {
|
||||
return request('/passwords/set-pin', { method: 'POST', body: { pin } });
|
||||
}
|
||||
export function unlock(pin) {
|
||||
return request('/passwords/unlock', { method: 'POST', body: { pin } });
|
||||
}
|
||||
export function lock() {
|
||||
return request('/passwords/lock', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 密码条目(需已解锁) ───────────────────────
|
||||
export function listEntries() {
|
||||
return request('/passwords');
|
||||
}
|
||||
/** data = { title, username?, password, url?, notes? } */
|
||||
export function createEntry(data) {
|
||||
return request('/passwords', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateEntry(id, data) {
|
||||
return request('/passwords/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteEntry(id) {
|
||||
return request('/passwords/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 获取当前用户资料(需登录) */
|
||||
export function getProfile() {
|
||||
return request('/profile');
|
||||
}
|
||||
|
||||
/** 更新资料(需登录);目前仅支持 avatar = /uploads/avatars/xxx 站内路径 */
|
||||
export function updateProfile(data) {
|
||||
return request('/profile', { method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
/** 发送修改密码的邮箱验证码(需登录) */
|
||||
export function sendPwCode() {
|
||||
return request('/profile/send-pw-code', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** 校验验证码并修改密码:{ code, oldPassword, newPassword } */
|
||||
export function changePassword(data) {
|
||||
return request('/profile/password', { method: 'PUT', body: data });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { request } from './client.js';
|
||||
import { THEME_SETTINGS_EVENT, THEME_SETTINGS_SYNC_KEY } from '../themeTokens.js';
|
||||
|
||||
const THEME_SETTING_KEYS = new Set([
|
||||
'primary_color', 'theme_wallpaper', 'theme_wallpaper_scale', 'theme_wallpaper_enabled',
|
||||
'nav_style', 'card_style', 'glass_blur', 'glass_opacity', 'theme_force_dark',
|
||||
]);
|
||||
|
||||
function noStorePath(path) {
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
return `${path}${separator}_ts=${Date.now()}`;
|
||||
}
|
||||
|
||||
function broadcastThemeSettings(settings) {
|
||||
if (typeof window === 'undefined' || !settings || !Object.keys(settings).some((key) => THEME_SETTING_KEYS.has(key))) return;
|
||||
const detail = { ...settings, updated_at: Date.now() };
|
||||
window.dispatchEvent(new CustomEvent(THEME_SETTINGS_EVENT, { detail }));
|
||||
try { window.localStorage.setItem(THEME_SETTINGS_SYNC_KEY, JSON.stringify(detail)); } catch {}
|
||||
}
|
||||
|
||||
/** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */
|
||||
export function getPublicSettings() {
|
||||
return request(noStorePath('/settings/public'), { auth: false });
|
||||
}
|
||||
|
||||
/** 全量设置(仅管理员) */
|
||||
export function getSettings() {
|
||||
return request(noStorePath('/settings'));
|
||||
}
|
||||
|
||||
/** 保存设置(仅管理员) */
|
||||
export function saveSettings(data) {
|
||||
return request('/settings', { method: 'PUT', body: data }).then((result) => {
|
||||
broadcastThemeSettings(data);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── Web 终端(安全敏感:root shell,PIN + 短 TTL token 双认证) ──
|
||||
// 后端契约见 routes/terminal.js:
|
||||
// GET /api/terminal/status → { hasPin, sessions, maxSessions, idleTimeoutMs }
|
||||
// POST /api/terminal/auth → { token, expiresIn, setup?, needs_setup?, message? }
|
||||
|
||||
/** 检查终端专用密码是否已设置 + 当前会话数 */
|
||||
export function terminalStatus() {
|
||||
return request('/terminal/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置(首次)/校验终端密码,成功发放 5 分钟短 TTL 终端 token。
|
||||
* 失败抛错:400 密码不合法 / 401 密码错误 / 429 尝试次数过多
|
||||
*/
|
||||
export function terminalAuth(pin) {
|
||||
return request('/terminal/auth', { method: 'POST', body: { pin } });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
export function normalizeTicketListResponse(data, fallbackPageSize = 20) {
|
||||
const payload = Array.isArray(data) ? { tickets: data } : (data && typeof data === 'object' ? data : {});
|
||||
const tickets = Array.isArray(payload.tickets)
|
||||
? payload.tickets
|
||||
: (Array.isArray(payload.list) ? payload.list : (Array.isArray(payload.items) ? payload.items : []));
|
||||
const pageSizeValue = Number(payload.pageSize ?? payload.page_size);
|
||||
const pageSize = Number.isInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : fallbackPageSize;
|
||||
const totalValue = Number(payload.total ?? payload.count);
|
||||
const total = Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : tickets.length;
|
||||
const totalPagesValue = Number(payload.totalPages ?? payload.total_pages ?? payload.pages);
|
||||
const totalPages = Number.isInteger(totalPagesValue) && totalPagesValue > 0
|
||||
? totalPagesValue
|
||||
: Math.max(1, Math.ceil(total / pageSize));
|
||||
return { tickets, total, totalPages };
|
||||
}
|
||||
|
||||
export function listTickets(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.pageSize) query.set('pageSize', String(params.pageSize));
|
||||
if (params.status) query.set('status', params.status);
|
||||
const qs = query.toString();
|
||||
return request('/tickets' + (qs ? '?' + qs : ''));
|
||||
}
|
||||
|
||||
export function createTicket(data) {
|
||||
return request('/tickets', { method: 'POST', body: data });
|
||||
}
|
||||
|
||||
export function getTicket(id) {
|
||||
return request('/tickets/' + encodeURIComponent(id));
|
||||
}
|
||||
|
||||
export function addMessage(id, content) {
|
||||
return request('/tickets/' + encodeURIComponent(id) + '/messages', {
|
||||
method: 'POST',
|
||||
body: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTicket(id) {
|
||||
return request('/tickets/' + encodeURIComponent(id) + '/close', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function reopenTicket(id) {
|
||||
return request('/tickets/' + encodeURIComponent(id) + '/reopen', { method: 'POST' });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { request, getToken } from './client.js';
|
||||
|
||||
// client.request 走 JSON,上传必须独立走 fetch + FormData + Bearer
|
||||
|
||||
async function upload(path, file, extra, fieldName = 'file') {
|
||||
const fd = new FormData();
|
||||
fd.append(fieldName, file);
|
||||
if (extra) {
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
|
||||
}
|
||||
}
|
||||
const headers = {};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
const res = await fetch('/api/upload' + path, { method: 'POST', headers, body: fd });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || '上传失败');
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 普通文件上传;opts 可选 { ref_type, ref_id }。返回 { id, url, tag, ... },tag 为 [image:]/[file:] 插入文本 */
|
||||
export function uploadFile(file, opts) {
|
||||
return upload('/file', file, opts);
|
||||
}
|
||||
|
||||
/** 头像上传,成功后直接更新用户 avatar,返回 { url } */
|
||||
export function uploadAvatar(file) {
|
||||
return upload('/avatar', file);
|
||||
}
|
||||
|
||||
/** 壁纸上传,返回 { url, filename } */
|
||||
export function uploadWallpaper(file) {
|
||||
return upload('/wallpaper', file);
|
||||
}
|
||||
|
||||
/** 版块图标上传(multipart 字段名 icon,≤1MB),返回 { url: '/uploads/icons/xxx.png' } */
|
||||
export function uploadIcon(file) {
|
||||
return upload('/icon', file, undefined, 'icon');
|
||||
}
|
||||
|
||||
/** 按 uid 获取头像地址(支持 QQ 自动头像) */
|
||||
export function avatarUrl(uid) {
|
||||
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
|
||||
}
|
||||
|
||||
/** 附件列表;可按 ref_type + ref_id 过滤 */
|
||||
export function listAttachments(refType, refId) {
|
||||
const q = [];
|
||||
if (refType && refId) {
|
||||
q.push('ref_type=' + encodeURIComponent(refType), 'ref_id=' + encodeURIComponent(refId));
|
||||
}
|
||||
return request('/upload/list' + (q.length ? '?' + q.join('&') : ''));
|
||||
}
|
||||
|
||||
/** 删除附件(本人或管理员) */
|
||||
export function deleteAttachment(id) {
|
||||
return request('/upload/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** 搜索引擎验证文件上传(仅管理员,multipart 字段名 file,≤64KB),返回 { url, message } */
|
||||
export function uploadVerifyFile(file) {
|
||||
return upload('/verify-file', file);
|
||||
}
|
||||
|
||||
/** 已上传验证文件列表(仅管理员),返回 [{ name, url }] */
|
||||
export function listVerifyFiles() {
|
||||
return request('/upload/verify-files');
|
||||
}
|
||||
|
||||
/** 删除验证文件(仅管理员),返回 { message } */
|
||||
export function deleteVerifyFile(name) {
|
||||
return request('/upload/verify-file/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 公开用户主页(无鉴权) ────────────────────────
|
||||
|
||||
/**
|
||||
* 公开用户资料:{ id, username, role, avatar, bio, created_at, last_active_at, stats }
|
||||
* 不存在时 404 { error: '用户不存在' }
|
||||
*/
|
||||
export function getPublicUser(id) {
|
||||
return request('/users/' + id);
|
||||
}
|
||||
|
||||
/** 用户帖子列表(分页)→ { list, total, page, pageSize, totalPages };论坛私密时 403 */
|
||||
export function getUserPosts(id, page = 1, pageSize = 10) {
|
||||
return request(`/users/${id}/posts?page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
|
||||
/** 用户回复列表(分页)→ { list, total, page, pageSize, totalPages };论坛私密时 403 */
|
||||
export function getUserReplies(id, page = 1, pageSize = 10) {
|
||||
return request(`/users/${id}/replies?page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/** 名称哈希 → 0..11 色调组(复用 style.css 的 --fi-* 12 色 MD3 变量,深浅色自适应;供 Banner 渐变等复用) */
|
||||
export function hashTone(name) {
|
||||
let h = 0;
|
||||
const k = String(name || '');
|
||||
for (let i = 0; i < k.length; i += 1) h = (h * 31 + k.charCodeAt(i)) >>> 0;
|
||||
return h % 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆形用户头像(MD3):
|
||||
* - src 非空 → 圆形 <img>(object-fit: cover)
|
||||
* - src 空 → 首字兜底(name[0] 大写 + 名称哈希底色)
|
||||
* - to 非空 → 外层包 <Link to={to}>(点击跳公开主页;内层 stopPropagation 防嵌套 Link 冒泡)
|
||||
* props: { src, name, size=36, className, to }
|
||||
*/
|
||||
export default function Avatar({ src = '', name = '', size = 36, className = '', to }) {
|
||||
const style = { width: size, height: size, fontSize: Math.round(size * 0.42) };
|
||||
const base = 'avatar' + (className ? ' ' + className : '');
|
||||
const alt = name ? `${name} 的头像` : '头像';
|
||||
const letter = String(name || '?').charAt(0).toUpperCase();
|
||||
|
||||
// 跳转版本:Link 作为圆形容器(cursor 指针),fallback 底色放内层 span
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={base + ' avatar-link'}
|
||||
style={style}
|
||||
aria-label={alt}
|
||||
title={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{src
|
||||
? <img src={src} alt={alt} />
|
||||
: (
|
||||
<span
|
||||
className={'avatar-fallback tone-' + hashTone(name)}
|
||||
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: style.fontSize }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<span className={base} style={style}>
|
||||
<img src={src} alt={alt} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`${base} avatar-fallback tone-${hashTone(name)}`} style={style} role="img" aria-label={alt}>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { listPosts } from '../api/blog.js';
|
||||
import BrandIcon, { BRAND_ICON_NAMES } from './BrandIcon.jsx';
|
||||
|
||||
/**
|
||||
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
|
||||
* 头像 / 简介 / 联系方式来自站点设置(/api/settings/public);
|
||||
* showRecent=true 时附加"最新文章"卡片(首页用)。
|
||||
* settings 由父组件从 /api/settings/public 取得后传入,避免重复请求。
|
||||
*/
|
||||
export default function BlogSidebar({ settings = {}, showRecent = false, forceShow = false }) {
|
||||
const [recent, setRecent] = useState(null); // null=加载中
|
||||
const [recentError, setRecentError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRecent) return;
|
||||
setRecent(null);
|
||||
setRecentError(false);
|
||||
listPosts()
|
||||
.then((posts) => setRecent((posts || []).slice(0, 8)))
|
||||
.catch(() => { setRecentError(true); setRecent([]); });
|
||||
}, [showRecent]);
|
||||
|
||||
// blog_show_sidebar=0 时整栏隐藏(仅博客页;首页 forceShow 强制显示)
|
||||
if (!forceShow && settings.blog_show_sidebar === '0') return null;
|
||||
|
||||
let contacts = [];
|
||||
if (settings.homepage_contacts) {
|
||||
try { contacts = JSON.parse(settings.homepage_contacts); } catch { contacts = []; }
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="homepage-sidebar" id="blogSidebar">
|
||||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<div className="homepage-avatar">
|
||||
{settings.homepage_avatar
|
||||
? <img src={settings.homepage_avatar} alt="avatar" />
|
||||
: <span className="material-icons" style={{ fontSize: 64, color: 'var(--md-ref-on-surface-variant)' }}>person</span>}
|
||||
</div>
|
||||
<div className="homepage-bio">{settings.homepage_bio || ''}</div>
|
||||
{contacts.length > 0 && (
|
||||
<div className="homepage-contacts">
|
||||
{contacts.map((l, i) => (
|
||||
<a key={i} href={l.url} target="_blank" rel="noopener" className="hp-contact-link" title={l.title || ''}>
|
||||
{BRAND_ICON_NAMES.includes(l.icon)
|
||||
? <BrandIcon name={l.icon} size={20} className="contact-icon" />
|
||||
: <span className="material-icons">{l.icon || 'link'}</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecent && (
|
||||
<div className="card">
|
||||
<div className="homepage-recent-header">最新文章</div>
|
||||
<div className="homepage-recent-list">
|
||||
{recentError
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载失败</div>
|
||||
: recent === null
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载中...</div>
|
||||
: recent.length === 0
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>暂无文章</div>
|
||||
: recent.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="homepage-recent-item">
|
||||
<span className="homepage-recent-title">{p.title}</span>
|
||||
<span className="homepage-recent-date">{(p.created_at || '').slice(0, 10)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 品牌图标(读本地彩色 SVG 文件,public/icons/brands/):
|
||||
* qq / bilibili / telegram / github / gitea / wechat —— 彩色图标,浏览器原生 <img> 渲染,离线可用。
|
||||
* props: { name, size=24, className, alt }
|
||||
* 未知 name(不在 BRAND_ICON_NAMES)→ null,调用方以 material-icons 兜底。
|
||||
*/
|
||||
|
||||
/** 支持的品牌图标名(供前台双通道渲染判断) */
|
||||
export const BRAND_ICON_NAMES = ['qq', 'bilibili', 'telegram', 'github', 'gitea', 'wechat'];
|
||||
|
||||
const LABELS = {
|
||||
qq: 'QQ',
|
||||
bilibili: '哔哩哔哩',
|
||||
telegram: 'Telegram',
|
||||
github: 'GitHub',
|
||||
gitea: 'Gitea',
|
||||
wechat: '微信',
|
||||
};
|
||||
|
||||
export default function BrandIcon({ name, size = 24, className = '', alt }) {
|
||||
if (!BRAND_ICON_NAMES.includes(name)) return null;
|
||||
return (
|
||||
<img
|
||||
src={`/icons/brands/${name}.svg`}
|
||||
alt={alt || LABELS[name] || name}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as captchaApi from '../api/captcha.js';
|
||||
import { useDialog } from '../lib/utils.js';
|
||||
|
||||
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
|
||||
let pending = null; // { action, type, resolve }
|
||||
let listener = null;
|
||||
|
||||
function notifyHost() {
|
||||
if (listener) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码流程入口(迁移自 public/js/captcha.js):
|
||||
* - null 无需验证 / 用户取消
|
||||
* - object { type: 'proof' | 'recaptcha' | 'turnstile', value }
|
||||
* proof → 内置验证码通过,value 为后端签发的 captcha_proof
|
||||
* recaptcha → reCAPTCHA 验证通过,value 为 siteverify token
|
||||
* turnstile → Turnstile 验证通过,value 为 siteverify token
|
||||
* 调用方提交时按 type 放入 captcha_proof / recaptcha_token / turnstile_token 字段。
|
||||
*/
|
||||
export function showCaptcha(action) {
|
||||
return new Promise((resolve) => {
|
||||
captchaApi.required(action)
|
||||
.then((r) => {
|
||||
if (!r || !r.required || r.type === 'none') { resolve(null); return; }
|
||||
pending = { action, type: r.type || 'builtin', resolve };
|
||||
notifyHost();
|
||||
})
|
||||
.catch(() => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
// 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用)
|
||||
function flushCallbacks(type) {
|
||||
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||
const q = window[key] || [];
|
||||
window[key] = [];
|
||||
q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } });
|
||||
}
|
||||
|
||||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||||
// 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪,
|
||||
// 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。
|
||||
function ensureThirdPartyScript(type) {
|
||||
if (type === 'recaptcha') {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
if (!window.onRecaptchaLoad) {
|
||||
window.onRecaptchaLoad = () => flushCallbacks('recaptcha');
|
||||
}
|
||||
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
||||
s.async = true; s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
} else {
|
||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||
if (!window.onTurnstileLoad) {
|
||||
window.onTurnstileLoad = () => flushCallbacks('turnstile');
|
||||
}
|
||||
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
||||
s.async = true; s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码
|
||||
const THIRD_PARTY_LOAD_TIMEOUT = 8000;
|
||||
|
||||
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
|
||||
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
|
||||
// 脚本注入后启动 8s 加载超时:届时库仍未就绪 → 调用 onTimeout 触发降级;
|
||||
// 库已就绪但回调错过 → 冲刷队列兜底(wrappedRender 执行并自行清理计时器)。
|
||||
// 返回清理函数:移除排队回调并清除 300ms 竞态与 8s 超时两个计时器,
|
||||
// 供弹窗关闭/重开时防泄漏、防残留降级状态。
|
||||
function queueRender(type, render, onTimeout) {
|
||||
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
|
||||
if (typeof window[lib] !== 'undefined') { render(); return null; }
|
||||
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||
window[key] = window[key] || [];
|
||||
|
||||
let done = false; // 已渲染 / 已超时降级 / 已清理:任一后不再执行 render
|
||||
let raceTimer = null;
|
||||
let timeoutTimer = null;
|
||||
|
||||
const wrappedRender = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(raceTimer);
|
||||
clearTimeout(timeoutTimer);
|
||||
render();
|
||||
};
|
||||
|
||||
window[key].push(wrappedRender);
|
||||
ensureThirdPartyScript(type);
|
||||
|
||||
// 300ms 竞态兜底:脚本恰好在「检查就绪」与「入队」之间完成加载、onload 已错过时重放队列
|
||||
raceTimer = setTimeout(() => {
|
||||
if (typeof window[lib] !== 'undefined') flushCallbacks(type);
|
||||
}, 300);
|
||||
|
||||
// 8s 加载超时:库仍未就绪 → 降级;就绪但回调错过 → 冲刷队列兜底
|
||||
timeoutTimer = setTimeout(() => {
|
||||
if (done) return;
|
||||
if (typeof window[lib] !== 'undefined') {
|
||||
flushCallbacks(type);
|
||||
} else {
|
||||
done = true;
|
||||
clearTimeout(raceTimer);
|
||||
if (onTimeout) onTimeout();
|
||||
}
|
||||
}, THIRD_PARTY_LOAD_TIMEOUT);
|
||||
|
||||
return () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(raceTimer);
|
||||
clearTimeout(timeoutTimer);
|
||||
const q = window[key];
|
||||
if (q) {
|
||||
const i = q.indexOf(wrappedRender);
|
||||
if (i >= 0) q.splice(i, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// SHA-256(Proof of Work 使用)
|
||||
async function sha256(str) {
|
||||
const buf = new TextEncoder().encode(str);
|
||||
const hash = await crypto.subtle.digest('SHA-256', buf);
|
||||
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// 内置 SVG 验证码弹窗(notice 为可选提示,用于第三方降级时告知用户)
|
||||
function BuiltinCaptcha({ onFinish, notice }) {
|
||||
const [svg, setSvg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [powDone, setPowDone] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const tokenRef = useRef(null);
|
||||
const imgRef = useRef(null);
|
||||
|
||||
const loadImage = async () => {
|
||||
setError('');
|
||||
setAnswer('');
|
||||
const data = await captchaApi.getImage();
|
||||
if (data && data.token) {
|
||||
tokenRef.current = data.token;
|
||||
setSvg(data.svg);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadImage();
|
||||
// 智能验证(Proof of Work)后台运行,20s 内找到满足难度的 nonce
|
||||
(async () => {
|
||||
try {
|
||||
const chal = await captchaApi.powChallenge();
|
||||
if (!chal || !chal.token) return;
|
||||
const target = '0'.repeat(chal.difficulty);
|
||||
const start = Date.now();
|
||||
let nonce = 0;
|
||||
while (Date.now() - start < 20000) {
|
||||
const hash = await sha256(chal.prefix + nonce);
|
||||
if (hash.startsWith(target)) {
|
||||
await captchaApi.powVerify(chal.token, String(nonce));
|
||||
setPowDone(true);
|
||||
return;
|
||||
}
|
||||
nonce++;
|
||||
}
|
||||
} catch { /* 失败不影响主流程 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 让后端返回的 SVG 自适应弹窗宽度(照搬 captcha.js 的样式处理)
|
||||
useEffect(() => {
|
||||
if (svg && imgRef.current) {
|
||||
const s = imgRef.current.querySelector('svg');
|
||||
if (s) s.setAttribute('style', 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block');
|
||||
}
|
||||
}, [svg]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!powDone) { setError('智能验证尚未完成,请稍候...'); return; }
|
||||
if (!answer.trim() || !tokenRef.current) { setError('请输入验证码'); return; }
|
||||
setVerifying(true);
|
||||
const v = await captchaApi.verify(tokenRef.current, answer.trim());
|
||||
setVerifying(false);
|
||||
if (v.success) {
|
||||
onFinish({ type: 'proof', value: v.proof || '' });
|
||||
} else {
|
||||
setError(v.error || '验证码错误');
|
||||
tokenRef.current = null;
|
||||
setAnswer('');
|
||||
loadImage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 12 }}>验证码</h3>
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
color: 'var(--md-ref-on-surface-variant)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: '0 0 12px',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--md-ref-surface-container-high)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
|
||||
{svg
|
||||
? <div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
: <div className="spinner" style={{ width: 24, height: 24, margin: '16px auto' }} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
placeholder="输入验证码"
|
||||
aria-label="输入验证码"
|
||||
maxLength={6}
|
||||
autoComplete="off"
|
||||
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
|
||||
<span className="material-icons">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
{error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
|
||||
{powDone ? (
|
||||
<>
|
||||
<span className="material-icons" style={{ fontSize: 16, color: '#4caf50' }}>check_circle</span>
|
||||
<span style={{ color: '#4caf50' }}>智能验证通过</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>smart_toy</span>
|
||||
<span>智能验证中...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="actions" style={{ justifyContent: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||||
<button type="button" className="btn btn-filled" onClick={submit} disabled={verifying}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方。
|
||||
// 脚本 8s 内加载不出来时自动降级到内置 SVG 验证码(proof 路径),不阻塞用户操作。
|
||||
function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
const siteKey = type === 'recaptcha'
|
||||
? (window._recaptchaSiteKey || '')
|
||||
: (window._turnstileSiteKey || '');
|
||||
const [status, setStatus] = useState('正在加载...');
|
||||
const [degraded, setDegraded] = useState(false);
|
||||
const widgetRef = useRef(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) { onFinish(null); return; }
|
||||
const container = widgetRef.current;
|
||||
const render = () => {
|
||||
if (doneRef.current) return; // 弹窗已取消/卸载或已降级,不再向已移除的容器渲染
|
||||
try {
|
||||
if (type === 'recaptcha') {
|
||||
const wid = window.grecaptcha.render(container, {
|
||||
sitekey: siteKey,
|
||||
callback: () => {
|
||||
setStatus('验证通过');
|
||||
let token = '';
|
||||
try { token = window.grecaptcha.getResponse(wid); } catch { /* 忽略 */ }
|
||||
setTimeout(() => onFinish({ type: 'recaptcha', value: token || '' }), 300);
|
||||
},
|
||||
'expired-callback': () => setStatus('验证已过期'),
|
||||
});
|
||||
} else {
|
||||
const wid = window.turnstile.render(container, {
|
||||
sitekey: siteKey,
|
||||
callback: () => {
|
||||
setStatus('验证通过');
|
||||
let token = '';
|
||||
try { token = window.turnstile.getResponse(wid); } catch { /* 忽略 */ }
|
||||
setTimeout(() => onFinish({ type: 'turnstile', value: token || '' }), 300);
|
||||
},
|
||||
'expired-callback': () => setStatus('验证已过期'),
|
||||
});
|
||||
}
|
||||
setStatus('请完成验证');
|
||||
} catch {
|
||||
setStatus('加载失败');
|
||||
setTimeout(() => onFinish(null), 2000);
|
||||
}
|
||||
};
|
||||
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
|
||||
// 8s 加载超时 → 自动降级到内置验证码,避免长时间卡「正在加载」阻塞登录
|
||||
const cleanupQueue = queueRender(type, render, () => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
setDegraded(true);
|
||||
});
|
||||
// 卸载/重开时:防止重复回调,清除超时计时器并移除排队回调(防泄漏/防残留降级状态)
|
||||
return () => {
|
||||
doneRef.current = true;
|
||||
if (cleanupQueue) cleanupQueue();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 降级:第三方脚本长时间加载不出来,复用内置 SVG 验证码完整流程
|
||||
// (校验仍走 loadImage/verify,调用方拿到 { type: 'proof', value })
|
||||
if (degraded) {
|
||||
return (
|
||||
<BuiltinCaptcha
|
||||
onFinish={onFinish}
|
||||
notice="第三方验证码加载失败,已切换为内置验证码"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
||||
<div ref={widgetRef} style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }} />
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>{status}</p>
|
||||
<div className="actions" style={{ justifyContent: 'center' }}>
|
||||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局挂载的验证码弹窗宿主:放入 Layout 内,配合 showCaptcha(action) 使用。
|
||||
*/
|
||||
export default function CaptchaModalHost() {
|
||||
const [request, setRequest] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
listener = () => setRequest(pending);
|
||||
return () => { listener = null; };
|
||||
}, []);
|
||||
|
||||
const finish = (proof) => {
|
||||
if (request) {
|
||||
const resolve = request.resolve;
|
||||
pending = null;
|
||||
setRequest(null);
|
||||
resolve(proof);
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
|
||||
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="验证码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{request.type === 'recaptcha' ? (
|
||||
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
||||
) : request.type === 'turnstile' ? (
|
||||
<ThirdPartyCaptcha type="turnstile" onFinish={finish} />
|
||||
) : (
|
||||
<BuiltinCaptcha onFinish={finish} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useDialog } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 403 等操作被拒错误弹窗(前台 MD3 风格):
|
||||
* 发帖/回复被禁言时后端返回 403 + 含到期时间的错误文案,用弹窗突出展示(snackbar 2.5s 太短)。
|
||||
* props: open / message / onClose
|
||||
*/
|
||||
export default function ErrorDialog({ open = false, message = '', onClose }) {
|
||||
const { dialogRef, onKeyDown } = useDialog(open, onClose);
|
||||
if (!open || !message) return null;
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="操作被拒绝"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget && onClose) onClose(); }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>⚠️ 操作被拒绝</h3>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12, whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{message}
|
||||
</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-filled" onClick={onClose}>知道了</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { safeOutUrl, parseFooterColumns } from '../lib/outlink.js';
|
||||
|
||||
/**
|
||||
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
|
||||
* 纯文本(不含 `<`)按原样文本渲染;含 `<` 时按 HTML+CSS 渲染(self-XSS 与音乐嵌入同级)。
|
||||
* 仅用于这三个字段,不扩大到其他数据。
|
||||
*/
|
||||
function HtmlOrText({ value, className }) {
|
||||
if (typeof value === 'string' && value.includes('<')) {
|
||||
return <span className={className} dangerouslySetInnerHTML={{ __html: value }} />;
|
||||
}
|
||||
return <span className={className}>{value}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台页脚(按 settings.footer_style 渲染 3 种样式,视觉对齐 /tmp/footer-demo.html 的 02/03/04):
|
||||
* - classic 经典左右:左 Powered + 版权,右 站点名 v{version} 胶囊徽章
|
||||
* - columns 分栏导航:品牌 + 固定导航栏 + 底部版权条
|
||||
* - glass 玻璃卡片:磨砂玻璃卡片(backdrop-filter blur + 半透明底 + 细边框)
|
||||
* 所有样式都保留站点名 + v{version} 元素;footer_desc 为空时回退到 site_description。
|
||||
*/
|
||||
export default function Footer({ settings = {}, version = '' }) {
|
||||
const siteName = settings.site_name || 'RainWeb';
|
||||
const style = settings.footer_style || 'classic';
|
||||
const copyright = settings.footer_copyright || '© 2026 Rainnya Blog. All rights reserved.';
|
||||
const powered = settings.footer_powered || 'Powered by RainnyaWeb';
|
||||
const desc = settings.footer_desc || settings.site_description || '';
|
||||
|
||||
if (style === 'columns') {
|
||||
// 栏目来源:后台 footer_columns 配置(非空优先,最多取前 3 栏);空则回退硬编码三栏导航
|
||||
const hardcodedCols = [
|
||||
{ title: '导航', links: [
|
||||
{ label: '首页', url: '/' },
|
||||
{ label: '博客', url: '/blog.html' },
|
||||
{ label: '论坛', url: '/forum.html' },
|
||||
] },
|
||||
{ title: '阅读', links: [
|
||||
{ label: '归档', url: '/archive.html' },
|
||||
{ label: 'RSS 订阅', url: '/feed.xml' },
|
||||
] },
|
||||
{ title: '账号', links: [
|
||||
{ label: '个人中心', url: '/profile.html' },
|
||||
{ label: '管理后台', url: '/admin', fullPage: true },
|
||||
{ label: '登录', url: '/login.html' },
|
||||
] },
|
||||
];
|
||||
const customCols = parseFooterColumns(settings.footer_columns);
|
||||
const cols = (customCols.length > 0 ? customCols : hardcodedCols).slice(0, 3);
|
||||
|
||||
/** 栏目链接:站内路径走 SPA Link(整页跳转项除外);http(s) 外链走 /out 确认页 */
|
||||
const colLink = (l, i) => {
|
||||
const isSite = l.url.startsWith('/');
|
||||
const isSpa = isSite && !l.fullPage;
|
||||
return isSpa
|
||||
? <Link key={i} to={l.url}>{l.label || l.url}</Link>
|
||||
: <a key={i} href={safeOutUrl(l.url)}>{l.label || l.url}</a>;
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="footer-columns">
|
||||
<div className="fc-grid">
|
||||
<div className="fc-brand">
|
||||
<div className="brand-row">
|
||||
<div className="brand-mark">{siteName.charAt(0)}</div>
|
||||
<div className="brand-name">{siteName}</div>
|
||||
{version && <span className="brand-version">v{version}</span>}
|
||||
</div>
|
||||
{desc && <HtmlOrText value={desc} className="brand-desc" />}
|
||||
</div>
|
||||
{cols.map((col, ci) => (
|
||||
<nav key={ci} className="fc-col">
|
||||
<div className="col-title">{col.title || '未命名栏目'}</div>
|
||||
<div className="col-links">
|
||||
{(col.links || []).map((l, li) => colLink(l, ci + '-' + li))}
|
||||
</div>
|
||||
</nav>
|
||||
))}
|
||||
</div>
|
||||
<div className="fc-bottom">
|
||||
<HtmlOrText value={powered} />
|
||||
<span className="sep" />
|
||||
<HtmlOrText value={copyright} />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === 'glass') {
|
||||
return (
|
||||
<div className="footer-glass-wrap">
|
||||
<span className="blob blob-1"></span>
|
||||
<span className="blob blob-2"></span>
|
||||
<span className="blob blob-3"></span>
|
||||
<footer className="footer-glass">
|
||||
<div className="fg-left">
|
||||
<div className="fg-logo">{siteName.charAt(0)}</div>
|
||||
<div>
|
||||
<div className="fg-name">{siteName}</div>
|
||||
<div className="fg-tag">
|
||||
{desc && <HtmlOrText value={desc} />}
|
||||
{desc && <span> · </span>}v{version}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fg-right">
|
||||
<HtmlOrText value={copyright} className="fg-copyright" />
|
||||
<HtmlOrText value={powered} className="fg-powered" />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// classic(默认):经典左右
|
||||
return (
|
||||
<footer className="footer-classic">
|
||||
<div className="fc-left">
|
||||
<HtmlOrText value={powered} />
|
||||
<span className="sep" />
|
||||
<HtmlOrText value={copyright} />
|
||||
</div>
|
||||
<div className="fc-right">
|
||||
<span>{siteName}</span>
|
||||
{version && <span className="fc-version">v{version}</span>}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 论坛版块图标(L1/L2 共用):
|
||||
* - cat.icon 非空:http(s) 或 / 开头的相对路径(如 /uploads/icons/xxx.png)渲染图片;否则按单个 emoji 字符渲染
|
||||
* - 无 icon 时取名称首字(大写)
|
||||
* - 底色:icon_color 自定义 hex(自动按亮度选深/浅文字色);
|
||||
* 空则按名称哈希选 12 色 MD3 container/onContainer 对(style.css 的 .tone-N,深浅色自适应)
|
||||
*/
|
||||
export default function ForumIcon({ icon = '', name = '', iconColor = '', size = 40 }) {
|
||||
const raw = String(icon || '').trim();
|
||||
const isUrl = /^https?:\/\//i.test(raw) || raw.startsWith('/');
|
||||
const isEmoji = !!raw && !isUrl;
|
||||
const letter = (name || '').trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
// 名称哈希 → 0..11 的色调组
|
||||
let h = 0;
|
||||
const key = String(name || '');
|
||||
for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0;
|
||||
|
||||
const style = { width: size, height: size, fontSize: Math.round(size * 0.48), borderRadius: 12 };
|
||||
|
||||
// 自定义 icon_color:hex → 按 luma 选深/浅文字色(近似 MD3 on-container)
|
||||
if (iconColor) {
|
||||
let hex = String(iconColor).replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
||||
const n = parseInt(hex, 16);
|
||||
if (!isNaN(n) && hex.length === 6) {
|
||||
const r = (n >> 16) & 255;
|
||||
const g = (n >> 8) & 255;
|
||||
const b = n & 255;
|
||||
const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
style.background = '#' + hex;
|
||||
style.color = luma > 0.6 ? 'rgba(0,0,0,0.8)' : '#ffffff';
|
||||
}
|
||||
}
|
||||
|
||||
if (isUrl) {
|
||||
return (
|
||||
<span className="forum-icon forum-icon-img" style={{ ...style, background: 'var(--md-ref-surface-container-high)' }}>
|
||||
<img src={raw} alt="" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isEmoji) {
|
||||
return (
|
||||
<span className="forum-icon" style={{ ...style, background: 'var(--md-ref-surface-container-high)' }}>
|
||||
{raw}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className={'forum-icon tone-' + (h % 12)} style={style}>{letter}</span>;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { getThemePreference, useTheme } from '../theme.jsx';
|
||||
import { applyThemeTokens, THEME_SETTINGS_EVENT, THEME_SETTINGS_SYNC_KEY } from '../themeTokens.js';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
import { isFamilyDomain } from '../lib/outlink.js';
|
||||
import MusicEmbed from './MusicEmbed.jsx';
|
||||
import CaptchaModalHost from './CaptchaModal.jsx';
|
||||
import Footer from './Footer.jsx';
|
||||
|
||||
/** QQ 邮箱自动头像(迁移自 nav.js) */
|
||||
function qqAvatar(user) {
|
||||
const m = user && user.email ? String(user.email).match(/^(\d+)@qq\.com$/i) : null;
|
||||
return m ? 'https://q1.qlogo.cn/g?b=qq&nk=' + m[1] + '&s=100' : '';
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { theme, toggleTheme, setTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const [settings, setSettings] = useState({});
|
||||
const [version, setVersion] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// ── 移动端汉堡抽屉 ──
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuBtnRef = useRef(null);
|
||||
const drawerRef = useRef(null);
|
||||
|
||||
// 抽屉焦点管理:打开锁定背景滚动并聚焦首个可聚焦项,关闭还原滚动并聚焦汉堡按钮
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
const first = drawerRef.current && drawerRef.current.querySelector('a, button, [tabindex]');
|
||||
if (first && typeof first.focus === 'function') first.focus();
|
||||
else if (drawerRef.current) drawerRef.current.focus();
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
if (menuBtnRef.current && typeof menuBtnRef.current.focus === 'function') menuBtnRef.current.focus();
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [menuOpen]);
|
||||
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
|
||||
// ── 全站外链拦截:http(s) 非本站链接 → /out 确认页(事件委托,覆盖 markdown 渲染出的外链)──
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.defaultPrevented) return;
|
||||
// 新标签打开(Ctrl/Cmd/Shift/中键)是用户主动分屏意图,不劫持
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
const href = (a.getAttribute('href') || '').trim();
|
||||
if (!/^https?:\/\//i.test(href)) return; // 站内路径(/ 开头)不拦
|
||||
// 本站同源(www.rainnya.asia)不拦——解析相对链接得到本站 origin 也自然跳过;
|
||||
// rainnya.asia 家族域名(含子域名)也视为站内,直接跳转不走确认页
|
||||
if (new URL(href, window.location.origin).origin === window.location.origin) return;
|
||||
if (isFamilyDomain(href)) return;
|
||||
e.preventDefault();
|
||||
window.location.href = '/out?url=' + encodeURIComponent(href);
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, []);
|
||||
|
||||
// 初始化向导:未完成安装时跳转 /setup.html(迁移自 nav.js)
|
||||
useEffect(() => {
|
||||
fetch('/api/setup/status')
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
if (s && !s.setup_complete && location.pathname !== '/setup.html') {
|
||||
window.location.href = '/setup.html';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新)
|
||||
useEffect(() => {
|
||||
const updateSettings = (next) => {
|
||||
if (!next || typeof next !== 'object') return;
|
||||
setSettings((previous) => ({ ...previous, ...next }));
|
||||
};
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
updateSettings(s);
|
||||
// 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js)
|
||||
window._recaptchaSiteKey = s.recaptcha_site_key || '';
|
||||
window._turnstileSiteKey = s.turnstile_site_key || '';
|
||||
})
|
||||
.catch(() => {});
|
||||
fetch('/api/version')
|
||||
.then((r) => r.json())
|
||||
.then((v) => { if (v && v.version) setVersion(v.version); })
|
||||
.catch(() => {});
|
||||
const loadUser = () => {
|
||||
if (getToken()) {
|
||||
authApi.me().then(setUser).catch(() => { setToken(null); setUser(null); });
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
window.addEventListener('authchange', loadUser);
|
||||
const onThemeSettings = (event) => updateSettings(event.detail);
|
||||
const onStorage = (event) => {
|
||||
if (event.key !== THEME_SETTINGS_SYNC_KEY || !event.newValue) return;
|
||||
try { updateSettings(JSON.parse(event.newValue)); } catch {}
|
||||
};
|
||||
window.addEventListener(THEME_SETTINGS_EVENT, onThemeSettings);
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
window.removeEventListener('authchange', loadUser);
|
||||
window.removeEventListener(THEME_SETTINGS_EVENT, onThemeSettings);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 强制深色模式(迁移自 nav.js applyTheme)
|
||||
useEffect(() => {
|
||||
setTheme(settings.theme_force_dark === '1' ? 'dark' : getThemePreference(), { persist: false });
|
||||
}, [settings.theme_force_dark, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeTokens({
|
||||
primary_color: settings.primary_color,
|
||||
mode: theme,
|
||||
glass_opacity: settings.glass_opacity,
|
||||
});
|
||||
}, [settings.primary_color, settings.glass_opacity, theme]);
|
||||
|
||||
// 壁纸背景 + 玻璃磨砂(迁移自 nav.js applyTheme,直接作用于 body)
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
const wallpaper = settings.theme_wallpaper || '';
|
||||
const scale = settings.theme_wallpaper_scale || 'cover';
|
||||
|
||||
if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
|
||||
body.classList.add('has-wallpaper');
|
||||
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
|
||||
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
|
||||
const bgRepeatMap = { repeat: 'repeat', stretch: 'no-repeat', cover: 'no-repeat', contain: 'no-repeat' };
|
||||
body.style.backgroundSize = bgSizeMap[scale] || 'cover';
|
||||
body.style.backgroundRepeat = bgRepeatMap[scale] || 'no-repeat';
|
||||
document.documentElement.style.setProperty('--wallpaper-overlay',
|
||||
theme === 'dark' ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.15)');
|
||||
document.documentElement.style.setProperty('--wallpaper-text',
|
||||
theme === 'dark' ? '#e6e1e5' : '#1c1b1f');
|
||||
} else {
|
||||
body.classList.remove('has-wallpaper');
|
||||
body.style.removeProperty('--wallpaper');
|
||||
body.style.backgroundSize = '';
|
||||
body.style.backgroundRepeat = '';
|
||||
document.documentElement.style.removeProperty('--wallpaper-overlay');
|
||||
document.documentElement.style.removeProperty('--wallpaper-text');
|
||||
}
|
||||
document.documentElement.style.setProperty('--glass-blur', (settings.glass_blur || '20') + 'px');
|
||||
}, [settings, theme]);
|
||||
|
||||
// 卡片玻璃样式(导航/路由切换后对卡片类名统一应用)
|
||||
useEffect(() => {
|
||||
const cs = settings.card_style || 'default';
|
||||
document.querySelectorAll('.card, .blog-card, .panel-card, .link-card, .forum-post-card, .password-card, .forum-cat-item, .chip').forEach((el) => {
|
||||
el.classList.toggle('glass-card', cs === 'glass');
|
||||
});
|
||||
}, [settings.card_style, location.pathname]);
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (settings.theme_force_dark === '1') {
|
||||
showSnackbar('已强制启用深色模式无法更改');
|
||||
return;
|
||||
}
|
||||
toggleTheme();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
notifyAuthChange();
|
||||
if (settings.rainid_enabled === '1') {
|
||||
// RainID 单点登录:清本地后联动登出 RainID 端会话(整页跳转,不显示本地提示)
|
||||
window.location.href = '/api/auth/oidc/logout';
|
||||
return;
|
||||
}
|
||||
showSnackbar('已退出登录');
|
||||
};
|
||||
|
||||
const siteName = settings.site_name || 'RainWeb';
|
||||
const isAdmin = user && user.role === 'admin';
|
||||
|
||||
const navClass = 'nav-bar'
|
||||
+ (settings.nav_style === 'glass' ? ' nav-glass' : '')
|
||||
+ (settings.nav_style === 'capsule' ? ' nav-capsule' : '');
|
||||
|
||||
// 导航标签(迁移自 nav.js 的显隐逻辑)
|
||||
const tabs = [
|
||||
{ to: '/', label: '首页', end: true, show: true },
|
||||
{ to: '/blog.html', label: '博客', show: true },
|
||||
{ to: '/forum.html', label: '论坛', show: !!user },
|
||||
{ to: '/tickets.html', label: '工单', show: true },
|
||||
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
||||
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
|
||||
];
|
||||
|
||||
let avatarUrl = user ? (user.avatar || '') : '';
|
||||
if (user && !avatarUrl) avatarUrl = qqAvatar(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className={navClass} id="mainNav">
|
||||
<div className="nav-left">
|
||||
<button
|
||||
ref={menuBtnRef}
|
||||
type="button"
|
||||
className="btn-icon nav-hamburger"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
aria-label="打开导航菜单"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className="material-icons">menu</span>
|
||||
</button>
|
||||
<Link to="/" className="nav-brand">{siteName}</Link>
|
||||
<span className="nav-version">v{version}</span>
|
||||
<div className="nav-tabs">
|
||||
{tabs.filter((t) => t.show).map((t) =>
|
||||
t.fullPage ? (
|
||||
<a key={t.to} href={t.to} className="nav-tab">{t.label}</a>
|
||||
) : (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) => 'nav-tab' + (isActive ? ' active' : '')}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-right">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleToggleTheme}
|
||||
aria-label="切换主题"
|
||||
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
|
||||
>
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
</button>
|
||||
{user ? (
|
||||
<>
|
||||
{/* 个人中心:桌面显示头像+用户名,移动端只显示头像(.nav-user-name 由 CSS 隐藏) */}
|
||||
<Link to="/profile.html" className="btn btn-tonal btn-sm nav-user" title="个人中心">
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
: <span className="material-icons" style={{ fontSize: 18 }}>person</span>}
|
||||
<span className="nav-user-name">{user.username}</span>
|
||||
</Link>
|
||||
{/* 退出登录统一在个人中心 / 移动端抽屉中操作,顶栏不再放退出按钮 */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login.html" className="btn btn-tonal btn-sm">登录</Link>
|
||||
<Link to="/register.html" className="btn btn-filled btn-sm">注册</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 移动端导航抽屉(汉堡菜单) */}
|
||||
<div className={'drawer-overlay' + (menuOpen ? ' open' : '')} onClick={closeMenu} aria-hidden="true" />
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className={'drawer' + (menuOpen ? ' open' : '')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="导航菜单"
|
||||
aria-hidden={!menuOpen}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') closeMenu(); }}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<span className="drawer-brand">{siteName}</span>
|
||||
<button type="button" className="btn-icon" onClick={closeMenu} aria-label="关闭菜单">
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="drawer-menu" aria-label="页面导航">
|
||||
{tabs.filter((t) => t.show).map((t) =>
|
||||
t.fullPage ? (
|
||||
<a key={t.to} href={t.to} className="drawer-item" onClick={closeMenu}>{t.label}</a>
|
||||
) : (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) => 'drawer-item' + (isActive ? ' active' : '')}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">账号</div>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/profile.html" className="drawer-item" onClick={closeMenu}>个人中心</Link>
|
||||
<button type="button" className="drawer-item drawer-item-btn" onClick={() => { closeMenu(); handleLogout(); }}>
|
||||
退出登录
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login.html" className="drawer-item" onClick={closeMenu}>登录</Link>
|
||||
<Link to="/register.html" className="drawer-item" onClick={closeMenu}>注册</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="drawer-footer">
|
||||
<button type="button" className="btn btn-tonal btn-sm" onClick={() => { closeMenu(); handleToggleTheme(); }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
{theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<Footer settings={settings} version={version} />
|
||||
|
||||
<MusicEmbed settings={settings} />
|
||||
<CaptchaModalHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* markdown 锁定块占位(未解锁时渲染,MD3 风格):
|
||||
* - login → 「登录后查看」+ 去登录按钮
|
||||
* - reply → 「评论后可见」+ 去评论按钮(onGoComment 滚动到评论区)
|
||||
* - password → 密码输入 + 解锁按钮(onUnlock(index, password),失败显示错误态)
|
||||
* 键盘:密码框 Enter 提交、Esc 清空;Tab 自然可达。
|
||||
*/
|
||||
export default function LockBlock({ index, type = 'password', onUnlock, onGoComment }) {
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pwdRef = useRef(null);
|
||||
|
||||
// 弹窗/焦点管理不适用这里(块内内联控件),但复用 useDialog 的 Esc 习惯:直接监听
|
||||
useEffect(() => {
|
||||
if (type === 'password') {
|
||||
const h = (e) => {
|
||||
if (e.key === 'Escape') { setPwd(''); setErr(''); }
|
||||
};
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}
|
||||
return undefined;
|
||||
}, [type]);
|
||||
|
||||
const tryUnlock = async () => {
|
||||
if (busy) return;
|
||||
if (!pwd) { setErr('请输入密码'); if (pwdRef.current) pwdRef.current.focus(); return; }
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await onUnlock(index, pwd);
|
||||
// 成功后父级重渲染(unlocked/lockContent 更新),本组件被内容替换
|
||||
} catch (e) {
|
||||
setErr((e && e.message) || '密码错误');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (type === 'login') {
|
||||
return (
|
||||
<div className="lock-placeholder">
|
||||
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
|
||||
<div className="lock-title">登录后查看</div>
|
||||
<div className="lock-desc">登录后可查看此内容</div>
|
||||
<Link to="/login.html" className="btn btn-filled btn-sm">去登录</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'reply') {
|
||||
return (
|
||||
<div className="lock-placeholder">
|
||||
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
|
||||
<div className="lock-title">评论后可见</div>
|
||||
<div className="lock-desc">发表评论后即可查看此内容</div>
|
||||
<button type="button" className="btn btn-filled btn-sm" onClick={onGoComment}>去评论</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// password
|
||||
return (
|
||||
<div className="lock-placeholder">
|
||||
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
|
||||
<div className="lock-title">密码可见</div>
|
||||
<div className="lock-desc">输入访问密码解锁内容</div>
|
||||
<div className="lock-form">
|
||||
<input
|
||||
ref={pwdRef}
|
||||
type="password"
|
||||
className={'lock-pwd-input' + (err ? ' error' : '')}
|
||||
value={pwd}
|
||||
placeholder="访问密码"
|
||||
aria-label="访问密码"
|
||||
autoComplete="off"
|
||||
onChange={(e) => { setPwd(e.target.value); if (err) setErr(''); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') tryUnlock();
|
||||
else if (e.key === 'Escape') { setPwd(''); setErr(''); }
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn btn-filled btn-sm" onClick={tryUnlock} disabled={busy}>
|
||||
{busy ? '解锁中…' : '解锁'}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="lock-error" role="alert"><span className="material-icons" style={{ fontSize: 14 }}>error_outline</span> {err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { uploadFile } from '../api/upload.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
import MarkdownRenderer from './MarkdownRenderer.jsx';
|
||||
|
||||
/**
|
||||
* 标准防抖:value 停止变化 debounceMs 后才更新返回值。
|
||||
* useEffect 内建 setTimeout,卸载时 clearTimeout 清理 timer。
|
||||
*/
|
||||
function useDebounced(value, debounceMs) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), debounceMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, debounceMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* 共用 Markdown 编辑器(博客写作页 / 论坛发帖弹窗):
|
||||
* 左侧编辑区(textarea)+ 右侧实时预览区,预览防抖刷新(默认 1200ms,停止输入才重渲染,
|
||||
* 避免每敲一键都重渲染造成"抖动")。
|
||||
*
|
||||
* 工具栏:加粗 / 斜体 / 链接 / 代码块 / 上传图片或附件(复用 uploadFile → [image:]/[file:] 标签)。
|
||||
* 附件标签在编辑区以明文显示,预览区由 MarkdownRenderer(DOMPurify 净化)渲染成 /uploads/ 链接。
|
||||
*
|
||||
* props:
|
||||
* value / onChange — 受控内容
|
||||
* placeholder — textarea 占位文案
|
||||
* label — textarea 无障碍标签(默认 "Markdown 内容")
|
||||
* compact — 紧凑模式(弹窗内使用,更小的字号/边距/最小高度)
|
||||
* debounceMs — 预览防抖毫秒数,默认 1200
|
||||
* className — 附加到根节点的 class
|
||||
*/
|
||||
export default function MarkdownEditor({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = '支持 Markdown 语法',
|
||||
label = 'Markdown 内容',
|
||||
compact = false,
|
||||
debounceMs = 1200,
|
||||
className = '',
|
||||
}) {
|
||||
const textareaRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
|
||||
// 锁定块插入弹窗
|
||||
const [lockOpen, setLockOpen] = useState(false);
|
||||
const [lockType, setLockType] = useState('login');
|
||||
const [lockPwd, setLockPwd] = useState('');
|
||||
const { dialogRef: lockDialogRef, onKeyDown: lockDialogKey } = useDialog(lockOpen, () => setLockOpen(false));
|
||||
|
||||
const LOCK_TYPES = [
|
||||
{ type: 'login', label: '登录可见', icon: 'person' },
|
||||
{ type: 'reply', label: '评论后可见', icon: 'chat_bubble' },
|
||||
{ type: 'password', label: '密码可见', icon: 'lock' },
|
||||
];
|
||||
|
||||
// 右侧预览的防抖值:停止输入 debounceMs 后才刷新
|
||||
const previewValue = useDebounced(value, debounceMs);
|
||||
const pending = value !== previewValue;
|
||||
|
||||
/** 在光标处插入文本,并在重渲染后把光标移到插入内容末尾 */
|
||||
const setValueAtCaret = (text) => {
|
||||
const v = value || '';
|
||||
const ta = textareaRef.current;
|
||||
if (ta && typeof ta.selectionStart === 'number') {
|
||||
const s = ta.selectionStart;
|
||||
const e = ta.selectionEnd;
|
||||
const next = v.slice(0, s) + text + v.slice(e);
|
||||
const caret = s + text.length;
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(caret, caret);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onChange(v + text);
|
||||
}
|
||||
};
|
||||
|
||||
/** 工具栏包装:选中文本用 before/after 包裹(无选中时用 fallback 占位) */
|
||||
const wrapSelection = (before, after, fallback) => {
|
||||
const v = value || '';
|
||||
const ta = textareaRef.current;
|
||||
if (ta && typeof ta.selectionStart === 'number') {
|
||||
const s = ta.selectionStart;
|
||||
const e = ta.selectionEnd;
|
||||
const inner = v.slice(s, e) || fallback || '';
|
||||
const next = v.slice(0, s) + before + inner + after + v.slice(e);
|
||||
const caret = s + before.length + inner.length + after.length;
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(caret, caret);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onChange(v + before + (fallback || '') + after);
|
||||
}
|
||||
};
|
||||
|
||||
/** 插入链接:光标定位到 url 位置方便直接输入 */
|
||||
const insertLink = () => {
|
||||
const v = value || '';
|
||||
const ta = textareaRef.current;
|
||||
if (ta && typeof ta.selectionStart === 'number') {
|
||||
const s = ta.selectionStart;
|
||||
const e = ta.selectionEnd;
|
||||
const text = v.slice(s, e) || '链接文字';
|
||||
const url = 'https://';
|
||||
const next = v.slice(0, s) + `[${text}](${url})` + v.slice(e);
|
||||
const caret = s + 1 + text.length + 2 + url.length; // 落在 (https:// 之后
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(caret, caret);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onChange(v + '[链接文字](https://)');
|
||||
}
|
||||
};
|
||||
|
||||
/** 上传附件/图片 → 插入 [image:]/[file:] 标签(复用 Write.jsx 的 uploadFile 模式) */
|
||||
const doUpload = async (file) => {
|
||||
try {
|
||||
const data = await uploadFile(file);
|
||||
setValueAtCaret('\n' + data.tag + '\n');
|
||||
setUploadStatus('已插入: ' + data.tag);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 插入锁定块:有选中文本则包裹,无选中插入占位文本。
|
||||
* 语法约定:`[lock:login]` / `[lock:reply]` / `[lock:password:密码]`(密码型带明文密码参数,后端解析存库)
|
||||
*/
|
||||
const doInsertLock = () => {
|
||||
const tag = lockType === 'password'
|
||||
? `[lock:password:${lockPwd.trim()}]`
|
||||
: `[lock:${lockType}]`;
|
||||
wrapSelection(tag + '\n', '\n[/lock]', '锁定内容');
|
||||
setLockOpen(false);
|
||||
setLockPwd('');
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (f) doUpload(f);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length === 0) { showSnackbar('请拖入图片文件'); return; }
|
||||
for (const f of files) await doUpload(f);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'md-editor' + (compact ? ' compact' : '') + (className ? ' ' + className : '')}>
|
||||
{/* 工具栏 */}
|
||||
<div className="md-editor-toolbar">
|
||||
<button type="button" className="btn-icon" title="加粗" aria-label="加粗" onClick={() => wrapSelection('**', '**', '加粗文字')}>
|
||||
<span className="material-icons">format_bold</span>
|
||||
</button>
|
||||
<button type="button" className="btn-icon" title="斜体" aria-label="斜体" onClick={() => wrapSelection('*', '*', '斜体文字')}>
|
||||
<span className="material-icons">format_italic</span>
|
||||
</button>
|
||||
<button type="button" className="btn-icon" title="插入链接" aria-label="插入链接" onClick={insertLink}>
|
||||
<span className="material-icons">link</span>
|
||||
</button>
|
||||
<button type="button" className="btn-icon" title="代码块" aria-label="插入代码块" onClick={() => wrapSelection('```\n', '\n```', '代码')}>
|
||||
<span className="material-icons">code</span>
|
||||
</button>
|
||||
<button type="button" className="btn-icon" title="上传图片或附件" aria-label="上传图片或附件" onClick={() => fileInputRef.current && fileInputRef.current.click()}>
|
||||
<span className="material-icons">upload</span>
|
||||
</button>
|
||||
<button type="button" className="btn-icon" title="插入锁定内容(登录/评论/密码可见)" aria-label="插入锁定内容" onClick={() => setLockOpen(true)}>
|
||||
<span className="material-icons">lock</span>
|
||||
</button>
|
||||
{uploadStatus && <span className="md-editor-upload-status">{uploadStatus}</span>}
|
||||
</div>
|
||||
|
||||
{/* 左编辑 / 右预览 */}
|
||||
<div className="md-editor-panes">
|
||||
<div
|
||||
className={'md-editor-pane md-editor-input' + (dragging ? ' dragover' : '')}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); }}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="md-editor-pane-head">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>edit_note</span> 内容
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
spellCheck="false"
|
||||
/>
|
||||
{dragging && (
|
||||
<div className="md-editor-drop-hint">
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>image</span> 松开以插入图片
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md-editor-pane md-editor-preview">
|
||||
<div className="md-editor-pane-head">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> 预览
|
||||
<span className={'md-editor-sync' + (pending ? ' pending' : '')}>
|
||||
{pending ? '待刷新…' : '已同步'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="md-editor-preview-body" role="region" aria-label="Markdown 实时预览">
|
||||
{previewValue.trim() ? (
|
||||
<MarkdownRenderer content={previewValue} useMarkdown previewMode />
|
||||
) : (
|
||||
<div className="md-editor-preview-empty">预览区 — 输入内容后稍候自动更新</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||||
|
||||
{/* 锁定块插入弹窗 */}
|
||||
{lockOpen && (
|
||||
<div
|
||||
ref={lockDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="插入锁定内容"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setLockOpen(false); }}
|
||||
onKeyDown={lockDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>插入锁定内容</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
|
||||
选择可见条件:满足条件后读者才可查看锁定部分
|
||||
</p>
|
||||
<div className="lock-type-options" role="radiogroup" aria-label="可见条件">
|
||||
{LOCK_TYPES.map((o) => (
|
||||
<button
|
||||
key={o.type}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={lockType === o.type}
|
||||
className={'lock-type-option' + (lockType === o.type ? ' active' : '')}
|
||||
onClick={() => setLockType(o.type)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>{o.icon}</span> {o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{lockType === 'password' && (
|
||||
<div className="form-group" style={{ marginTop: 12 }}>
|
||||
<label htmlFor="lockPwdInput">访问密码</label>
|
||||
<input
|
||||
id="lockPwdInput"
|
||||
type="text"
|
||||
value={lockPwd}
|
||||
onChange={(e) => setLockPwd(e.target.value)}
|
||||
placeholder="读者解锁时输入的密码"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setLockOpen(false)}>取消</button>
|
||||
<button
|
||||
className="btn btn-filled"
|
||||
onClick={doInsertLock}
|
||||
disabled={lockType === 'password' && !lockPwd.trim()}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { escapeHtml } from '../lib/utils.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import LockBlock from './LockBlock.jsx';
|
||||
|
||||
/**
|
||||
* 锁块附件鉴权地址:后端 /api/upload/locked 按 (ref_type, ref_id, block, file) + token 校验,
|
||||
* 只允许取「该帖该块」已解锁的附件。缺 refType/refId/token 返回 null → 调用方回退原直链。
|
||||
*/
|
||||
function lockedAttachmentUrl(refType, refId, blockIndex, filename, token) {
|
||||
if (!refType || refId == null || !token) return null;
|
||||
return `/api/upload/locked?ref_type=${encodeURIComponent(refType)}&ref_id=${refId}&block=${encodeURIComponent(blockIndex)}&file=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/** [image:文件名] → 图片标签;lockedUrl 存在则 src 走鉴权接口,否则 /uploads/ 直链 */
|
||||
function imageTag(filename, lockedUrl) {
|
||||
if (!filename) return '';
|
||||
const src = lockedUrl || `/uploads/${encodeURIComponent(filename)}`;
|
||||
return `<img src="${src}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
|
||||
}
|
||||
|
||||
/** [file:文件名] → 下载链接;lockedUrl 存在则 href 走鉴权接口(保留 target=_blank 与样式),否则原下载逻辑 */
|
||||
function fileTag(filename, lockedUrl) {
|
||||
if (!filename) return '';
|
||||
const token = getToken() || '';
|
||||
const href = lockedUrl || `/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}`;
|
||||
return `<a href="${href}" target="_blank" class="file-link" style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--md-ref-surface-container);border-radius:8px;margin:4px 0;text-decoration:none;color:var(--md-ref-primary);font-size:14px">
|
||||
<span class="material-icons" style="font-size:18px">attachment</span> ${escapeHtml(filename)}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥离内联 [lock:] 成对标签,保留块内内容(只去标签不去内容)。
|
||||
* 保留导出供外部/预览使用;renderContent 不再调用——组件层已用切段方案接管(保留锁块容器)。
|
||||
*/
|
||||
export function stripLockTags(content) {
|
||||
return String(content).replace(/\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\]/g, '$1');
|
||||
}
|
||||
|
||||
/** 从整块标签提取 lock 类型参数:'login' / 'reply' / 'password'(无参数返回 '') */
|
||||
function extractLockType(block) {
|
||||
const m = String(block).match(/^\[lock(?::([^\]]*))?\]/);
|
||||
const param = (m && m[1]) || '';
|
||||
return param.split(':')[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按内联 [lock:] 成对标签切段(split 带捕获组):
|
||||
* 交替产出 [文本, 整块(含标签), 块内内容, 文本, …](i%3:0=文本 / 1=整块 / 2=块内内容)
|
||||
*/
|
||||
const INLINE_LOCK_RE = /(\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\])/g;
|
||||
|
||||
/** 统计文本段内内联 [lock:] 成对标签数量(全文锁块索引游标推进用) */
|
||||
function countInlineLocks(text) {
|
||||
const m = String(text).match(INLINE_LOCK_RE);
|
||||
return m ? m.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一段文本:文本段走 renderContent;内联 [lock:] 段渲染「已解锁内容」容器。
|
||||
* 已解锁块(admin/作者/登录或评论后可见)后端在 content 里原样保留 [lock:...]...[/lock] 标签,
|
||||
* 这里按标签切成锁块容器(同款背景+边框 + 🔓 已解锁头部),内容递归 MarkdownRenderer 渲染。
|
||||
* rest.inlineStartIdx:本段起始内联块在全文中的索引(buildNodes 用锁块游标给出),
|
||||
* 用于查 locks 数组拿该块的附件 token(块内 [image:]/[file:] 走鉴权接口)。
|
||||
*/
|
||||
function renderSegments(text, useMarkdown, rest) {
|
||||
const { locks, refType, refId, blockCtx: parentCtx } = rest;
|
||||
const parts = String(text).split(INLINE_LOCK_RE);
|
||||
const nodes = [];
|
||||
let textIdx = 0;
|
||||
let lockIdx = 0;
|
||||
let tagIdx = rest.inlineStartIdx || 0; // 内联块在全文中的索引游标
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const p = parts[i];
|
||||
if (i % 3 === 1) {
|
||||
// 整块(含标签),紧随其后 i+1 是对应块内内容
|
||||
const inner = parts[i + 1] || '';
|
||||
const type = extractLockType(p);
|
||||
const idx = tagIdx++;
|
||||
// 内联块附件上下文:已处锁块内(parentCtx)时沿用父块 token(嵌套块文件同属父块);
|
||||
// 顶层则按本段索引查 locks 数组拿该块的 token(无 token 保持直链向后兼容)
|
||||
let childCtx = null;
|
||||
if (parentCtx) {
|
||||
childCtx = parentCtx;
|
||||
} else {
|
||||
const meta = (Array.isArray(locks) && locks.find((l) => l.index === idx)) || {};
|
||||
if (meta.token) childCtx = { index: idx, token: meta.token };
|
||||
}
|
||||
nodes.push(
|
||||
<div key={'reveal' + lockIdx++} className="lock-reveal">
|
||||
<div className="lock-placeholder lock-revealed">
|
||||
<div className="lock-revealed-head">
|
||||
<span className="material-icons" style={{ fontSize: 16 }} aria-hidden="true">lock_open</span>
|
||||
已解锁内容
|
||||
{type ? <span className="lock-revealed-type">{type}</span> : null}
|
||||
</div>
|
||||
<div className="lock-revealed-body">
|
||||
<MarkdownRenderer
|
||||
content={inner}
|
||||
useMarkdown={useMarkdown}
|
||||
locks={locks}
|
||||
unlocked={rest.unlocked}
|
||||
lockContent={rest.lockContent}
|
||||
onUnlock={rest.onUnlock}
|
||||
onGoComment={rest.onGoComment}
|
||||
refType={refType}
|
||||
refId={refId}
|
||||
blockCtx={childCtx}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
i += 1; // 跳过内容段(已作为 inner 渲染)
|
||||
} else if (p) {
|
||||
// 文本段:仅当外层处于已解锁锁块内(parentCtx)时附件才改走鉴权接口
|
||||
const attach = parentCtx ? { refType, refId, blockIndex: parentCtx.index, token: parentCtx.token || '' } : null;
|
||||
nodes.push(<RawBlock key={'text' + textIdx++} html={renderContent(p, useMarkdown, attach)} />);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
|
||||
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
|
||||
* attach(可选)= { refType, refId, blockIndex, token }:处于已解锁锁块内时的附件鉴权上下文;
|
||||
* 缺省则 [image:]/[file:] 照旧 /uploads/ 直链与下载逻辑。本函数只处理单段文本,lock 分段在组件层完成。
|
||||
*/
|
||||
export function renderContent(content, useMarkdown, attach) {
|
||||
if (content == null) return '';
|
||||
|
||||
// 锁块附件地址生成器:无 attach 上下文或无 token 时返回 null → 走原直链/下载逻辑
|
||||
const lockedUrl = (filename) => (attach
|
||||
? lockedAttachmentUrl(attach.refType, attach.refId, attach.blockIndex, filename, attach.token)
|
||||
: null);
|
||||
|
||||
if (!useMarkdown) {
|
||||
let html = escapeHtml(content);
|
||||
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f, lockedUrl(f)));
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f, lockedUrl(f)));
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// Markdown 模式:先抽取自定义标签,避免被 marked 转义
|
||||
const images = [];
|
||||
const files = [];
|
||||
let html = content.replace(/\[image:([^\]]+)\]/g, (m, f) => { images.push(f); return `\x00IMG${images.length - 1}\x00`; });
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => { files.push(f); return `\x00FILE${files.length - 1}\x00`; });
|
||||
|
||||
html = marked.parse(html, { breaks: true, gfm: true });
|
||||
|
||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)], lockedUrl(images[parseInt(i)])));
|
||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)], lockedUrl(files[parseInt(i)])));
|
||||
html = DOMPurify.sanitize(html);
|
||||
|
||||
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
||||
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
|
||||
let h2Index = 0;
|
||||
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
|
||||
return html;
|
||||
}
|
||||
|
||||
/** 单段文本块:dangerouslySetInnerHTML 包装(renderContent 已 DOMPurify 净化) */
|
||||
function RawBlock({ html }) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建渲染节点(两层分段):
|
||||
* 1. @@LOCK<n>@@ 占位分段(详情页 locks 非空)——lock 段:已解锁+有内容 → 递归渲染(.lock-reveal 淡入),
|
||||
* 否则 LockBlock 锁定卡片;文本段 → 再按内联 [lock:] 切段(已解锁容器 / 文本)。
|
||||
* 文本段内联块索引用全文游标(nextIdx)衔接,保证与后端 parseLocks 的 index 一致。
|
||||
* 2. locks 为空(含 previewMode 编辑预览):整段按内联 [lock:] 切段,作者视角同样显示已解锁容器
|
||||
*/
|
||||
function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }) {
|
||||
if (content == null) return null;
|
||||
const str = String(content);
|
||||
const hasLocks = Array.isArray(locks) && locks.length > 0;
|
||||
|
||||
if (hasLocks) {
|
||||
// 服务端已把未解锁块替换为 @@LOCK<n>@@;split 交替产出 [文本, 索引, 文本, 索引…]
|
||||
const parts = str.split(/@@LOCK(\d+)@@/);
|
||||
const nodes = [];
|
||||
let nextIdx = 0; // 全文锁块索引游标:内联块与 @@LOCK 占位按文档顺序递增
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const p = parts[i];
|
||||
if (i % 2 === 1) {
|
||||
const n = parseInt(p, 10);
|
||||
nextIdx = n + 1; // 占位符自带索引,同步游标
|
||||
const meta = locks.find((l) => l.index === n) || {};
|
||||
const type = meta.type || 'password';
|
||||
const lockEntry = unlocked && unlocked.has(n) && lockContent && lockContent.get(n);
|
||||
if (lockEntry) {
|
||||
// lockContent 兼容纯字符串或 { content, token };token 优先取 locks 数组项(后端按块签发)
|
||||
const raw = typeof lockEntry === 'object' ? lockEntry.content : lockEntry;
|
||||
const entryToken = typeof lockEntry === 'object' && lockEntry.token ? lockEntry.token : '';
|
||||
const token = (meta.token || entryToken) || '';
|
||||
nodes.push(
|
||||
<div key={'lock' + n} className="lock-reveal">
|
||||
<MarkdownRenderer
|
||||
content={raw}
|
||||
useMarkdown={useMarkdown}
|
||||
locks={locks}
|
||||
unlocked={unlocked}
|
||||
lockContent={lockContent}
|
||||
onUnlock={onUnlock}
|
||||
onGoComment={onGoComment}
|
||||
refType={refType}
|
||||
refId={refId}
|
||||
blockCtx={{ index: n, token }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
nodes.push(<LockBlock key={'lock' + n} index={n} type={type} onUnlock={onUnlock} onGoComment={onGoComment} />);
|
||||
}
|
||||
} else if (p) {
|
||||
// 文本段:可能仍含内联 [lock:](已解锁块)→ 切段渲染已解锁容器
|
||||
nodes.push(...renderSegments(p, useMarkdown, {
|
||||
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
|
||||
inlineStartIdx: nextIdx,
|
||||
}));
|
||||
nextIdx += countInlineLocks(p);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// locks 为空:整段按内联 [lock:] 切段(previewMode 作者预览同样显示「已解锁内容」容器)
|
||||
return renderSegments(str, useMarkdown, {
|
||||
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
|
||||
inlineStartIdx: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染器(前台统一入口):
|
||||
* props:
|
||||
* content / useMarkdown — 同原有
|
||||
* locks — [{index, type}](新后端带 {index, type, token})详情接口返回的锁定块元信息
|
||||
* unlocked — Set<number> 已解锁索引(login/reply 由服务端判定后内容直接给原文;
|
||||
* password 类解锁后内容存 lockContent)
|
||||
* lockContent — Map<number, string | {content, token}> password 块解锁拿到的 markdown(仅内存态)
|
||||
* onUnlock — async (index, password?) → 解锁;失败 throw
|
||||
* onGoComment — reply 块「去评论」滚动回调
|
||||
* previewMode — 编辑器作者预览:锁定标签直接展开
|
||||
* refType — 'blog' | 'forum'(可选):提供后锁块内 [image:]/[file:] 附件改走鉴权接口
|
||||
* refId — 详情 id(可选,配合 refType)
|
||||
* blockCtx — {index, token}(可选):当前渲染上下文处于已解锁锁块内,附件用该块的 token 鉴权
|
||||
*/
|
||||
export default function MarkdownRenderer({
|
||||
content = '',
|
||||
useMarkdown = true,
|
||||
locks,
|
||||
unlocked,
|
||||
lockContent,
|
||||
onUnlock,
|
||||
onGoComment,
|
||||
previewMode = false,
|
||||
refType,
|
||||
refId,
|
||||
blockCtx,
|
||||
}) {
|
||||
const nodes = useMemo(
|
||||
() => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }),
|
||||
[content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx]
|
||||
);
|
||||
return <div className="md-body">{nodes}</div>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 音乐嵌入(迁移自 public/js/music-embed.js):
|
||||
* 读取 settings 的 music_embed_enabled / music_embed_code / music_embed_position /
|
||||
* music_embed_autohide / music_embed_idle_timeout,支持自动隐藏与悬停展开。
|
||||
* 全局组件,挂在 Layout 内。
|
||||
*/
|
||||
export default function MusicEmbed({ settings }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const enabled = settings.music_embed_enabled === '1';
|
||||
const code = settings.music_embed_code || '';
|
||||
const position = settings.music_embed_position || 'right';
|
||||
const autoHide = settings.music_embed_autohide === '1';
|
||||
const idleTimeout = (parseInt(settings.music_embed_idle_timeout, 10) || 10) * 1000;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
|
||||
};
|
||||
|
||||
const expand = () => {
|
||||
clearTimer();
|
||||
setCollapsed(false);
|
||||
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
};
|
||||
|
||||
const startLeaveTimer = () => {
|
||||
if (!autoHide) return;
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !code) return;
|
||||
setCollapsed(false);
|
||||
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
return clearTimer;
|
||||
}, [enabled, code, autoHide, idleTimeout]);
|
||||
|
||||
if (!enabled || !code) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`music-embed ${position} ${collapsed ? 'collapsed' : 'expanded'}`}
|
||||
onMouseEnter={expand}
|
||||
onMouseLeave={startLeaveTimer}
|
||||
>
|
||||
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
|
||||
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
|
||||
<button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
|
||||
<span className="material-icons">music_note</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 全站作者名渲染(前台):
|
||||
* - name 显示名(后端 author_name 已归一 = nickname||username,调用方负责兜底匿名/游客)
|
||||
* - uid 用户 id;showUid 时显示 #id 后缀
|
||||
* - title 自定义头衔文案(非空才渲染 chip)
|
||||
* - titleColor 头衔 chip 背景色(hex,可空 → 默认中性色)
|
||||
* - role 'admin' → 系统身份 chip「管理员」(固定系统配色)
|
||||
* - moderatorLabel 版主 chip 文案(如「版主·闲聊」);接口未提供时留空则不渲染
|
||||
* - showUid UID 后缀开关:帖子发布者/楼主/博客作者/个人主页恒显;
|
||||
* 评论作者/回复楼层由 show_uid_in_comments 设置控制
|
||||
*
|
||||
* 渲染 = 名字 + 系统身份 chip(管理员/版主)+ 自定义头衔 chip + UID 后缀,
|
||||
* 样式命名空间 .username-* / .title-chip(见 public/css/style.css)。
|
||||
*/
|
||||
export default function UserName({
|
||||
name = '',
|
||||
uid,
|
||||
title = '',
|
||||
titleColor = '',
|
||||
role = '',
|
||||
showUid = false,
|
||||
moderatorLabel = '',
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<span className={'username' + (className ? ' ' + className : '')}>
|
||||
<span className="username-name">{name || '匿名'}</span>
|
||||
{role === 'admin' ? <span className="username-chip username-chip-role">管理员</span> : null}
|
||||
{moderatorLabel ? <span className="username-chip username-chip-moderator">{moderatorLabel}</span> : null}
|
||||
{title ? (
|
||||
<span
|
||||
className={'username-chip title-chip' + (titleColor ? ' title-chip-colored' : '')}
|
||||
style={titleColor ? { background: titleColor } : undefined}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
{showUid && uid != null ? <span className="username-uid">#{uid}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 外链跳转确认页工具(配合后端 GET /out?url=<encoded>):
|
||||
* - 站内路径(/ 开头)原样返回
|
||||
* - http(s) 外链 → /out?url=<encodeURIComponent(url)>(确认页 5s 倒计时后跳转)
|
||||
* - 其他协议(javascript: 等)→ 返回 '#' 并 console 警告(防 XSS 链接)
|
||||
*/
|
||||
export function safeOutUrl(url) {
|
||||
const u = String(url || '').trim();
|
||||
if (!u) return '#';
|
||||
if (u.startsWith('/')) return u; // 站内绝对/相对路径(含 /uploads、/out 本身)
|
||||
if (/^https?:\/\//i.test(u)) {
|
||||
// 本站家族域名(rainnya.asia 及子域名)直接跳转,不走确认页
|
||||
if (isFamilyDomain(u)) return u;
|
||||
return '/out?url=' + encodeURIComponent(u);
|
||||
}
|
||||
console.warn('[outlink] 不支持的链接协议,已拦截:', u);
|
||||
return '#';
|
||||
}
|
||||
|
||||
/** 判断链接是否为站内链接(/ 开头) */
|
||||
export function isInternalUrl(url) {
|
||||
return String(url || '').startsWith('/');
|
||||
}
|
||||
|
||||
/** 本站家族域名:rainnya.asia 及所有子域名(www.rainnya.asia / rainid.rainnya.asia 等)视为站内,直接跳转不走确认页 */
|
||||
export function isFamilyDomain(url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return hostname === 'rainnya.asia' || hostname.endsWith('.rainnya.asia');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* footer_columns(JSON 字符串)解析为 [{ title, links: [{ label, url }] }];
|
||||
* 解析失败或非数组返回 [](前端回退硬编码栏目)。
|
||||
*/
|
||||
export function parseFooterColumns(raw) {
|
||||
try {
|
||||
const arr = JSON.parse(raw || '[]');
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr
|
||||
.filter((c) => c && typeof c === 'object')
|
||||
.map((c) => ({
|
||||
title: String(c.title || ''),
|
||||
links: Array.isArray(c.links)
|
||||
? c.links
|
||||
.filter((l) => l && typeof l === 'object')
|
||||
.map((l) => ({ label: String(l.label || ''), url: String(l.url || '') }))
|
||||
: [],
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/** HTML 转义:& < > " ' 全转义 */
|
||||
export function escapeHtml(str) {
|
||||
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
||||
switch (ch) {
|
||||
case '&': return '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
case "'": return ''';
|
||||
default: return ch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 分页接口返回归一化:兼容裸数组与 { posts|list|items, total, page, pageSize } 两种形态 */
|
||||
export function normalizePagedList(data) {
|
||||
if (Array.isArray(data)) return { items: data, total: data.length, page: 1, pageSize: data.length };
|
||||
if (data && typeof data === 'object') {
|
||||
const items = data.posts || data.list || data.items || [];
|
||||
return {
|
||||
items,
|
||||
total: typeof data.total === 'number' ? data.total : items.length,
|
||||
page: data.page || 1,
|
||||
pageSize: typeof data.pageSize === 'number' ? data.pageSize : (items.length || 20),
|
||||
};
|
||||
}
|
||||
return { items: [], total: 0, page: 1, pageSize: 20 };
|
||||
}
|
||||
|
||||
/** 格式化时间为 YYYY-MM-DD HH:mm */
|
||||
export function formatDate(input) {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 简单节流:fn 在 wait 毫秒内最多执行一次 */
|
||||
export function throttle(fn, wait = 200) {
|
||||
let last = 0;
|
||||
return function (...args) {
|
||||
const now = Date.now();
|
||||
if (now - last >= wait) {
|
||||
last = now;
|
||||
return fn.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 底部 toast(依赖 index.html 中的 #snackbar,类名沿用 v1) */
|
||||
export function showSnackbar(msg) {
|
||||
const el = document.getElementById('snackbar');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hide');
|
||||
el.classList.add('show');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => {
|
||||
el.classList.add('hide');
|
||||
setTimeout(() => el.classList.remove('show', 'hide'), 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button) */
|
||||
export function focusDialog(container) {
|
||||
if (!container) return;
|
||||
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
|
||||
if (el && typeof el.focus === 'function') el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗键盘/焦点管理(B3):
|
||||
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
|
||||
* - Esc 关闭弹窗
|
||||
* - 关闭后焦点还给触发元素
|
||||
* 返回 { dialogRef, onKeyDown }:dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
|
||||
*/
|
||||
export function useDialog(open, onClose) {
|
||||
const dialogRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement;
|
||||
focusDialog(dialogRef.current);
|
||||
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
if (onClose) onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return { dialogRef, onKeyDown };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getArchive, listPosts } from '../api/blog.js';
|
||||
|
||||
/** 归档页(路由 /archive.html):
|
||||
* archive API 只返回 {month, count},无按月文章列表接口;
|
||||
* 列表接口 /blog/posts 无分页返回全部已发布文章(文章量小),
|
||||
* 前端按 created_at 客户端分组,配合 archive API 的月份与计数展示。
|
||||
*/
|
||||
export default function Archive() {
|
||||
const [months, setMonths] = useState([]); // [{month, count, posts:[]}]
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getArchive(), listPosts()])
|
||||
.then(([arc, posts]) => {
|
||||
const byMonth = new Map();
|
||||
(posts || []).forEach((p) => {
|
||||
const m = (p.created_at || '').slice(0, 7); // YYYY-MM
|
||||
if (!m) return;
|
||||
if (!byMonth.has(m)) byMonth.set(m, []);
|
||||
byMonth.get(m).push(p);
|
||||
});
|
||||
// 以 archive API 的月份顺序为准(desc),count 以实际分组统计为准
|
||||
const countMap = new Map();
|
||||
(arc || []).forEach((a) => countMap.set(a.month, a.count));
|
||||
const list = Array.from(byMonth.entries()).map(([month, ps]) => ({
|
||||
month,
|
||||
count: ps.length,
|
||||
posts: ps,
|
||||
}));
|
||||
// 补齐 archive 里有、分组里没有的月份(理论上不会出现)
|
||||
(arc || []).forEach((a) => {
|
||||
if (!byMonth.has(a.month)) list.push({ month: a.month, count: a.count, posts: [] });
|
||||
});
|
||||
list.sort((a, b) => (a.month < b.month ? 1 : -1));
|
||||
setMonths(list);
|
||||
})
|
||||
.catch((e) => setError(e.message || '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const fmtMonth = (m) => {
|
||||
const [y, mm] = m.split('-');
|
||||
return `${y}年${mm}月`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
|
||||
</Link>
|
||||
<h1 className="article-title" style={{ fontSize: 26 }}>
|
||||
文章归档
|
||||
{!loading && months.length > 0 && (
|
||||
<span className="tag-title-count">(共 {months.reduce((s, m) => s + m.count, 0)} 篇)</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{loading && <div className="loading"><div className="spinner"></div></div>}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{!loading && !error && months.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🗂️</div><p>暂无文章</p></div>
|
||||
)}
|
||||
|
||||
<div className="archive-list">
|
||||
{months.map((m) => (
|
||||
<section key={m.month} className="archive-month">
|
||||
<h2 className="archive-month-title">
|
||||
{fmtMonth(m.month)}
|
||||
<span className="archive-month-count">{m.count} 篇</span>
|
||||
</h2>
|
||||
{m.posts.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>暂无文章</p>
|
||||
) : (
|
||||
m.posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="archive-item">
|
||||
<span className="archive-item-title">{p.title}</span>
|
||||
<span className="archive-item-date">{(p.created_at || '').slice(0, 10)}</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import BlogSidebar from '../components/BlogSidebar.jsx';
|
||||
import { listPosts, searchPosts, getTags } from '../api/blog.js';
|
||||
import { getPublicSettings } from '../api/settings.js';
|
||||
|
||||
/** 摘要:优先取 excerpt,否则从内容中剥离 markdown 符号截取(照搬 blog.js) */
|
||||
function excerptOf(p) {
|
||||
if (p.excerpt) return p.excerpt;
|
||||
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
|
||||
}
|
||||
|
||||
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
|
||||
* 顶部搜索框(/api/blog/search)+ 标签云(/api/blog/tags,点击进 /tag/:name)+ 归档入口 */
|
||||
export default function Blog() {
|
||||
const navigate = useNavigate();
|
||||
const [settings, setSettings] = useState({});
|
||||
const [posts, setPosts] = useState(null); // null=加载中
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 分享链接跳转 SPA 完整版:/blog/:id/share → 302 到此页 ?share=<id>,
|
||||
// 检测到后客户端 navigate 到详情(React Router 内部跳转,不触发整页刷新 → 完整 SPA UI)。
|
||||
// 只在首次挂载处理一次;replaceState 清掉 share 参数避免刷新重复跳。
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const shareId = params.get('share');
|
||||
if (shareId && /^\d+$/.test(shareId)) {
|
||||
history.replaceState({}, '', window.location.pathname);
|
||||
navigate('/blog/' + shareId);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// 搜索状态
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState(null); // null=未搜索
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
// 标签云
|
||||
const [tags, setTags] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings().then(setSettings).catch(() => {});
|
||||
listPosts()
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => setError(e.message || '加载失败'));
|
||||
getTags()
|
||||
.then((ts) => setTags(ts || []))
|
||||
.catch(() => setTags([]));
|
||||
}, []);
|
||||
|
||||
const doSearch = async (e) => {
|
||||
e && e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q) { setResults(null); return; }
|
||||
setSearching(true);
|
||||
try {
|
||||
const rs = await searchPosts(q);
|
||||
setResults(rs || []);
|
||||
} catch (err) {
|
||||
setResults([]);
|
||||
setError(err.message || '搜索失败');
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// 搜索结果以列表呈现(区别于瀑布流卡片)
|
||||
const renderResults = () => (
|
||||
<div className="search-results">
|
||||
<div className="search-results-head">
|
||||
<span>搜索结果({results.length})</span>
|
||||
<button className="btn btn-text btn-sm" onClick={clearSearch}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>close</span> 清空
|
||||
</button>
|
||||
</div>
|
||||
{results.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">🔍</div><p>没有找到相关内容</p></div>
|
||||
) : (
|
||||
<div className="search-results-list">
|
||||
{results.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="search-result-item">
|
||||
<div className="sri-title">{p.title}</div>
|
||||
{p.excerpt && <div className="sri-excerpt">{p.excerpt}</div>}
|
||||
<div className="sri-meta">
|
||||
<span>{p.author_name || '管理员'}</span>
|
||||
<span className="sep">·</span>
|
||||
<span>{p.created_at}</span>
|
||||
{p.tags && <span className="sri-tags">{p.tags}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="homepage-layout blog-layout">
|
||||
<h1 className="sr-only">博客</h1>
|
||||
<BlogSidebar settings={settings} />
|
||||
<div className="homepage-content">
|
||||
{/* 工具条:搜索框 + 归档 */}
|
||||
<form className="blog-toolbar" onSubmit={doSearch}>
|
||||
<div className="search-box">
|
||||
<span className="material-icons">search</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索文章标题 / 内容..."
|
||||
aria-label="搜索文章"
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" className="search-clear" onClick={() => setQuery('')} aria-label="清空输入">
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn-tonal btn-sm" disabled={searching}>
|
||||
{searching ? '搜索中...' : '搜索'}
|
||||
</button>
|
||||
<Link to="/archive.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
|
||||
</Link>
|
||||
<a href="/feed.xml" target="_blank" rel="noopener" className="btn btn-text btn-sm" title="RSS 订阅" aria-label="RSS 订阅">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>rss_feed</span> RSS
|
||||
</a>
|
||||
</form>
|
||||
|
||||
{/* 标签云 */}
|
||||
{tags.length > 0 && (
|
||||
<div className="tag-cloud card">
|
||||
<div className="tag-cloud-title">
|
||||
<span className="material-icons">local_offer</span> 标签
|
||||
</div>
|
||||
<div className="tag-cloud-body">
|
||||
{tags.map((t) => (
|
||||
<Link key={t.name} to={`/tag/${encodeURIComponent(t.name)}`} className="tag-chip" style={{ fontSize: tagSize(t.count) }}>
|
||||
{t.name}
|
||||
<span className="tag-count">{t.count}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索结果 / 瀑布流 */}
|
||||
{results !== null ? (
|
||||
renderResults()
|
||||
) : (
|
||||
<>
|
||||
{!posts && !error && (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{posts && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="blog-waterfall">
|
||||
{posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
|
||||
<div className="blog-featured"></div>
|
||||
<div className="blog-title">{p.title}</div>
|
||||
<div className="blog-excerpt">{excerptOf(p)}</div>
|
||||
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签字号随文章数渐变(经典标签云效果):1 篇 ~12px,8 篇及以上 ~18px */
|
||||
function tagSize(count) {
|
||||
const c = Number(count) || 1;
|
||||
const size = 12 + Math.min(c, 8) * 0.75;
|
||||
return size.toFixed(1) + 'px';
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import * as blogApi from '../api/blog.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import Avatar from '../components/Avatar.jsx';
|
||||
import UserName from '../components/UserName.jsx';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
|
||||
function countWords(content) {
|
||||
const plain = String(content || '')
|
||||
.replace(/\[image:[^\]]*\]|\[file:[^\]]*\]/g, '')
|
||||
.replace(/[#*`\[\]()>|~_!-]/g, '');
|
||||
const cjk = (plain.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const words = plain.replace(/[\u4e00-\u9fff\u3400-\u4dbf]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
|
||||
return cjk + words;
|
||||
}
|
||||
|
||||
/**
|
||||
* 博客详情页(路由 /blog/:id):
|
||||
* 正文(MarkdownRenderer)+ 阅读量/标签/点赞 + 目录/字数 + 上一篇/下一篇 +
|
||||
* 嵌套评论(parent_id 回复)。
|
||||
*/
|
||||
export default function BlogDetail() {
|
||||
const { id } = useParams();
|
||||
const [post, setPost] = useState(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [prevnext, setPrevnext] = useState(null);
|
||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||
const [toc, setToc] = useState([]);
|
||||
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
// 评论作者 UID 显示开关(site_settings.show_uid_in_comments,默认显示)
|
||||
const [showCommentUid, setShowCommentUid] = useState(true);
|
||||
// markdown 锁定块:locks 元信息 / 已解锁索引(Set)/ password 块内容(Map,仅内存态)/ viewer 判定结果
|
||||
const [locks, setLocks] = useState([]);
|
||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||
const [lockContent, setLockContent] = useState(() => new Map());
|
||||
const [viewer, setViewer] = useState(null);
|
||||
|
||||
// 分享链接:/blog/:id/share → 服务端 302 到 SPA 完整版(运行时拼接,不硬编码域名)
|
||||
const shareUrl = typeof window !== 'undefined'
|
||||
? window.location.origin + '/blog/' + id + '/share'
|
||||
: '';
|
||||
|
||||
// 分享弹窗键盘/焦点管理(Esc 关闭 + 焦点还给触发按钮)
|
||||
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
|
||||
|
||||
/** 剪贴板写入:优先 Async Clipboard,失败降级 execCommand(非安全上下文/LAN 部署) */
|
||||
const copyShareLink = async () => {
|
||||
const ok = await (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = shareUrl;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const res = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return res;
|
||||
} catch { return false; }
|
||||
}
|
||||
})();
|
||||
if (ok) showSnackbar('链接已复制');
|
||||
else showSnackbar('复制失败,请手动复制');
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setReplyTo(null);
|
||||
try {
|
||||
const [p, cs, pn, lk] = await Promise.all([
|
||||
blogApi.getPost(id),
|
||||
blogApi.listComments(id),
|
||||
blogApi.getPrevNext(id),
|
||||
blogApi.getLikeState(id),
|
||||
]);
|
||||
setPost(p);
|
||||
setComments(cs || []);
|
||||
setPrevnext(pn);
|
||||
setLike(lk || { liked: false, count: 0 });
|
||||
// 锁定块:详情接口返回 locks/unlocked/viewer(旧数据无则空)
|
||||
setLocks((p && p.locks) || []);
|
||||
setUnlocked(new Set((p && p.unlocked) || []));
|
||||
setLockContent(new Map());
|
||||
setViewer((p && p.viewer) || null);
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// 评论 UID 开关(公开设置;后端未加字段时默认显示)
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => setShowCommentUid(s.show_uid_in_comments !== '0'))
|
||||
.catch(() => setShowCommentUid(true));
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
||||
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
||||
|
||||
// 目录:从已渲染的 .md-body 提取 h2(仅一级章节)打锚点;短文章(<500 字)不提取
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
if (wordCount < 500) { setToc([]); return; }
|
||||
const body = document.querySelector('.blog-article .md-body');
|
||||
if (!body) { setToc([]); return; }
|
||||
const items = [...body.querySelectorAll('h2')].map((h, i) => {
|
||||
if (!h.id) h.id = 'toc-' + i;
|
||||
return { id: h.id, text: (h.textContent || '').trim(), level: 2 };
|
||||
});
|
||||
setToc(items);
|
||||
}, [post, wordCount]);
|
||||
|
||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
||||
|
||||
// ── markdown 锁定块 ──
|
||||
// 解锁 password 块:调解锁接口,内容 + 附件 token(lockToken)仅存内存 state(刷新失效)
|
||||
const unlockLock = async (index, password) => {
|
||||
const res = await blogApi.unlockLock(id, index, password);
|
||||
if (!res || !res.ok || !res.content) throw new Error((res && res.error) || '解锁失败');
|
||||
setLockContent((prev) => {
|
||||
const m = new Map(prev);
|
||||
m.set(index, { content: res.content, token: res.lockToken || '' });
|
||||
return m;
|
||||
});
|
||||
setUnlocked((prev) => { const s = new Set(prev); s.add(index); return s; });
|
||||
};
|
||||
|
||||
// reply 块「去评论」:滚动到评论区(锁定块在正文中,评论区在下方)
|
||||
const scrollToComment = () => {
|
||||
const el = document.querySelector('.comment-form') || document.querySelector('.comment-list');
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
else window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// ── 点赞(乐观更新)──
|
||||
const toggleLike = async () => {
|
||||
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
||||
const next = !like.liked;
|
||||
setLike((s) => ({ liked: next, count: Math.max(0, s.count + (next ? 1 : -1)) }));
|
||||
try {
|
||||
const r = next ? await blogApi.likePost(id) : await blogApi.unlikePost(id);
|
||||
setLike({ liked: r.liked, count: r.count });
|
||||
} catch (e) {
|
||||
setLike((s) => ({ liked: !s.liked, count: Math.max(0, s.count + (s.liked ? 1 : -1)) }));
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 嵌套评论 ──
|
||||
const childrenOf = useCallback((pid) => comments.filter((c) => c.parent_id === pid), [comments]);
|
||||
const rootComments = comments.filter((c) => !c.parent_id);
|
||||
|
||||
const renderComment = (c, depth = 0) => {
|
||||
const kids = childrenOf(c.id);
|
||||
return (
|
||||
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
|
||||
<div className="reply-meta">
|
||||
<Avatar src={c.author_avatar} name={c.author_name} size={22} className="avatar-sm" to={c.author_id ? `/u/${c.author_id}` : undefined} />
|
||||
<UserName
|
||||
name={c.author_name || '游客'}
|
||||
uid={c.author_id}
|
||||
title={c.author_title}
|
||||
titleColor={c.author_title_color}
|
||||
role={c.author_role}
|
||||
showUid={showCommentUid && !!c.author_id}
|
||||
/> · {c.created_at}
|
||||
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
|
||||
</div>
|
||||
<div className="reply-body">{c.content}</div>
|
||||
{user && (
|
||||
<button
|
||||
className="btn btn-text btn-sm reply-btn"
|
||||
onClick={() => {
|
||||
setReplyTo({ id: c.id, name: c.author_name || '游客' });
|
||||
setCommentText(`@${c.author_name || '游客'} `);
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 14 }}>reply</span> 回复
|
||||
</button>
|
||||
)}
|
||||
{kids.length > 0 && (
|
||||
<div className="reply-children">
|
||||
{kids.map((k) => renderComment(k, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
const content = commentText.trim();
|
||||
if (!content) { showSnackbar('评论不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await blogApi.createComment(id, content, replyTo ? replyTo.id : 0);
|
||||
showSnackbar('评论已发表');
|
||||
setCommentText('');
|
||||
setReplyTo(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{error || '文章不存在'}</p>
|
||||
<Link to="/blog.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回博客</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="article-layout">
|
||||
<div className="blog-article article-main">
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
|
||||
</Link>
|
||||
|
||||
<h1 className="article-title">{post.title}</h1>
|
||||
|
||||
<div className="article-meta">
|
||||
<Avatar src={post.author_avatar} name={post.author_name} size={24} to={post.author_id ? `/u/${post.author_id}` : undefined} />
|
||||
<UserName
|
||||
name={post.author_name || '管理员'}
|
||||
uid={post.author_id}
|
||||
title={post.author_title}
|
||||
titleColor={post.author_title_color}
|
||||
role={post.author_role}
|
||||
showUid={!!post.author_id}
|
||||
/> · {post.created_at}
|
||||
<span className="meta-stat" title="阅读量">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
|
||||
</span>
|
||||
<span className="meta-stat" title="字数">约 {wordCount} 字</span>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="article-tags">
|
||||
{tags.map((t) => (
|
||||
<Link key={t} to={`/tag/${encodeURIComponent(t)}`} className="chip article-tag-chip">{t}</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-actions">
|
||||
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||
<span className="like-count">{like.count}</span>
|
||||
</button>
|
||||
<button className="btn btn-tonal btn-sm" onClick={() => setShareOpen(true)} aria-label="分享文章">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MarkdownRenderer
|
||||
content={post.content}
|
||||
useMarkdown={post.use_markdown}
|
||||
locks={locks}
|
||||
unlocked={unlocked}
|
||||
lockContent={lockContent}
|
||||
onUnlock={unlockLock}
|
||||
onGoComment={scrollToComment}
|
||||
refType="blog"
|
||||
refId={post.id}
|
||||
/>
|
||||
|
||||
{/* 上一篇 / 下一篇 */}
|
||||
{prevnext && (prevnext.prev || prevnext.next) && (
|
||||
<nav className="prevnext-nav">
|
||||
<div className="pn-col pn-prev">
|
||||
{prevnext.prev ? (
|
||||
<Link to={`/blog/${prevnext.prev.id}`}>
|
||||
<span className="pn-label">上一篇</span>
|
||||
<span className="pn-title">{prevnext.prev.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">上一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pn-col pn-next">
|
||||
{prevnext.next ? (
|
||||
<Link to={`/blog/${prevnext.next.id}`}>
|
||||
<span className="pn-label">下一篇</span>
|
||||
<span className="pn-title">{prevnext.next.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">下一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
|
||||
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
|
||||
) : (
|
||||
<div className="comment-list">
|
||||
{rootComments.map((c) => renderComment(c))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
<div className="comment-form">
|
||||
{replyTo && (
|
||||
<div className="reply-to-hint">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>reply</span>
|
||||
回复 <strong>@{replyTo.name}</strong>
|
||||
<button
|
||||
className="btn btn-text btn-sm"
|
||||
onClick={() => { setReplyTo(null); setCommentText(''); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>close</span> 取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: replyTo ? 8 : 0 }}>
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={replyTo ? `回复 @${replyTo.name}...` : '写下你的评论...'}
|
||||
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
|
||||
/>
|
||||
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
|
||||
发表评论
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}>
|
||||
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="article-toc-side">
|
||||
<div className="toc-title">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
|
||||
目录 <span className="toc-count">{toc.length}</span>
|
||||
</div>
|
||||
<ul className="toc-list">
|
||||
{toc.map((t) => (
|
||||
<li key={t.id} className="toc-item">
|
||||
<a
|
||||
href={'#' + t.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(t.id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 分享弹窗(与全站 dialog 风格一致:Esc/遮罩关闭、焦点管理) */}
|
||||
{shareOpen && (
|
||||
<div
|
||||
ref={shareDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="分享文章"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
|
||||
onKeyDown={shareDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>分享文章</h3>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
|
||||
复制链接分享,好友打开直达完整阅读页面
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="shareUrlInput">分享链接</label>
|
||||
<input
|
||||
id="shareUrlInput"
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
|
||||
<button className="btn btn-filled" onClick={copyShareLink}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTheme } from '../theme.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
|
||||
/**
|
||||
* 面板嵌入页(迁移自 embed.html):
|
||||
* 入口参数 ?url=&title=&proxy=1 —— iframe 展示目标站点;
|
||||
* proxy=1 或 HTTPS 页内嵌 HTTP 目标时走 /api/proxy/fetch?url=(代理剥 X-Frame-Options/CSP)。
|
||||
* 工具栏:关闭(有来源则 history.back)、标题、原站(新标签)、主题切换。
|
||||
*/
|
||||
export default function Embed() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const url = searchParams.get('url');
|
||||
const title = searchParams.get('title') || url || '嵌入';
|
||||
const useProxy = searchParams.get('proxy') === '1';
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
|
||||
const isHttpsPage = window.location.protocol === 'https:';
|
||||
const isHttpTarget = url ? url.startsWith('http:') : false;
|
||||
const needsProxy = useProxy || (isHttpsPage && isHttpTarget);
|
||||
// proxy 模式:iframe 无法携带 Authorization header,改用 ?token= 认证(后端 queryTokenAuth 转写)
|
||||
const iframeSrc = url
|
||||
? (needsProxy
|
||||
? '/api/proxy/fetch?url=' + encodeURIComponent(url) + '&token=' + encodeURIComponent(getToken() || '')
|
||||
: url)
|
||||
: '';
|
||||
|
||||
// 非代理嵌入时 2s 后提示"无法嵌入?安装扩展"(照 v1)
|
||||
useEffect(() => {
|
||||
if (!url || needsProxy) return;
|
||||
const t = setTimeout(() => setShowHint(true), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [url, needsProxy]);
|
||||
|
||||
const handleClose = (e) => {
|
||||
// 有来源页时返回上一页,否则跳首页
|
||||
if (document.referrer && document.referrer !== window.location.href) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<h1 className="sr-only">嵌入页面</h1>
|
||||
<div
|
||||
className="embed-toolbar"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
|
||||
>
|
||||
<a href="/" className="btn-icon" onClick={handleClose} title="关闭"><span className="material-icons">close</span></a>
|
||||
<span className="embed-title" style={{ flex: 1, fontWeight: 500, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
|
||||
{url && (
|
||||
<a href={url} target="_blank" rel="noopener" className="btn btn-text btn-sm" title="在新标签页中打开">
|
||||
<span className="material-icons">open_in_new</span> 原站
|
||||
</a>
|
||||
)}
|
||||
<button className="btn-icon" onClick={toggleTheme} title="切换主题">
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showHint && (
|
||||
<div style={{ padding: '8px 16px', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', fontSize: 13, textAlign: 'center', flexShrink: 0 }}>
|
||||
无法嵌入?试试安装{' '}
|
||||
<a href="https://chromewebstore.google.com/search/ignore%20x-frame" target="_blank" rel="noopener" style={{ color: 'inherit', fontWeight: 600, textDecoration: 'underline' }}>
|
||||
Ignore X-Frame-Headers
|
||||
</a>{' '}
|
||||
扩展,或点击右上角「原站」在新标签页打开
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHint(false)}
|
||||
aria-label="关闭提示"
|
||||
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{iframeSrc ? (
|
||||
<iframe
|
||||
className="embed-container"
|
||||
title={title}
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
|
||||
loading="lazy"
|
||||
src={iframeSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state" style={{ flex: 1 }}>
|
||||
<div className="empty-icon">🔗</div>
|
||||
<p>缺少 url 参数,无法加载嵌入内容</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import * as announcementsApi from '../api/announcements.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import ForumIcon from '../components/ForumIcon.jsx';
|
||||
|
||||
/** 版主名提取:兼容 ["a","b"] / [{username:"a"}] / "a,b" 三种后端形态 */
|
||||
function moderatorsOf(cat) {
|
||||
const m = cat && cat.moderators;
|
||||
if (Array.isArray(m)) {
|
||||
return m
|
||||
.map((x) => (typeof x === 'string' ? x : (x && x.username) || ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
if (typeof m === 'string') {
|
||||
return m.split(',').map((s) => s.trim()).filter(Boolean).slice(0, 3);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 论坛首页(路由 /forum.html,L1 版块索引):
|
||||
* 全站公告区 + 页头(标题/发帖)+ 版块卡片网格(聚合统计)。
|
||||
* 分享链接处理:?share=<id> → 帖子详情;?share_c=<id> → 版块页(replaceState 清参)。
|
||||
* 游客预览开关:forum_guest_visible==='0' 且未登录 → 登录提示。
|
||||
*/
|
||||
export default function Forum() {
|
||||
const navigate = useNavigate();
|
||||
const [categories, setCategories] = useState(null); // null=加载中
|
||||
const [announcements, setAnnouncements] = useState([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
const [moderatedIds, setModeratedIds] = useState([]);
|
||||
const [guestLocked, setGuestLocked] = useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
|
||||
// 分享链接跳转 SPA 完整版:?share=<id> / ?share_c=<id> → 客户端 navigate,
|
||||
// 只在首次挂载处理一次;replaceState 清参避免刷新重复跳。
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const shareC = params.get('share_c');
|
||||
if (shareC && /^\d+$/.test(shareC)) {
|
||||
history.replaceState({}, '', window.location.pathname);
|
||||
navigate('/forum/c/' + shareC);
|
||||
return;
|
||||
}
|
||||
const shareId = params.get('share');
|
||||
if (shareId && /^\d+$/.test(shareId)) {
|
||||
history.replaceState({}, '', window.location.pathname);
|
||||
navigate('/forum/' + shareId);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => { setGuestLocked(s.forum_guest_visible === '0'); setSettingsLoaded(true); })
|
||||
.catch(() => { setSettingsLoaded(true); });
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
// 我管理的版块(admin 恒真由 role 判断;版主取真实归属),用于「管理我的版块」入口
|
||||
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
// 全站公告(公开接口,无公告不渲染)
|
||||
announcementsApi.listActive().then(setAnnouncements).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 版块加载:已登录或游客可见才拉取(锁定时不请求,避免后端 401 报错噪音)
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
if (getToken() || !guestLocked) {
|
||||
forumApi.listCategories()
|
||||
.then((cs) => setCategories(cs || []))
|
||||
.catch((e) => { setLoadError(e.message || '加载失败'); setCategories([]); });
|
||||
}
|
||||
}, [settingsLoaded, guestLocked]);
|
||||
|
||||
// 发帖按钮:已登录整页跳转 Write 页论坛模式(统一编辑体验);未登录跳登录页
|
||||
const handleNewPostBtn = () => {
|
||||
if (user) window.location.href = '/write.html?type=forum';
|
||||
else navigate('/login.html');
|
||||
};
|
||||
|
||||
// 版主/管理员 → 页头显示「管理我的版块」入口
|
||||
const canManage = !!user && (user.role === 'admin' || moderatedIds.length > 0);
|
||||
|
||||
// 游客锁定:未登录且论坛关闭游客预览 → 登录提示卡片
|
||||
const locked = settingsLoaded && guestLocked && !getToken();
|
||||
if (locked) {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
|
||||
<div className="empty-icon">🔒</div>
|
||||
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见,登录后即可浏览与发帖</p>
|
||||
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-home">
|
||||
{announcements.length > 0 && (
|
||||
<div className="announcement-bar">
|
||||
<span className="material-icons ann-icon">campaign</span>
|
||||
<span className="ann-content">
|
||||
{announcements.map((a) => (
|
||||
<span key={a.id} className="ann-item">
|
||||
{a.title ? <strong>{a.title}</strong> : null}
|
||||
{a.title ? ':' : ''}{a.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="forum-home-head">
|
||||
<h1 className="page-title" style={{ margin: 0 }}>论坛</h1>
|
||||
<div className="forum-home-actions">
|
||||
<a className="btn-icon" href="/feed/forum.xml" target="_blank" rel="noopener" title="RSS 订阅(论坛全站)" aria-label="RSS 订阅">
|
||||
<span className="material-icons">rss_feed</span>
|
||||
</a>
|
||||
{canManage && (
|
||||
<Link to="/forum/manage" className="btn btn-tonal">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>manage_accounts</span> 管理我的版块
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-filled" onClick={handleNewPostBtn}>
|
||||
{user ? '发帖' : '登录发帖'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
|
||||
{loadError && <div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败</p></div>}
|
||||
{categories !== null && !loadError && categories.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无版块</p></div>
|
||||
)}
|
||||
|
||||
{categories !== null && categories.length > 0 && (
|
||||
<div className="forum-grid">
|
||||
{categories.map((c) => {
|
||||
const mods = moderatorsOf(c);
|
||||
const postsCount = typeof c.post_count === 'number' ? c.post_count : null;
|
||||
const todayCount = typeof c.today_count === 'number' ? c.today_count : null;
|
||||
return (
|
||||
<Link key={c.id} to={'/forum/c/' + c.id} className="card board-card">
|
||||
<div className="board-card-top">
|
||||
<ForumIcon icon={c.icon} name={c.name} iconColor={c.icon_color} size={44} />
|
||||
<div className="board-card-title">
|
||||
<span className="board-name">{c.name}</span>
|
||||
{c.announcement ? <span className="board-badge" title="版块公告">📢</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="board-desc">{c.description || '暂无描述'}</p>
|
||||
<div className="board-meta">
|
||||
<span>{postsCount != null ? `${postsCount} 帖` : ''}</span>
|
||||
{todayCount ? <span className="board-today">今日 {todayCount}</span> : null}
|
||||
{!postsCount && !todayCount ? <span>暂无帖子</span> : null}
|
||||
</div>
|
||||
{mods.length > 0 && (
|
||||
<div className="board-mods">版主:{mods.join('、')}</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar, useDialog, normalizePagedList } from '../lib/utils.js';
|
||||
import ForumIcon from '../components/ForumIcon.jsx';
|
||||
import Avatar from '../components/Avatar.jsx';
|
||||
import UserName from '../components/UserName.jsx';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 论坛版块页(路由 /forum/c/:id,L2):
|
||||
* 面包屑 + 版块头(icon/统计/分享/发帖/编辑公告)+ 版块公告 +
|
||||
* 子分类 chips 筛选(?sub_category=)+ 置顶区 / 精华区 + 帖子列表 + 分页。
|
||||
* 版主/管理员可编辑公告、置顶/加精、删除帖子(canManage)。
|
||||
* 游客锁定:forum_guest_visible==='0' 且未登录 → 登录提示。
|
||||
*/
|
||||
export default function ForumCategory() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const catId = parseInt(id, 10);
|
||||
|
||||
const [cat, setCat] = useState(null); // 版块详情(含聚合)
|
||||
const [pageData, setPageData] = useState(null); // 归一化分页数据
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
const [moderatedIds, setModeratedIds] = useState([]);
|
||||
const [guestLocked, setGuestLocked] = useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
|
||||
// URL 参数:page / sub_category
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1);
|
||||
const filterSub = searchParams.get('sub_category') || '';
|
||||
|
||||
// 分享弹窗(复用 BlogDetail 模式)
|
||||
const shareUrl = typeof window !== 'undefined'
|
||||
? window.location.origin + '/forum/c/' + catId + '/share'
|
||||
: '';
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
|
||||
|
||||
// 编辑公告弹窗
|
||||
const [annOpen, setAnnOpen] = useState(false);
|
||||
const [annText, setAnnText] = useState('');
|
||||
const [annSaving, setAnnSaving] = useState(false);
|
||||
const { dialogRef: annDialogRef, onKeyDown: annDialogKey } = useDialog(annOpen, () => setAnnOpen(false));
|
||||
|
||||
const loadPosts = useCallback(async (catId_, page_, sub) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await forumApi.listPosts({ categoryId: catId_, page: page_, subCategory: sub });
|
||||
setPageData(normalizePagedList(data));
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
setPageData({ items: [], total: 0, page: page_, pageSize: PAGE_SIZE });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => { setGuestLocked(s.forum_guest_visible === '0'); setSettingsLoaded(true); })
|
||||
.catch(() => { setSettingsLoaded(true); });
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
// 我管理的版块(api 已归一化为 id 数组;admin 由 role 恒真)
|
||||
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 版块 + 帖子加载:已登录或游客可见才拉取
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
const canLoad = getToken() || !guestLocked;
|
||||
if (!canLoad) return;
|
||||
forumApi.getCategory(catId)
|
||||
.then((c) => setCat(c || null))
|
||||
.catch(() => setCat(null));
|
||||
loadPosts(catId, page, filterSub);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsLoaded, guestLocked, catId, page, filterSub]);
|
||||
|
||||
const canManage = !!user && (user.role === 'admin' || moderatedIds.includes(catId));
|
||||
|
||||
// 从当前页数据分桶:置顶 / 精华(非置顶)/ 普通列表
|
||||
const items = (pageData && pageData.items) || [];
|
||||
const pinned = useMemo(() => items.filter((p) => p.is_pinned), [items]);
|
||||
const essence = useMemo(() => items.filter((p) => !p.is_pinned && p.is_essence), [items]);
|
||||
const rest = useMemo(() => items.filter((p) => !p.is_pinned && !p.is_essence), [items]);
|
||||
|
||||
const totalPages = pageData ? Math.max(1, Math.ceil(pageData.total / (pageData.pageSize || PAGE_SIZE))) : 1;
|
||||
|
||||
const goPage = (p) => {
|
||||
if (p < 1 || p > totalPages || p === page) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (p <= 1) next.delete('page');
|
||||
else next.set('page', String(p));
|
||||
setSearchParams(next);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const pickSub = (sub) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('page');
|
||||
if (sub) next.set('sub_category', sub);
|
||||
else next.delete('sub_category');
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
// 发帖按钮:?type=forum&category=id 预选版块
|
||||
const handleNewPostBtn = () => {
|
||||
if (user) window.location.href = `/write.html?type=forum&category=${catId}`;
|
||||
else navigate('/login.html');
|
||||
};
|
||||
|
||||
/** 剪贴板写入:优先 Async Clipboard,失败降级 execCommand(非安全上下文/LAN 部署) */
|
||||
const copyShareLink = async () => {
|
||||
const ok = await (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = shareUrl;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const res = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return res;
|
||||
} catch { return false; }
|
||||
}
|
||||
})();
|
||||
if (ok) showSnackbar('链接已复制');
|
||||
else showSnackbar('复制失败,请手动复制');
|
||||
};
|
||||
|
||||
const openAnnEdit = () => {
|
||||
setAnnText((cat && cat.announcement) || '');
|
||||
setAnnOpen(true);
|
||||
};
|
||||
const saveAnnouncement = async () => {
|
||||
setAnnSaving(true);
|
||||
try {
|
||||
await forumApi.updateAnnouncement(catId, annText.trim());
|
||||
showSnackbar('公告已更新');
|
||||
setAnnOpen(false);
|
||||
const c = await forumApi.getCategory(catId);
|
||||
setCat(c || null);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setAnnSaving(false);
|
||||
};
|
||||
|
||||
// 游客锁定
|
||||
if (settingsLoaded && guestLocked && !getToken()) {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
|
||||
<div className="empty-icon">🔒</div>
|
||||
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见</p>
|
||||
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-category">
|
||||
{/* 面包屑 */}
|
||||
<nav className="breadcrumbs" aria-label="面包屑">
|
||||
<Link to="/">首页</Link>
|
||||
<span className="sep">/</span>
|
||||
<Link to="/forum.html">论坛</Link>
|
||||
<span className="sep">/</span>
|
||||
<span>{cat ? cat.name : '版块'}</span>
|
||||
</nav>
|
||||
|
||||
{cat && (
|
||||
<>
|
||||
{/* 版块头 */}
|
||||
<div className="card category-head">
|
||||
<ForumIcon icon={cat.icon} name={cat.name} iconColor={cat.icon_color} size={48} />
|
||||
<div className="category-head-main">
|
||||
<h1>{cat.name}</h1>
|
||||
<p className="category-head-desc">{cat.description || '暂无描述'}</p>
|
||||
<div className="category-head-stats">
|
||||
<span>{typeof cat.post_count === 'number' ? `${cat.post_count} 帖子` : ''}</span>
|
||||
{cat.today_count ? <span>今日 {cat.today_count}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="category-head-actions">
|
||||
<a className="btn-icon" href={'/feed/forum/c/' + catId + '.xml'} target="_blank" rel="noopener" title="RSS 订阅(本版块)" aria-label="RSS 订阅">
|
||||
<span className="material-icons">rss_feed</span>
|
||||
</a>
|
||||
<button className="btn btn-text btn-sm" onClick={() => setShareOpen(true)}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
|
||||
</button>
|
||||
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>edit</span> 发帖
|
||||
</button>
|
||||
{canManage && (
|
||||
<button className="btn btn-tonal btn-sm" onClick={openAnnEdit}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>campaign</span> 编辑公告
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版块公告 */}
|
||||
{cat.announcement && (
|
||||
<div className="announcement-bar">
|
||||
<span className="material-icons ann-icon">campaign</span>
|
||||
<span className="ann-content">{cat.announcement}</span>
|
||||
{canManage && (
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-on-primary-container)', flexShrink: 0 }} onClick={openAnnEdit}>编辑</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 子分类 chips */}
|
||||
{(cat.sub_categories || '').split(',').map((s) => s.trim()).filter(Boolean).length > 0 && (
|
||||
<div className="chips" style={{ marginBottom: 16 }}>
|
||||
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => pickSub('')}>全部</button>
|
||||
{(cat.sub_categories || '').split(',').map((s) => s.trim()).filter(Boolean).map((s) => (
|
||||
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => pickSub(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 帖子列表 */}
|
||||
{loading && <div className="loading"><div className="spinner"></div></div>}
|
||||
{error && <div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败</p></div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{pinned.length > 0 && (
|
||||
<section className="forum-section">
|
||||
<div className="section-label"><span>📌</span> 置顶</div>
|
||||
{pinned.map((p) => <PostCard key={p.id} p={p} />)}
|
||||
</section>
|
||||
)}
|
||||
{essence.length > 0 && (
|
||||
<section className="forum-section">
|
||||
<div className="section-label"><span>⭐</span> 精华</div>
|
||||
{essence.map((p) => <PostCard key={p.id} p={p} />)}
|
||||
</section>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<section className="forum-section">
|
||||
{pinned.length === 0 && essence.length === 0 && rest.length > 0
|
||||
? null
|
||||
: <div className="section-label"><span>💬</span> 最新</div>}
|
||||
{rest.map((p) => <PostCard key={p.id} p={p} />)}
|
||||
</section>
|
||||
)}
|
||||
{items.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
|
||||
)}
|
||||
|
||||
{/* 分页条 */}
|
||||
{pageData && totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-tonal btn-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => goPage(page - 1)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>chevron_left</span> 上一页
|
||||
</button>
|
||||
<span className="page-info">第 {page} / {totalPages} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-tonal btn-sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => goPage(page + 1)}
|
||||
>
|
||||
下一页 <span className="material-icons" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 分享弹窗 */}
|
||||
{shareOpen && (
|
||||
<div
|
||||
ref={shareDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="分享版块"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
|
||||
onKeyDown={shareDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>分享版块</h3>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
|
||||
复制链接分享,好友打开直达版块页面
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="shareCatInput">分享链接</label>
|
||||
<input
|
||||
id="shareCatInput"
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
|
||||
<button className="btn btn-filled" onClick={copyShareLink}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑公告弹窗(版主/管理员) */}
|
||||
{annOpen && (
|
||||
<div
|
||||
ref={annDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="编辑版块公告"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setAnnOpen(false); }}
|
||||
onKeyDown={annDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>编辑版块公告</h3>
|
||||
<div className="form-group">
|
||||
<label htmlFor="annInput">公告内容(留空清除)</label>
|
||||
<textarea
|
||||
id="annInput"
|
||||
value={annText}
|
||||
onChange={(e) => setAnnText(e.target.value)}
|
||||
placeholder="展示在版块顶部的公告"
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setAnnOpen(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={saveAnnouncement} disabled={annSaving}>
|
||||
{annSaving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 帖子行卡片:标题 + 徽章 + meta */
|
||||
function PostCard({ p }) {
|
||||
return (
|
||||
<Link to={'/forum/' + p.id} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
|
||||
<div className="post-title">
|
||||
{p.title}
|
||||
{p.is_pinned ? <span className="post-badge pin">📌</span> : null}
|
||||
{p.is_essence ? <span className="post-badge essence">⭐</span> : null}
|
||||
</div>
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Avatar src={p.author_avatar} name={p.author_name} size={22} className="avatar-sm" to={p.author_id ? `/u/${p.author_id}` : undefined} />
|
||||
<UserName
|
||||
name={p.author_name || '匿名'}
|
||||
uid={p.author_id}
|
||||
title={p.author_title}
|
||||
titleColor={p.author_title_color}
|
||||
role={p.author_role}
|
||||
showUid={!!p.author_id}
|
||||
/>
|
||||
<span>{p.created_at}</span>
|
||||
<span>{p.reply_count || 0} 回复</span>
|
||||
{p.sub_category ? (
|
||||
<span className="chip chip-tonal">{p.sub_category}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import ErrorDialog from '../components/ErrorDialog.jsx';
|
||||
import Avatar from '../components/Avatar.jsx';
|
||||
import UserName from '../components/UserName.jsx';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/** 后端时间 'YYYY-MM-DD HH:MM:SS' → 'YYYY年M月D日 HH:MM'(编辑标记用,与创建时间同源) */
|
||||
function formatEditTime(s) {
|
||||
const m = String(s || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||
if (!m) return s || '';
|
||||
return `${m[1]}年${parseInt(m[2], 10)}月${parseInt(m[3], 10)}日 ${m[4]}:${m[5]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 论坛帖子详情(路由 /forum/:id,L3):
|
||||
* 面包屑(首页/论坛/版块/标题)+ 楼主标识 + 楼层(楼主1,回复i+2)+ 置顶/加精徽章 +
|
||||
* 管理操作(置顶/加精/删除,admin 或版主)+ 分享弹窗 + 回复列表/回复框。
|
||||
* 回复无需验证码(照 v1:仅发帖走 captcha_forum)。
|
||||
* 游客预览开关:forum_guest_visible==='0' 且未登录 → 登录提示。
|
||||
*/
|
||||
export default function ForumDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [post, setPost] = useState(null);
|
||||
const [replies, setReplies] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [moderatedIds, setModeratedIds] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [guestLocked, setGuestLocked] = useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
// 回复楼层作者 UID 显示开关(site_settings.show_uid_in_comments,默认显示)
|
||||
const [showReplyUid, setShowReplyUid] = useState(true);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [adminBusy, setAdminBusy] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(''); // 403 禁言等错误 → 弹窗
|
||||
// markdown 锁定块
|
||||
const [locks, setLocks] = useState([]);
|
||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||
const [lockContent, setLockContent] = useState(() => new Map());
|
||||
|
||||
const shareUrl = typeof window !== 'undefined'
|
||||
? window.location.origin + '/forum/' + id + '/share'
|
||||
: '';
|
||||
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await forumApi.getPost(id);
|
||||
setPost(data.post);
|
||||
setReplies(data.replies || []);
|
||||
// 锁定块:兼容放 post 内或响应顶层(旧数据无则空)
|
||||
const lp = (data.post && data.post.locks) || data.locks;
|
||||
const up = (data.post && data.post.unlocked) || data.unlocked;
|
||||
setLocks(Array.isArray(lp) ? lp : []);
|
||||
setUnlocked(new Set(Array.isArray(up) ? up : []));
|
||||
setLockContent(new Map());
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)+ 回复 UID 开关
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
setGuestLocked(s.forum_guest_visible === '0');
|
||||
setShowReplyUid(s.show_uid_in_comments !== '0');
|
||||
setSettingsLoaded(true);
|
||||
})
|
||||
.catch(() => { setSettingsLoaded(true); });
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
// 我管理的版块(api 已归一化为 id 数组;admin 由 role 恒真)——挂载时拉一次
|
||||
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 帖子加载:已登录或游客可见才拉取(锁定时跳过,避免后端 401 报错噪音)
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
if (!(guestLocked && !getToken())) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsLoaded, guestLocked, load]);
|
||||
|
||||
const catId = post ? post.category_id : null;
|
||||
// 站长保护:站长(username='admin')的帖子仅站长本人可操作(版主/其他 admin 由后端 403 兜底)
|
||||
const isOwnerPost = !!post && (post.author_username || '') === 'admin';
|
||||
const isSelf = !!user && !!post && user.id === post.author_id;
|
||||
// 管理操作(置顶/加精):admin 或版主;站长帖排除非站长本人
|
||||
const canManage = !!user && (user.role === 'admin' || (catId != null && moderatedIds.includes(catId)))
|
||||
&& !(isOwnerPost && !isSelf);
|
||||
// 编辑/删除:作者本人 / 版主 / admin(站长帖仅站长本人)
|
||||
const canDeletePost = (isSelf || canManage) && !!post;
|
||||
const canEdit = canDeletePost;
|
||||
|
||||
/** 剪贴板写入:优先 Async Clipboard,失败降级 execCommand(非安全上下文/LAN 部署) */
|
||||
const copyShareLink = async () => {
|
||||
const ok = await (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = shareUrl;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const res = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return res;
|
||||
} catch { return false; }
|
||||
}
|
||||
})();
|
||||
if (ok) showSnackbar('链接已复制');
|
||||
else showSnackbar('复制失败,请手动复制');
|
||||
};
|
||||
|
||||
const submitReply = async () => {
|
||||
const content = replyText.trim();
|
||||
if (!content) { showSnackbar('回复内容不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await forumApi.reply(id, content);
|
||||
showSnackbar('回复成功');
|
||||
setReplyText('');
|
||||
await load();
|
||||
} catch (e) {
|
||||
// 403:被禁言等拒绝操作 → 弹窗展示后端错误文案(含到期时间)
|
||||
if (e.status === 403) setSubmitError(e.message);
|
||||
else showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const deletePost = async () => {
|
||||
if (!window.confirm('确定删除?')) return;
|
||||
try {
|
||||
await forumApi.deletePost(post.id);
|
||||
showSnackbar('已删除');
|
||||
navigate('/forum.html');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReply = async (replyId) => {
|
||||
if (!window.confirm('确定删除?')) return;
|
||||
try {
|
||||
await forumApi.deleteReply(replyId);
|
||||
showSnackbar('已删除');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── markdown 锁定块 ──
|
||||
const unlockLock = async (index, password) => {
|
||||
const res = await forumApi.unlockLock(id, index, password);
|
||||
if (!res || !res.ok || !res.content) throw new Error((res && res.error) || '解锁失败');
|
||||
// 内容 + 附件 token(lockToken)一并存内存态(token 供块内 [image:]/[file:] 走鉴权接口)
|
||||
setLockContent((prev) => {
|
||||
const m = new Map(prev);
|
||||
m.set(index, { content: res.content, token: res.lockToken || '' });
|
||||
return m;
|
||||
});
|
||||
setUnlocked((prev) => { const s = new Set(prev); s.add(index); return s; });
|
||||
};
|
||||
|
||||
// reply 块「去评论」:滚动到回复框
|
||||
const scrollToComment = () => {
|
||||
const el = document.querySelector('.post-detail textarea') || document.querySelector('.post-detail .reply-item');
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const togglePin = async () => {
|
||||
if (!canManage) return;
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
await forumApi.setPinned(post.id, !post.is_pinned);
|
||||
showSnackbar(post.is_pinned ? '已取消置顶' : '已置顶');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setAdminBusy(false);
|
||||
};
|
||||
|
||||
const toggleEssence = async () => {
|
||||
if (!canManage) return;
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
await forumApi.setEssence(post.id, !post.is_essence);
|
||||
showSnackbar(post.is_essence ? '已取消加精' : '已加精');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setAdminBusy(false);
|
||||
};
|
||||
|
||||
// 游客锁定:未登录且论坛关闭游客预览 → 登录提示(需在 loading 判断前,锁定时不拉取帖子)
|
||||
if (settingsLoaded && guestLocked && !getToken()) {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
|
||||
<div className="empty-icon">🔒</div>
|
||||
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见</p>
|
||||
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{error || '帖子不存在'}</p>
|
||||
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回论坛</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="post-detail">
|
||||
<div className="post-header">
|
||||
{/* 面包屑:首页 / 论坛 / 版块 / 标题 */}
|
||||
<nav className="breadcrumbs" aria-label="面包屑">
|
||||
<Link to="/">首页</Link>
|
||||
<span className="sep">/</span>
|
||||
<Link to="/forum.html">论坛</Link>
|
||||
<span className="sep">/</span>
|
||||
{post.category_id != null ? (
|
||||
<>
|
||||
<Link to={'/forum/c/' + post.category_id}>{post.category_name || '版块'}</Link>
|
||||
<span className="sep">/</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="breadcrumb-title">{post.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* 标题 + 置顶/加精徽章 */}
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{post.title}</span>
|
||||
{post.is_pinned ? <span className="post-badge pin">📌 置顶</span> : null}
|
||||
{post.is_essence ? <span className="post-badge essence">⭐ 精华</span> : null}
|
||||
</h1>
|
||||
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Avatar src={post.author_avatar} name={post.author_name} size={28} to={post.author_id ? `/u/${post.author_id}` : undefined} />
|
||||
<UserName
|
||||
name={post.author_name || '匿名'}
|
||||
uid={post.author_id}
|
||||
title={post.author_title}
|
||||
titleColor={post.author_title_color}
|
||||
role={post.author_role}
|
||||
showUid={!!post.author_id}
|
||||
/>
|
||||
<span className="lz-chip">楼主</span>
|
||||
<span>{post.created_at}</span>
|
||||
<span className="floor-num">#1 楼</span>
|
||||
<span className="chip chip-static">{post.category_name || ''}</span>
|
||||
{post.sub_category && (
|
||||
<span className="chip chip-tonal">{post.sub_category}</span>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?type=forum&edit=${post.id}`} className="btn btn-text btn-sm" aria-label="编辑帖子">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>edit</span> 编辑
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-text btn-sm" onClick={() => setShareOpen(true)}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑标记:已编辑过 → 展示更新时间与次数 */}
|
||||
{post.edit_count > 0 && (
|
||||
<div className="post-edit-info">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>edit_note</span>
|
||||
本文章于 {formatEditTime(post.updated_at)} 更新。已更新 {post.edit_count} 次。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 管理操作区:置顶 / 加精 / 删除 */}
|
||||
{canManage && (
|
||||
<div className="post-admin-actions">
|
||||
<button type="button" className="btn btn-tonal btn-sm" onClick={togglePin} disabled={adminBusy}>
|
||||
📌 {post.is_pinned ? '取消置顶' : '置顶'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-tonal btn-sm" onClick={toggleEssence} disabled={adminBusy}>
|
||||
⭐ {post.is_essence ? '取消加精' : '加精'}
|
||||
</button>
|
||||
{canDeletePost && (
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)' }} onClick={deletePost} disabled={adminBusy}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
<MarkdownRenderer
|
||||
content={post.content}
|
||||
useMarkdown={post.use_markdown}
|
||||
locks={locks}
|
||||
unlocked={unlocked}
|
||||
lockContent={lockContent}
|
||||
onUnlock={unlockLock}
|
||||
onGoComment={scrollToComment}
|
||||
refType="forum"
|
||||
refId={post.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '2px solid var(--md-ref-primary-container)', margin: '24px 0', borderRadius: 2 }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>回复 ({replies.length})</h4>
|
||||
|
||||
{user ? (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="写下你的回复...(支持 Markdown)"
|
||||
style={{ flex: 1, minHeight: 60, fontSize: 14, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitReply} disabled={submitting}>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>
|
||||
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以回复
|
||||
</p>
|
||||
)}
|
||||
|
||||
{replies.length === 0 ? (
|
||||
<div className="text-muted" style={{ padding: 16 }}>暂无回复</div>
|
||||
) : (
|
||||
replies.map((r, i) => {
|
||||
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin' || (catId != null && moderatedIds.includes(catId)));
|
||||
// 楼层:楼主 1,回复 i+2(后端返回 floor 优先)
|
||||
const floor = r.floor != null ? r.floor : i + 2;
|
||||
return (
|
||||
<div className="floor-item" key={r.id}>
|
||||
<div className="floor-head">
|
||||
<Avatar src={r.author_avatar} name={r.author_name} size={24} to={r.author_id ? `/u/${r.author_id}` : undefined} />
|
||||
<UserName
|
||||
name={r.author_name || '匿名'}
|
||||
uid={r.author_id}
|
||||
title={r.author_title}
|
||||
titleColor={r.author_title_color}
|
||||
role={r.author_role}
|
||||
showUid={showReplyUid && !!r.author_id}
|
||||
/>
|
||||
{r.author_id === post.author_id ? <span className="lz-chip">楼主</span> : null}
|
||||
<span className="floor-num">#{floor} 楼</span>
|
||||
<span className="floor-time">{r.created_at}</span>
|
||||
{canDeleteReply && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除回复"
|
||||
className="floor-del"
|
||||
onClick={() => deleteReply(r.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="floor-body">{r.content}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 分享弹窗(复用 BlogDetail 模式:Esc/遮罩关闭、焦点管理) */}
|
||||
{shareOpen && (
|
||||
<div
|
||||
ref={shareDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="分享帖子"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
|
||||
onKeyDown={shareDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>分享帖子</h3>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
|
||||
复制链接分享,好友打开直达完整阅读页面
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="sharePostInput">分享链接</label>
|
||||
<input
|
||||
id="sharePostInput"
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
|
||||
<button className="btn btn-filled" onClick={copyShareLink}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 403 禁言等拒绝操作提示 */}
|
||||
<ErrorDialog open={!!submitError} message={submitError} onClose={() => setSubmitError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import { uploadIcon } from '../api/upload.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar, normalizePagedList } from '../lib/utils.js';
|
||||
import ForumIcon from '../components/ForumIcon.jsx';
|
||||
import Avatar from '../components/Avatar.jsx';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DURATIONS = [
|
||||
{ value: 1, label: '1 天' },
|
||||
{ value: 7, label: '7 天' },
|
||||
{ value: 30, label: '30 天' },
|
||||
{ value: 'forever', label: '永久' },
|
||||
];
|
||||
const TABS = [
|
||||
{ key: 'posts', label: '帖子管理', icon: 'forum' },
|
||||
{ key: 'announcement', label: '公告编辑', icon: 'campaign' },
|
||||
{ key: 'mutes', label: '用户禁言', icon: 'block' },
|
||||
{ key: 'profile', label: '版块设置', icon: 'tune' },
|
||||
];
|
||||
// 图标底色色板(与后台 ForumManage 同源 12 色;留空 = 按名称哈希自动配色)
|
||||
const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e',
|
||||
'#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8'];
|
||||
|
||||
/** 版主名提取:兼容数组 / 逗号字符串 */
|
||||
function moderatorNamesOf(cat) {
|
||||
const m = cat && cat.moderators;
|
||||
if (Array.isArray(m)) {
|
||||
return m.map((x) => (typeof x === 'string' ? x : (x && x.username) || '')).filter(Boolean);
|
||||
}
|
||||
if (typeof m === 'string') return m.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单版块管理台(路由 /forum/manage/:id,版主/管理员):
|
||||
* Tab 1 帖子管理(搜索 + 分页 + 置顶/加精切换/删除)
|
||||
* Tab 2 公告编辑(PUT /categories/:id/announcement)
|
||||
* Tab 3 用户禁言(列表 / 添加固定时长 1·7·30天·永久 / 解除;版主只读展示)
|
||||
* Tab 4 版块设置(名称/描述/图标/图标底色 → PUT /categories/:id/profile 部分更新)
|
||||
* 权限:非版主且非 admin → 无权限提示。
|
||||
*/
|
||||
export default function ForumManageCategory() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const catId = parseInt(id, 10);
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const [managedIds, setManagedIds] = useState([]);
|
||||
const [permission, setPermission] = useState('checking'); // checking | ok | denied
|
||||
const [cat, setCat] = useState(null); // 版块详情(含聚合/公告)
|
||||
const [catMissing, setCatMissing] = useState(false);
|
||||
|
||||
const [tab, setTab] = useState('posts');
|
||||
|
||||
// ── Tab1 帖子管理 ──
|
||||
const [qInput, setQInput] = useState(''); // 输入框(回车/点按钮才生效)
|
||||
const [q, setQ] = useState(''); // 已应用的关键词
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageData, setPageData] = useState(null);
|
||||
const [postsBusy, setPostsBusy] = useState(false);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
// ── Tab2 公告编辑 ──
|
||||
const [annText, setAnnText] = useState('');
|
||||
const [annSaving, setAnnSaving] = useState(false);
|
||||
|
||||
// ── Tab3 用户禁言 ──
|
||||
const [mutes, setMutes] = useState([]);
|
||||
const [muteUsername, setMuteUsername] = useState('');
|
||||
const [muteDuration, setMuteDuration] = useState(7);
|
||||
const [muteBusy, setMuteBusy] = useState(false);
|
||||
|
||||
// ── Tab4 版块设置 ──
|
||||
const [profile, setProfile] = useState({ name: '', description: '', icon: '', icon_color: '' });
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [iconUploading, setIconUploading] = useState(false);
|
||||
const iconFileRef = useRef(null);
|
||||
|
||||
const canManage = !!user && (user.role === 'admin' || managedIds.includes(catId));
|
||||
|
||||
// 权限 + 版块信息加载
|
||||
useEffect(() => {
|
||||
if (!getToken()) { navigate('/login.html'); return; }
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const u = await me();
|
||||
const ids = await forumApi.listModerated();
|
||||
if (!mounted) return;
|
||||
setUser(u);
|
||||
setManagedIds(ids);
|
||||
if (u.role === 'admin' || ids.includes(catId)) {
|
||||
setPermission('ok');
|
||||
const c = await forumApi.getCategory(catId).catch(() => null);
|
||||
if (!mounted) return;
|
||||
if (c) {
|
||||
setCat(c);
|
||||
setAnnText(c.announcement || '');
|
||||
setProfile({
|
||||
name: c.name || '',
|
||||
description: c.description || '',
|
||||
icon: c.icon || '',
|
||||
icon_color: c.icon_color || '',
|
||||
});
|
||||
}
|
||||
else setCatMissing(true);
|
||||
} else {
|
||||
setPermission('denied');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setPermission('denied');
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [catId, navigate]);
|
||||
|
||||
const loadPosts = useCallback(async () => {
|
||||
if (!catId) return;
|
||||
setPostsBusy(true);
|
||||
try {
|
||||
const data = await forumApi.listPosts({ categoryId: catId, page, q: q || undefined });
|
||||
setPageData(normalizePagedList(data));
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
setPageData({ items: [], total: 0, page, pageSize: PAGE_SIZE });
|
||||
} finally {
|
||||
setPostsBusy(false);
|
||||
}
|
||||
}, [catId, page, q]);
|
||||
|
||||
const loadMutes = useCallback(async () => {
|
||||
if (!catId) return;
|
||||
try {
|
||||
const ms = await forumApi.listMutes(catId);
|
||||
setMutes(Array.isArray(ms) ? ms : []);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
setMutes([]);
|
||||
}
|
||||
}, [catId]);
|
||||
|
||||
// 帖子列表加载(权限通过后)
|
||||
useEffect(() => {
|
||||
if (permission === 'ok') loadPosts();
|
||||
}, [permission, loadPosts]);
|
||||
|
||||
// 切到禁言 Tab 时拉取
|
||||
useEffect(() => {
|
||||
if (permission === 'ok' && tab === 'mutes') loadMutes();
|
||||
}, [permission, tab, loadMutes]);
|
||||
|
||||
const items = (pageData && pageData.items) || [];
|
||||
const totalPages = pageData ? Math.max(1, Math.ceil(pageData.total / (pageData.pageSize || PAGE_SIZE))) : 1;
|
||||
const moderatorNames = useMemo(() => (cat ? moderatorNamesOf(cat) : []), [cat]);
|
||||
|
||||
const goPage = (p) => {
|
||||
if (p < 1 || p > totalPages || p === page) return;
|
||||
setPage(p);
|
||||
};
|
||||
const search = () => { setQ(qInput.trim()); setPage(1); };
|
||||
|
||||
// ── 帖子操作 ──
|
||||
const togglePin = async (p) => {
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await forumApi.setPinned(p.id, !p.is_pinned);
|
||||
showSnackbar(p.is_pinned ? '已取消置顶' : '已置顶');
|
||||
await loadPosts();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setActing(false);
|
||||
};
|
||||
const toggleEssence = async (p) => {
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await forumApi.setEssence(p.id, !p.is_essence);
|
||||
showSnackbar(p.is_essence ? '已取消加精' : '已加精');
|
||||
await loadPosts();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setActing(false);
|
||||
};
|
||||
const confirmDeletePost = async (p) => {
|
||||
if (!window.confirm(`确定删除帖子「${p.title}」?回复将一并删除`)) return;
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await forumApi.deletePost(p.id);
|
||||
showSnackbar('已删除');
|
||||
if (items.length === 1 && page > 1) setPage((cur) => cur - 1);
|
||||
else await loadPosts();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setActing(false);
|
||||
};
|
||||
|
||||
// ── 公告 ──
|
||||
const saveAnnouncement = async () => {
|
||||
setAnnSaving(true);
|
||||
try {
|
||||
await forumApi.updateAnnouncement(catId, annText.trim());
|
||||
showSnackbar('公告已更新');
|
||||
const c = await forumApi.getCategory(catId).catch(() => null);
|
||||
if (c) setCat(c);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setAnnSaving(false);
|
||||
};
|
||||
|
||||
// ── 版块设置 ──
|
||||
// 图标上传:前端先拦类型(png/jpg/jpeg/gif/webp,不含 svg)与 ≤1MB,成功后把返回 url 填入 icon 字段
|
||||
const handleIconFile = async (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
e.target.value = ''; // 允许连续选择同一文件
|
||||
if (!f) return;
|
||||
const IMG_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
if (!IMG_TYPES.includes(f.type)) { showSnackbar('仅支持 png/jpg/jpeg/gif/webp 图片'); return; }
|
||||
if (f.size > 1024 * 1024) { showSnackbar('图片不能超过 1MB'); return; }
|
||||
setIconUploading(true);
|
||||
try {
|
||||
const data = await uploadIcon(f);
|
||||
if (data && data.url) {
|
||||
setProfile((p) => ({ ...p, icon: data.url })); // 实时预览自动生效
|
||||
showSnackbar('图标已上传,记得保存设置');
|
||||
} else {
|
||||
showSnackbar('上传失败,请重试');
|
||||
}
|
||||
} catch (err) {
|
||||
showSnackbar(err.message);
|
||||
}
|
||||
setIconUploading(false);
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
const name = profile.name.trim();
|
||||
if (!name) { showSnackbar('名称不能为空'); return; }
|
||||
// 部分更新:只提交与当前值不同的字段(icon_color 留空 = 清除 → 按名称哈希配色)
|
||||
const fields = {};
|
||||
if (name !== (cat.name || '')) fields.name = name;
|
||||
if (profile.description.trim() !== (cat.description || '')) fields.description = profile.description.trim();
|
||||
if (profile.icon.trim() !== (cat.icon || '')) fields.icon = profile.icon.trim();
|
||||
if ((profile.icon_color || '') !== (cat.icon_color || '')) fields.icon_color = profile.icon_color.trim();
|
||||
if (Object.keys(fields).length === 0) { showSnackbar('没有改动'); return; }
|
||||
setProfileSaving(true);
|
||||
try {
|
||||
const res = await forumApi.updateCategoryProfile(catId, fields);
|
||||
showSnackbar((res && res.message) || '已保存');
|
||||
// 用后端返回的已更新字段合并进 cat,避免整页刷新
|
||||
if (res && res.category) {
|
||||
setCat((prev) => (prev ? { ...prev, ...res.category } : prev));
|
||||
setProfile((p) => ({
|
||||
...p,
|
||||
name: res.category.name,
|
||||
description: res.category.description || '',
|
||||
icon: res.category.icon || '',
|
||||
icon_color: res.category.icon_color || '',
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setProfileSaving(false);
|
||||
};
|
||||
|
||||
// ── 禁言 ──
|
||||
const addMute = async () => {
|
||||
const name = muteUsername.trim();
|
||||
if (!name) { showSnackbar('请输入用户名'); return; }
|
||||
setMuteBusy(true);
|
||||
try {
|
||||
await forumApi.createMute(catId, name, muteDuration);
|
||||
showSnackbar('已禁言');
|
||||
setMuteUsername('');
|
||||
loadMutes();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setMuteBusy(false);
|
||||
};
|
||||
const unmute = async (m) => {
|
||||
if (!window.confirm(`确定解除「${m.username || '用户#' + m.user_id}」的禁言?`)) return;
|
||||
try {
|
||||
await forumApi.deleteMute(catId, m.user_id);
|
||||
showSnackbar('已解除');
|
||||
loadMutes();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载中
|
||||
if (permission === 'checking') {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
// 无权限
|
||||
if (permission === 'denied') {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
|
||||
<div className="empty-icon">🔒</div>
|
||||
<p style={{ fontWeight: 500 }}>没有管理权限</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>你不是该版块的版主,无法进入管理台</p>
|
||||
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 16 }}>返回论坛</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 版块不存在
|
||||
if (catMissing || !cat) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>版块不存在</p>
|
||||
<Link to="/forum/manage" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回我的版块</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-manage-cat">
|
||||
{/* 面包屑:论坛 / 管理 / 版块名 */}
|
||||
<nav className="breadcrumbs" aria-label="面包屑">
|
||||
<Link to="/forum.html">论坛</Link>
|
||||
<span className="sep">/</span>
|
||||
<Link to="/forum/manage">管理</Link>
|
||||
<span className="sep">/</span>
|
||||
<span>{cat.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* 版块信息 */}
|
||||
<div className="card category-head">
|
||||
<ForumIcon icon={cat.icon} name={cat.name} iconColor={cat.icon_color} size={48} />
|
||||
<div className="category-head-main">
|
||||
<h1>{cat.name} · 管理台</h1>
|
||||
<p className="category-head-desc">{cat.description || '暂无描述'}</p>
|
||||
<div className="category-head-stats">
|
||||
<span>{typeof cat.post_count === 'number' ? `${cat.post_count} 帖子` : ''}</span>
|
||||
{cat.today_count ? <span>今日 {cat.today_count}</span> : null}
|
||||
{moderatorNames.length > 0 ? <span>版主:{moderatorNames.join('、')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="category-head-actions">
|
||||
<Link to={'/forum/c/' + catId} className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>visibility</span> 查看版块
|
||||
</Link>
|
||||
<Link to="/forum/manage" className="btn btn-text btn-sm">全部版块</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 栏 */}
|
||||
<div className="manage-tabs" role="tablist" aria-label="管理功能">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
className={'manage-tab' + (tab === t.key ? ' active' : '')}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
<span className="material-icons" aria-hidden="true">{t.icon}</span> {t.label}
|
||||
{t.key === 'mutes' && mutes.length > 0 ? <span className="manage-tab-count">{mutes.length}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 1:帖子管理 */}
|
||||
{tab === 'posts' && (
|
||||
<div className="card manage-card">
|
||||
<div className="manage-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索帖子标题…"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') search(); }}
|
||||
aria-label="搜索帖子标题"
|
||||
/>
|
||||
<button className="btn btn-tonal btn-sm" onClick={search}>搜索</button>
|
||||
</div>
|
||||
|
||||
{postsBusy && <div className="loading" style={{ padding: 24 }}><div className="spinner"></div></div>}
|
||||
|
||||
{!postsBusy && items.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '24px 0' }}>
|
||||
<div className="empty-icon">📝</div><p>暂无帖子</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!postsBusy && items.map((p) => {
|
||||
// 站长保护:站长(username='admin')的帖子仅站长本人可操作(列表带作者信息 → 前端隐藏操作)
|
||||
const isOwnerPost = (p.author_name || '') === 'admin';
|
||||
const canActOnPost = !isOwnerPost || (user && p.author_id === user.id);
|
||||
return (
|
||||
<div className="manage-post-row" key={p.id}>
|
||||
<div className="manage-post-main">
|
||||
<div className="manage-post-title">
|
||||
<span className="manage-post-title-text">{p.title}</span>
|
||||
{p.is_pinned ? <span className="post-badge pin">📌</span> : null}
|
||||
{p.is_essence ? <span className="post-badge essence">⭐</span> : null}
|
||||
</div>
|
||||
<div className="manage-post-meta">
|
||||
<Avatar src={p.author_avatar} name={p.author_name} size={22} className="avatar-sm" to={p.author_id ? `/u/${p.author_id}` : undefined} />
|
||||
<span>{p.author_name || '匿名'}</span>
|
||||
<span>{p.created_at}</span>
|
||||
<span>{p.reply_count || 0} 回复</span>
|
||||
{p.sub_category ? <span className="chip chip-tonal">{p.sub_category}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{canActOnPost ? (
|
||||
<div className="manage-post-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={'manage-action' + (p.is_pinned ? ' on' : '')}
|
||||
title={p.is_pinned ? '取消置顶' : '置顶'}
|
||||
aria-label={p.is_pinned ? '取消置顶' : '置顶'}
|
||||
aria-pressed={!!p.is_pinned}
|
||||
disabled={acting}
|
||||
onClick={() => togglePin(p)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>push_pin</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'manage-action' + (p.is_essence ? ' on' : '')}
|
||||
title={p.is_essence ? '取消加精' : '加精'}
|
||||
aria-label={p.is_essence ? '取消加精' : '加精'}
|
||||
aria-pressed={!!p.is_essence}
|
||||
disabled={acting}
|
||||
onClick={() => toggleEssence(p)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>star</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="manage-action danger"
|
||||
title="删除帖子"
|
||||
aria-label="删除帖子"
|
||||
disabled={acting}
|
||||
onClick={() => confirmDeletePost(p)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>delete</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="chip chip-static" title="站长帖子仅站长本人可操作">🔒 站长帖</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button type="button" className="btn btn-tonal btn-sm" disabled={page <= 1} onClick={() => goPage(page - 1)}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>chevron_left</span> 上一页
|
||||
</button>
|
||||
<span className="page-info">第 {page} / {totalPages} 页</span>
|
||||
<button type="button" className="btn btn-tonal btn-sm" disabled={page >= totalPages} onClick={() => goPage(page + 1)}>
|
||||
下一页 <span className="material-icons" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2:公告编辑 */}
|
||||
{tab === 'announcement' && (
|
||||
<div className="card manage-card" style={{ maxWidth: 640 }}>
|
||||
<h3 className="manage-card-title">板块公告</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
|
||||
展示在版块顶部,留空可清除当前公告
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="annText">公告内容</label>
|
||||
<textarea
|
||||
id="annText"
|
||||
value={annText}
|
||||
onChange={(e) => setAnnText(e.target.value)}
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-filled" onClick={saveAnnouncement} disabled={annSaving}>
|
||||
{annSaving ? '保存中…' : '保存公告'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3:用户禁言 */}
|
||||
{tab === 'mutes' && (
|
||||
<>
|
||||
<div className="card manage-card" style={{ maxWidth: 640 }}>
|
||||
<h3 className="manage-card-title">添加禁言</h3>
|
||||
<div className="mute-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入要禁言的用户名"
|
||||
value={muteUsername}
|
||||
onChange={(e) => setMuteUsername(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addMute(); }}
|
||||
aria-label="被禁言用户名"
|
||||
/>
|
||||
<select
|
||||
value={muteDuration}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setMuteDuration(v === 'forever' ? v : parseInt(v, 10));
|
||||
}}
|
||||
aria-label="禁言时长"
|
||||
>
|
||||
{DURATIONS.map((d) => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-filled btn-sm" onClick={addMute} disabled={muteBusy || !muteUsername.trim()}>
|
||||
{muteBusy ? '提交中…' : '禁言'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: 12 }}>被禁用户在本版块发帖 / 回复将被拒绝,到期自动解除</p>
|
||||
</div>
|
||||
|
||||
<div className="card manage-card">
|
||||
<h3 className="manage-card-title">禁言列表 ({mutes.length})</h3>
|
||||
{mutes.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>暂无禁言</p>
|
||||
) : (
|
||||
mutes.map((m) => {
|
||||
const permanent = m.permanent || m.muted_until == null || m.muted_until === '永久';
|
||||
return (
|
||||
<div className="mute-row" key={m.user_id}>
|
||||
<div className="mute-main">
|
||||
<div className="mute-user">{m.username || '用户#' + m.user_id}</div>
|
||||
<div className={'mute-expiry' + (permanent ? ' permanent' : '')}>
|
||||
{permanent ? '永久禁言' : `到期:${m.muted_until}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-text btn-sm"
|
||||
style={{ color: 'var(--md-ref-error)' }}
|
||||
onClick={() => unmute(m)}
|
||||
>
|
||||
解除
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{moderatorNames.length > 0 && (
|
||||
<div className="card manage-card">
|
||||
<h3 className="manage-card-title">版主</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>
|
||||
当前版主(仅管理员可更改):{moderatorNames.join('、')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tab 4:版块设置 */}
|
||||
{tab === 'profile' && (
|
||||
<div className="card manage-card profile-form">
|
||||
<h3 className="manage-card-title">版块设置</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginBottom: 16 }}>
|
||||
修改版块的名称、描述与图标,保存后前台版块索引与版块页即时生效
|
||||
</p>
|
||||
|
||||
{/* 实时预览 */}
|
||||
<div className="profile-preview">
|
||||
<ForumIcon icon={profile.icon} name={profile.name} iconColor={profile.icon_color} size={48} />
|
||||
<div className="profile-preview-hint">
|
||||
预览:{profile.name || '(未填写名称)'}
|
||||
{!profile.icon && !profile.icon_color ? ' · 图标与底色按名称自动生成' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileName">名称 *</label>
|
||||
<input
|
||||
id="profileName"
|
||||
type="text"
|
||||
value={profile.name}
|
||||
onChange={(e) => setProfile((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="版块名称(≤50 字)"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileDesc">描述</label>
|
||||
<textarea
|
||||
id="profileDesc"
|
||||
value={profile.description}
|
||||
onChange={(e) => setProfile((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="版块简介,展示在版块头与卡片上"
|
||||
style={{ minHeight: 80 }}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileIcon">图标</label>
|
||||
<div className="icon-input-row">
|
||||
<input
|
||||
id="profileIcon"
|
||||
type="text"
|
||||
value={profile.icon}
|
||||
onChange={(e) => setProfile((p) => ({ ...p, icon: e.target.value }))}
|
||||
placeholder="支持上传 ≤1MB 图片,或输入 emoji / 图片 URL"
|
||||
maxLength={100}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-tonal btn-sm icon-upload-btn"
|
||||
onClick={() => iconFileRef.current && iconFileRef.current.click()}
|
||||
disabled={iconUploading}
|
||||
aria-label="上传版块图标图片"
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>
|
||||
{iconUploading ? '上传中…' : '上传图片'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={iconFileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
aria-label="选择版块图标图片(png/jpg/jpeg/gif/webp,≤1MB)"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleIconFile}
|
||||
/>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
支持上传 ≤1MB 图片(png/jpg/jpeg/gif/webp),或输入单个 emoji / 图片链接;留空取名称首字
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>图标底色</label>
|
||||
<div className="profile-palette" role="group" aria-label="选择图标底色">
|
||||
<button
|
||||
type="button"
|
||||
className={'profile-swatch clear' + (!profile.icon_color ? ' selected' : '')}
|
||||
aria-pressed={!profile.icon_color}
|
||||
aria-label="自动配色(留空,按名称哈希)"
|
||||
title="自动配色(留空)"
|
||||
onClick={() => setProfile((p) => ({ ...p, icon_color: '' }))}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
{COLOR_PALETTE.map((col) => (
|
||||
<button
|
||||
type="button"
|
||||
key={col}
|
||||
className={'profile-swatch' + (profile.icon_color === col ? ' selected' : '')}
|
||||
style={{ background: col }}
|
||||
aria-pressed={profile.icon_color === col}
|
||||
aria-label={'底色 ' + col}
|
||||
title={col}
|
||||
onClick={() => setProfile((p) => ({ ...p, icon_color: p.icon_color === col ? '' : col }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
留空时按版块名称哈希自动配色,深浅色主题自适应
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-filled" onClick={saveProfile} disabled={profileSaving}>
|
||||
{profileSaving ? '保存中…' : '保存设置'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-text"
|
||||
onClick={() => setProfile({
|
||||
name: cat.name || '',
|
||||
description: cat.description || '',
|
||||
icon: cat.icon || '',
|
||||
icon_color: cat.icon_color || '',
|
||||
})}
|
||||
disabled={profileSaving}
|
||||
>
|
||||
撤销修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import ForumIcon from '../components/ForumIcon.jsx';
|
||||
|
||||
/**
|
||||
* 版主子管理页(路由 /forum/manage):我管理的版块列表(卡片)→ 进入单版块管理台。
|
||||
* 权限:版主(/forum/moderated 非空)或 admin(全版块);无权限显示提示 + 返回论坛。
|
||||
*/
|
||||
export default function ForumManagePanel() {
|
||||
const navigate = useNavigate();
|
||||
const [cats, setCats] = useState(null); // null=加载中
|
||||
const [permission, setPermission] = useState('checking'); // checking | ok | denied
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) { navigate('/login.html'); return; }
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const u = await me();
|
||||
const cs = await forumApi.listModeratedCategories(u);
|
||||
if (!mounted) return;
|
||||
setCats(cs || []);
|
||||
setPermission(u.role === 'admin' || (cs && cs.length > 0) ? 'ok' : 'denied');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setError(e.message || '加载失败');
|
||||
setPermission('denied');
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [navigate]);
|
||||
|
||||
// 无权限 / 加载失败
|
||||
if (permission === 'denied') {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
|
||||
<div className="empty-icon">🔒</div>
|
||||
<p style={{ fontWeight: 500 }}>{error ? '加载失败' : '没有管理权限'}</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>
|
||||
{error || '只有版主或管理员可以管理版块'}
|
||||
</p>
|
||||
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 16 }}>返回论坛</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-manage-panel">
|
||||
<div className="forum-home-head">
|
||||
<h1 className="page-title" style={{ margin: 0 }}>我的版块</h1>
|
||||
<Link to="/forum.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回论坛
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{cats === null && <div className="loading"><div className="spinner"></div></div>}
|
||||
{cats !== null && cats.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无版块</p></div>
|
||||
)}
|
||||
{cats !== null && cats.length > 0 && (
|
||||
<div className="forum-grid">
|
||||
{cats.map((c) => (
|
||||
<Link key={c.id} to={'/forum/manage/' + c.id} className="card board-card">
|
||||
<div className="board-card-top">
|
||||
<ForumIcon icon={c.icon} name={c.name} iconColor={c.icon_color} size={44} />
|
||||
<div className="board-card-title">
|
||||
<span className="board-name">{c.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.description ? <p className="board-desc">{c.description}</p> : null}
|
||||
<div className="board-meta">
|
||||
<span>{typeof c.post_count === 'number' ? `${c.post_count} 帖` : '暂无帖子'}</span>
|
||||
<span className="board-today">进入管理台 →</span>
|
||||
</div>
|
||||
{c.announcement ? (
|
||||
<div className="board-mods" title="当前公告">📢 {c.announcement}</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import BlogSidebar from '../components/BlogSidebar.jsx';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { getPublicSettings } from '../api/settings.js';
|
||||
|
||||
/**
|
||||
* 首页(迁移自 index.html 的内联 HOMEPAGE):
|
||||
* 侧栏(头像/简介/联系方式 + 最新文章)+ 主页内容(settings.homepage_content,Markdown)。
|
||||
*/
|
||||
export default function Home() {
|
||||
const [settings, setSettings] = useState({});
|
||||
const [contentError, setContentError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings()
|
||||
.then((s) => { setSettings(s); setContentError(false); })
|
||||
.catch(() => { setSettings({}); setContentError(true); });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="homepage-layout">
|
||||
<h1 className="sr-only">首页</h1>
|
||||
<BlogSidebar settings={settings} showRecent forceShow />
|
||||
<div className="homepage-content">
|
||||
<div className="card">
|
||||
{contentError
|
||||
? <p style={{ color: 'var(--md-ref-on-surface-variant)' }}>内容加载失败</p>
|
||||
: <MarkdownRenderer content={settings.homepage_content || ''} useMarkdown />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
|
||||
/** RainID OIDC 回跳错误码 → 用户可读文案 */
|
||||
const OIDC_ERROR_TEXT = {
|
||||
access_denied: '已在 RainID 拒绝授权,可重新登录',
|
||||
consent_required: '请重新完整走 RainID 登录',
|
||||
login_required: '请重新完整走 RainID 登录',
|
||||
invalid_grant: '登录状态已失效,请重新登录',
|
||||
rate_limited: '请求过于频繁,请稍后再试',
|
||||
invalid_scope: '配置错误,请联系管理员',
|
||||
invalid_request: '配置错误,请联系管理员',
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录页(迁移自 login.html):
|
||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error)
|
||||
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
|
||||
*/
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
const [oidcBusy, setOidcBusy] = useState(false);
|
||||
|
||||
/** 清理地址栏的 oidc 过渡参数(替换为纯路径,避免残留/刷新重复兑换) */
|
||||
const clearOidcParams = () => {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
};
|
||||
|
||||
/** RainID 回跳收尾:一次性过渡码换 token → 复用本地登录成功逻辑 */
|
||||
const handleOidcTicket = async (ticket) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.oidcExchange(ticket);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
clearOidcParams();
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
clearOidcParams();
|
||||
setError(e.message || 'RainID 登录失败,请重试');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 1) RainID 回跳优先处理(一次性码 30s 有效,须先兑换)
|
||||
// 注意:OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次;
|
||||
// 兑换后 navigate 触发 location 变化时不再重跑(避免覆盖 from 目标)。
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('oidc_ticket');
|
||||
const oidcError = params.get('oidc_error');
|
||||
if (ticket) { handleOidcTicket(ticket); return; }
|
||||
if (oidcError) {
|
||||
setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败,请重试');
|
||||
clearOidcParams();
|
||||
return;
|
||||
}
|
||||
// 2) 已登录直接回首页
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
// 3) 公开设置:rainid_enabled → 显示 RainID 按钮
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => { if (s.rainid_enabled === '1') setRainidEnabled(true); })
|
||||
.catch(() => {});
|
||||
// 4) 人机验证需求
|
||||
captchaRequired('login')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('login');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
/** RainID 整页跳转(loading 防重复点击) */
|
||||
const handleRainId = () => {
|
||||
if (oidcBusy) return;
|
||||
setOidcBusy(true);
|
||||
authApi.oidcLoginRedirect();
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.login(username.trim(), password, capResult || undefined);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录")
|
||||
setError(e.message || '登录失败');
|
||||
setBusy(false);
|
||||
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
|
||||
// 重置「已验证」标记允许用户重新完成验证,否则按钮保持 disabled 死锁。
|
||||
setCapDone(false);
|
||||
setCapResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card login-card">
|
||||
<h1>登录</h1>
|
||||
<p className="subtitle">欢迎回到 RainWeb</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="输入用户名"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') document.getElementById('loginPw').focus(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id="loginPw"
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="输入密码"
|
||||
autoComplete="current-password"
|
||||
style={{ width: '100%', paddingRight: 44 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex="-1"
|
||||
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: 'var(--md-ref-on-surface-variant)', cursor: 'pointer', padding: 4, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{showPw ? 'visibility' : 'visibility_off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{capRequired && (
|
||||
<button
|
||||
className="btn w-full"
|
||||
onClick={doCaptcha}
|
||||
disabled={capDone}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||
: { background: 'var(--md-ref-surface-container)', color: 'var(--md-ref-on-surface)', border: '1px solid var(--md-ref-outline-variant)' }),
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{rainidEnabled && (
|
||||
<button
|
||||
className="btn btn-outline w-full"
|
||||
onClick={handleRainId}
|
||||
disabled={busy || oidcBusy}
|
||||
style={{ display: 'inline-flex', marginBottom: 8, justifyContent: 'center', height: 44, gap: 8, alignItems: 'center' }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>fingerprint</span>
|
||||
<span>{oidcBusy ? '正在跳转 RainID…' : '使用 RainID 登录'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||||
{busy ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<span className="text-muted">没有账户?</span>
|
||||
<Link to="/register.html" className="btn-text btn" style={{ fontSize: 14 }}>注册</Link>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}>← 返回主页</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as pwApi from '../api/passwords.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
|
||||
* 可选长度与字符集,确保每个选中的字符集至少出现一次后洗牌。
|
||||
*/
|
||||
function makePassword({ length = 16, lower = true, upper = true, digits = true, symbols = true } = {}) {
|
||||
const sets = [];
|
||||
if (lower) sets.push('abcdefghijklmnopqrstuvwxyz');
|
||||
if (upper) sets.push('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
||||
if (digits) sets.push('0123456789');
|
||||
if (symbols) sets.push('!@#$%^&*()_+');
|
||||
if (sets.length === 0) return '';
|
||||
const all = sets.join('');
|
||||
const total = Math.max(length, sets.length);
|
||||
const rand = new Uint32Array(total);
|
||||
crypto.getRandomValues(rand);
|
||||
const chars = [];
|
||||
let idx = 0;
|
||||
// 每个选中字符集至少贡献一个字符,保证复杂度
|
||||
for (const s of sets) chars.push(s[rand[idx++] % s.length]);
|
||||
for (; idx < length; idx++) chars.push(all[rand[idx] % all.length]);
|
||||
// Fisher-Yates 洗牌(同样走 crypto)
|
||||
const shuf = new Uint32Array(chars.length);
|
||||
crypto.getRandomValues(shuf);
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = shuf[i] % (i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码箱(迁移自 passwords.html + passwords.js):
|
||||
* PIN 状态检查 → 未设 PIN 引导设置 / 已设解锁 / 解锁后条目列表 + 增删改 + 复制 + 生成器 + 锁定。
|
||||
* 全部交互用 React 数据绑定,无字符串拼接事件(修复 v1 M6 onclick 注入点)。
|
||||
* 注意:GET /api/passwords/ 返回的是会话解锁后服务端解密出的明文密码。
|
||||
*/
|
||||
export default function Passwords() {
|
||||
const navigate = useNavigate();
|
||||
const [phase, setPhase] = useState('loading'); // loading | setup | locked | unlocked
|
||||
const [entries, setEntries] = useState(null); // null=加载中
|
||||
const [entriesError, setEntriesError] = useState('');
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [pinBusy, setPinBusy] = useState(false);
|
||||
|
||||
// 设置/修改 PIN 弹窗
|
||||
const [pinDialog, setPinDialog] = useState(false);
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [confirmPin, setConfirmPin] = useState('');
|
||||
const [pinSaving, setPinSaving] = useState(false);
|
||||
|
||||
// 修改 PIN 警告确认(修改后旧密文无法解密,需重新添加条目)
|
||||
const [pinChangeWarn, setPinChangeWarn] = useState(false);
|
||||
|
||||
// 添加/编辑条目弹窗
|
||||
const [pwDialog, setPwDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [fTitle, setFTitle] = useState('');
|
||||
const [fUsername, setFUsername] = useState('');
|
||||
const [fPassword, setFPassword] = useState('');
|
||||
const [fUrl, setFUrl] = useState('');
|
||||
const [fNotes, setFNotes] = useState('');
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
// 详情 / 生成器 / 删除确认
|
||||
const [detailId, setDetailId] = useState(null);
|
||||
const [genDialog, setGenDialog] = useState(false);
|
||||
const [genLen, setGenLen] = useState(16);
|
||||
const [genLower, setGenLower] = useState(true);
|
||||
const [genUpper, setGenUpper] = useState(true);
|
||||
const [genDigits, setGenDigits] = useState(true);
|
||||
const [genSymbols, setGenSymbols] = useState(true);
|
||||
const [genResult, setGenResult] = useState('');
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { showSnackbar('请先登录'); setTimeout(() => navigate('/login.html'), 1000); return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') {
|
||||
showSnackbar('需要管理员权限');
|
||||
setTimeout(() => navigate('/'), 1000);
|
||||
return;
|
||||
}
|
||||
pwApi.pinStatus()
|
||||
.then((s) => {
|
||||
if (!s.hasPin) setPhase('setup');
|
||||
else if (s.unlocked) { setPhase('unlocked'); loadEntries(); }
|
||||
else setPhase('locked');
|
||||
})
|
||||
.catch((e) => showSnackbar(e.message));
|
||||
})
|
||||
.catch(() => navigate('/login.html'));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadEntries = async () => {
|
||||
setEntries(null);
|
||||
setEntriesError('');
|
||||
try {
|
||||
const list = await pwApi.listEntries();
|
||||
setEntries(list || []);
|
||||
} catch (e) {
|
||||
setEntriesError(e.message || '加载失败');
|
||||
setEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
const savePin = async () => {
|
||||
// 校验文案与后端 setPin 保持一致(PIN ≥6 位,建议含字母;v1 的"至少4位"已修正)
|
||||
if (!newPin || newPin.length < 6) { showSnackbar('PIN 至少 6 位,建议包含字母'); return; }
|
||||
if (newPin !== confirmPin) { showSnackbar('两次输入的 PIN 不一致'); return; }
|
||||
setPinSaving(true);
|
||||
try {
|
||||
await pwApi.setPin(newPin);
|
||||
showSnackbar('PIN 设置成功');
|
||||
setPinDialog(false);
|
||||
setNewPin(''); setConfirmPin('');
|
||||
setPhase('unlocked');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPinSaving(false);
|
||||
};
|
||||
|
||||
const submitPin = async () => {
|
||||
if (!pinInput) { showSnackbar('请输入 PIN 码'); return; }
|
||||
setPinBusy(true);
|
||||
try {
|
||||
await pwApi.unlock(pinInput);
|
||||
showSnackbar('已解锁');
|
||||
setPinInput('');
|
||||
setPinError('');
|
||||
setPhase('unlocked');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
// 后端错误直显:401 'PIN 错误' / 429 '尝试次数过多,请稍后再试'(3 次/5 分钟锁定)
|
||||
setPinError(e.message || '解锁失败');
|
||||
setPinInput('');
|
||||
}
|
||||
setPinBusy(false);
|
||||
};
|
||||
|
||||
const lockVault = async () => {
|
||||
try {
|
||||
await pwApi.lock();
|
||||
setPhase('locked');
|
||||
setPinInput('');
|
||||
setPinError('');
|
||||
setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false); setPinChangeWarn(false);
|
||||
showSnackbar('已锁定');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const openPinDialog = () => {
|
||||
setNewPin('');
|
||||
setConfirmPin('');
|
||||
setPinDialog(true);
|
||||
};
|
||||
|
||||
// 修改 PIN:先弹警告确认,确认后再打开设置弹窗
|
||||
const openPinChange = () => {
|
||||
setPinChangeWarn(true);
|
||||
};
|
||||
|
||||
const proceedPinChange = () => {
|
||||
setPinChangeWarn(false);
|
||||
openPinDialog();
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setEditingId(null);
|
||||
setFTitle(''); setFUsername(''); setFPassword(''); setFUrl(''); setFNotes('');
|
||||
setPwDialog(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (entry) => {
|
||||
setEditingId(entry.id);
|
||||
setFTitle(entry.title);
|
||||
setFUsername(entry.username || '');
|
||||
setFPassword(entry.password || '');
|
||||
setFUrl(entry.url || '');
|
||||
setFNotes(entry.notes || '');
|
||||
setPwDialog(true);
|
||||
};
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!fTitle.trim() || !fPassword) { showSnackbar('标题和密码不能为空'); return; }
|
||||
const data = {
|
||||
title: fTitle.trim(),
|
||||
username: fUsername.trim(),
|
||||
password: fPassword,
|
||||
url: fUrl.trim(),
|
||||
notes: fNotes.trim(),
|
||||
};
|
||||
setPwSaving(true);
|
||||
try {
|
||||
if (editingId) await pwApi.updateEntry(editingId, data);
|
||||
else await pwApi.createEntry(data);
|
||||
showSnackbar('保存成功');
|
||||
setPwDialog(false);
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwSaving(false);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setConfirmBusy(true);
|
||||
try {
|
||||
await pwApi.deleteEntry(detailId);
|
||||
showSnackbar('已删除');
|
||||
setConfirmDialog(false);
|
||||
setDetailId(null);
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setConfirmBusy(false);
|
||||
};
|
||||
|
||||
const openGenerator = () => {
|
||||
setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }));
|
||||
setGenDialog(true);
|
||||
};
|
||||
|
||||
const useGenerated = () => {
|
||||
setFPassword(genResult);
|
||||
setGenDialog(false);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, label) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => showSnackbar(label + ' 已复制'))
|
||||
.catch(() => showSnackbar('复制失败'));
|
||||
};
|
||||
|
||||
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
|
||||
|
||||
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
|
||||
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
|
||||
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
|
||||
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
|
||||
|
||||
// 加载中
|
||||
if (phase === 'loading') {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* PIN 屏(设置 或 解锁)——全屏覆盖页,非弹窗 */}
|
||||
{phase === 'setup' || phase === 'locked' ? (
|
||||
<div className="pin-overlay">
|
||||
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
{phase === 'setup' ? '首次使用,请设置 PIN 码' : '请输入 PIN 码解锁'}
|
||||
</div>
|
||||
{pinError && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 8 }}>{pinError}</div>}
|
||||
{phase === 'locked' && (
|
||||
<input
|
||||
type="password"
|
||||
className="pin-input"
|
||||
maxLength={6}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={pinInput}
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button className="btn btn-filled" onClick={phase === 'setup' ? openPinDialog : submitPin} disabled={pinBusy}>
|
||||
{phase === 'setup' ? '设置' : (pinBusy ? '解锁中...' : '解锁')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 已解锁:密码箱 */
|
||||
<div>
|
||||
<div className="admin-header">
|
||||
<h2>已保存的密码</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-tonal" onClick={lockVault}>
|
||||
<span className="material-icons">lock</span> 锁定
|
||||
</button>
|
||||
<button className="btn btn-text" onClick={openPinChange}>
|
||||
<span className="material-icons">edit</span> 修改 PIN
|
||||
</button>
|
||||
<button className="btn btn-filled" onClick={openAddDialog}>
|
||||
<span className="material-icons">add</span> 添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries === null && <div className="loading"><div className="spinner"></div></div>}
|
||||
{entriesError && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败: {entriesError}</p></div>
|
||||
)}
|
||||
{entries && entries.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🔒</div><p>暂无密码记录</p></div>
|
||||
)}
|
||||
{entries && entries.length > 0 && (
|
||||
<div className="password-grid">
|
||||
{entries.map((p) => (
|
||||
<div key={p.id} className="password-card-wrap">
|
||||
<button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||
<div className="pw-title">{p.title}</div>
|
||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||
</button>
|
||||
<div className="pw-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ width: 40, height: 40, fontSize: 16 }}
|
||||
title="复制密码"
|
||||
aria-label={`复制 ${p.title} 的密码`}
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 弹窗统一挂载在顶层(setup/locked 阶段也能弹出——功能修复关键,勿移入 phase 分支) ===== */}
|
||||
{/* 结构与论坛发帖弹窗一致:.dialog-overlay + .dialog,MD3 CSS 变量天然适配浅/深色模式 */}
|
||||
|
||||
{/* 设置/修改 PIN 弹窗 */}
|
||||
{pinDialog && (
|
||||
<div
|
||||
ref={pinDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置 PIN 码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
|
||||
onKeyDown={pinDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 360 }}>
|
||||
<h3>设置 PIN 码</h3>
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>PIN 码用于加密保护您的密码数据。</p>
|
||||
<div className="form-group">
|
||||
<label>PIN 码 *(至少6位,建议包含字母)</label>
|
||||
<input type="password" value={newPin} onChange={(e) => setNewPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认 PIN 码 *</label>
|
||||
<input type="password" value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPinDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={savePin} disabled={pinSaving}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑条目弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editingId ? '编辑密码' : '添加密码'}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={fTitle} onChange={(e) => setFTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" value={fUsername} onChange={(e) => setFUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码 *</label>
|
||||
<input type="text" value={fPassword} onChange={(e) => setFPassword(e.target.value)} />
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button className="btn btn-text btn-sm" onClick={openGenerator}>生成随机密码</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>网址</label>
|
||||
<input type="url" value={fUrl} onChange={(e) => setFUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea value={fNotes} onChange={(e) => setFNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={savePassword} disabled={pwSaving}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detailEntry && (
|
||||
<div
|
||||
ref={detailDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={detailEntry.title}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
|
||||
onKeyDown={detailDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 500 }}>
|
||||
<h3>{detailEntry.title}</h3>
|
||||
<div className="password-detail">
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">用户名</span>
|
||||
<span className="pw-value">{detailEntry.username || ''}</span>
|
||||
<button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">密码</span>
|
||||
<span className="pw-value">{detailEntry.password}</span>
|
||||
<button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
{detailEntry.url ? (
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">网址</span>
|
||||
<span className="pw-value">
|
||||
<a href={detailEntry.url} target="_blank" rel="noopener" style={{ color: 'var(--md-ref-primary)' }}>{detailEntry.url}</a>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{detailEntry.notes ? (
|
||||
<div className="pw-field" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||
<span className="pw-label">备注</span>
|
||||
<span style={{ fontSize: 14 }}>{detailEntry.notes}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="actions" style={{ marginTop: 16 }}>
|
||||
<button className="btn btn-text" onClick={() => { setDetailId(null); openEditDialog(detailEntry); }}>编辑</button>
|
||||
<button className="btn btn-text" style={{ color: 'var(--md-ref-error)' }} onClick={() => { setDetailId(null); setConfirmDialog(true); }}>删除</button>
|
||||
<button className="btn btn-text" onClick={() => setDetailId(null)}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 密码生成器弹窗 */}
|
||||
{genDialog && (
|
||||
<div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
|
||||
<div className="dialog" style={{ maxWidth: 380 }}>
|
||||
<h3>生成随机密码</h3>
|
||||
<div className="form-group">
|
||||
<label>长度</label>
|
||||
<input type="number" min={6} max={64} value={genLen} onChange={(e) => setGenLen(parseInt(e.target.value, 10) || 16)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genLower} onChange={(e) => setGenLower(e.target.checked)} /> 小写</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genUpper} onChange={(e) => setGenUpper(e.target.checked)} /> 大写</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genDigits} onChange={(e) => setGenDigits(e.target.checked)} /> 数字</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genSymbols} onChange={(e) => setGenSymbols(e.target.checked)} /> 符号</label>
|
||||
</div>
|
||||
{genResult && (
|
||||
<div style={{ padding: 12, borderRadius: 8, background: 'var(--md-ref-surface-container)', fontFamily: 'monospace', fontSize: 16, textAlign: 'center', marginBottom: 12, wordBreak: 'break-all' }}>
|
||||
{genResult}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setGenDialog(false)}>取消</button>
|
||||
<button className="btn btn-text" onClick={() => setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }))}>重新生成</button>
|
||||
<button className="btn btn-filled" onClick={useGenerated} disabled={!genResult}>使用此密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改 PIN 警告确认弹窗 */}
|
||||
{pinChangeWarn && (
|
||||
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认修改 PIN</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>⚠️ 修改 PIN 后,现有密码条目将无法解密,需要重新添加。确定继续?</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPinChangeWarn(false)}>取消</button>
|
||||
<button className="btn btn-danger" onClick={proceedPinChange}>确定继续</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDialog && (
|
||||
<div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认操作</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录?</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setConfirmDialog(false)}>取消</button>
|
||||
<button className="btn btn-danger" onClick={confirmDelete} disabled={confirmBusy}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Placeholder({ name }) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card" style={{ textAlign: 'center', padding: '64px 16px' }}>
|
||||
<h2 style={{ marginBottom: '12px' }}>{name}</h2>
|
||||
<p style={{ color: 'var(--md-ref-on-surface-variant)' }}>建设中</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as profileApi from '../api/profile.js';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
||||
import { getToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||
|
||||
/** FileReader + Image 加载图片(头像裁剪前读取) */
|
||||
function loadImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = reject;
|
||||
i.src = r.result;
|
||||
};
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** 个人中心(迁移自 profile.html):资料 / 头像上传(512x512 裁剪)/ 邮箱状态 / 修改密码 / 退出 */
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
// RainID 单点登录:开启时个人中心资料/改密交给 RainID(rainid_enabled 公开设置)
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
// QQ 号(rainweb 层字段,后端返回 qq 时显示编辑;用于 QQ 头像渲染优先级)
|
||||
const [qq, setQq] = useState('');
|
||||
// 个性签名(公开主页 bio)
|
||||
const [bio, setBio] = useState('');
|
||||
// 对外昵称 + 个人博客(全站作者名 / 个人主页展示)
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [infoBusy, setInfoBusy] = useState(false);
|
||||
|
||||
// 修改密码弹窗(两步:发送验证码 → 输入验证码/新旧密码)
|
||||
const [pwDialog, setPwDialog] = useState(false);
|
||||
const [pwStep, setPwStep] = useState(1);
|
||||
const [pwCode, setPwCode] = useState('');
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwBusy, setPwBusy] = useState(false);
|
||||
|
||||
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
|
||||
useEffect(() => {
|
||||
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
|
||||
}, [pwDialog, pwStep]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const u = await profileApi.getProfile();
|
||||
setUser(u);
|
||||
setQq(u.qq || ''); // 后端提供 qq 字段时预填
|
||||
setBio(u.bio || ''); // 个性签名
|
||||
setNickname(u.nickname || ''); // 对外昵称
|
||||
setWebsite(u.website || ''); // 个人博客
|
||||
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
|
||||
const av = await avatarUrl(u.id);
|
||||
setAvatar(av.url || '');
|
||||
// RainID 开关(公开设置)
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => setRainidEnabled(s.rainid_enabled === '1'))
|
||||
.catch(() => setRainidEnabled(false));
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
if (e.status === 401) { navigate('/login.html'); return; }
|
||||
}
|
||||
setLoading(false);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) { navigate('/login.html'); return; }
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
|
||||
const handleAvatarChange = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
// 客户端裁剪为 512x512(照 profile.html)
|
||||
const img = await loadImage(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, 512, 512);
|
||||
const s = Math.min(img.width, img.height);
|
||||
const sx = (img.width - s) / 2;
|
||||
const sy = (img.height - s) / 2;
|
||||
ctx.drawImage(img, sx, sy, s, s, 0, 0, 512, 512);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
||||
// 用 File 携带文件名,满足后端扩展名/MIME 校验
|
||||
const avatarFile = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
await uploadAvatar(avatarFile);
|
||||
showSnackbar('头像已更新');
|
||||
notifyAuthChange(); // 刷新全局登录态头像
|
||||
load();
|
||||
} catch (err) {
|
||||
showSnackbar(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// 对外昵称 + 个人博客 + QQ 号 + 个性签名统一保存
|
||||
const saveAll = async () => {
|
||||
if (nickname.trim().length > 20) { showSnackbar('昵称最多 20 字'); return; }
|
||||
if (website.trim() && !/^https?:\/\//i.test(website.trim())) { showSnackbar('个人博客需以 http(s):// 开头'); return; }
|
||||
setInfoBusy(true);
|
||||
try {
|
||||
await profileApi.updateProfile({ nickname: nickname.trim(), website: website.trim(), qq: qq.trim(), bio: bio.trim() });
|
||||
showSnackbar('资料已保存');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setInfoBusy(false);
|
||||
};
|
||||
|
||||
const sendPwCode = async () => {
|
||||
setPwBusy(true);
|
||||
try {
|
||||
await profileApi.sendPwCode();
|
||||
showSnackbar('验证码已发送');
|
||||
setPwStep(2);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwBusy(false);
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!pwCode || pwCode.length !== 8) { showSnackbar('请输入完整的 8 位验证码'); return; }
|
||||
if (!oldPw || !newPw) { showSnackbar('请填写完整'); return; }
|
||||
if (newPw.length < 6) { showSnackbar('新密码至少6位'); return; }
|
||||
if (newPw !== confirmPw) { showSnackbar('两次密码不一致'); return; }
|
||||
setPwBusy(true);
|
||||
try {
|
||||
await profileApi.changePassword({ code: pwCode.trim(), oldPassword: oldPw, newPassword: newPw });
|
||||
showSnackbar('密码已修改');
|
||||
setPwDialog(false);
|
||||
setPwStep(1);
|
||||
setPwCode(''); setOldPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<h1 className="page-title">个人中心</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
) : error || !user ? (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error || '加载失败'}</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="avatar" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 64, height: 64, borderRadius: '50%', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, fontWeight: 600 }}>
|
||||
{(user.username || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="更换头像"
|
||||
onClick={() => fileRef.current && fileRef.current.click()}
|
||||
style={{ position: 'absolute', bottom: -4, right: -4, width: 28, height: 28, background: 'var(--md-ref-surface-container)', fontSize: 14, boxShadow: '0 2px 8px var(--md-shadow)' }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>camera_alt</span>
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarChange} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 20, fontWeight: 600 }}>{user.username}</h3>
|
||||
<span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>邮箱(注册后不可修改)</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px', borderRadius: 12, background: 'var(--md-ref-surface-container)', fontSize: 15 }}>
|
||||
<span>{user.email || '未设置'}</span>
|
||||
{user.email_verified
|
||||
? <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)' }}>已验证</span>
|
||||
: <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-error)', color: 'var(--md-ref-on-error)' }}>未验证</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对外昵称 + 个人博客:显示在帖子/评论/个人主页 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileNickname">对外昵称</label>
|
||||
<input
|
||||
id="profileNickname"
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="显示在帖子 / 评论 / 个人主页的作者名"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileWebsite">个人博客</label>
|
||||
<input
|
||||
id="profileWebsite"
|
||||
type="url"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QQ 号:rainweb 层字段(后端返回 qq 时显示;用于 QQ 头像渲染优先级) */}
|
||||
{typeof user.qq !== 'undefined' && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileQq">QQ 号(用于 QQ 头像)</label>
|
||||
<input id="profileQq" type="text" value={qq} onChange={(e) => setQq(e.target.value)} placeholder="QQ 号码" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 个性签名:公开主页展示 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileBio">个性签名</label>
|
||||
<textarea
|
||||
id="profileBio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="写一句自我介绍,会展示在公开主页上"
|
||||
style={{ minHeight: 72 }}
|
||||
maxLength={200}
|
||||
/>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>展示在个人主页的签名区,最多 200 字</span>
|
||||
<span>{bio.length}/200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--md-ref-on-surface-variant)', margin: '4px 0 12px' }}>
|
||||
昵称留空则显示用户名;博客会以「个人博客」链接展示在主页(外链跳确认页)
|
||||
</p>
|
||||
|
||||
{/* 统一保存按钮 */}
|
||||
<button className="btn btn-tonal" onClick={saveAll} disabled={infoBusy}>
|
||||
{infoBusy ? '保存中…' : '保存全部'}
|
||||
</button>
|
||||
|
||||
{/* RainID 开启:改密/资料交 RainID,本地只保留头像与 QQ */}
|
||||
{rainidEnabled && (
|
||||
<div className="rainid-notice">
|
||||
<span className="material-icons" style={{ fontSize: 22 }}>badge</span>
|
||||
<div className="rainid-notice-body">
|
||||
<div className="rainid-notice-title">账号由 RainID 统一管理</div>
|
||||
<div className="rainid-notice-desc">资料与密码请在 RainID 个人中心修改;头像与 QQ 仍在本站管理</div>
|
||||
</div>
|
||||
<a
|
||||
className="btn btn-tonal btn-sm"
|
||||
href="https://rainid.rainnya.asia/account"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="前往 RainID 个人中心(新窗口打开)"
|
||||
>
|
||||
前往 RainID 个人中心
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!rainidEnabled && (
|
||||
<>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '24px 0' }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 12 }}>安全设置</h4>
|
||||
<button className="btn btn-outline" onClick={() => { setPwStep(1); setPwDialog(true); }}>修改密码</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 8 }} onClick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ textAlign: 'center', padding: 16, color: 'var(--md-ref-on-surface-variant)', fontSize: 13 }}>
|
||||
UID: {user.id} · 注册时间: {user.created_at}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="修改密码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>修改密码</h3>
|
||||
{pwStep === 1 ? (
|
||||
<>
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>验证码将发送到您的注册邮箱</p>
|
||||
<button className="btn btn-filled w-full" onClick={sendPwCode} disabled={pwBusy}>
|
||||
{pwBusy ? '发送中...' : '发送验证码'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>验证码</label>
|
||||
<input type="text" value={pwCode} onChange={(e) => setPwCode(e.target.value)} placeholder="8 位验证码" maxLength={8} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>原密码</label>
|
||||
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>新密码(至少6位)</label>
|
||||
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={changePassword} disabled={pwBusy}>确认修改</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as emailApi from '../api/email.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/** RainID 注册页地址(后端 rainid_register_redirect=1 时整页跳转) */
|
||||
const RAINID_REGISTER_URL = 'https://rainid.rainnya.asia/register';
|
||||
|
||||
/**
|
||||
* 注册页(迁移自 register.html):
|
||||
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
||||
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
||||
* RainID:rainid_register_redirect=1 → 整页跳转 RainID 注册(不显示本地表单);
|
||||
* 否则保留本地注册,表单下方提供「使用 RainID 登录」入口。
|
||||
*/
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [verifyMode, setVerifyMode] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
const [redirecting, setRedirecting] = useState(false); // 正在跳转 RainID 注册
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
if (s.rainid_register_redirect === '1') {
|
||||
// 注册托管给 RainID:短暂提示后整页替换(避免白屏感)
|
||||
setRedirecting(true);
|
||||
setTimeout(() => { window.location.replace(RAINID_REGISTER_URL); }, 400);
|
||||
return;
|
||||
}
|
||||
if (s.rainid_enabled === '1') setRainidEnabled(true);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
captchaRequired('register')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('register');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
/** RainID 登录入口(与登录页同逻辑) */
|
||||
const handleRainId = () => {
|
||||
authApi.oidcLoginRedirect();
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
||||
if (password.length < 6) { setError('密码至少6位'); return; }
|
||||
if (password !== confirm) { setError('两次密码不一致'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.register({
|
||||
username: username.trim(),
|
||||
password,
|
||||
email: email.trim(),
|
||||
captcha: capResult || undefined,
|
||||
});
|
||||
if (data.requires_verification) {
|
||||
setVerifyMode(true); // 进入邮箱验证步骤
|
||||
} else {
|
||||
showSnackbar('注册成功,请登录');
|
||||
setTimeout(() => navigate('/login.html'), 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || '注册失败');
|
||||
// 与登录一致:验证码 proof 一次性消费,注册失败后重置验证状态允许重新验证
|
||||
setCapDone(false);
|
||||
setCapResult(null);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!code || code.length !== 8) { setError('请输入完整的 8 位验证码'); return; }
|
||||
setError('');
|
||||
try {
|
||||
await emailApi.completeRegister(code.trim(), username.trim(), password);
|
||||
showSnackbar('注册成功,请登录');
|
||||
setTimeout(() => navigate('/login.html'), 1000);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const resendCode = async () => {
|
||||
try {
|
||||
await emailApi.sendVerify(email.trim(), username.trim());
|
||||
showSnackbar('验证码已重新发送');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// RainID 注册托管:整页跳转前短暂显示提示
|
||||
if (redirecting) {
|
||||
return (
|
||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
|
||||
<span className="material-icons" style={{ fontSize: 36, color: 'var(--md-ref-primary)' }}>fingerprint</span>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 500, marginTop: 12 }}>正在跳转 RainID 注册…</h1>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 8 }}>
|
||||
本平台注册已由 RainID 单点登录托管,即将跳转
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 8 }}>
|
||||
{verifyMode ? '邮箱验证' : '创建账户'}
|
||||
</h1>
|
||||
<p className="subtitle" style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
|
||||
{verifyMode ? '请输入邮箱中的验证码' : '加入 RainWeb'}
|
||||
</p>
|
||||
|
||||
{!verifyMode && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>用户名 *</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="用户名" autoComplete="username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>邮箱 *</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" autoComplete="email" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码 *</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认密码 *</label>
|
||||
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="再次输入密码" autoComplete="new-password" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{verifyMode && (
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>邮箱验证码</label>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>验证码已发送到您的邮箱,请输入 8 位数字验证码</p>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="输入 8 位验证码"
|
||||
maxLength={8}
|
||||
inputMode="numeric"
|
||||
style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={handleVerify} disabled={busy}>验证并完成注册</button>
|
||||
<button className="btn btn-text btn-sm mt-16" onClick={resendCode} style={{ marginTop: 8 }}>重新发送验证码</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{!verifyMode && (
|
||||
<>
|
||||
{capRequired && (
|
||||
<button
|
||||
className="btn w-full"
|
||||
onClick={doCaptcha}
|
||||
disabled={capDone}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||
: { background: '#fff', color: '#333', border: '1px solid #333' }),
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-filled w-full" onClick={handleRegister} disabled={busy}>
|
||||
{busy ? '注册中...' : '注册'}
|
||||
</button>
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<span className="text-muted">已有账户?</span>
|
||||
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
||||
</div>
|
||||
{rainidEnabled && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text btn-sm"
|
||||
onClick={handleRainId}
|
||||
style={{ fontSize: 14, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>fingerprint</span>
|
||||
已有 RainID 账号?使用 RainID 登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
|
||||
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}>← 返回主页</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { request } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
const THEMES = [
|
||||
{ color: '#6750a4', name: 'default', label: '默认' },
|
||||
{ color: '#0288d1', name: 'ocean', label: '海洋' },
|
||||
{ color: '#2e7d32', name: 'nature', label: '自然' },
|
||||
{ color: '#e65100', name: 'sunset', label: '日落' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 初始化向导(迁移自 setup.html + routes/setup.js 三步流程):
|
||||
* 1. 管理员密码(≥6 位、两次一致、不能等于 admin123)→ 2. 网站名称 + 主题色 → 3. 确认并完成。
|
||||
* 提交 POST /api/setup/complete;已完成初始化时提示并跳首页。
|
||||
*/
|
||||
export default function Setup() {
|
||||
const [status, setStatus] = useState(null); // null=检查中
|
||||
const [step, setStep] = useState(1);
|
||||
const [adminPw, setAdminPw] = useState('');
|
||||
const [adminPwConfirm, setAdminPwConfirm] = useState('');
|
||||
const [siteName, setSiteName] = useState('RainWeb');
|
||||
const [theme, setTheme] = useState({ color: '#6750a4', name: 'default', label: '默认' });
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request('/setup/status')
|
||||
.then((s) => setStatus(s || {}))
|
||||
.catch(() => setStatus({ setup_complete: true })); // 接口异常不阻塞,视为已完成
|
||||
}, []);
|
||||
|
||||
const next = (n) => {
|
||||
setError('');
|
||||
if (n === 2) {
|
||||
if (adminPw.length < 6) { setError('密码至少6位'); return; }
|
||||
if (adminPw !== adminPwConfirm) { setError('两次密码不一致'); return; }
|
||||
if (adminPw === 'admin123') { setError('新密码不能与默认密码相同'); return; }
|
||||
}
|
||||
setStep(n);
|
||||
};
|
||||
|
||||
const complete = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await request('/setup/complete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
password: adminPw,
|
||||
site_name: siteName.trim() || 'RainWeb',
|
||||
primary_color: theme.color,
|
||||
theme_preset: theme.name,
|
||||
},
|
||||
});
|
||||
showSnackbar('初始化完成!');
|
||||
setTimeout(() => { window.location.href = '/'; }, 1000);
|
||||
} catch (e) {
|
||||
setError(e.message || '初始化失败');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === null) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
// 已完成初始化:提示并跳首页
|
||||
if (status.setup_complete) {
|
||||
return (
|
||||
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>✅ 已完成初始化</h1>
|
||||
<p className="text-muted" style={{ marginBottom: 24, fontSize: 14 }}>RainWeb 已初始化完成,可正常使用。</p>
|
||||
<a href="/" className="btn btn-filled w-full">进入首页</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stepDot = (n) => ({
|
||||
width: n === step ? 28 : 10,
|
||||
height: 10,
|
||||
borderRadius: n === step ? 5 : '50%',
|
||||
background: n < step ? 'var(--md-ref-primary-container)' : (n === step ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 480, padding: '40px 32px' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 4 }}>🌧️ RainWeb</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
|
||||
欢迎使用!请完成以下初始化设置
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 24 }}>
|
||||
{[1, 2, 3].map((n) => <div key={n} style={stepDot(n)} />)}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>设置管理员密码</h3>
|
||||
<div className="form-group">
|
||||
<label>新密码 *</label>
|
||||
<input type="password" value={adminPw} onChange={(e) => setAdminPw(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认密码 *</label>
|
||||
<input type="password" value={adminPwConfirm} onChange={(e) => setAdminPwConfirm(e.target.value)} placeholder="再次输入" autoComplete="new-password" onKeyDown={(e) => { if (e.key === 'Enter') next(2); }} />
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={() => next(2)}>下一步</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>网站设置</h3>
|
||||
<div className="form-group">
|
||||
<label>网站名称</label>
|
||||
<input type="text" value={siteName} onChange={(e) => setSiteName(e.target.value)} placeholder="我的网站" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>选择主题色</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
||||
{THEMES.map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
onClick={() => setTheme({ color: t.color, name: t.name, label: t.label })}
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
border: '2px solid ' + (theme.name === t.name ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
background: theme.name === t.name ? 'var(--md-ref-primary-container)' : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', margin: '0 auto 8px', background: t.color }} />
|
||||
<div>{t.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={() => next(3)}>下一步</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>确认并完成</h3>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 14 }}>
|
||||
<div style={{ marginBottom: 8 }}><strong>管理员密码</strong>: 已设置</div>
|
||||
<div style={{ marginBottom: 8 }}><strong>网站名称</strong>: {siteName.trim() || 'RainWeb'}</div>
|
||||
<div><strong>主题色</strong>: {theme.label}</div>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={complete} disabled={busy}>
|
||||
{busy ? '处理中...' : '完成初始化'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginTop: 12 }}>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { getTagPosts } from '../api/blog.js';
|
||||
|
||||
/** 摘要:优先 excerpt,否则剥离 markdown 符号截取 */
|
||||
function excerptOf(p) {
|
||||
if (p.excerpt) return p.excerpt;
|
||||
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
|
||||
}
|
||||
|
||||
/** 标签页(路由 /tag/:name):该标签下的文章列表,复用博客卡片样式 */
|
||||
export default function Tag() {
|
||||
const { name } = useParams();
|
||||
const [posts, setPosts] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setPosts(null);
|
||||
setError('');
|
||||
getTagPosts(name)
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => setError(e.message || '加载失败'));
|
||||
}, [name]);
|
||||
|
||||
const displayName = name || '';
|
||||
|
||||
return (
|
||||
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
|
||||
</Link>
|
||||
<h1 className="article-title" style={{ fontSize: 26 }}>
|
||||
标签:{displayName}
|
||||
{posts && <span className="tag-title-count">({posts.length} 篇)</span>}
|
||||
</h1>
|
||||
|
||||
{!posts && !error && (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{posts && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🏷️</div><p>该标签下暂无文章</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="blog-waterfall">
|
||||
{posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
|
||||
<div className="blog-featured"></div>
|
||||
<div className="blog-title">{p.title}</div>
|
||||
<div className="blog-excerpt">{excerptOf(p)}</div>
|
||||
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as ticketsApi from '../api/tickets.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { safeSourceUrl } from './Tickets.jsx';
|
||||
|
||||
const categories = [['forum_bug', '论坛 Bug'], ['site_bug', '站内 Bug'], ['feature', '功能建议'], ['account', '账号问题'], ['other', '其他问题']];
|
||||
const priorities = [['low', '低'], ['normal', '普通'], ['high', '高'], ['urgent', '紧急']];
|
||||
|
||||
export default function TicketCreate() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const search = new URLSearchParams(location.search);
|
||||
const sourceUrl = safeSourceUrl((location.state && location.state.sourceUrl) || search.get('source_url') || search.get('from') || window.location.pathname);
|
||||
const [form, setForm] = useState({ subject: '', description: '', category: 'site_bug', priority: 'normal', source_url: sourceUrl });
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const update = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
|
||||
if (!getToken()) return <div className="empty-state" style={{ maxWidth: 460, margin: '48px auto' }}><div className="empty-icon" aria-hidden="true">🔒</div><h1 style={{ fontSize: 22 }}>登录后提交工单</h1><p className="text-muted">请先登录,再反馈论坛或站内问题。</p><Link to="/login.html" state={{ from: location.pathname }} className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link></div>;
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
const subject = form.subject.trim(); const description = form.description.trim();
|
||||
if (subject.length < 2) { setError('请填写问题标题(至少 2 个字)'); return; }
|
||||
if (description.length < 10) { setError('请详细描述问题(至少 10 个字)'); return; }
|
||||
setError(''); setBusy(true);
|
||||
try {
|
||||
const sourceValue = String(form.source_url ?? '');
|
||||
const sourceUrl = safeSourceUrl(sourceValue);
|
||||
const hasInvalidSource = sourceValue.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(sourceValue) || sourceValue.includes('\\');
|
||||
if (hasInvalidSource && !sourceUrl) {
|
||||
setError('来源地址不合法,请填写站内路径或 http/https 地址');
|
||||
return;
|
||||
}
|
||||
const data = await ticketsApi.createTicket({
|
||||
...form,
|
||||
subject,
|
||||
description,
|
||||
source: form.category === 'forum_bug' ? 'forum' : 'site',
|
||||
source_url: sourceUrl,
|
||||
browser_info: [navigator.userAgent, `${window.innerWidth}x${window.innerHeight}`].join(' | ').slice(0, 1000),
|
||||
});
|
||||
if (!data.ticket || !data.ticket.id) throw new Error('工单创建成功但未返回编号');
|
||||
navigate('/tickets/' + data.ticket.id, { replace: true });
|
||||
} catch (err) { setError(err.message || '提交失败,请稍后重试'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return <div style={{ maxWidth: 760, margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: 22 }}><Link to="/tickets.html" className="btn btn-text btn-sm">← 返回工单</Link><h1 className="page-title" style={{ margin: '12px 0 6px' }}>提交问题</h1><p className="text-muted" style={{ margin: 0 }}>描述得越具体,越有助于快速定位问题。</p></div>
|
||||
<form className="card" onSubmit={submit} noValidate style={{ padding: 22 }}>
|
||||
{error && <div role="alert" className="empty-state" style={{ padding: 12, marginBottom: 18, textAlign: 'left' }}><p style={{ margin: 0 }}>{error}</p></div>}
|
||||
<div className="form-group"><label htmlFor="ticket-subject">问题标题 <span aria-hidden="true">*</span></label><input id="ticket-subject" value={form.subject} onChange={update('subject')} maxLength={120} required aria-describedby="ticket-subject-help" placeholder="例如:论坛帖子无法回复" /><p id="ticket-subject-help" className="text-muted" style={{ fontSize: 13 }}>请用一句话概括遇到的问题(最多 120 字)。</p></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}><div className="form-group"><label htmlFor="ticket-category">问题类型 <span aria-hidden="true">*</span></label><select id="ticket-category" value={form.category} onChange={update('category')} required>{categories.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div><div className="form-group"><label htmlFor="ticket-priority">优先级</label><select id="ticket-priority" value={form.priority} onChange={update('priority')}>{priorities.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div></div>
|
||||
<div className="form-group"><label htmlFor="ticket-description">问题描述 <span aria-hidden="true">*</span></label><textarea id="ticket-description" value={form.description} onChange={update('description')} maxLength={20000} required rows={8} aria-describedby="ticket-description-help" placeholder="请描述发生了什么、如何复现,以及你期望的结果。" /><p id="ticket-description-help" className="text-muted" style={{ fontSize: 13 }}>至少 10 个字,最多 20000 字。</p></div>
|
||||
<div className="form-group"><label htmlFor="ticket-source-url">发现问题的页面地址</label><input id="ticket-source-url" type="url" value={form.source_url} onChange={update('source_url')} maxLength={1000} aria-describedby="ticket-source-help" /><p id="ticket-source-help" className="text-muted" style={{ fontSize: 13 }}>已自动记录当前页面地址;如果问题来自其他页面,可以在这里修改。</p></div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', flexWrap: 'wrap' }}><Link to="/tickets.html" className="btn btn-tonal">取消</Link><button type="submit" className="btn btn-filled" disabled={busy}>{busy ? '提交中…' : '提交工单'}</button></div>
|
||||
</form>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import * as ticketsApi from '../api/tickets.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { categoryLabel, formatTime, priorityLabel, safeSourceUrl, statusInfo } from './Tickets.jsx';
|
||||
|
||||
const STEPS = ['open', 'processing', 'waiting', 'resolved', 'closed'];
|
||||
const PUBLIC_EVENTS = new Set(['ticket_created', 'message_added', 'status_changed', 'ticket_closed', 'ticket_reopened']);
|
||||
|
||||
function eventLabel(event) {
|
||||
if (event.event_type === 'ticket_created') return '工单已创建';
|
||||
if (event.event_type === 'message_added') return '新增公开回复';
|
||||
if (event.event_type === 'ticket_closed') return '工单已关闭';
|
||||
if (event.event_type === 'ticket_reopened') return '工单已重新打开';
|
||||
return event.new_value ? `状态更新为“${statusInfo(event.new_value).label}”` : '工单状态已更新';
|
||||
}
|
||||
|
||||
function TicketProgress({ status }) {
|
||||
const current = STEPS.indexOf(status);
|
||||
return (
|
||||
<ol className="ticket-detail-progress" aria-label={`工单处理进度,当前为${statusInfo(status).label}`}>
|
||||
{STEPS.map((step, index) => {
|
||||
const info = statusInfo(step);
|
||||
const state = index === current ? 'current' : index < current ? 'done' : 'upcoming';
|
||||
return <li key={step} className={`ticket-detail-progress-item is-${state}`} aria-current={state === 'current' ? 'step' : undefined}>
|
||||
<span className="ticket-detail-progress-line" aria-hidden="true" />
|
||||
<span className="ticket-detail-progress-dot" aria-hidden="true">{state === 'done' ? '✓' : index + 1}</span>
|
||||
<span className="ticket-detail-progress-label">{info.label}</span>
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ status }) {
|
||||
const info = statusInfo(status);
|
||||
return <span className={`chip ticket-status ticket-status--${info.tone}`}><span className="material-icons" aria-hidden="true">{info.icon}</span>{info.label}</span>;
|
||||
}
|
||||
|
||||
export default function TicketDetail() {
|
||||
const { id } = useParams();
|
||||
const [user, setUser] = useState(null); const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true); const [error, setError] = useState('');
|
||||
const [reply, setReply] = useState(''); const [busy, setBusy] = useState(false);
|
||||
const detailRequestRef = useRef(0);
|
||||
const activeIdRef = useRef(id);
|
||||
activeIdRef.current = id;
|
||||
const load = useCallback(async () => {
|
||||
if (String(activeIdRef.current) !== String(id)) return;
|
||||
const requestId = ++detailRequestRef.current;
|
||||
setLoading(true); setError(''); setData(null);
|
||||
try {
|
||||
const nextData = await ticketsApi.getTicket(id);
|
||||
if (requestId === detailRequestRef.current) setData(nextData);
|
||||
} catch (e) {
|
||||
if (requestId === detailRequestRef.current) setError(e.message || '工单加载失败');
|
||||
} finally {
|
||||
if (requestId === detailRequestRef.current) setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
useEffect(() => { if (!getToken()) { setLoading(false); return; } me().then(setUser).catch(() => { setUser(null); setLoading(false); }); }, []);
|
||||
useEffect(() => { if (user) load(); }, [user, load]);
|
||||
|
||||
if (!getToken()) return <div className="empty-state ticket-detail-guard" role="status"><div className="empty-icon" aria-hidden="true">🔒</div><h1>登录后查看工单</h1><p className="text-muted">登录后才能查看工单详情和回复。</p><Link to="/login.html" state={{ from: window.location.pathname }} className="btn btn-filled">去登录</Link></div>;
|
||||
if (loading) return <div className="loading" role="status" aria-label="正在加载工单"><div className="spinner" /></div>;
|
||||
if (error || !data || !data.ticket) return <div className="empty-state" role="alert"><div className="empty-icon" aria-hidden="true">⚠️</div><p>{error || '工单不存在或无权访问'}</p><button type="button" className="btn btn-tonal btn-sm" onClick={load}>重试</button><Link to="/tickets.html" className="btn btn-text btn-sm">返回工单</Link></div>;
|
||||
|
||||
const { ticket } = data; const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
const events = (Array.isArray(data.events) ? data.events : []).filter((event) => PUBLIC_EVENTS.has(event.event_type));
|
||||
const sourceUrl = safeSourceUrl(ticket.source_url); const canReply = ticket.status !== 'closed';
|
||||
const runAction = async (action, confirmation) => {
|
||||
if (!window.confirm(confirmation)) return;
|
||||
const actionId = id;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await action(actionId);
|
||||
if (String(activeIdRef.current) === String(actionId)) await load();
|
||||
} catch (e) {
|
||||
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '操作失败');
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const sendReply = async (e) => {
|
||||
e.preventDefault();
|
||||
const content = reply.trim();
|
||||
if (!content) { setError('回复内容不能为空'); return; }
|
||||
const actionId = id;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await ticketsApi.addMessage(actionId, content);
|
||||
if (String(activeIdRef.current) === String(actionId)) {
|
||||
setReply('');
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '回复失败');
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return <div className="ticket-detail-page">
|
||||
<Link to="/tickets.html" className="btn btn-text btn-sm ticket-detail-back">← 返回工单列表</Link>
|
||||
<div className="ticket-detail-layout">
|
||||
<div className="ticket-detail-main">
|
||||
<article className="card ticket-detail-header"><div className="ticket-detail-kicker">{ticket.ticket_no || `工单 #${ticket.id}`}</div><h1 className="ticket-detail-title">{ticket.subject || ticket.title || '未命名工单'}</h1><div className="ticket-detail-tags"><StatusChip status={ticket.status} /><span className="chip chip-static">{categoryLabel(ticket.category)}</span><span className="chip chip-static">{priorityLabel(ticket.priority)}</span></div><TicketProgress status={ticket.status} /></article>
|
||||
<article className="card ticket-detail-description"><h2>问题描述</h2><p>{ticket.description}</p>{sourceUrl && <p className="ticket-detail-source"><span className="text-muted">发现页面</span><a href={sourceUrl} target="_blank" rel="noopener noreferrer">{sourceUrl}</a></p>}</article>
|
||||
<section className="ticket-detail-conversation" aria-labelledby="ticket-messages-heading"><div className="ticket-detail-section-heading"><h2 id="ticket-messages-heading">沟通记录</h2><span className="text-muted">{messages.length} 条公开回复</span></div>{messages.length === 0 ? <div className="card ticket-detail-empty"><span className="material-icons" aria-hidden="true">forum</span><p>暂无回复,提交后管理员会在这里跟进。</p></div> : <ol className="ticket-detail-messages">{messages.map((message) => <li key={message.id} className="card ticket-detail-message"><div className="ticket-detail-message-meta"><strong>{message.author_username || message.author_name || '用户'}</strong><time className="text-muted" dateTime={message.created_at}>{formatTime(message.created_at)}</time></div><p>{message.content || message.body}</p></li>)}</ol>}</section>
|
||||
{events.length > 0 && <section className="ticket-detail-events" aria-labelledby="ticket-events-heading"><h2 id="ticket-events-heading">处理记录</h2><ol>{events.map((event) => <li key={event.id}><span>{eventLabel(event)}</span><time className="text-muted" dateTime={event.created_at}>{formatTime(event.created_at)}</time></li>)}</ol></section>}
|
||||
{canReply ? <form className="card ticket-detail-reply" onSubmit={sendReply}><label htmlFor="ticket-reply">追加回复</label><textarea id="ticket-reply" value={reply} onChange={(e) => setReply(e.target.value)} rows={5} maxLength={20000} placeholder="补充信息或回复处理结果" aria-describedby="ticket-reply-help" /><div className="ticket-detail-reply-footer"><p id="ticket-reply-help" className="text-muted">回复将对工单参与者公开。</p><button type="submit" className="btn btn-filled" disabled={busy}>{busy ? '发送中…' : '发送回复'}</button></div></form> : <div className="card ticket-detail-closed" role="status">工单已关闭,如需继续反馈请提交新的工单。</div>}
|
||||
</div>
|
||||
<aside className="card ticket-detail-sidebar" aria-label="工单信息"><div className="ticket-detail-sidebar-status"><span className="text-muted">当前状态</span><StatusChip status={ticket.status} /></div><dl><div><dt>问题类型</dt><dd>{categoryLabel(ticket.category)}</dd></div><div><dt>创建时间</dt><dd>{formatTime(ticket.created_at)}</dd></div><div><dt>最近更新</dt><dd>{formatTime(ticket.updated_at)}</dd></div></dl><div className="ticket-detail-actions">{ticket.status === 'resolved' && <button type="button" className="btn btn-filled" disabled={busy} onClick={() => runAction(ticketsApi.closeTicket, '确认将此工单标记为已关闭吗?')}>确认已解决</button>}{ticket.status !== 'closed' && ticket.status !== 'resolved' && <button type="button" className="btn btn-tonal" disabled={busy} onClick={() => runAction(ticketsApi.closeTicket, '确认关闭此工单吗?')}>关闭工单</button>}{ticket.status === 'closed' && <button type="button" className="btn btn-tonal" disabled={busy} onClick={() => runAction(ticketsApi.reopenTicket, '确认重新打开此工单吗?')}>重新打开</button>}</div></aside>
|
||||
</div>
|
||||
{error && <div className="ticket-detail-error" role="alert">{error}</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import * as ticketsApi from '../api/tickets.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
|
||||
const STATUS = {
|
||||
open: { label: '待处理', icon: 'inbox', tone: 'warning' },
|
||||
processing: { label: '处理中', icon: 'sync', tone: 'primary' },
|
||||
waiting: { label: '等待用户', icon: 'schedule', tone: 'secondary' },
|
||||
resolved: { label: '已解决', icon: 'check_circle', tone: 'success' },
|
||||
closed: { label: '已关闭', icon: 'lock', tone: 'neutral' },
|
||||
};
|
||||
const CATEGORY = { forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '功能建议', account: '账号问题', report: '内容举报', other: '其他问题' };
|
||||
|
||||
export function statusInfo(status) { return STATUS[status] || { label: status || '未知状态', icon: 'help', tone: 'neutral' }; }
|
||||
export function categoryLabel(category) { return CATEGORY[category] || category || '其他问题'; }
|
||||
export function priorityLabel(priority) {
|
||||
return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[priority] || '普通';
|
||||
}
|
||||
/** 工单来源只允许 http(s) 或本站相对路径,避免把用户可控值直接作为危险链接。 */
|
||||
export function safeSourceUrl(value) {
|
||||
const valueText = String(value ?? '');
|
||||
if (!valueText || valueText.length > 1000) return '';
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/.test(valueText) || valueText.includes('\\')) return '';
|
||||
const raw = valueText.trim();
|
||||
if (!raw || raw.startsWith('//')) return '';
|
||||
if (raw.startsWith('/')) return raw;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
export function formatTime(value) {
|
||||
if (!value) return '';
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||
return m ? `${m[1]}年${Number(m[2])}月${Number(m[3])}日 ${m[4]}:${m[5]}` : String(value);
|
||||
}
|
||||
|
||||
function StatusChip({ status }) {
|
||||
const info = statusInfo(status);
|
||||
return <span className={'chip ticket-status ticket-status--' + info.tone}><span className="material-icons" aria-hidden="true" style={{ fontSize: 16 }}>{info.icon}</span>{info.label}</span>;
|
||||
}
|
||||
|
||||
function LoginGuide() {
|
||||
return <div className="empty-state" style={{ maxWidth: 460, margin: '48px auto' }}>
|
||||
<div className="empty-icon" aria-hidden="true">🎫</div>
|
||||
<h1 style={{ fontSize: 22, margin: '0 0 8px' }}>登录后使用工单</h1>
|
||||
<p className="text-muted">登录后可以提交论坛或站内问题,并随时查看处理进度。</p>
|
||||
<Link to="/login.html" state={{ from: '/tickets.html' }} className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function Tickets() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const listRequestRef = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const requestId = ++listRequestRef.current;
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await ticketsApi.listTickets({ page, pageSize: 20, status });
|
||||
if (requestId !== listRequestRef.current) return;
|
||||
const normalized = ticketsApi.normalizeTicketListResponse(data, 20);
|
||||
setTickets(normalized.tickets);
|
||||
setTotal(normalized.total);
|
||||
setTotalPages(normalized.totalPages);
|
||||
} catch (e) {
|
||||
if (requestId !== listRequestRef.current) return;
|
||||
setError(e.message || '工单加载失败');
|
||||
} finally {
|
||||
if (requestId === listRequestRef.current) setLoading(false);
|
||||
}
|
||||
}, [page, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) { setLoading(false); return; }
|
||||
me().then(setUser).catch(() => { setUser(null); setLoading(false); });
|
||||
}, []);
|
||||
useEffect(() => { if (user) load(); }, [user, load]);
|
||||
|
||||
const updateStatus = (value) => { setStatus(value); setPage(1); };
|
||||
|
||||
if (!getToken() || (!user && !loading)) return <LoginGuide />;
|
||||
|
||||
return <div style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
<div><h1 className="page-title" style={{ margin: 0 }}>工单中心</h1><p className="text-muted" style={{ margin: '6px 0 0' }}>反馈论坛或站内问题,查看处理进度</p></div>
|
||||
<Link to="/tickets/new" className="btn btn-filled"><span className="material-icons" aria-hidden="true" style={{ fontSize: 18 }}>add</span>提交问题</Link>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 12, marginBottom: 16 }}>
|
||||
<label htmlFor="ticket-status-filter" style={{ marginRight: 10 }}>筛选状态</label>
|
||||
<select id="ticket-status-filter" value={status} onChange={(e) => updateStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
|
||||
<option value="">全部工单</option><option value="open">待处理</option><option value="processing">处理中</option><option value="waiting">等待用户</option><option value="resolved">已解决</option><option value="closed">已关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading && <div className="loading" role="status" aria-label="正在加载工单"><div className="spinner" /></div>}
|
||||
{error && <div className="empty-state" role="alert"><div className="empty-icon" aria-hidden="true">⚠️</div><p>{error}</p><button type="button" className="btn btn-tonal btn-sm" onClick={load}>重试</button></div>}
|
||||
{!loading && !error && tickets.length === 0 && <div className="empty-state"><div className="empty-icon" aria-hidden="true">📭</div><p>还没有工单</p><p className="text-muted">如果你在论坛或站内遇到问题,可以提交一条反馈。</p><Link to="/tickets/new" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>提交第一个问题</Link></div>}
|
||||
{!loading && !error && tickets.length > 0 && <section aria-labelledby="recent-tickets-heading">
|
||||
<h2 id="recent-tickets-heading" style={{ fontSize: 18, margin: '24px 0 12px' }}>最近创建的工单</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 12 }}>
|
||||
{tickets.map((ticket) => <li key={ticket.id}><Link to={'/tickets/' + ticket.id} className="card" style={{ display: 'block', textDecoration: 'none', padding: 18 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}><div style={{ minWidth: 0 }}><div className="text-muted" style={{ fontSize: 13 }}>{ticket.ticket_no || `工单 #${ticket.id}`}</div><h3 style={{ margin: '5px 0 8px', overflowWrap: 'anywhere' }}>{ticket.subject || ticket.title || '未命名工单'}</h3></div><StatusChip status={ticket.status} /></div>
|
||||
<div className="text-muted" style={{ fontSize: 13, display: 'flex', gap: 12, flexWrap: 'wrap' }}><span>{categoryLabel(ticket.category)}</span><span>创建于 {formatTime(ticket.created_at)}</span><span>更新于 {formatTime(ticket.updated_at)}</span></div>
|
||||
</Link></li>)}
|
||||
</ul>
|
||||
</section>}
|
||||
{!loading && !error && totalPages > 1 && <nav className="ticket-pagination" aria-label="工单分页" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginTop: 20 }}>
|
||||
<button type="button" className="btn btn-text btn-sm" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>上一页</button>
|
||||
<span className="text-muted" aria-live="polite">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button type="button" className="btn btn-text btn-sm" disabled={page >= totalPages} onClick={() => setPage((current) => current + 1)}>下一页</button>
|
||||
</nav>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import * as usersApi from '../api/users.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import Avatar, { hashTone } from '../components/Avatar.jsx';
|
||||
import { safeOutUrl } from '../lib/outlink.js';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** 后端时间 'YYYY-MM-DD HH:MM:SS'(UTC)→ 相对时间(刚刚 / n 分钟前 / n 小时前 / n 天前 / n 个月前 / n 年前) */
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(String(ts).includes('T') ? ts : String(ts).replace(' ', 'T') + 'Z');
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const MIN = 60e3;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
if (diff < MIN) return '刚刚';
|
||||
if (diff < HOUR) return Math.floor(diff / MIN) + ' 分钟前';
|
||||
if (diff < DAY) return Math.floor(diff / HOUR) + ' 小时前';
|
||||
if (diff < 30 * DAY) return Math.floor(diff / DAY) + ' 天前';
|
||||
if (diff < 365 * DAY) return Math.floor(diff / (30 * DAY)) + ' 个月前';
|
||||
return Math.floor(diff / (365 * DAY)) + ' 年前';
|
||||
}
|
||||
|
||||
/** 注册时间只到年月:'2026-08-12 10:00' → '2026-08' */
|
||||
function regDate(s) {
|
||||
return String(s || '').slice(0, 7);
|
||||
}
|
||||
|
||||
/** 骨架块 */
|
||||
function Skel({ w, h, r = 8, style }) {
|
||||
return <div className="profile-skel" style={{ width: w, height: h, borderRadius: r, ...style }} />;
|
||||
}
|
||||
|
||||
/** 统计格(2×2) */
|
||||
function StatCell({ label, value }) {
|
||||
return (
|
||||
<div className="profile-stat">
|
||||
<div className="profile-stat-num">{value ?? 0}</div>
|
||||
<div className="profile-stat-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开个人主页(路由 /u/:id):
|
||||
* Banner 渐变条 + 身份卡(头像/用户名/签名/元信息/统计 2×2)+ 内容流(帖子/回复 Tab + 加载更多)。
|
||||
* 自己视角显示「这是你的公开主页」+ 编辑资料入口;他人视角零操作。
|
||||
* 404 → 「用户不存在或已注销」;论坛私密 → 内容区 403 提示。
|
||||
*/
|
||||
export default function UserProfile() {
|
||||
const { id } = useParams();
|
||||
const uid = parseInt(id, 10);
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const [status, setStatus] = useState('loading'); // loading | ok | notfound | error
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [meUser, setMeUser] = useState(null);
|
||||
|
||||
const [tab, setTab] = useState('posts'); // posts | replies
|
||||
const [list, setList] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [listError, setListError] = useState('');
|
||||
const [private403, setPrivate403] = useState(false);
|
||||
|
||||
// 加载公开用户 + 当前登录态(判断是否本人)
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setStatus('loading');
|
||||
setUser(null);
|
||||
setList([]);
|
||||
setPage(1);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
setPrivate403(false);
|
||||
setListError('');
|
||||
usersApi.getPublicUser(uid)
|
||||
.then((u) => { if (mounted) { setUser(u); setStatus('ok'); } })
|
||||
.catch((e) => {
|
||||
if (!mounted) return;
|
||||
if (e.status === 404) setStatus('notfound');
|
||||
else { setLoadError(e.message || '加载失败'); setStatus('error'); }
|
||||
});
|
||||
if (getToken()) me().then(setMeUser).catch(() => setMeUser(null));
|
||||
else setMeUser(null);
|
||||
return () => { mounted = false; };
|
||||
}, [uid]);
|
||||
|
||||
// 加载内容列表(tab / page 变化)
|
||||
const loadList = useCallback(async (uid_, tab_, page_) => {
|
||||
setListLoading(true);
|
||||
setListError('');
|
||||
setPrivate403(false);
|
||||
try {
|
||||
const res = tab_ === 'posts'
|
||||
? await usersApi.getUserPosts(uid_, page_, PAGE_SIZE)
|
||||
: await usersApi.getUserReplies(uid_, page_, PAGE_SIZE);
|
||||
const items = (res && res.list) || [];
|
||||
setList((prev) => (page_ === 1 ? items : [...prev, ...items]));
|
||||
setTotal(res ? res.total || 0 : 0);
|
||||
setTotalPages(res ? res.totalPages || 0 : 0);
|
||||
} catch (e) {
|
||||
if (e.status === 403) {
|
||||
// 论坛私密:登录后可看,未登录 403
|
||||
setPrivate403(true);
|
||||
setList([]);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
} else {
|
||||
setListError(e.message || '加载失败');
|
||||
}
|
||||
}
|
||||
setListLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'ok') loadList(uid, tab, page);
|
||||
}, [status, uid, tab, page, loadList]);
|
||||
|
||||
const switchTab = (t) => {
|
||||
if (t === tab) return;
|
||||
setTab(t);
|
||||
setPage(1);
|
||||
setList([]);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
};
|
||||
|
||||
const isSelf = !!meUser && meUser.id === user?.id;
|
||||
|
||||
// ── 状态分支 ──
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="profile-page" aria-busy="true" aria-label="正在加载个人主页">
|
||||
<Skel w="100%" h={180} r={0} style={{ marginBottom: 0 }} />
|
||||
<div className="profile-grid">
|
||||
<div className="profile-identity">
|
||||
<div className="card profile-card profile-card-skeleton">
|
||||
<Skel w={80} h={80} r="50%" />
|
||||
<Skel w={140} h={24} r={6} />
|
||||
<Skel w="100%" h={14} r={4} />
|
||||
<Skel w="70%" h={14} r={4} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-content">
|
||||
<Skel w={140} h={36} r={20} />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div className="card profile-list-item profile-item-skeleton" key={i}>
|
||||
<Skel w="75%" h={16} r={4} />
|
||||
<Skel w="40%" h={12} r={4} style={{ marginTop: 10 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'notfound') {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '64px auto' }}>
|
||||
<div className="empty-icon">🔍</div>
|
||||
<p style={{ fontWeight: 500 }}>用户不存在或已注销</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>这个公开主页可能已被删除</p>
|
||||
<Link to="/" className="btn btn-tonal btn-sm" style={{ marginTop: 16 }}>返回首页</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '64px auto' }}>
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p style={{ fontWeight: 500 }}>{loadError || '加载失败'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-tonal btn-sm"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banner 渐变(用户名哈希色调)+ 首字水印 + 用户名(让纯装饰渐变"有内容感")──
|
||||
const displayName = user.display_name || user.nickname || user.username || '';
|
||||
const tone = hashTone(user.username || String(user.id || ''));
|
||||
const h = tone * 30;
|
||||
const bannerGrad = `linear-gradient(135deg, hsl(${h} 48% 82%), hsl(${(h + 40) % 360} 58% 68%))`;
|
||||
|
||||
const stats = (user && user.stats) || {};
|
||||
const listEmpty = !listLoading && list.length === 0 && !listError && !private403;
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-banner" style={{ background: bannerGrad }} aria-hidden="true">
|
||||
<span className="profile-banner-char">{displayName.charAt(0)}</span>
|
||||
<span className="profile-banner-name">{displayName}</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-grid">
|
||||
{/* 左列:身份卡 */}
|
||||
<aside className="profile-identity">
|
||||
<div className="card profile-card">
|
||||
<Avatar src={user.avatar} name={displayName || user.username} size={80} />
|
||||
<div className="profile-name-row">
|
||||
<h1 className="profile-name">{displayName || user.username}</h1>
|
||||
{user.role === 'admin' ? <span className="profile-role-chip">管理员</span> : null}
|
||||
{user.title ? (
|
||||
<span
|
||||
className={'username-chip title-chip' + (user.title_color ? ' title-chip-colored' : '')}
|
||||
style={user.title_color ? { background: user.title_color } : undefined}
|
||||
>
|
||||
{user.title}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{user.bio ? <p className="profile-bio">{user.bio}</p> : null}
|
||||
<div className="profile-meta-line">
|
||||
<span>UID {user.id}</span>
|
||||
{user.created_at ? <span>注册于 {regDate(user.created_at)}</span> : null}
|
||||
{user.last_active_at ? <span>{relativeTime(user.last_active_at)}活跃</span> : null}
|
||||
{user.qq ? <span>QQ:{user.qq}</span> : null}
|
||||
{user.website ? (
|
||||
<a href={safeOutUrl(user.website)} target="_blank" rel="noopener noreferrer" title="个人博客">
|
||||
<span className="material-icons" style={{ fontSize: 13 }}>link</span> 个人博客
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="profile-stats">
|
||||
<StatCell label="帖子" value={stats.posts} />
|
||||
<StatCell label="回复" value={stats.replies} />
|
||||
<StatCell label="文章" value={stats.articles} />
|
||||
<StatCell label="获赞" value={stats.likes} />
|
||||
</div>
|
||||
{isSelf && (
|
||||
<div className="profile-self-area">
|
||||
<div className="profile-self-notice">这是你的公开主页</div>
|
||||
<Link to="/profile.html" className="btn btn-tonal btn-sm">编辑资料</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右列:内容流 */}
|
||||
<div className="profile-content">
|
||||
<div className="profile-tabs" role="tablist" aria-label="内容筛选">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="profile-tab-posts"
|
||||
aria-selected={tab === 'posts'}
|
||||
aria-controls="profile-panel"
|
||||
className={'nav-tab' + (tab === 'posts' ? ' active' : '')}
|
||||
onClick={() => switchTab('posts')}
|
||||
>
|
||||
帖子
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="profile-tab-replies"
|
||||
aria-selected={tab === 'replies'}
|
||||
aria-controls="profile-panel"
|
||||
className={'nav-tab' + (tab === 'replies' ? ' active' : '')}
|
||||
onClick={() => switchTab('replies')}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="profile-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={tab === 'posts' ? 'profile-tab-posts' : 'profile-tab-replies'}
|
||||
>
|
||||
{private403 && (
|
||||
<div className="empty-state" style={{ padding: '40px 16px' }}>
|
||||
<div className="empty-icon">🔒</div>
|
||||
<p style={{ fontWeight: 500 }}>论坛已设为私密</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>登录后可查看该用户的论坛内容</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listError && (
|
||||
<div className="empty-state" style={{ padding: '40px 16px' }}>
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{listError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listLoading && page === 1 && !private403 && (
|
||||
<div className="profile-list-skeleton" aria-label="正在加载">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div className="card profile-list-item profile-item-skeleton" key={i}>
|
||||
<Skel w="70%" h={16} r={4} />
|
||||
<Skel w="45%" h={12} r={4} style={{ marginTop: 10 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listEmpty && (
|
||||
<div className="empty-state" style={{ padding: '48px 16px' }}>
|
||||
<div className="empty-icon">{tab === 'posts' ? '📝' : '💬'}</div>
|
||||
<p>{tab === 'posts' ? '还没有发过帖子' : '还没有回复过'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!listLoading && !listError && !private403 && list.map((item) => (
|
||||
tab === 'posts' ? (
|
||||
<Link key={item.id} to={'/forum/' + item.id} className="card profile-list-item">
|
||||
<div className="profile-item-title">
|
||||
{item.is_pinned ? <span className="post-badge pin">📌</span> : null}
|
||||
<span className="profile-item-title-text">{item.title}</span>
|
||||
</div>
|
||||
<div className="profile-item-meta">
|
||||
{item.category_name ? <span className="chip chip-static">{item.category_name}</span> : null}
|
||||
<span>{relativeTime(item.created_at)}</span>
|
||||
<span>{item.reply_count || 0} 回复</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link key={item.id} to={'/forum/' + item.post_id} className="card profile-list-item">
|
||||
<div className="profile-item-preview">{item.content}</div>
|
||||
<div className="profile-item-meta">
|
||||
<span className="profile-reply-at">回复于 {item.post_title || '帖子'}</span>
|
||||
<span>{relativeTime(item.created_at)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* 加载更多 / 到底 */}
|
||||
{!listError && !private403 && list.length > 0 && totalPages > page && (
|
||||
<div className="profile-load-more">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-tonal btn-sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={listLoading}
|
||||
>
|
||||
{listLoading ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!listError && !private403 && list.length > 0 && totalPages > 0 && page >= totalPages && (
|
||||
<div className="profile-end-hint">已经到底啦</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import * as blogApi from '../api/blog.js';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import MarkdownEditor from '../components/MarkdownEditor.jsx';
|
||||
import ErrorDialog from '../components/ErrorDialog.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/** 板块的子分类列表(论坛模式;照搬原 Forum.jsx 发帖弹窗) */
|
||||
function subCatsOf(cat) {
|
||||
if (!cat || !cat.sub_categories) return [];
|
||||
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 写文章 / 发帖页(路由 /write.html):
|
||||
* - 默认博客模式(?edit=id 进入编辑):标题/摘要/标签/发布开关 → /api/blog
|
||||
* - ?type=forum 论坛模式(?type=forum&edit=id 进入编辑):标题 + 板块/子分类选择 → /api/forum/posts
|
||||
* - 发帖走验证码(captcha_forum);编辑不需要验证码,且不改版块(下拉只读)
|
||||
* 内容编辑统一用共用 MarkdownEditor(左编辑右实时预览 + 防抖刷新)。
|
||||
*/
|
||||
export default function Write() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const mode = searchParams.get('type') === 'forum' ? 'forum' : 'blog';
|
||||
const isForum = mode === 'forum';
|
||||
const editId = searchParams.get('edit') ? parseInt(searchParams.get('edit'), 10) : null;
|
||||
const isForumEdit = isForum && !!editId;
|
||||
// 论坛模式预选版块:?category=id 优先(无则第一个);编辑时由帖子原版块决定
|
||||
const categoryParam = searchParams.get('category');
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [excerpt, setExcerpt] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [published, setPublished] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [submitError, setSubmitError] = useState(''); // 403 禁言等错误 → 弹窗
|
||||
|
||||
// 论坛模式:板块 + 子分类(默认选中第一个板块)
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [fpCategory, setFpCategory] = useState('');
|
||||
const [fpSub, setFpSub] = useState('');
|
||||
|
||||
// 博客编辑模式:加载已有文章
|
||||
useEffect(() => {
|
||||
if (!isForum && editId) {
|
||||
blogApi.getPost(editId)
|
||||
.then((p) => {
|
||||
setTitle(p.title);
|
||||
setExcerpt(p.excerpt || '');
|
||||
setTags(p.tags || '');
|
||||
setContent(p.content);
|
||||
setPublished(!!p.published);
|
||||
})
|
||||
.catch((e) => setLoadError(e.message || '加载文章失败'));
|
||||
}
|
||||
// 论坛编辑模式:加载帖子预填(title/category/sub_category/content)
|
||||
if (isForum && editId) {
|
||||
let mounted = true;
|
||||
forumApi.getPost(editId)
|
||||
.then((data) => {
|
||||
const p = data && data.post;
|
||||
if (!mounted || !p) return;
|
||||
setTitle(p.title);
|
||||
setContent(p.content || '');
|
||||
setFpCategory(String(p.category_id || ''));
|
||||
setFpSub(p.sub_category || '');
|
||||
})
|
||||
.catch((e) => { if (mounted) setLoadError(e.message || '加载帖子失败'); });
|
||||
return () => { mounted = false; };
|
||||
}
|
||||
return undefined;
|
||||
}, [isForum, editId]);
|
||||
|
||||
// 论坛模式:拉取板块列表(?category= 参数预选,无则第一个;编辑时由帖子原版块决定)
|
||||
useEffect(() => {
|
||||
if (!isForum) return;
|
||||
let mounted = true;
|
||||
forumApi.listCategories()
|
||||
.then((cs) => {
|
||||
if (!mounted) return;
|
||||
setCategories(cs || []);
|
||||
if (editId || !cs || !cs.length) return; // 编辑模式跳过自动选中
|
||||
setFpCategory((cur) => {
|
||||
if (cur) return cur;
|
||||
if (categoryParam && cs.some((c) => String(c.id) === String(categoryParam))) {
|
||||
return String(categoryParam);
|
||||
}
|
||||
return String(cs[0].id);
|
||||
});
|
||||
})
|
||||
.catch((e) => { if (mounted) setLoadError(e.message || '加载板块失败'); });
|
||||
return () => { mounted = false; };
|
||||
}, [isForum, editId]);
|
||||
|
||||
const handleCategoryChange = (e) => {
|
||||
setFpCategory(e.target.value);
|
||||
setFpSub(''); // 切换板块后重置子分类
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||||
if (isForum && !fpCategory) { showSnackbar('请选择板块'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isForum) {
|
||||
if (editId) {
|
||||
// 编辑帖子:不需要验证码;部分更新只提交 title/content/sub_category(版块不可改)
|
||||
await forumApi.updatePost(editId, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
sub_category: fpSub,
|
||||
use_markdown: 1,
|
||||
});
|
||||
showSnackbar('已更新');
|
||||
navigate('/forum/' + editId);
|
||||
} else {
|
||||
const base = {
|
||||
category_id: parseInt(fpCategory, 10),
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
sub_category: fpSub,
|
||||
use_markdown: 1,
|
||||
};
|
||||
// 验证码:captcha_forum 开启时走 showCaptcha 拿 proof / 第三方 token
|
||||
//(服务端 /api/forum/posts 强制校验,与旧发帖弹窗流程一致)
|
||||
const cap = await captchaRequired('forum');
|
||||
if (cap && cap.required) {
|
||||
const result = await showCaptcha('forum');
|
||||
if (result === null) { setSaving(false); return; } // 取消
|
||||
applyCaptchaResult(base, result);
|
||||
}
|
||||
const post = await forumApi.createPost(base);
|
||||
showSnackbar('发布成功');
|
||||
navigate('/forum/' + post.id);
|
||||
}
|
||||
} else {
|
||||
const data = {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
excerpt: excerpt.trim(),
|
||||
tags: tags.trim(),
|
||||
published,
|
||||
use_markdown: 1,
|
||||
};
|
||||
let id = editId;
|
||||
if (editId) {
|
||||
await blogApi.updatePost(editId, data);
|
||||
showSnackbar('已更新');
|
||||
} else {
|
||||
const created = await blogApi.createPost(data);
|
||||
id = created.id;
|
||||
showSnackbar('已发布');
|
||||
}
|
||||
navigate('/blog/' + id);
|
||||
}
|
||||
} catch (e) {
|
||||
// 403:被禁言等拒绝操作 → 弹窗展示后端错误文案(含到期时间)
|
||||
if (e.status === 403) setSubmitError(e.message);
|
||||
else showSnackbar(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const heading = isForum ? (editId ? '编辑帖子' : '发帖') : (editId ? '编辑文章' : '写文章');
|
||||
const backHref = isForum ? '/forum.html' : '/blog.html';
|
||||
const backText = isForum ? '返回论坛' : '返回博客';
|
||||
|
||||
return (
|
||||
<div className="write-page" style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px' }}>
|
||||
<div className="write-header" style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>{heading}</h1>
|
||||
<a href={backHref} className="btn btn-text btn-sm">
|
||||
<span className="material-icons">arrow_back</span> {backText}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
{loadError ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{loadError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isForum && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>板块 *</label>
|
||||
{isForumEdit ? (
|
||||
<>
|
||||
<select value={fpCategory} disabled aria-label="板块(编辑时不可修改)">
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>编辑帖子不支持修改版块</div>
|
||||
</>
|
||||
) : (
|
||||
<select value={fpCategory} onChange={handleCategoryChange}>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>帖子分类(可选)</label>
|
||||
<select value={fpSub} onChange={(e) => setFpSub(e.target.value)}>
|
||||
<option value="">无</option>
|
||||
{subCatsOf(categories.find((c) => c.id === parseInt(fpCategory, 10))).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={isForum ? '帖子标题' : '文章标题'}
|
||||
/>
|
||||
</div>
|
||||
{!isForum && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>摘要</label>
|
||||
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标签</label>
|
||||
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="多个标签用英文逗号分隔,如:技术,生活,随笔" />
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>多个标签用英文逗号分隔,用于标签云与标签页</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>内容 *</label>
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder={isForum ? '支持 Markdown 语法,可插入附件/图片' : '支持 Markdown 语法,拖入图片自动上传'}
|
||||
label={isForum ? '帖子内容' : '文章内容'}
|
||||
/>
|
||||
</div>
|
||||
{!isForum && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto', fontSize: 14, fontWeight: 500, color: 'var(--md-ref-on-surface-variant)' }}>
|
||||
<input type="checkbox" checked={published} onChange={(e) => setPublished(e.target.checked)} /> 发布
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-filled" onClick={submit} disabled={saving}>
|
||||
{isForum ? (editId ? '保存修改' : '发布') : (editId ? '保存修改' : '发布文章')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 403 禁言等拒绝操作提示 */}
|
||||
<ErrorDialog open={!!submitError} message={submitError} onClose={() => setSubmitError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { applyThemeTokens } from './themeTokens.js';
|
||||
|
||||
// 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制
|
||||
// (public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类)
|
||||
const THEME_KEY = 'theme';
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
export function getThemePreference() {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
if (saved === 'dark' || saved === 'light') return saved;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setThemeState] = useState(getThemePreference);
|
||||
const setTheme = useCallback((nextTheme, { persist = true } = {}) => {
|
||||
if (nextTheme !== 'dark' && nextTheme !== 'light') return;
|
||||
if (persist) localStorage.setItem(THEME_KEY, nextTheme);
|
||||
setThemeState(nextTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
applyThemeTokens({ mode: theme });
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
|
||||
setTheme,
|
||||
}),
|
||||
[theme]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user