Compare commits
| 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 |
@@ -9,10 +9,13 @@ npm start # 生产:node server.js(serve 构建产物 + /api)
|
|||||||
npm run dev # 开发:vite dev server(5173,/api 与 /uploads 代理到 3101)
|
npm run dev # 开发:vite dev server(5173,/api 与 /uploads 代理到 3101)
|
||||||
npm run build # 构建 React 前端 → public/dist/(index.html + admin.html + assets)
|
npm run build # 构建 React 前端 → public/dist/(index.html + admin.html + assets)
|
||||||
npm run preview # 预览构建产物
|
npm run preview # 预览构建产物
|
||||||
node cli.js <cmd> # status | start | stop | restart | port [N] | password [pwd] | config | captcha | upgrade
|
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),后端端口改了就同步改。
|
- 端口:`.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` 后刷新验证(需强刷一次,见下)。
|
- 验证方式:后端改动启动后 `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` 缺失/旧产物。
|
- **部署后浏览器必须拿新 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)。
|
- 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete` 用 `setup_complete` 门禁,非 adminOnly——新装无 token)。
|
||||||
@@ -40,6 +43,7 @@ node cli.js <cmd> # status | start | stop | restart | port [N] | password [pw
|
|||||||
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html`、`/forum.html` 等),SPA fallback 已支持,链接不用改。
|
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html`、`/forum.html` 等),SPA fallback 已支持,链接不用改。
|
||||||
- `frontend/index.html` 含 `${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。
|
- `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`。
|
- 主题:`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/白屏。
|
- 构建:`npm run build` → `public/dist/`(gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。
|
||||||
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`(marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
|
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`(marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
|
||||||
- 登录态:token 存 localStorage(key `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js` 的 `notifyAuthChange`)。
|
- 登录态:token 存 localStorage(key `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js` 的 `notifyAuthChange`)。
|
||||||
@@ -52,7 +56,7 @@ node cli.js <cmd> # status | start | stop | restart | port [N] | password [pw
|
|||||||
## 版本与更新
|
## 版本与更新
|
||||||
|
|
||||||
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
|
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
|
||||||
- **Web 更新接口已移除**(P0 删除 `/api/update/check`、`/api/update/run`,无 RCE 面)。升级只走 `cli.js upgrade`:git pull(本地 origin:git.rainnya.asia 镜像)+ npm install + 重启。
|
- **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 入库)。
|
- 上传附件在 `uploads/`(gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。
|
||||||
|
|
||||||
## 约定
|
## 约定
|
||||||
|
|||||||
@@ -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
|
1. **使用**:允许安装、运行、复制本软件,用于个人学习、研究、内部开发。
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
2. **修改**:允许对本软件源码进行修改、扩展、二次开发。
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
3. **分发**:允许分发本软件的原始版本或修改后的衍生版本。
|
||||||
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. **商业使用**:不得将本软件或其衍生版本用于任何商业目的,包括但不限于:
|
||||||
|
- 直接销售本软件或其衍生版本
|
||||||
|
- 提供收费的托管服务(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 家族)· 保留所有权利**
|
||||||
|
|||||||
@@ -106,15 +106,23 @@ npm run cli -- <command>
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `status` | 查看运行状态 |
|
| `status [--json]` | 查看版本、服务、数据库和备份状态 |
|
||||||
| `start` | 启动服务器 |
|
| `health [--json]` | HTTP 健康检查和 SQLite 完整性检查;不健康时返回非 0 |
|
||||||
| `stop` | 停止服务器 |
|
| `db-check [--json]` | 只读检查数据库完整性和关键表 |
|
||||||
| `restart` | 重启服务器 |
|
| `start` | 校验 PID 后启动服务器,已运行时不会重复启动 |
|
||||||
|
| `stop` | 只停止确认属于本项目的 `server.js`,优雅等待退出 |
|
||||||
|
| `restart` | 优雅停止并等待后启动服务器 |
|
||||||
| `port [number]` | 查看/修改端口 |
|
| `port [number]` | 查看/修改端口 |
|
||||||
| `password [new-pass]` | 修改管理员密码 |
|
| `password` | 交互式修改管理员密码(兼容旧的末尾参数写法) |
|
||||||
| `captcha` | 交互式配置验证码 |
|
| `captcha` | 交互式配置验证码;也支持 `--type`、`--login`、`--register`、`--forum`、`--failed`、`--threshold` |
|
||||||
| `config` | 查看所有配置 |
|
| `config [--json]` | 查看配置,敏感值统一脱敏 |
|
||||||
| `upgrade` | 一键升级(git pull + npm install + 重启) |
|
| `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` 设置毫秒超时。密码默认交互输入,不建议在命令行中传递明文密码。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -214,16 +222,18 @@ npm start
|
|||||||
## 升级
|
## 升级
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方法1: 一键升级(推荐)
|
# 方法1:一键升级(推荐;仅限自托管机器本地执行)
|
||||||
node cli.js upgrade
|
node cli.js upgrade
|
||||||
|
|
||||||
# 方法2: 手动
|
# 方法2:手动
|
||||||
git pull
|
git pull --ff-only
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
node cli.js restart
|
node cli.js restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`upgrade` 要求 git 工作区干净,只执行 fast-forward 更新;失败会返回非 0,不会假报成功。`backup prune` 默认只预览不删除,`backup restore` 必须同时满足服务已停止和 `--yes`。
|
||||||
|
|
||||||
## 版本
|
## 版本
|
||||||
|
|
||||||
当前版本记录在项目根目录的 `VERSION` 文件中,导航栏标题右侧会显示当前版本号。
|
当前版本记录在项目根目录的 `VERSION` 文件中,导航栏标题右侧会显示当前版本号。
|
||||||
|
|||||||
@@ -1,328 +1,632 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
const { execSync, spawn } = require('child_process');
|
const { execFileSync, spawn } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
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');
|
const PKG = require('./package.json');
|
||||||
const db = require('./db');
|
|
||||||
|
// 只读命令不加载 db.js,避免检查状态时意外触发数据库迁移或写入。
|
||||||
|
let writableDb = null;
|
||||||
|
function getWritableDb() {
|
||||||
|
if (!writableDb) writableDb = require('./db');
|
||||||
|
return writableDb;
|
||||||
|
}
|
||||||
|
|
||||||
const HELP = `
|
const HELP = `
|
||||||
RainWeb CLI v${PKG.version}
|
RainWeb CLI v${PKG.version}
|
||||||
Usage: node cli.js <command> [options]
|
用法:node cli.js <命令> [参数] [选项]
|
||||||
|
|
||||||
Commands:
|
命令:
|
||||||
status Show server and system status
|
status 查看版本、服务、数据库和备份状态
|
||||||
start Start the server
|
health HTTP 健康检查和 SQLite 完整性检查
|
||||||
restart Restart the server
|
db-check 只读检查 SQLite 完整性和关键表
|
||||||
stop Stop the server
|
start 启动服务器(防重复启动)
|
||||||
port [number] Show or change listen port (default: 3001)
|
stop 优雅停止本项目的 server.js
|
||||||
password [new-pass] Change admin password (leave empty for prompt)
|
restart 停止后等待并启动服务器
|
||||||
captcha Interactive captcha rule configuration
|
port [端口] 查看或修改监听端口
|
||||||
config Show all current settings
|
password 交互式修改管理员密码
|
||||||
backup Backup database to backups/ (readonly, safe to run while running)
|
captcha 交互式配置验证码规则
|
||||||
upgrade Git pull + npm install + restart (one-click upgrade)
|
config 查看配置(敏感值统一脱敏)
|
||||||
help Show this help
|
backup 创建数据库备份
|
||||||
|
backup list 列出并检查备份完整性
|
||||||
|
backup prune --keep <数量> 预览清理旧备份;加 --yes 才会删除
|
||||||
|
backup restore <文件> --yes 服务停止后恢复备份,显式确认才覆盖
|
||||||
|
upgrade 备份、检查工作区、快进升级、构建并验活
|
||||||
|
help 显示帮助
|
||||||
|
|
||||||
Examples:
|
通用选项:
|
||||||
node cli.js status
|
--yes 跳过需要确认的操作
|
||||||
node cli.js port 8080
|
--json 以 JSON 输出(status/health/config/backup list 等支持)
|
||||||
node cli.js password MyNewP@ss123
|
--timeout <毫秒> 设置 HTTP 检查超时(100-60000)
|
||||||
node cli.js captcha
|
|
||||||
node cli.js backup
|
说明:
|
||||||
node cli.js upgrade
|
password 默认交互输入;为兼容旧脚本仍接受末尾明文参数,但不在帮助示例中推荐。
|
||||||
|
upgrade 仅用于自托管机器上的本地操作,不会修改云端或远程服务。
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function main() {
|
class CliError extends Error {
|
||||||
const cmd = process.argv[2] || 'help';
|
constructor(message, code = 1) {
|
||||||
|
super(message);
|
||||||
switch (cmd) {
|
this.name = 'CliError';
|
||||||
case 'status': return cmdStatus();
|
this.exitCode = code;
|
||||||
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 'backup': return cmdBackup();
|
|
||||||
case 'upgrade': return cmdUpgrade();
|
|
||||||
case 'help':
|
|
||||||
default:
|
|
||||||
console.log(HELP);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Status ===
|
function parseArgs(argv) {
|
||||||
async function cmdStatus() {
|
const positionals = [];
|
||||||
console.log(`RainWeb v${PKG.version}`);
|
const options = { yes: false, json: false };
|
||||||
console.log(`Node.js: ${process.version}`);
|
const valueOptions = new Set(['keep', 'timeout', 'type', 'login', 'register', 'forum', 'failed', 'threshold']);
|
||||||
console.log(`Platform: ${process.platform}`);
|
const booleanOptions = new Set(['yes', 'json']);
|
||||||
console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`);
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
// 最近一次备份时间
|
if (token === '--') {
|
||||||
const latestBackup = getLatestBackup();
|
positionals.push(...argv.slice(i + 1));
|
||||||
console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份'));
|
break;
|
||||||
|
|
||||||
// Check if server is running
|
|
||||||
try {
|
|
||||||
await httpGet('http://localhost:' + (getConfigPort()));
|
|
||||||
console.log('Server: RUNNING');
|
|
||||||
} catch {
|
|
||||||
console.log('Server: STOPPED');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show admin info
|
|
||||||
try {
|
|
||||||
await db.getDb();
|
|
||||||
const admin = db.get("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'})`);
|
|
||||||
}
|
}
|
||||||
} 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() {
|
function getConfigPort() {
|
||||||
try {
|
const config = readJson(CONFIG_PATH);
|
||||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
const value = Number(process.env.PORT || config.port || 3001);
|
||||||
return config.port || 3001;
|
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : 3001;
|
||||||
} catch { return 3001; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConfigPort(port) {
|
function setConfigPort(port) {
|
||||||
let config = {};
|
const config = readJson(CONFIG_PATH);
|
||||||
try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {}
|
|
||||||
config.port = port;
|
config.port = port;
|
||||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmdPort() {
|
function dbExists() { return fs.existsSync(DB_PATH); }
|
||||||
const arg = process.argv[3];
|
|
||||||
if (arg) {
|
function openReadonlyDatabase(file = DB_PATH) {
|
||||||
const port = parseInt(arg);
|
if (!fs.existsSync(file)) throw new CliError(`数据库不存在:${file}`);
|
||||||
if (isNaN(port) || port < 1 || port > 65535) {
|
try { return new Database(file, { readonly: true, fileMustExist: true }); }
|
||||||
console.error('Invalid port number. Use 1-65535.');
|
catch (error) { throw new CliError(`无法打开数据库:${error.message}`); }
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
setConfigPort(port);
|
|
||||||
console.log(`Port set to ${port}. Restart to apply.`);
|
|
||||||
} else {
|
|
||||||
console.log(`Current port: ${getConfigPort()}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Password ===
|
function inspectDatabase(file = DB_PATH) {
|
||||||
async function cmdPassword() {
|
const connection = openReadonlyDatabase(file);
|
||||||
await warnIfServerRunning();
|
try {
|
||||||
let newPass = process.argv[3];
|
const quickCheck = connection.pragma('quick_check', { simple: true });
|
||||||
if (!newPass) {
|
const foreignKeyErrors = connection.prepare('PRAGMA foreign_key_check').all();
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
const tables = connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all().map((row) => row.name);
|
||||||
newPass = await new Promise(resolve => {
|
const requiredTables = ['users', 'site_settings', 'tickets', 'ticket_messages', 'ticket_events'];
|
||||||
rl.question('New admin password (min 6 chars): ', resolve);
|
const missingTables = requiredTables.filter((name) => !tables.includes(name));
|
||||||
});
|
return {
|
||||||
rl.close();
|
file,
|
||||||
}
|
size: fs.statSync(file).size,
|
||||||
if (!newPass || newPass.length < 6) {
|
userVersion: connection.pragma('user_version', { simple: true }),
|
||||||
console.error('Password must be at least 6 characters.');
|
quickCheck,
|
||||||
process.exit(1);
|
foreignKeyErrors,
|
||||||
}
|
missingTables,
|
||||||
|
valid: quickCheck === 'ok' && foreignKeyErrors.length === 0 && missingTables.length === 0,
|
||||||
await db.getDb();
|
};
|
||||||
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
|
} catch (error) {
|
||||||
if (!admin) { console.error('No admin user found.'); process.exit(1); }
|
throw new CliError(`数据库完整性检查失败:${error.message}`);
|
||||||
|
} finally { connection.close(); }
|
||||||
const hash = bcrypt.hashSync(newPass, 10);
|
|
||||||
db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
|
||||||
console.log('Admin password updated successfully.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Captcha ===
|
function formatBytes(bytes) {
|
||||||
async function cmdCaptcha() {
|
if (!Number.isFinite(bytes)) return '未知';
|
||||||
await warnIfServerRunning();
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
await db.getDb();
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
const q = (q) => new Promise(resolve => rl.question(q, resolve));
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
||||||
|
|
||||||
console.log('=== Captcha Rule Configuration ===\n');
|
|
||||||
console.log('Current settings:');
|
|
||||||
['login','register','forum','failed'].forEach(k => {
|
|
||||||
const v = db.get("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
|
|
||||||
console.log(` ${k}: ${v ? v.value : '0'}`);
|
|
||||||
});
|
|
||||||
const type = db.get("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) db.run("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
|
|
||||||
|
|
||||||
for (const scope of ['login', 'register', 'forum']) {
|
|
||||||
const current = db.get("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'}]: `);
|
|
||||||
db.run("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): ');
|
|
||||||
db.run("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) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
|
||||||
}
|
|
||||||
|
|
||||||
rl.close();
|
|
||||||
console.log('\nCaptcha rules updated.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Config ===
|
function formatMtime(date) {
|
||||||
async function cmdConfig() {
|
const pad = (value) => String(value).padStart(2, '0');
|
||||||
await db.getDb();
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
const rows = db.all("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()}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Start / Stop / Restart ===
|
function print(value, args) {
|
||||||
function findPidFile() { return path.join(__dirname, 'server.pid'); }
|
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) {
|
function isRunning(pid) {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cmdStop() {
|
function readProcessDetails(pid) {
|
||||||
const pidFile = findPidFile();
|
if (!isRunning(pid)) return null;
|
||||||
if (fs.existsSync(pidFile)) {
|
let cwd = '';
|
||||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'));
|
let command = '';
|
||||||
if (isRunning(pid)) {
|
if (process.platform === 'win32') {
|
||||||
try { process.kill(pid); console.log('Server stopped (PID: ' + pid + ')'); } catch { console.log('Could not stop process.'); }
|
try {
|
||||||
} else { console.log('Server not running.'); }
|
const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ' + $args[0]; if ($p) { $p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress }";
|
||||||
fs.unlinkSync(pidFile);
|
const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script, String(pid)], { encoding: 'utf8' }).trim();
|
||||||
} else {
|
const processInfo = output ? JSON.parse(output) : null;
|
||||||
// Try to find node process
|
command = processInfo && processInfo.CommandLine ? processInfo.CommandLine : '';
|
||||||
console.log('No PID file found. Try: taskkill /F /IM node.exe (Windows) or pkill node (Linux/Mac)');
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Backup ===
|
|
||||||
async function cmdBackup() {
|
|
||||||
const srcPath = path.join(__dirname, 'data', 'rainweb.db');
|
|
||||||
if (!fs.existsSync(srcPath)) {
|
|
||||||
console.error('数据库不存在:' + srcPath + '(首次启动 server 后才会创建)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backupDir = path.join(__dirname, 'backups');
|
|
||||||
fs.mkdirSync(backupDir, { recursive: true });
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const destPath = path.join(backupDir,
|
|
||||||
`rainweb-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.db`);
|
|
||||||
|
|
||||||
// 只读打开源库,不写数据,安全(server 运行中也可执行)
|
|
||||||
// better-sqlite3 v13:src.backup(destPath) 接受目标文件路径(string),返回 Promise
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
const src = new Database(srcPath, { readonly: true });
|
|
||||||
try {
|
try {
|
||||||
await src.backup(destPath);
|
if (process.platform !== 'win32') {
|
||||||
} finally {
|
cwd = fs.realpathSync(`/proc/${pid}/cwd`);
|
||||||
src.close();
|
command = fs.readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = fs.statSync(destPath).size;
|
|
||||||
console.log(`备份成功:${destPath}(${size} bytes)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 backups/ 目录下最新的备份文件(按 mtime),无备份返回 null
|
|
||||||
function getLatestBackup() {
|
|
||||||
const backupDir = path.join(__dirname, 'backups');
|
|
||||||
if (!fs.existsSync(backupDir)) return null;
|
|
||||||
let latest = null;
|
|
||||||
for (const f of fs.readdirSync(backupDir)) {
|
|
||||||
if (!f.startsWith('rainweb-') || !f.endsWith('.db')) continue;
|
|
||||||
const filePath = path.join(backupDir, f);
|
|
||||||
const mtime = fs.statSync(filePath).mtime;
|
|
||||||
if (!latest || mtime > latest.mtime) latest = { file: f, mtime };
|
|
||||||
}
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
||||||
|
|
||||||
function formatMtime(date) {
|
|
||||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Upgrade ===
|
|
||||||
async function cmdUpgrade() {
|
|
||||||
console.log('=== RainWeb Upgrade ===\n');
|
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(__dirname, '.git'))) {
|
|
||||||
console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('1. Pulling latest code via git...');
|
|
||||||
try {
|
|
||||||
execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
|
|
||||||
} catch {
|
|
||||||
console.error('Git pull failed. Check for conflicts.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n2. Installing dependencies...');
|
|
||||||
try {
|
|
||||||
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
|
|
||||||
} catch {
|
|
||||||
console.error('npm install failed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n3. Restarting server...');
|
|
||||||
await cmdRestart();
|
|
||||||
console.log('\n=== Upgrade complete! ===');
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Helper ===
|
|
||||||
// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行
|
|
||||||
async function warnIfServerRunning() {
|
|
||||||
try {
|
|
||||||
await httpGet('http://localhost:' + (getConfigPort()));
|
|
||||||
console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行');
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
if (!command && process.platform !== 'win32') {
|
||||||
|
try { command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim(); } catch {}
|
||||||
|
}
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function httpGet(url) {
|
function listCandidatePids() {
|
||||||
return new Promise((resolve, reject) => {
|
try { return fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)).map(Number); }
|
||||||
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); })
|
catch {
|
||||||
.on('error', reject);
|
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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,18 +27,25 @@ function initTables() {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
|
||||||
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
|
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
|
||||||
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
|
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')))`);
|
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||||
|
|
||||||
db.exec(`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,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||||
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
|
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
|
||||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||||
|
// M2:启动时清理超 15 分钟的 pending 注册记录(验证码过期,防表无限增长 / 验证码长期间可试)
|
||||||
|
db.exec("DELETE FROM pending_users WHERE created_at < datetime('now','-15 minutes')");
|
||||||
|
|
||||||
db.exec(`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,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||||
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
|
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||||
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
|
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
|
||||||
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
|
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')))`);
|
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||||
// Migration: add columns if missing
|
// Migration: add columns if missing
|
||||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||||
@@ -168,6 +175,235 @@ function migrateSchema() {
|
|||||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } 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 {}
|
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) {
|
for (const m of migrations) {
|
||||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||||
@@ -209,6 +445,10 @@ function seedDefaults() {
|
|||||||
captcha_register: '0',
|
captcha_register: '0',
|
||||||
captcha_forum: '0',
|
captcha_forum: '0',
|
||||||
captcha_type: 'builtin',
|
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>',
|
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_enabled: '0',
|
||||||
music_embed_code: '',
|
music_embed_code: '',
|
||||||
@@ -219,8 +459,19 @@ function seedDefaults() {
|
|||||||
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
|
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_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: '',
|
footer_desc: '',
|
||||||
|
// 页脚栏目自定义:JSON 字符串 [{title, links:[{label,url}]}];空串时前台回退现有硬编码栏目
|
||||||
|
footer_columns: '',
|
||||||
comment_moderate: '0',
|
comment_moderate: '0',
|
||||||
comment_notify: '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)) {
|
for (const [k, v] of Object.entries(defaults)) {
|
||||||
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
|
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
|
||||||
@@ -269,6 +520,13 @@ function setSetting(key, value) {
|
|||||||
else run('INSERT INTO site_settings (key, value) VALUES (?, ?)', [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() {
|
function seedSampleData() {
|
||||||
const existing = get("SELECT COUNT(*) as c FROM forum_categories");
|
const existing = get("SELECT COUNT(*) as c FROM forum_categories");
|
||||||
if (existing && existing.c > 0) return;
|
if (existing && existing.c > 0) return;
|
||||||
@@ -303,4 +561,4 @@ function seedSampleData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getDb, run, get, all, getSetting, setSetting };
|
module.exports = { getDb, run, get, all, getSetting, setSetting, transaction };
|
||||||
|
|||||||
@@ -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,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/
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { ThemeProvider } from './theme.jsx';
|
import { ThemeProvider } from './theme.jsx';
|
||||||
import Layout from './components/Layout.jsx';
|
import Layout from './components/Layout.jsx';
|
||||||
import Home from './pages/Home.jsx';
|
import Home from './pages/Home.jsx';
|
||||||
@@ -8,15 +8,21 @@ import BlogDetail from './pages/BlogDetail.jsx';
|
|||||||
import Tag from './pages/Tag.jsx';
|
import Tag from './pages/Tag.jsx';
|
||||||
import Archive from './pages/Archive.jsx';
|
import Archive from './pages/Archive.jsx';
|
||||||
import Forum from './pages/Forum.jsx';
|
import Forum from './pages/Forum.jsx';
|
||||||
|
import ForumCategory from './pages/ForumCategory.jsx';
|
||||||
import ForumDetail from './pages/ForumDetail.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 Passwords from './pages/Passwords.jsx';
|
||||||
import Login from './pages/Login.jsx';
|
import Login from './pages/Login.jsx';
|
||||||
import Register from './pages/Register.jsx';
|
import Register from './pages/Register.jsx';
|
||||||
import Profile from './pages/Profile.jsx';
|
import Profile from './pages/Profile.jsx';
|
||||||
import Write from './pages/Write.jsx';
|
import Write from './pages/Write.jsx';
|
||||||
import ForumManage from './pages/ForumManage.jsx';
|
|
||||||
import Embed from './pages/Embed.jsx';
|
import Embed from './pages/Embed.jsx';
|
||||||
import Setup from './pages/Setup.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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -30,13 +36,21 @@ export default function App() {
|
|||||||
<Route path="/tag/:name" element={<Tag />} />
|
<Route path="/tag/:name" element={<Tag />} />
|
||||||
<Route path="/archive.html" element={<Archive />} />
|
<Route path="/archive.html" element={<Archive />} />
|
||||||
<Route path="/forum.html" element={<Forum />} />
|
<Route path="/forum.html" element={<Forum />} />
|
||||||
|
<Route path="/forum/c/:id" element={<ForumCategory />} />
|
||||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
<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="/passwords.html" element={<Passwords />} />
|
||||||
<Route path="/login.html" element={<Login />} />
|
<Route path="/login.html" element={<Login />} />
|
||||||
<Route path="/register.html" element={<Register />} />
|
<Route path="/register.html" element={<Register />} />
|
||||||
<Route path="/profile.html" element={<Profile />} />
|
<Route path="/profile.html" element={<Profile />} />
|
||||||
<Route path="/write.html" element={<Write />} />
|
<Route path="/write.html" element={<Write />} />
|
||||||
<Route path="/forum-manage.html" element={<ForumManage />} />
|
{/* 前台论坛管理页已退役 → 后台 /admin/forum(服务端另有 302) */}
|
||||||
|
<Route path="/forum-manage.html" element={<Navigate to="/admin/forum" replace />} />
|
||||||
<Route path="/embed.html" element={<Embed />} />
|
<Route path="/embed.html" element={<Embed />} />
|
||||||
<Route path="/setup.html" element={<Setup />} />
|
<Route path="/setup.html" element={<Setup />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTheme as useMuiTheme } from '@mui/material/styles';
|
import { useTheme as useMuiTheme } from '@mui/material/styles';
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
@@ -13,44 +13,38 @@ import List from '@mui/material/List';
|
|||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
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 MenuIcon from '@mui/icons-material/Menu';
|
||||||
import HomeIcon from '@mui/icons-material/Home';
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
import GridViewIcon from '@mui/icons-material/GridView';
|
import GridViewIcon from '@mui/icons-material/GridView';
|
||||||
import LogoutIcon from '@mui/icons-material/Logout';
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||||
import PaletteIcon from '@mui/icons-material/Palette';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import EmailIcon from '@mui/icons-material/Email';
|
|
||||||
import ArticleIcon from '@mui/icons-material/Article';
|
|
||||||
import ForumIcon from '@mui/icons-material/Forum';
|
|
||||||
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
|
|
||||||
import PeopleIcon from '@mui/icons-material/People';
|
|
||||||
import CampaignIcon from '@mui/icons-material/Campaign';
|
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
|
||||||
import { logout } from '../api/auth.js';
|
import { logout } from '../api/auth.js';
|
||||||
import SnackHost, { showSnack } from './snack.jsx';
|
import SnackHost, { showSnack } from './snack.jsx';
|
||||||
|
import { NAV_GROUPS, searchNav } from './navConfig.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
/** 等元素挂载(跨路由跳转后目标页异步渲染,最多轮询 ~2s) */
|
||||||
{ path: '/dashboard', label: '仪表盘', icon: <DashboardIcon /> },
|
function waitForId(id, tries = 40) {
|
||||||
{ path: '/settings', label: '站点设置', icon: <SettingsIcon /> },
|
return document.getElementById(id)
|
||||||
{ path: '/captcha', label: '验证码', icon: <VerifiedUserIcon /> },
|
? Promise.resolve()
|
||||||
{ path: '/theme', label: '主题', icon: <PaletteIcon /> },
|
: tries > 0
|
||||||
{ path: '/homepage', label: '首页设置', icon: <HomeIcon /> },
|
? new Promise((res) => setTimeout(() => res(waitForId(id, tries - 1)), 50))
|
||||||
{ path: '/email', label: '邮件配置', icon: <EmailIcon /> },
|
: Promise.resolve();
|
||||||
{ path: '/blog', label: '博客管理', icon: <ArticleIcon /> },
|
}
|
||||||
{ path: '/comments', label: '评论管理', icon: <ChatBubbleOutlineOutlinedIcon /> },
|
|
||||||
{ path: '/forum', label: '论坛管理', icon: <ForumIcon /> },
|
|
||||||
{ path: '/users', label: '用户管理', icon: <PeopleIcon /> },
|
|
||||||
{ path: '/announcements', label: '公告管理', icon: <CampaignIcon /> },
|
|
||||||
{ path: '/links', label: '面板链接', icon: <LinkIcon /> },
|
|
||||||
{ path: '/uploads', label: '附件管理', icon: <AttachFileIcon /> },
|
|
||||||
{ path: '/import', label: '数据导入', icon: <UploadFileIcon /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 后台布局:AppBar(返回前台 + 退出)+ Drawer 导航 + 内容区 Outlet */
|
/** 顶栏搜索下拉最多展示条数 */
|
||||||
|
const SEARCH_LIMIT = 5;
|
||||||
|
|
||||||
|
/** 后台布局:AppBar(中间设置搜索 + 工作台/返回前台/退出)+ Drawer(分组折叠导航)+ 内容区 Outlet */
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -58,19 +52,193 @@ export default function AdminLayout() {
|
|||||||
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
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 = (
|
const drawerContent = (
|
||||||
<List>
|
<>
|
||||||
{NAV_ITEMS.map((item) => (
|
<Toolbar />
|
||||||
<ListItemButton
|
<List dense>{NAV_GROUPS.map(renderGroup)}</List>
|
||||||
key={item.path}
|
</>
|
||||||
selected={location.pathname === item.path}
|
|
||||||
onClick={() => { navigate(item.path); setMobileOpen(false); }}
|
|
||||||
>
|
|
||||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
|
||||||
<ListItemText primary={item.label} />
|
|
||||||
</ListItemButton>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -82,24 +250,40 @@ export default function AdminLayout() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
||||||
<Toolbar>
|
{/* 顶行:汉堡(移动) + 标题(左) + 搜索(桌面中) + 操作按钮(右) */}
|
||||||
|
<Toolbar sx={{ gap: 1, minHeight: isMobile ? 56 : 64 }}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 1 }}>
|
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 0.5 }}>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography>
|
<Typography variant="h6" noWrap sx={{ flexShrink: 0, fontSize: { xs: 18, sm: 22 } }}>管理后台</Typography>
|
||||||
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
|
{!isMobile && (
|
||||||
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
|
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', minWidth: 0, px: 2 }}>
|
||||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
|
<Box sx={{ width: '100%', maxWidth: 480 }}>{searchField}</Box>
|
||||||
</Button>
|
</Box>
|
||||||
<Button color="inherit" href="/" title="返回前台">
|
)}
|
||||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0, sm: 0.5 }, ml: 'auto', flexShrink: 0 }}>
|
||||||
</Button>
|
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
|
||||||
<Button color="inherit" onClick={handleLogout}>
|
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
|
||||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />退出
|
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
|
||||||
</Button>
|
</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>
|
</Toolbar>
|
||||||
|
{/* 移动:第二行全宽搜索(顶栏空间不足,收窄到独立一行不拥挤) */}
|
||||||
|
{isMobile && (
|
||||||
|
<Toolbar variant="dense" sx={{ pt: 0, pb: 1, gap: 1, minHeight: 48 }}>
|
||||||
|
{searchField}
|
||||||
|
</Toolbar>
|
||||||
|
)}
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
|
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
|
||||||
@@ -111,19 +295,18 @@ export default function AdminLayout() {
|
|||||||
ModalProps={{ keepMounted: true }}
|
ModalProps={{ keepMounted: true }}
|
||||||
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
|
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
|
||||||
>
|
>
|
||||||
<Toolbar />
|
|
||||||
{drawerContent}
|
{drawerContent}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
) : (
|
) : (
|
||||||
<Drawer variant="permanent" sx={{ width: 240, '& .MuiDrawer-paper': { width: 240 } }}>
|
<Drawer variant="permanent" sx={{ width: 240, '& .MuiDrawer-paper': { width: 240 } }}>
|
||||||
<Toolbar />
|
|
||||||
{drawerContent}
|
{drawerContent}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box component="main" sx={{ flexGrow: 1, p: { xs: 2, md: 3 }, minWidth: 0 }}>
|
<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' }}>
|
||||||
<Toolbar />
|
{/* AppBar 占位:桌面 64 / 移动 56 + 搜索行 48 */}
|
||||||
|
<Box sx={{ height: isMobile ? 104 : 64 }} />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
<SnackHost />
|
<SnackHost />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { lazy, Suspense, useEffect, useState } from 'react';
|
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
@@ -6,6 +6,7 @@ import CssBaseline from '@mui/material/CssBaseline';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { createMD3Theme } from './theme.js';
|
import { createMD3Theme } from './theme.js';
|
||||||
|
import { THEME_SETTINGS_EVENT } from '../themeTokens.js';
|
||||||
import './theme.css';
|
import './theme.css';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import { me } from '../api/auth.js';
|
import { me } from '../api/auth.js';
|
||||||
@@ -20,11 +21,14 @@ import EmailSettings from './pages/EmailSettings.jsx';
|
|||||||
import BlogManage from './pages/BlogManage.jsx';
|
import BlogManage from './pages/BlogManage.jsx';
|
||||||
import CommentManage from './pages/CommentManage.jsx';
|
import CommentManage from './pages/CommentManage.jsx';
|
||||||
import ForumManage from './pages/ForumManage.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 Users from './pages/Users.jsx';
|
||||||
import Announcements from './pages/Announcements.jsx';
|
import Announcements from './pages/Announcements.jsx';
|
||||||
import Links from './pages/Links.jsx';
|
import Links from './pages/Links.jsx';
|
||||||
import Uploads from './pages/Uploads.jsx';
|
import Uploads from './pages/Uploads.jsx';
|
||||||
import ImportDb from './pages/ImportDb.jsx';
|
import ImportDb from './pages/ImportDb.jsx';
|
||||||
|
import TicketManage from './pages/TicketManage.jsx';
|
||||||
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
||||||
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
||||||
|
|
||||||
@@ -35,14 +39,13 @@ const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/a
|
|||||||
|
|
||||||
function AdminApp() {
|
function AdminApp() {
|
||||||
const [status, setStatus] = useState('checking'); // checking | ok
|
const [status, setStatus] = useState('checking'); // checking | ok
|
||||||
const [primary, setPrimary] = useState('#6750a4');
|
const [themeSettings, setThemeSettings] = useState({ primary_color: '#6750a4', glass_opacity: '0.6', theme_force_dark: '0' });
|
||||||
|
|
||||||
// 深色模式跟随前台偏好:整页跳转后 data-theme 属性不保留,
|
const preferredTheme = localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
|
||||||
// 以 localStorage 的 theme 为兜底(前台 theme.jsx 持久化同一 key)。
|
const dark = themeSettings.theme_force_dark === '1' || preferredTheme === 'dark';
|
||||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
||||||
|| localStorage.getItem('theme') === 'dark';
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
|
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
else document.documentElement.removeAttribute('data-theme');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,10 +62,26 @@ function AdminApp() {
|
|||||||
})
|
})
|
||||||
.catch(() => { window.location.href = '/login.html'; });
|
.catch(() => { window.location.href = '/login.html'; });
|
||||||
getSettings()
|
getSettings()
|
||||||
.then((s) => { if (s.primary_color) setPrimary(s.primary_color); })
|
.then((s) => setThemeSettings((current) => ({ ...current, ...s })))
|
||||||
.catch(() => {});
|
.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') {
|
if (status !== 'ok') {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
@@ -71,8 +90,6 @@ function AdminApp() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const theme = createMD3Theme({ mode: dark ? 'dark' : 'light', primary });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
@@ -104,11 +121,14 @@ function AdminApp() {
|
|||||||
<Route path="/blog" element={<BlogManage />} />
|
<Route path="/blog" element={<BlogManage />} />
|
||||||
<Route path="/comments" element={<CommentManage />} />
|
<Route path="/comments" element={<CommentManage />} />
|
||||||
<Route path="/forum" element={<ForumManage />} />
|
<Route path="/forum" element={<ForumManage />} />
|
||||||
|
<Route path="/posts" element={<PostManage />} />
|
||||||
|
<Route path="/rss" element={<RssManage />} />
|
||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
<Route path="/announcements" element={<Announcements />} />
|
<Route path="/announcements" element={<Announcements />} />
|
||||||
<Route path="/links" element={<Links />} />
|
<Route path="/links" element={<Links />} />
|
||||||
<Route path="/uploads" element={<Uploads />} />
|
<Route path="/uploads" element={<Uploads />} />
|
||||||
<Route path="/import" element={<ImportDb />} />
|
<Route path="/import" element={<ImportDb />} />
|
||||||
|
<Route path="/tickets" element={<TicketManage />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -20,10 +20,13 @@ import { getSettings, saveSettings } from '../../api/settings.js';
|
|||||||
import { showSnack } from '../snack.jsx';
|
import { showSnack } from '../snack.jsx';
|
||||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||||
|
|
||||||
/** 博客管理:博客侧栏开关 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
|
/** 博客管理:博客侧栏开关 + 评论设置 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
|
||||||
export default function BlogManage() {
|
export default function BlogManage() {
|
||||||
const [posts, setPosts] = useState(null);
|
const [posts, setPosts] = useState(null);
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
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 [confirm, setConfirm] = useState(null); // { id, title }
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -35,7 +38,12 @@ export default function BlogManage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
getSettings().then((s) => setShowSidebar(s.blog_show_sidebar !== '0')).catch(() => {});
|
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]);
|
}, [load]);
|
||||||
|
|
||||||
const saveSidebar = async () => {
|
const saveSidebar = async () => {
|
||||||
@@ -47,6 +55,19 @@ export default function BlogManage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const togglePublish = async (p) => {
|
||||||
try {
|
try {
|
||||||
await updatePost(p.id, {
|
await updatePost(p.id, {
|
||||||
@@ -84,7 +105,7 @@ export default function BlogManage() {
|
|||||||
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
|
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper sx={{ p: 2, mb: 2, maxWidth: 640 }}>
|
<Paper id="sec-blog-sidebar" sx={{ p: 2, mb: 2, maxWidth: 640 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
|
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
|
||||||
@@ -94,6 +115,40 @@ export default function BlogManage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</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}>
|
<TableContainer component={Paper}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function CaptchaSettings() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 640 }}>
|
<Box sx={{ maxWidth: 640 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
|
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
|
||||||
<Paper sx={{ p: 3 }}>
|
<Paper id="sec-captcha-type" sx={{ p: 3 }}>
|
||||||
<FormControl fullWidth margin="normal">
|
<FormControl fullWidth margin="normal">
|
||||||
<InputLabel>验证码类型</InputLabel>
|
<InputLabel>验证码类型</InputLabel>
|
||||||
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
|
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
|
||||||
@@ -86,7 +86,7 @@ export default function CaptchaSettings() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{showScope && (
|
{showScope && (
|
||||||
<FormGroup>
|
<FormGroup id="sec-captcha-scope">
|
||||||
<FormControlLabel control={<Switch checked={form.captcha_login} onChange={setSwitch('captcha_login')} />} label="登录验证" />
|
<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_register} onChange={setSwitch('captcha_register')} />} label="注册验证" />
|
||||||
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
|
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
|
||||||
@@ -94,7 +94,7 @@ export default function CaptchaSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showRecaptcha && (
|
{showRecaptcha && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box id="sec-captcha-recaptcha" sx={{ mt: 2 }}>
|
||||||
<Typography variant="subtitle1">Google reCAPTCHA V2 配置</Typography>
|
<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="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..." />
|
<TextField fullWidth label="Secret Key" value={form.recaptcha_secret_key} onChange={set('recaptcha_secret_key')} margin="normal" placeholder="6L..." />
|
||||||
@@ -102,7 +102,7 @@ export default function CaptchaSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showTurnstile && (
|
{showTurnstile && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box id="sec-captcha-turnstile" sx={{ mt: 2 }}>
|
||||||
<Typography variant="subtitle1">Cloudflare Turnstile 配置</Typography>
|
<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="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..." />
|
<TextField fullWidth label="Secret Key" value={form.turnstile_secret_key} onChange={set('turnstile_secret_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||||
|
|||||||
@@ -9,24 +9,53 @@ import ListItem from '@mui/material/ListItem';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import IconButton from '@mui/material/IconButton';
|
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 RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { listPendingComments, approveComment, rejectComment } from '../../api/blog.js';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import { listPendingComments, listAllComments, approveComment, rejectComment, deleteComment } from '../../api/blog.js';
|
||||||
import { showSnack } from '../snack.jsx';
|
import { showSnack } from '../snack.jsx';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
/** 时间格式化:yyyy-mm-dd hh:mm */
|
/** 时间格式化:yyyy-mm-dd hh:mm */
|
||||||
function fmtTime(t) {
|
function fmtTime(t) {
|
||||||
if (!t) return '';
|
if (!t) return '';
|
||||||
return String(t).replace('T', ' ').slice(0, 16);
|
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() {
|
export default function CommentManage() {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [pending, setPending] = useState([]);
|
const [pending, setPending] = useState([]);
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
const load = useCallback(() => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
listPendingComments()
|
listPendingComments()
|
||||||
@@ -37,6 +66,23 @@ export default function CommentManage() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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) => {
|
const act = async (id, fn, okMsg) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -49,17 +95,34 @@ export default function CommentManage() {
|
|||||||
setBusy(false);
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
<Typography variant="h5">评论管理</Typography>
|
<Typography variant="h5">评论管理</Typography>
|
||||||
<IconButton onClick={load} title="刷新" disabled={busy}><RefreshIcon /></IconButton>
|
<IconButton onClick={() => (tab === 0 ? load() : loadAll(allPage))} title="刷新" disabled={busy || allLoading}><RefreshIcon /></IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper sx={{ mb: 2 }}>
|
<Paper sx={{ mb: 2 }}>
|
||||||
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
|
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
|
||||||
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
|
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
|
||||||
<Tab label="全部评论" />
|
<Tab label={`全部评论${allTotal ? ` (${allTotal})` : ''}`} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -114,12 +177,62 @@ export default function CommentManage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
<>
|
||||||
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
|
<TableContainer component={Paper}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Table size="small">
|
||||||
当前后端仅提供「待审核」评论的管理接口;已通过 / 已拒绝评论可在对应文章页查看。
|
<TableHead>
|
||||||
</Typography>
|
<TableRow>
|
||||||
</Paper>
|
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ export default function EmailSettings() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 640 }}>
|
<Box sx={{ maxWidth: 640 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
|
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-smtp" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>SMTP 服务器</Typography>
|
<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="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="端口" 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="用户名" 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" />
|
<TextField fullWidth label="密码" type="password" value={form.smtp_pass} onChange={set('smtp_pass')} margin="normal" />
|
||||||
</Paper>
|
</Paper>
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-sender" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>发件人信息</Typography>
|
<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="发件人邮箱" 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" />
|
<TextField fullWidth label="发件人名称" value={form.smtp_from_name} onChange={set('smtp_from_name')} margin="normal" placeholder="RainWeb" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
import Card from '@mui/material/Card';
|
import Card from '@mui/material/Card';
|
||||||
import CardContent from '@mui/material/CardContent';
|
import CardContent from '@mui/material/CardContent';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
@@ -10,41 +11,90 @@ import IconButton from '@mui/material/IconButton';
|
|||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
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 Dialog from '@mui/material/Dialog';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import { listCategories, createCategory, updateCategory, deleteCategory, listPosts } from '../../api/forum.js';
|
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 { showSnack } from '../snack.jsx';
|
||||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||||
|
|
||||||
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
|
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0', icon: '', icon_color: '' };
|
||||||
|
|
||||||
/** 论坛管理:板块卡片(帖子数)+ 添加/编辑弹窗 + 删除(MUI 版,同前台 ForumManage API) */
|
// 版块图标色板(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() {
|
export default function ForumManage() {
|
||||||
const [cats, setCats] = useState([]);
|
const [cats, setCats] = useState([]);
|
||||||
const [counts, setCounts] = useState({});
|
|
||||||
const [dialog, setDialog] = useState(false);
|
const [dialog, setDialog] = useState(false);
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [form, setForm] = useState(EMPTY);
|
const [form, setForm] = useState(EMPTY);
|
||||||
const [confirm, setConfirm] = useState(null);
|
const [confirm, setConfirm] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
const load = useCallback(() => {
|
||||||
listCategories()
|
listCategories()
|
||||||
.then((cs) => setCats(cs || []))
|
.then((cs) => setCats(cs || []))
|
||||||
.catch((e) => showSnack(e.message, 'error'));
|
.catch((e) => showSnack(e.message, 'error'));
|
||||||
listPosts()
|
|
||||||
.then((ps) => {
|
|
||||||
const c = {};
|
|
||||||
(ps || []).forEach((p) => { c[p.category_id] = (c[p.category_id] || 0) + 1; });
|
|
||||||
setCounts(c);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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 openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||||
const openEdit = (c) => {
|
const openEdit = (c) => {
|
||||||
@@ -55,6 +105,8 @@ export default function ForumManage() {
|
|||||||
announcement: c.announcement || '',
|
announcement: c.announcement || '',
|
||||||
sub_categories: c.sub_categories || '',
|
sub_categories: c.sub_categories || '',
|
||||||
sort_order: String(c.sort_order || 0),
|
sort_order: String(c.sort_order || 0),
|
||||||
|
icon: c.icon || '',
|
||||||
|
icon_color: c.icon_color || '',
|
||||||
});
|
});
|
||||||
setDialog(true);
|
setDialog(true);
|
||||||
};
|
};
|
||||||
@@ -68,6 +120,8 @@ export default function ForumManage() {
|
|||||||
announcement: form.announcement.trim(),
|
announcement: form.announcement.trim(),
|
||||||
sub_categories: form.sub_categories.trim(),
|
sub_categories: form.sub_categories.trim(),
|
||||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||||
|
icon: form.icon.trim(),
|
||||||
|
icon_color: form.icon_color.trim(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (editingId) await updateCategory(editingId, data);
|
if (editingId) await updateCategory(editingId, data);
|
||||||
@@ -93,6 +147,50 @@ export default function ForumManage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 版主指派 ──
|
||||||
|
// 分类聚合里的 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
@@ -100,34 +198,76 @@ export default function ForumManage() {
|
|||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
|
||||||
</Box>
|
</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 ? (
|
{cats.length === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary">暂无板块,点击"添加板块"创建</Typography>
|
<Typography variant="body2" color="text.secondary">暂无板块,点击"添加板块"创建</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{cats.map((c) => (
|
{cats.map((c) => {
|
||||||
<Grid item xs={12} sm={6} md={4} key={c.id}>
|
const mods = moderatorNames(c);
|
||||||
<Card>
|
return (
|
||||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<Grid item xs={12} sm={6} md={4} key={c.id}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
<Card>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>{c.name}</Typography>
|
<CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Chip size="small" label={`排序 ${c.sort_order}`} variant="outlined" />
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
</Box>
|
<Box
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, mb: 1 }}>{c.description || '无描述'}</Typography>
|
sx={{
|
||||||
{c.announcement && (
|
width: 36, height: 36, borderRadius: 3, flexShrink: 0, fontWeight: 700, fontSize: 17,
|
||||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>📢 {c.announcement}</Typography>
|
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
||||||
)}
|
background: c.icon_color || 'rgba(0,0,0,0.08)',
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>{counts[c.id] || 0} 个帖子</Typography>
|
color: '#fff',
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
|
}}
|
||||||
<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>
|
{c.icon ? (
|
||||||
</Box>
|
/^https?:\/\//i.test(c.icon)
|
||||||
</CardContent>
|
? <Box component="img" src={c.icon} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
</Card>
|
: c.icon
|
||||||
</Grid>
|
) : (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>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 添加 / 编辑板块 */}
|
||||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
|
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -135,7 +275,33 @@ export default function ForumManage() {
|
|||||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: 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="板块公告" 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="例: 求助,分享,讨论,建议" />
|
<TextField fullWidth label="帖子分类(逗号分隔)" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} margin="normal" placeholder="例: 求助,分享,讨论,建议" />
|
||||||
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
|
<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>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||||
@@ -143,6 +309,65 @@ export default function ForumManage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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
|
<ConfirmDialog
|
||||||
open={!!confirm}
|
open={!!confirm}
|
||||||
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
|
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
|
||||||
|
|||||||
@@ -103,14 +103,21 @@ export default function Homepage() {
|
|||||||
<Box sx={{ maxWidth: 720 }}>
|
<Box sx={{ maxWidth: 720 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
|
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-profile" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>个人信息</Typography>
|
<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="头像 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="一段简短的自我介绍" />
|
<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>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, mb: 1 }}>联系链接(最多5个)</Typography>
|
||||||
{contacts.map((c, i) => (
|
{contacts.map((c, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
<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: 110 }} />
|
<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="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 }} />
|
<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>
|
<IconButton size="small" color="error" onClick={() => removeContact(i)} title="删除"><DeleteIcon fontSize="small" /></IconButton>
|
||||||
@@ -119,7 +126,7 @@ export default function Homepage() {
|
|||||||
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
|
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-home-content" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主页内容</Typography>
|
<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] 标签" />
|
<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' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
@@ -131,7 +138,7 @@ export default function Homepage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-music" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
|
||||||
<FormControlLabel control={<Switch checked={form.music_embed_enabled} onChange={setSwitch('music_embed_enabled')} />} label="所有页面显示音乐播放器" />
|
<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 fullWidth label="嵌入代码" multiline rows={3} value={form.music_embed_code} onChange={set('music_embed_code')} margin="normal" placeholder="粘贴网易云音乐 iframe 代码" />
|
||||||
|
|||||||
@@ -21,22 +21,52 @@ import DialogActions from '@mui/material/DialogActions';
|
|||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
|
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
|
||||||
|
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||||
import { showSnack } from '../snack.jsx';
|
import { showSnack } from '../snack.jsx';
|
||||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
title: '', url: '', embed_url: '', use_proxy: false, description: '',
|
title: '', url: '', embed_url: '', use_proxy: false, description: '',
|
||||||
icon: '', category: '默认', version: '', sort_order: '0',
|
icon: '', category: '默认', version: '', sort_order: '0',
|
||||||
|
slug: '', trusted: true, permissions: [], scale: '1',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 面板链接管理:CRUD + 代理嵌入开关(迁移自 v1 面板链接卡片) */
|
/* 面板代理配置字段的展示与编辑选项(与工作台 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() {
|
export default function Links() {
|
||||||
const [links, setLinks] = useState(null);
|
const [links, setLinks] = useState(null);
|
||||||
const [dialog, setDialog] = useState(false);
|
const [dialog, setDialog] = useState(false);
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [form, setForm] = useState(EMPTY);
|
const [form, setForm] = useState(EMPTY);
|
||||||
const [confirm, setConfirm] = useState(null);
|
const [confirm, setConfirm] = useState(null);
|
||||||
|
const [proxyHosts, setProxyHosts] = useState('');
|
||||||
|
const [proxySaving, setProxySaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
listAdminLinks()
|
listAdminLinks()
|
||||||
@@ -44,7 +74,10 @@ export default function Links() {
|
|||||||
.catch((e) => showSnack(e.message, 'error'));
|
.catch((e) => showSnack(e.message, 'error'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
getSettings().then((s) => setProxyHosts(s.proxy_allowed_hosts || '')).catch(() => {});
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||||
const openEdit = (l) => {
|
const openEdit = (l) => {
|
||||||
@@ -54,6 +87,10 @@ export default function Links() {
|
|||||||
url: l.url,
|
url: l.url,
|
||||||
embed_url: l.embed_url || '',
|
embed_url: l.embed_url || '',
|
||||||
use_proxy: !!l.use_proxy,
|
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 || '',
|
description: l.description || '',
|
||||||
icon: l.icon || '',
|
icon: l.icon || '',
|
||||||
category: l.category || '默认',
|
category: l.category || '默认',
|
||||||
@@ -70,6 +107,10 @@ export default function Links() {
|
|||||||
url: form.url.trim(),
|
url: form.url.trim(),
|
||||||
embed_url: form.embed_url.trim(),
|
embed_url: form.embed_url.trim(),
|
||||||
use_proxy: form.use_proxy ? 1 : 0,
|
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(),
|
description: form.description.trim(),
|
||||||
icon: form.icon.trim(),
|
icon: form.icon.trim(),
|
||||||
category: form.category.trim() || '默认',
|
category: form.category.trim() || '默认',
|
||||||
@@ -99,6 +140,17 @@ export default function Links() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveProxy = async () => {
|
||||||
|
setProxySaving(true);
|
||||||
|
try {
|
||||||
|
await saveSettings({ proxy_allowed_hosts: proxyHosts });
|
||||||
|
showSnack('面板代理设置已保存');
|
||||||
|
} catch (e) {
|
||||||
|
showSnack(e.message, 'error');
|
||||||
|
}
|
||||||
|
setProxySaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
@@ -115,24 +167,43 @@ export default function Links() {
|
|||||||
<TableCell>版本</TableCell>
|
<TableCell>版本</TableCell>
|
||||||
<TableCell>URL</TableCell>
|
<TableCell>URL</TableCell>
|
||||||
<TableCell>嵌入URL</TableCell>
|
<TableCell>嵌入URL</TableCell>
|
||||||
|
<TableCell>代理配置</TableCell>
|
||||||
<TableCell>分类</TableCell>
|
<TableCell>分类</TableCell>
|
||||||
<TableCell align="right">操作</TableCell>
|
<TableCell align="right">操作</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{links === null ? (
|
{links === null ? (
|
||||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
|
||||||
) : links.length === 0 ? (
|
) : links.length === 0 ? (
|
||||||
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
|
<TableRow><TableCell colSpan={8}>暂无面板</TableCell></TableRow>
|
||||||
) : links.map((l) => (
|
) : links.map((l) => (
|
||||||
<TableRow key={l.id}>
|
<TableRow key={l.id}>
|
||||||
<TableCell>{l.sort_order}</TableCell>
|
<TableCell>{l.sort_order}</TableCell>
|
||||||
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</Box></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={{ color: 'text.secondary', fontSize: 13 }}>{l.version || '-'}</TableCell>
|
||||||
<TableCell sx={{ maxWidth: 180 }}>
|
<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>
|
<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>
|
||||||
<TableCell sx={{ maxWidth: 140, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.embed_url || '-'}</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><Chip size="small" label={l.category} variant="outlined" /></TableCell>
|
||||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
|
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
|
||||||
@@ -144,6 +215,27 @@ export default function Links() {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</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">
|
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
|
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -151,6 +243,44 @@ export default function Links() {
|
|||||||
<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 *" 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="留空则新标签页打开" />
|
<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 限制)" />
|
<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="描述" 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="图标 (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.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,8 +10,17 @@ import FormHelperText from '@mui/material/FormHelperText';
|
|||||||
import Radio from '@mui/material/Radio';
|
import Radio from '@mui/material/Radio';
|
||||||
import RadioGroup from '@mui/material/RadioGroup';
|
import RadioGroup from '@mui/material/RadioGroup';
|
||||||
import Switch from '@mui/material/Switch';
|
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 { getSettings, saveSettings } from '../../api/settings.js';
|
||||||
|
import { uploadVerifyFile, listVerifyFiles, deleteVerifyFile } from '../../api/upload.js';
|
||||||
import { showSnack } from '../snack.jsx';
|
import { showSnack } from '../snack.jsx';
|
||||||
|
import { parseFooterColumns } from '../../lib/outlink.js';
|
||||||
|
|
||||||
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
|
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
|
||||||
const FOOTER_STYLES = [
|
const FOOTER_STYLES = [
|
||||||
@@ -20,7 +29,7 @@ const FOOTER_STYLES = [
|
|||||||
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 基本设置 + 页脚设置(v2:页脚样式 / 版权 / Powered by 均支持自定义) */
|
/** 基本设置 + 页脚设置 + 搜索引擎验证 + RainID 单点登录(v2) */
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
site_name: '',
|
site_name: '',
|
||||||
@@ -31,14 +40,64 @@ export default function Settings() {
|
|||||||
footer_copyright: '',
|
footer_copyright: '',
|
||||||
footer_powered: '',
|
footer_powered: '',
|
||||||
footer_desc: '',
|
footer_desc: '',
|
||||||
comment_moderate: '0',
|
rainid_enabled: '0',
|
||||||
comment_notify: '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 [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(() => {
|
useEffect(() => {
|
||||||
getSettings()
|
getSettings()
|
||||||
.then((s) => setForm({
|
.then((s) => {
|
||||||
|
setForm({
|
||||||
site_name: s.site_name || '',
|
site_name: s.site_name || '',
|
||||||
site_description: s.site_description || '',
|
site_description: s.site_description || '',
|
||||||
site_url: s.site_url || '',
|
site_url: s.site_url || '',
|
||||||
@@ -47,10 +106,17 @@ export default function Settings() {
|
|||||||
footer_copyright: s.footer_copyright || '',
|
footer_copyright: s.footer_copyright || '',
|
||||||
footer_powered: s.footer_powered || '',
|
footer_powered: s.footer_powered || '',
|
||||||
footer_desc: s.footer_desc || '',
|
footer_desc: s.footer_desc || '',
|
||||||
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
|
rainid_enabled: s.rainid_enabled === '1' ? '1' : '0',
|
||||||
comment_notify: s.comment_notify === '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'));
|
.catch((e) => showSnack(e.message, 'error'));
|
||||||
|
loadVerifyFiles(); // 加载已上传的验证文件
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||||
@@ -59,7 +125,10 @@ export default function Settings() {
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveSettings(form);
|
const body = { ...form, footer_columns: JSON.stringify(columns) };
|
||||||
|
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
|
||||||
|
if (!body.rainid_client_secret) delete body.rainid_client_secret;
|
||||||
|
await saveSettings(body);
|
||||||
showSnack('设置已保存');
|
showSnack('设置已保存');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnack(e.message, 'error');
|
showSnack(e.message, 'error');
|
||||||
@@ -67,44 +136,34 @@ export default function Settings() {
|
|||||||
setSaving(false);
|
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;
|
const footStyle = form.footer_style;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 640 }}>
|
<Box sx={{ maxWidth: 640 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
|
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
|
||||||
|
|
||||||
<Paper sx={{ p: 3 }}>
|
<Paper id="sec-basic" sx={{ p: 3 }}>
|
||||||
<TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" />
|
<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="网站描述(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="网站域名" 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" />
|
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mt: 2 }}>
|
<Paper id="sec-footer" sx={{ p: 3, mt: 2 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>评论设置</Typography>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Switch checked={form.comment_moderate === '1'} onChange={toggle('comment_moderate')} />}
|
|
||||||
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={form.comment_notify === '1'} onChange={toggle('comment_notify')} />}
|
|
||||||
label={(
|
|
||||||
<Box sx={{ py: 0.5 }}>
|
|
||||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
|
|
||||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mt: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
|
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
|
||||||
|
|
||||||
<FormControl component="fieldset" sx={{ mb: 1 }}>
|
<FormControl component="fieldset" sx={{ mb: 1 }}>
|
||||||
@@ -131,8 +190,50 @@ export default function Settings() {
|
|||||||
<TextField fullWidth label="网站简介(页脚品牌区)" value={form.footer_desc} onChange={set('footer_desc')} margin="normal" placeholder="留空则自动使用网站描述" multiline minRows={2} />
|
<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." />
|
<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 sx={{ mt: 1 }}>
|
||||||
导航栏目固定不可编辑(首页 / 博客 / 论坛 / 管理后台 / 个人中心)
|
分栏导航默认显示固定栏目(首页 / 博客 / 论坛 / 管理后台 / 个人中心),可在下方自定义
|
||||||
</FormHelperText>
|
</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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -142,6 +243,113 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</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 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import { getSettings, saveSettings } from '../../api/settings.js';
|
|||||||
import { uploadWallpaper } from '../../api/upload.js';
|
import { uploadWallpaper } from '../../api/upload.js';
|
||||||
import { showSnack } from '../snack.jsx';
|
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 主题卡片) */
|
/** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */
|
||||||
export default function ThemeSettings() {
|
export default function ThemeSettings() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -61,10 +70,15 @@ export default function ThemeSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
const validationError = validateThemeForm(form);
|
||||||
|
if (validationError) {
|
||||||
|
showSnack(validationError, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveSettings({
|
const settings = {
|
||||||
primary_color: form.primary_color,
|
primary_color: form.primary_color.trim().toLowerCase(),
|
||||||
theme_wallpaper: form.theme_wallpaper.trim(),
|
theme_wallpaper: form.theme_wallpaper.trim(),
|
||||||
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
||||||
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
|
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
|
||||||
@@ -72,8 +86,10 @@ export default function ThemeSettings() {
|
|||||||
nav_style: form.nav_style,
|
nav_style: form.nav_style,
|
||||||
card_style: form.card_style,
|
card_style: form.card_style,
|
||||||
glass_blur: form.glass_blur,
|
glass_blur: form.glass_blur,
|
||||||
glass_opacity: form.glass_opacity,
|
glass_opacity: String(Number(form.glass_opacity)),
|
||||||
});
|
};
|
||||||
|
await saveSettings(settings);
|
||||||
|
window.dispatchEvent(new CustomEvent('rainweb:theme-update', { detail: settings }));
|
||||||
showSnack('主题设置已保存');
|
showSnack('主题设置已保存');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnack(e.message, 'error');
|
showSnack(e.message, 'error');
|
||||||
@@ -85,15 +101,15 @@ export default function ThemeSettings() {
|
|||||||
<Box sx={{ maxWidth: 640 }}>
|
<Box sx={{ maxWidth: 640 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
|
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-primary-color" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<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' }} />
|
<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" sx={{ width: 160 }} />
|
<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>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-wallpaper" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
||||||
@@ -119,7 +135,7 @@ export default function ThemeSettings() {
|
|||||||
</TextField>
|
</TextField>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-styles" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>样式设置</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>样式设置</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 0.5 }}>导航栏样式</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 }}>
|
<ToggleButtonGroup exclusive value={form.nav_style} onChange={(e, v) => v && setForm((p) => ({ ...p, nav_style: v }))} size="small" sx={{ mb: 2 }}>
|
||||||
@@ -134,7 +150,7 @@ export default function ThemeSettings() {
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-glass" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>玻璃效果</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>玻璃效果</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">模糊强度: {form.glass_blur}px</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 }} />
|
<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 }} />
|
||||||
@@ -142,7 +158,7 @@ export default function ThemeSettings() {
|
|||||||
<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 }} />
|
<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>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper id="sec-dark" sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
|
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,19 +19,31 @@ import Select from '@mui/material/Select';
|
|||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import { listUsers, deleteUser, resetUserPassword, setUserRole, registerByAdmin } from '../../api/auth.js';
|
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 { showSnack } from '../snack.jsx';
|
||||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||||
|
|
||||||
/** 用户管理:列表/添加/改密/改权/删除(迁移自 v1 用户卡片) */
|
/** 头衔色板(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() {
|
export default function Users() {
|
||||||
const [users, setUsers] = useState(null);
|
const [users, setUsers] = useState(null);
|
||||||
|
const [selfId, setSelfId] = useState(null); // 当前管理员自己的 id(自我保护:禁止改权/删除自己)
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
|
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
|
||||||
const [pwDialog, setPwDialog] = useState(null); // { id, username }
|
|
||||||
|
// 编辑大弹窗
|
||||||
|
const [editUser, setEditUser] = useState(null); // 行数据 { id, username, ... }
|
||||||
|
const [editForm, setEditForm] = useState(EMPTY_EDIT);
|
||||||
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
|
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
|
||||||
const [roleDialog, setRoleDialog] = useState(null); // { id, username, role }
|
const [deleteTarget, setDeleteTarget] = useState(null); // { id, username }
|
||||||
const [confirm, setConfirm] = useState(null); // { id, username }
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
@@ -42,6 +54,11 @@ export default function Users() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// 自我 id(禁止对自己改权/删除)
|
||||||
|
useEffect(() => {
|
||||||
|
me().then((u) => setSelfId(u && u.id)).catch(() => setSelfId(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addUser = async () => {
|
const addUser = async () => {
|
||||||
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
|
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -57,15 +74,52 @@ export default function Users() {
|
|||||||
setBusy(false);
|
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 () => {
|
const resetPw = async () => {
|
||||||
if (!pwDialog) return;
|
if (!editUser) return;
|
||||||
if (!pwForm.p1 || pwForm.p1.length < 6) { showSnack('密码至少6位', 'error'); return; }
|
if (!pwForm.p1 || pwForm.p1.length < 6) { showSnack('密码至少6位', 'error'); return; }
|
||||||
if (pwForm.p1 !== pwForm.p2) { showSnack('两次密码不一致', 'error'); return; }
|
if (pwForm.p1 !== pwForm.p2) { showSnack('两次密码不一致', 'error'); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await resetUserPassword(pwDialog.id, pwForm.p1);
|
await resetUserPassword(editUser.id, pwForm.p1);
|
||||||
showSnack('密码已重置');
|
showSnack('密码已重置');
|
||||||
setPwDialog(null);
|
|
||||||
setPwForm({ p1: '', p2: '' });
|
setPwForm({ p1: '', p2: '' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnack(e.message, 'error');
|
showSnack(e.message, 'error');
|
||||||
@@ -73,13 +127,14 @@ export default function Users() {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeRole = async () => {
|
const doDelete = async () => {
|
||||||
if (!roleDialog) return;
|
if (!deleteTarget) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await setUserRole(roleDialog.id, roleDialog.role);
|
await deleteUser(deleteTarget.id);
|
||||||
showSnack('角色已更新');
|
showSnack('已删除');
|
||||||
setRoleDialog(null);
|
setDeleteTarget(null);
|
||||||
|
setEditUser(null); // 删除后关闭编辑弹窗
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnack(e.message, 'error');
|
showSnack(e.message, 'error');
|
||||||
@@ -87,19 +142,7 @@ export default function Users() {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doDelete = async () => {
|
const isSelf = !!editUser && editUser.id === selfId;
|
||||||
if (!confirm) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await deleteUser(confirm.id);
|
|
||||||
showSnack('已删除');
|
|
||||||
setConfirm(null);
|
|
||||||
load();
|
|
||||||
} catch (e) {
|
|
||||||
showSnack(e.message, 'error');
|
|
||||||
}
|
|
||||||
setBusy(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -113,7 +156,7 @@ export default function Users() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>ID</TableCell>
|
<TableCell>ID</TableCell>
|
||||||
<TableCell>用户名</TableCell>
|
<TableCell>用户</TableCell>
|
||||||
<TableCell>邮箱</TableCell>
|
<TableCell>邮箱</TableCell>
|
||||||
<TableCell>验证</TableCell>
|
<TableCell>验证</TableCell>
|
||||||
<TableCell>角色</TableCell>
|
<TableCell>角色</TableCell>
|
||||||
@@ -129,7 +172,12 @@ export default function Users() {
|
|||||||
) : users.map((u) => (
|
) : users.map((u) => (
|
||||||
<TableRow key={u.id}>
|
<TableRow key={u.id}>
|
||||||
<TableCell>{u.id}</TableCell>
|
<TableCell>{u.id}</TableCell>
|
||||||
<TableCell><Box sx={{ fontWeight: 600 }}>{u.username}</Box></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 sx={{ color: 'text.secondary' }}>{u.email || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip size="small" label={u.email_verified ? '已验证' : '未验证'} color={u.email_verified ? 'primary' : 'default'} variant={u.email_verified ? 'filled' : 'outlined'} />
|
<Chip size="small" label={u.email_verified ? '已验证' : '未验证'} color={u.email_verified ? 'primary' : 'default'} variant={u.email_verified ? 'filled' : 'outlined'} />
|
||||||
@@ -139,9 +187,7 @@ export default function Users() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
|
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
|
||||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
<Button size="small" onClick={() => { setPwDialog({ id: u.id, username: u.username }); setPwForm({ p1: '', p2: '' }); }}>改密</Button>
|
<Button size="small" startIcon={<EditIcon fontSize="small" />} onClick={() => openEdit(u)}>编辑</Button>
|
||||||
<Button size="small" onClick={() => setRoleDialog({ id: u.id, username: u.username, role: u.role })}>改权</Button>
|
|
||||||
<Button size="small" color="error" onClick={() => setConfirm({ id: u.id, username: u.username })}>删除</Button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -169,43 +215,165 @@ export default function Users() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 重置密码 */}
|
{/* 编辑用户大弹窗 */}
|
||||||
<Dialog open={!!pwDialog} onClose={() => setPwDialog(null)} fullWidth maxWidth="xs">
|
<Dialog open={!!editUser} onClose={closeEdit} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>重置密码</DialogTitle>
|
<DialogTitle>编辑用户</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>重置用户 {pwDialog ? pwDialog.username : ''} 的密码</Typography>
|
{editUser && (
|
||||||
<TextField fullWidth label="新密码(至少6位)" type="password" value={pwForm.p1} onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))} margin="normal" />
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
|
||||||
<TextField fullWidth label="确认新密码" type="password" value={pwForm.p2} onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))} margin="normal" />
|
{/* 基本信息 */}
|
||||||
</DialogContent>
|
<Box>
|
||||||
<DialogActions>
|
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>基本信息</Typography>
|
||||||
<Button onClick={() => setPwDialog(null)}>取消</Button>
|
<TextField fullWidth label="用户名(不可修改)" value={editUser.username} margin="normal" disabled />
|
||||||
<Button variant="contained" onClick={resetPw} disabled={busy}>确认重置</Button>
|
<TextField
|
||||||
</DialogActions>
|
fullWidth
|
||||||
</Dialog>
|
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 />
|
||||||
<Dialog open={!!roleDialog} onClose={() => setRoleDialog(null)} fullWidth maxWidth="xs">
|
|
||||||
<DialogTitle>修改角色</DialogTitle>
|
{/* 头衔 */}
|
||||||
<DialogContent>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>修改用户 {roleDialog ? roleDialog.username : ''} 的角色</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>头衔</Typography>
|
||||||
<FormControl fullWidth>
|
<TextField
|
||||||
<InputLabel>角色</InputLabel>
|
fullWidth
|
||||||
<Select value={roleDialog ? roleDialog.role : 'user'} onChange={(e) => setRoleDialog((p) => (p ? { ...p, role: e.target.value } : p))} label="角色">
|
label="自定义头衔"
|
||||||
<MenuItem value="user">普通用户</MenuItem>
|
value={editForm.title}
|
||||||
<MenuItem value="admin">管理员</MenuItem>
|
onChange={(e) => setEditForm((p) => ({ ...p, title: e.target.value }))}
|
||||||
</Select>
|
margin="normal"
|
||||||
</FormControl>
|
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>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setRoleDialog(null)}>取消</Button>
|
<Button onClick={closeEdit}>取消</Button>
|
||||||
<Button variant="contained" onClick={changeRole} disabled={busy}>确认修改</Button>
|
<Button variant="contained" onClick={saveProfile} disabled={busy}>保存资料</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!confirm}
|
open={!!deleteTarget}
|
||||||
message={`确定要删除 "${confirm ? confirm.username : ''}" 吗?`}
|
message={`确定要删除用户 "${deleteTarget ? deleteTarget.username : ''}" 吗?此操作不可恢复`}
|
||||||
onClose={() => setConfirm(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
onConfirm={doDelete}
|
onConfirm={doDelete}
|
||||||
confirmText="确认删除"
|
confirmText="确认删除"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节
|
* 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节
|
||||||
* 前台 token 变量未注入后台页面,这里按明暗各写一份。
|
* 颜色、焦点色和玻璃透明度由后台 MUI Theme 注入 --admin-* 语义变量。
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
/* 内容区入场动效(对应前台 contentFadeIn) */
|
/* 内容区入场动效(对应前台 contentFadeIn) */
|
||||||
@@ -18,14 +18,266 @@ body {
|
|||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
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 { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: #cac4d0; border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: var(--admin-outline-variant, #cac4d0); border-radius: 3px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #79747e; }
|
::-webkit-scrollbar-thumb:hover { background: var(--admin-outline, #79747e); }
|
||||||
|
|
||||||
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; }
|
:focus-visible {
|
||||||
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; }
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createTheme, alpha } from '@mui/material/styles';
|
import { createTheme, alpha } from '@mui/material/styles';
|
||||||
|
import { buildThemeTokens, DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY } from '../themeTokens.js';
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 后台 Material Design 3 主题
|
* 后台 Material Design 3 主题
|
||||||
@@ -10,7 +11,7 @@ import { createTheme, alpha } from '@mui/material/styles';
|
|||||||
* surface 色阶、弱边框 + 低阴影(surface tint)。
|
* surface 色阶、弱边框 + 低阴影(surface tint)。
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
const DEFAULT_SEED = '#6750a4';
|
const DEFAULT_SEED = DEFAULT_PRIMARY_COLOR;
|
||||||
|
|
||||||
/* ---------- 颜色工具 ---------- */
|
/* ---------- 颜色工具 ---------- */
|
||||||
|
|
||||||
@@ -242,9 +243,10 @@ function md3Shadows() {
|
|||||||
|
|
||||||
/* ---------- 主题工厂 ---------- */
|
/* ---------- 主题工厂 ---------- */
|
||||||
|
|
||||||
export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) {
|
export function createMD3Theme({ mode = 'light', primary = DEFAULT_PRIMARY_COLOR, glassOpacity = DEFAULT_GLASS_OPACITY } = {}) {
|
||||||
const dark = mode === 'dark';
|
const p = buildThemeTokens({ mode, primary_color: primary, glass_opacity: glassOpacity });
|
||||||
const p = buildPalette(primary, dark);
|
const safeGlassOpacity = p.glassOpacity;
|
||||||
|
const dark = p.mode === 'dark';
|
||||||
const shadows = md3Shadows();
|
const shadows = md3Shadows();
|
||||||
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
|
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
|
||||||
|
|
||||||
@@ -301,6 +303,25 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {})
|
|||||||
},
|
},
|
||||||
shadows,
|
shadows,
|
||||||
components: {
|
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 + 细边框,去硬阴影 */
|
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
|
||||||
MuiAppBar: {
|
MuiAppBar: {
|
||||||
defaultProps: { color: 'inherit', elevation: 0 },
|
defaultProps: { color: 'inherit', elevation: 0 },
|
||||||
@@ -396,7 +417,7 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {})
|
|||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
rounded: { borderRadius: 12 },
|
rounded: { borderRadius: 12 },
|
||||||
elevation1: {
|
elevation1: {
|
||||||
backgroundColor: p.surfaceContainerLow,
|
backgroundColor: alpha(p.surfaceContainerLow, safeGlassOpacity),
|
||||||
border: `1px solid ${p.outlineVariant}`,
|
border: `1px solid ${p.outlineVariant}`,
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
backgroundImage: 'none',
|
backgroundImage: 'none',
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ export function me() {
|
|||||||
return request('/auth/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 并通知登录态变更 */
|
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
|
||||||
export function logout() {
|
export function logout() {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
@@ -42,3 +53,7 @@ export function resetUserPassword(id, newPassword) {
|
|||||||
export function setUserRole(id, role) {
|
export function setUserRole(id, role) {
|
||||||
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { 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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export function createPost(data) {
|
|||||||
export function updatePost(id, data) {
|
export function updatePost(id, data) {
|
||||||
return request('/blog/posts/' + id, { method: 'PUT', body: 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) {
|
export function deletePost(id) {
|
||||||
return request('/blog/posts/' + id, { method: 'DELETE' });
|
return request('/blog/posts/' + id, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
@@ -66,3 +74,8 @@ export function approveComment(id) {
|
|||||||
export function rejectComment(id) {
|
export function rejectComment(id) {
|
||||||
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { request } from './client.js';
|
import { request } from './client.js';
|
||||||
|
|
||||||
// ── 分类 ────────────────────────────────────────
|
// ── 分类 ────────────────────────────────────────
|
||||||
|
/** 版块列表(含聚合:posts_count / today_count / moderators / icon / icon_color) */
|
||||||
export function listCategories() {
|
export function listCategories() {
|
||||||
return request('/forum/categories');
|
return request('/forum/categories');
|
||||||
}
|
}
|
||||||
|
/** 版块详情(含聚合与公告) */
|
||||||
|
export function getCategory(id) {
|
||||||
|
return request('/forum/categories/' + id);
|
||||||
|
}
|
||||||
export function createCategory(data) {
|
export function createCategory(data) {
|
||||||
return request('/forum/categories', { method: 'POST', body: data });
|
return request('/forum/categories', { method: 'POST', body: data });
|
||||||
}
|
}
|
||||||
@@ -13,11 +18,30 @@ export function updateCategory(id, data) {
|
|||||||
export function deleteCategory(id) {
|
export function deleteCategory(id) {
|
||||||
return request('/forum/categories/' + id, { method: 'DELETE' });
|
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 } });
|
||||||
|
}
|
||||||
|
|
||||||
// ── 帖子 ────────────────────────────────────────
|
// ── 帖子 ────────────────────────────────────────
|
||||||
/** 帖子列表;categoryId 可选 */
|
/** 帖子列表;opts = { categoryId, page, subCategory, q },返回 { posts, total, page, pageSize }(兼容裸数组) */
|
||||||
export function listPosts(categoryId) {
|
export function listPosts(opts = {}) {
|
||||||
return request('/forum/posts' + (categoryId ? '?category_id=' + encodeURIComponent(categoryId) : ''));
|
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) {
|
export function getPost(id) {
|
||||||
return request('/forum/posts/' + id);
|
return request('/forum/posts/' + id);
|
||||||
@@ -32,6 +56,64 @@ export function reply(postId, content) {
|
|||||||
export function deletePost(id) {
|
export function deletePost(id) {
|
||||||
return request('/forum/posts/' + id, { method: 'DELETE' });
|
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) {
|
export function deleteReply(id) {
|
||||||
return request('/forum/replies/' + id, { method: 'DELETE' });
|
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 } });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import { request } from './client.js';
|
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_* 等 */
|
/** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */
|
||||||
export function getPublicSettings() {
|
export function getPublicSettings() {
|
||||||
return request('/settings/public');
|
return request(noStorePath('/settings/public'), { auth: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全量设置(仅管理员) */
|
/** 全量设置(仅管理员) */
|
||||||
export function getSettings() {
|
export function getSettings() {
|
||||||
return request('/settings');
|
return request(noStorePath('/settings'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存设置(仅管理员) */
|
/** 保存设置(仅管理员) */
|
||||||
export function saveSettings(data) {
|
export function saveSettings(data) {
|
||||||
return request('/settings', { method: 'PUT', body: data });
|
return request('/settings', { method: 'PUT', body: data }).then((result) => {
|
||||||
|
broadcastThemeSettings(data);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import { request, getToken } from './client.js';
|
|||||||
|
|
||||||
// client.request 走 JSON,上传必须独立走 fetch + FormData + Bearer
|
// client.request 走 JSON,上传必须独立走 fetch + FormData + Bearer
|
||||||
|
|
||||||
async function upload(path, file, extra) {
|
async function upload(path, file, extra, fieldName = 'file') {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append(fieldName, file);
|
||||||
if (extra) {
|
if (extra) {
|
||||||
for (const [k, v] of Object.entries(extra)) {
|
for (const [k, v] of Object.entries(extra)) {
|
||||||
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
|
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
|
||||||
@@ -38,6 +38,11 @@ export function uploadWallpaper(file) {
|
|||||||
return upload('/wallpaper', 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 自动头像) */
|
/** 按 uid 获取头像地址(支持 QQ 自动头像) */
|
||||||
export function avatarUrl(uid) {
|
export function avatarUrl(uid) {
|
||||||
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
|
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
|
||||||
@@ -56,3 +61,18 @@ export function listAttachments(refType, refId) {
|
|||||||
export function deleteAttachment(id) {
|
export function deleteAttachment(id) {
|
||||||
return request('/upload/' + id, { method: 'DELETE' });
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { listPosts } from '../api/blog.js';
|
import { listPosts } from '../api/blog.js';
|
||||||
|
import BrandIcon, { BRAND_ICON_NAMES } from './BrandIcon.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
|
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
|
||||||
@@ -42,7 +43,9 @@ export default function BlogSidebar({ settings = {}, showRecent = false, forceSh
|
|||||||
<div className="homepage-contacts">
|
<div className="homepage-contacts">
|
||||||
{contacts.map((l, i) => (
|
{contacts.map((l, i) => (
|
||||||
<a key={i} href={l.url} target="_blank" rel="noopener" className="hp-contact-link" title={l.title || ''}>
|
<a key={i} href={l.url} target="_blank" rel="noopener" className="hp-contact-link" title={l.title || ''}>
|
||||||
<span className="material-icons">{l.icon || 'link'}</span>
|
{BRAND_ICON_NAMES.includes(l.icon)
|
||||||
|
? <BrandIcon name={l.icon} size={20} className="contact-icon" />
|
||||||
|
: <span className="material-icons">{l.icon || 'link'}</span>}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,10 +31,23 @@ export function showCaptcha(action) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用)
|
||||||
|
function flushCallbacks(type) {
|
||||||
|
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||||
|
const q = window[key] || [];
|
||||||
|
window[key] = [];
|
||||||
|
q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } });
|
||||||
|
}
|
||||||
|
|
||||||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||||||
|
// 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪,
|
||||||
|
// 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。
|
||||||
function ensureThirdPartyScript(type) {
|
function ensureThirdPartyScript(type) {
|
||||||
if (type === 'recaptcha') {
|
if (type === 'recaptcha') {
|
||||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||||
|
if (!window.onRecaptchaLoad) {
|
||||||
|
window.onRecaptchaLoad = () => flushCallbacks('recaptcha');
|
||||||
|
}
|
||||||
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
||||||
@@ -43,6 +56,9 @@ function ensureThirdPartyScript(type) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||||
|
if (!window.onTurnstileLoad) {
|
||||||
|
window.onTurnstileLoad = () => flushCallbacks('turnstile');
|
||||||
|
}
|
||||||
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
||||||
@@ -52,6 +68,66 @@ function ensureThirdPartyScript(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码
|
||||||
|
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 使用)
|
// SHA-256(Proof of Work 使用)
|
||||||
async function sha256(str) {
|
async function sha256(str) {
|
||||||
const buf = new TextEncoder().encode(str);
|
const buf = new TextEncoder().encode(str);
|
||||||
@@ -59,8 +135,8 @@ async function sha256(str) {
|
|||||||
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内置 SVG 验证码弹窗
|
// 内置 SVG 验证码弹窗(notice 为可选提示,用于第三方降级时告知用户)
|
||||||
function BuiltinCaptcha({ onFinish }) {
|
function BuiltinCaptcha({ onFinish, notice }) {
|
||||||
const [svg, setSvg] = useState('');
|
const [svg, setSvg] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [answer, setAnswer] = useState('');
|
const [answer, setAnswer] = useState('');
|
||||||
@@ -129,6 +205,23 @@ function BuiltinCaptcha({ onFinish }) {
|
|||||||
return (
|
return (
|
||||||
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
|
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
|
||||||
<h3 style={{ marginBottom: 12 }}>验证码</h3>
|
<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 }}>
|
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
|
||||||
{svg
|
{svg
|
||||||
? <div dangerouslySetInnerHTML={{ __html: svg }} />
|
? <div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
@@ -172,12 +265,14 @@ function BuiltinCaptcha({ onFinish }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
|
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方。
|
||||||
|
// 脚本 8s 内加载不出来时自动降级到内置 SVG 验证码(proof 路径),不阻塞用户操作。
|
||||||
function ThirdPartyCaptcha({ type, onFinish }) {
|
function ThirdPartyCaptcha({ type, onFinish }) {
|
||||||
const siteKey = type === 'recaptcha'
|
const siteKey = type === 'recaptcha'
|
||||||
? (window._recaptchaSiteKey || '')
|
? (window._recaptchaSiteKey || '')
|
||||||
: (window._turnstileSiteKey || '');
|
: (window._turnstileSiteKey || '');
|
||||||
const [status, setStatus] = useState('正在加载...');
|
const [status, setStatus] = useState('正在加载...');
|
||||||
|
const [degraded, setDegraded] = useState(false);
|
||||||
const widgetRef = useRef(null);
|
const widgetRef = useRef(null);
|
||||||
const doneRef = useRef(false);
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
@@ -185,6 +280,7 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
|||||||
if (!siteKey) { onFinish(null); return; }
|
if (!siteKey) { onFinish(null); return; }
|
||||||
const container = widgetRef.current;
|
const container = widgetRef.current;
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
if (doneRef.current) return; // 弹窗已取消/卸载或已降级,不再向已移除的容器渲染
|
||||||
try {
|
try {
|
||||||
if (type === 'recaptcha') {
|
if (type === 'recaptcha') {
|
||||||
const wid = window.grecaptcha.render(container, {
|
const wid = window.grecaptcha.render(container, {
|
||||||
@@ -215,25 +311,31 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
|||||||
setTimeout(() => onFinish(null), 2000);
|
setTimeout(() => onFinish(null), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (type === 'recaptcha') {
|
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
|
||||||
if (typeof window.grecaptcha !== 'undefined') render();
|
// 8s 加载超时 → 自动降级到内置验证码,避免长时间卡「正在加载」阻塞登录
|
||||||
else {
|
const cleanupQueue = queueRender(type, render, () => {
|
||||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
if (doneRef.current) return;
|
||||||
window.recaptchaCallbacks.push(render);
|
doneRef.current = true;
|
||||||
ensureThirdPartyScript(type);
|
setDegraded(true);
|
||||||
}
|
});
|
||||||
} else {
|
// 卸载/重开时:防止重复回调,清除超时计时器并移除排队回调(防泄漏/防残留降级状态)
|
||||||
if (typeof window.turnstile !== 'undefined') render();
|
return () => {
|
||||||
else {
|
doneRef.current = true;
|
||||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
if (cleanupQueue) cleanupQueue();
|
||||||
window.turnstileCallbacks.push(render);
|
};
|
||||||
ensureThirdPartyScript(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 卸载时不触发重复回调
|
|
||||||
return () => { doneRef.current = true; };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 降级:第三方脚本长时间加载不出来,复用内置 SVG 验证码完整流程
|
||||||
|
// (校验仍走 loadImage/verify,调用方拿到 { type: 'proof', value })
|
||||||
|
if (degraded) {
|
||||||
|
return (
|
||||||
|
<BuiltinCaptcha
|
||||||
|
onFinish={onFinish}
|
||||||
|
notice="第三方验证码加载失败,已切换为内置验证码"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||||||
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { safeOutUrl, parseFooterColumns } from '../lib/outlink.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
|
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
|
||||||
@@ -28,18 +29,35 @@ export default function Footer({ settings = {}, version = '' }) {
|
|||||||
const desc = settings.footer_desc || settings.site_description || '';
|
const desc = settings.footer_desc || settings.site_description || '';
|
||||||
|
|
||||||
if (style === 'columns') {
|
if (style === 'columns') {
|
||||||
// 导航栏目固定不可编辑(后台提示),首页/博客/论坛走 SPA,管理后台整页跳转
|
// 栏目来源:后台 footer_columns 配置(非空优先,最多取前 3 栏);空则回退硬编码三栏导航
|
||||||
const navCols = [
|
const hardcodedCols = [
|
||||||
{ title: '导航', links: [
|
{ title: '导航', links: [
|
||||||
{ to: '/', label: '首页' },
|
{ label: '首页', url: '/' },
|
||||||
{ to: '/blog.html', label: '博客' },
|
{ label: '博客', url: '/blog.html' },
|
||||||
{ to: '/forum.html', label: '论坛' },
|
{ label: '论坛', url: '/forum.html' },
|
||||||
] },
|
] },
|
||||||
{ title: '其他', links: [
|
{ title: '阅读', links: [
|
||||||
{ to: '/admin', label: '管理后台', external: true },
|
{ label: '归档', url: '/archive.html' },
|
||||||
{ to: '/profile.html', label: '个人中心' },
|
{ 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 (
|
return (
|
||||||
<footer className="footer-columns">
|
<footer className="footer-columns">
|
||||||
<div className="fc-grid">
|
<div className="fc-grid">
|
||||||
@@ -51,17 +69,11 @@ export default function Footer({ settings = {}, version = '' }) {
|
|||||||
</div>
|
</div>
|
||||||
{desc && <HtmlOrText value={desc} className="brand-desc" />}
|
{desc && <HtmlOrText value={desc} className="brand-desc" />}
|
||||||
</div>
|
</div>
|
||||||
{navCols.map((col) => (
|
{cols.map((col, ci) => (
|
||||||
<nav key={col.title} className="fc-col">
|
<nav key={ci} className="fc-col">
|
||||||
<div className="col-title">{col.title}</div>
|
<div className="col-title">{col.title || '未命名栏目'}</div>
|
||||||
<div className="col-links">
|
<div className="col-links">
|
||||||
{col.links.map((l) =>
|
{(col.links || []).map((l, li) => colLink(l, ci + '-' + li))}
|
||||||
l.external ? (
|
|
||||||
<a key={l.to} href={l.to}>{l.label}</a>
|
|
||||||
) : (
|
|
||||||
<Link key={l.to} to={l.to}>{l.label}</Link>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useTheme } from '../theme.jsx';
|
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 authApi from '../api/auth.js';
|
||||||
import * as settingsApi from '../api/settings.js';
|
import * as settingsApi from '../api/settings.js';
|
||||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
import { showSnackbar } from '../lib/utils.js';
|
||||||
|
import { isFamilyDomain } from '../lib/outlink.js';
|
||||||
import MusicEmbed from './MusicEmbed.jsx';
|
import MusicEmbed from './MusicEmbed.jsx';
|
||||||
import CaptchaModalHost from './CaptchaModal.jsx';
|
import CaptchaModalHost from './CaptchaModal.jsx';
|
||||||
import Footer from './Footer.jsx';
|
import Footer from './Footer.jsx';
|
||||||
@@ -23,6 +25,48 @@ export default function Layout() {
|
|||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
const [user, setUser] = useState(null);
|
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)
|
// 初始化向导:未完成安装时跳转 /setup.html(迁移自 nav.js)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/setup/status')
|
fetch('/api/setup/status')
|
||||||
@@ -38,9 +82,13 @@ export default function Layout() {
|
|||||||
|
|
||||||
// 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新)
|
// 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const updateSettings = (next) => {
|
||||||
|
if (!next || typeof next !== 'object') return;
|
||||||
|
setSettings((previous) => ({ ...previous, ...next }));
|
||||||
|
};
|
||||||
settingsApi.getPublicSettings()
|
settingsApi.getPublicSettings()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
setSettings(s);
|
updateSettings(s);
|
||||||
// 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js)
|
// 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js)
|
||||||
window._recaptchaSiteKey = s.recaptcha_site_key || '';
|
window._recaptchaSiteKey = s.recaptcha_site_key || '';
|
||||||
window._turnstileSiteKey = s.turnstile_site_key || '';
|
window._turnstileSiteKey = s.turnstile_site_key || '';
|
||||||
@@ -59,17 +107,33 @@ export default function Layout() {
|
|||||||
};
|
};
|
||||||
loadUser();
|
loadUser();
|
||||||
window.addEventListener('authchange', loadUser);
|
window.addEventListener('authchange', loadUser);
|
||||||
return () => window.removeEventListener('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)
|
// 强制深色模式(迁移自 nav.js applyTheme)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.theme_force_dark === '1') {
|
setTheme(settings.theme_force_dark === '1' ? 'dark' : getThemePreference(), { persist: false });
|
||||||
setTheme('dark');
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}
|
|
||||||
}, [settings.theme_force_dark, setTheme]);
|
}, [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)
|
// 壁纸背景 + 玻璃磨砂(迁移自 nav.js applyTheme,直接作用于 body)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
@@ -118,6 +182,11 @@ export default function Layout() {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
notifyAuthChange();
|
notifyAuthChange();
|
||||||
|
if (settings.rainid_enabled === '1') {
|
||||||
|
// RainID 单点登录:清本地后联动登出 RainID 端会话(整页跳转,不显示本地提示)
|
||||||
|
window.location.href = '/api/auth/oidc/logout';
|
||||||
|
return;
|
||||||
|
}
|
||||||
showSnackbar('已退出登录');
|
showSnackbar('已退出登录');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,6 +202,7 @@ export default function Layout() {
|
|||||||
{ to: '/', label: '首页', end: true, show: true },
|
{ to: '/', label: '首页', end: true, show: true },
|
||||||
{ to: '/blog.html', label: '博客', show: true },
|
{ to: '/blog.html', label: '博客', show: true },
|
||||||
{ to: '/forum.html', label: '论坛', show: !!user },
|
{ to: '/forum.html', label: '论坛', show: !!user },
|
||||||
|
{ to: '/tickets.html', label: '工单', show: true },
|
||||||
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
||||||
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
|
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
|
||||||
];
|
];
|
||||||
@@ -144,6 +214,17 @@ export default function Layout() {
|
|||||||
<>
|
<>
|
||||||
<nav className={navClass} id="mainNav">
|
<nav className={navClass} id="mainNav">
|
||||||
<div className="nav-left">
|
<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>
|
<Link to="/" className="nav-brand">{siteName}</Link>
|
||||||
<span className="nav-version">v{version}</span>
|
<span className="nav-version">v{version}</span>
|
||||||
<div className="nav-tabs">
|
<div className="nav-tabs">
|
||||||
@@ -174,13 +255,14 @@ export default function Layout() {
|
|||||||
</button>
|
</button>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<Link to="/profile.html" className="btn btn-tonal btn-sm" title="个人中心">
|
{/* 个人中心:桌面显示头像+用户名,移动端只显示头像(.nav-user-name 由 CSS 隐藏) */}
|
||||||
|
<Link to="/profile.html" className="btn btn-tonal btn-sm nav-user" title="个人中心">
|
||||||
{avatarUrl
|
{avatarUrl
|
||||||
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover', marginRight: 4 }} />
|
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover' }} />
|
||||||
: <span className="material-icons" style={{ fontSize: 18, marginRight: 2 }}>person</span>}
|
: <span className="material-icons" style={{ fontSize: 18 }}>person</span>}
|
||||||
{user.username}
|
<span className="nav-user-name">{user.username}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button className="btn btn-text btn-sm" onClick={handleLogout}>退出</button>
|
{/* 退出登录统一在个人中心 / 移动端抽屉中操作,顶栏不再放退出按钮 */}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -191,6 +273,65 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</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">
|
<main className="page">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,32 +3,143 @@ import { marked } from 'marked';
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { escapeHtml } from '../lib/utils.js';
|
import { escapeHtml } from '../lib/utils.js';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
|
import LockBlock from './LockBlock.jsx';
|
||||||
|
|
||||||
/** [image:文件名] → /uploads/ 图片 */
|
/**
|
||||||
function imageTag(filename) {
|
* 锁块附件鉴权地址:后端 /api/upload/locked 按 (ref_type, ref_id, block, file) + token 校验,
|
||||||
if (!filename) return '';
|
* 只允许取「该帖该块」已解锁的附件。缺 refType/refId/token 返回 null → 调用方回退原直链。
|
||||||
return `<img src="/uploads/${encodeURIComponent(filename)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
|
*/
|
||||||
|
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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [file:文件名] → 带 token 的下载链接 */
|
/** [image:文件名] → 图片标签;lockedUrl 存在则 src 走鉴权接口,否则 /uploads/ 直链 */
|
||||||
function fileTag(filename) {
|
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 '';
|
if (!filename) return '';
|
||||||
const token = getToken() || '';
|
const token = getToken() || '';
|
||||||
return `<a href="/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}" 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">
|
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>`;
|
<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=false 走纯文本(转义 + 标签替换 + <br>);
|
||||||
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
|
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
|
||||||
|
* attach(可选)= { refType, refId, blockIndex, token }:处于已解锁锁块内时的附件鉴权上下文;
|
||||||
|
* 缺省则 [image:]/[file:] 照旧 /uploads/ 直链与下载逻辑。本函数只处理单段文本,lock 分段在组件层完成。
|
||||||
*/
|
*/
|
||||||
export function renderContent(content, useMarkdown) {
|
export function renderContent(content, useMarkdown, attach) {
|
||||||
if (content == null) return '';
|
if (content == null) return '';
|
||||||
|
|
||||||
|
// 锁块附件地址生成器:无 attach 上下文或无 token 时返回 null → 走原直链/下载逻辑
|
||||||
|
const lockedUrl = (filename) => (attach
|
||||||
|
? lockedAttachmentUrl(attach.refType, attach.refId, attach.blockIndex, filename, attach.token)
|
||||||
|
: null);
|
||||||
|
|
||||||
if (!useMarkdown) {
|
if (!useMarkdown) {
|
||||||
let html = escapeHtml(content);
|
let html = escapeHtml(content);
|
||||||
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f));
|
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f, lockedUrl(f)));
|
||||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f));
|
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f, lockedUrl(f)));
|
||||||
html = html.replace(/\n/g, '<br>');
|
html = html.replace(/\n/g, '<br>');
|
||||||
return DOMPurify.sanitize(html);
|
return DOMPurify.sanitize(html);
|
||||||
}
|
}
|
||||||
@@ -41,8 +152,8 @@ export function renderContent(content, useMarkdown) {
|
|||||||
|
|
||||||
html = marked.parse(html, { breaks: true, gfm: true });
|
html = marked.parse(html, { breaks: true, gfm: true });
|
||||||
|
|
||||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
|
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)]));
|
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)], lockedUrl(files[parseInt(i)])));
|
||||||
html = DOMPurify.sanitize(html);
|
html = DOMPurify.sanitize(html);
|
||||||
|
|
||||||
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
||||||
@@ -52,7 +163,110 @@ export function renderContent(content, useMarkdown) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
|
/** 单段文本块:dangerouslySetInnerHTML 包装(renderContent 已 DOMPurify 净化) */
|
||||||
const html = useMemo(() => renderContent(content, useMarkdown), [content, useMarkdown]);
|
function RawBlock({ html }) {
|
||||||
return <div className="md-body" dangerouslySetInnerHTML={{ __html: 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,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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,21 @@ export function escapeHtml(str) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 分页接口返回归一化:兼容裸数组与 { 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 */
|
/** 格式化时间为 YYYY-MM-DD HH:mm */
|
||||||
export function formatDate(input) {
|
export function formatDate(input) {
|
||||||
const d = input instanceof Date ? input : new Date(input);
|
const d = input instanceof Date ? input : new Date(input);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import BlogSidebar from '../components/BlogSidebar.jsx';
|
import BlogSidebar from '../components/BlogSidebar.jsx';
|
||||||
import { listPosts, searchPosts, getTags } from '../api/blog.js';
|
import { listPosts, searchPosts, getTags } from '../api/blog.js';
|
||||||
import { getPublicSettings } from '../api/settings.js';
|
import { getPublicSettings } from '../api/settings.js';
|
||||||
@@ -13,10 +13,23 @@ function excerptOf(p) {
|
|||||||
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
|
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
|
||||||
* 顶部搜索框(/api/blog/search)+ 标签云(/api/blog/tags,点击进 /tag/:name)+ 归档入口 */
|
* 顶部搜索框(/api/blog/search)+ 标签云(/api/blog/tags,点击进 /tag/:name)+ 归档入口 */
|
||||||
export default function Blog() {
|
export default function Blog() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [settings, setSettings] = useState({});
|
const [settings, setSettings] = useState({});
|
||||||
const [posts, setPosts] = useState(null); // null=加载中
|
const [posts, setPosts] = useState(null); // null=加载中
|
||||||
const [error, setError] = useState('');
|
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 [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState(null); // null=未搜索
|
const [results, setResults] = useState(null); // null=未搜索
|
||||||
@@ -114,6 +127,9 @@ export default function Blog() {
|
|||||||
<Link to="/archive.html" className="btn btn-text btn-sm">
|
<Link to="/archive.html" className="btn btn-text btn-sm">
|
||||||
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
|
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
|
||||||
</Link>
|
</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>
|
</form>
|
||||||
|
|
||||||
{/* 标签云 */}
|
{/* 标签云 */}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import * as blogApi from '../api/blog.js';
|
|||||||
import { me } from '../api/auth.js';
|
import { me } from '../api/auth.js';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
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:] 标签后,中文字符 + 英文单词数 */
|
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
|
||||||
function countWords(content) {
|
function countWords(content) {
|
||||||
@@ -34,6 +37,47 @@ export default function BlogDetail() {
|
|||||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||||
const [toc, setToc] = useState([]);
|
const [toc, setToc] = useState([]);
|
||||||
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -50,6 +94,11 @@ export default function BlogDetail() {
|
|||||||
setComments(cs || []);
|
setComments(cs || []);
|
||||||
setPrevnext(pn);
|
setPrevnext(pn);
|
||||||
setLike(lk || { liked: false, count: 0 });
|
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) {
|
} catch (e) {
|
||||||
setError(e.message || '加载失败');
|
setError(e.message || '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -59,6 +108,10 @@ export default function BlogDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
// 评论 UID 开关(公开设置;后端未加字段时默认显示)
|
||||||
|
settingsApi.getPublicSettings()
|
||||||
|
.then((s) => setShowCommentUid(s.show_uid_in_comments !== '0'))
|
||||||
|
.catch(() => setShowCommentUid(true));
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
me().then(setUser).catch(() => setUser(null));
|
me().then(setUser).catch(() => setUser(null));
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +137,26 @@ export default function BlogDetail() {
|
|||||||
|
|
||||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
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 () => {
|
const toggleLike = async () => {
|
||||||
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
||||||
@@ -107,7 +180,15 @@ export default function BlogDetail() {
|
|||||||
return (
|
return (
|
||||||
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
|
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
|
||||||
<div className="reply-meta">
|
<div className="reply-meta">
|
||||||
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
|
<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>}
|
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="reply-body">{c.content}</div>
|
<div className="reply-body">{c.content}</div>
|
||||||
@@ -171,7 +252,15 @@ export default function BlogDetail() {
|
|||||||
<h1 className="article-title">{post.title}</h1>
|
<h1 className="article-title">{post.title}</h1>
|
||||||
|
|
||||||
<div className="article-meta">
|
<div className="article-meta">
|
||||||
{post.author_name || '管理员'} · {post.created_at}
|
<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="meta-stat" title="阅读量">
|
||||||
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
|
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
|
||||||
</span>
|
</span>
|
||||||
@@ -191,12 +280,25 @@ export default function BlogDetail() {
|
|||||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||||
<span className="like-count">{like.count}</span>
|
<span className="like-count">{like.count}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-tonal btn-sm" onClick={() => setShareOpen(true)} aria-label="分享文章">
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
|
||||||
|
</button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
<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) && (
|
{prevnext && (prevnext.prev || prevnext.next) && (
|
||||||
@@ -293,6 +395,43 @@ export default function BlogDetail() {
|
|||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,280 +1,174 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import * as forumApi from '../api/forum.js';
|
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 { me } from '../api/auth.js';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import { uploadFile } from '../api/upload.js';
|
import ForumIcon from '../components/ForumIcon.jsx';
|
||||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
|
||||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
|
||||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
|
||||||
|
|
||||||
/** 板块的子分类列表 */
|
/** 版主名提取:兼容 ["a","b"] / [{username:"a"}] / "a,b" 三种后端形态 */
|
||||||
function subCatsOf(cat) {
|
function moderatorsOf(cat) {
|
||||||
if (!cat || !cat.sub_categories) return [];
|
const m = cat && cat.moderators;
|
||||||
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
|
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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 论坛首页:分类导航(侧栏 + 子分类 chips 筛选)+ 帖子列表 + 发帖弹窗(含验证码) */
|
/**
|
||||||
|
* 论坛首页(路由 /forum.html,L1 版块索引):
|
||||||
|
* 全站公告区 + 页头(标题/发帖)+ 版块卡片网格(聚合统计)。
|
||||||
|
* 分享链接处理:?share=<id> → 帖子详情;?share_c=<id> → 版块页(replaceState 清参)。
|
||||||
|
* 游客预览开关:forum_guest_visible==='0' 且未登录 → 登录提示。
|
||||||
|
*/
|
||||||
export default function Forum() {
|
export default function Forum() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState(null); // null=加载中
|
||||||
const [currentCatId, setCurrentCatId] = useState(null); // null=全部最新
|
const [announcements, setAnnouncements] = useState([]);
|
||||||
const [filterSub, setFilterSub] = useState('');
|
|
||||||
const [posts, setPosts] = useState(null); // null=加载中
|
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [user, setUser] = useState(null);
|
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,
|
||||||
const [showNewPost, setShowNewPost] = useState(false);
|
// 只在首次挂载处理一次;replaceState 清参避免刷新重复跳。
|
||||||
const [npCategory, setNpCategory] = useState('');
|
useEffect(() => {
|
||||||
const [npSub, setNpSub] = useState('');
|
const params = new URLSearchParams(window.location.search);
|
||||||
const [npTitle, setNpTitle] = useState('');
|
const shareC = params.get('share_c');
|
||||||
const [npContent, setNpContent] = useState('');
|
if (shareC && /^\d+$/.test(shareC)) {
|
||||||
const [npStatus, setNpStatus] = useState('');
|
history.replaceState({}, '', window.location.pathname);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
navigate('/forum/c/' + shareC);
|
||||||
const fileRef = useRef(null);
|
return;
|
||||||
|
|
||||||
const load = useCallback(async (catId, sub) => {
|
|
||||||
setPosts(null);
|
|
||||||
setLoadError('');
|
|
||||||
try {
|
|
||||||
const ps = catId ? await forumApi.listPosts(catId) : await forumApi.listPosts();
|
|
||||||
const filtered = sub ? (ps || []).filter((p) => p.sub_category === sub) : (ps || []);
|
|
||||||
setPosts(filtered);
|
|
||||||
} catch (e) {
|
|
||||||
setLoadError(e.message || '加载失败');
|
|
||||||
setPosts([]);
|
|
||||||
}
|
}
|
||||||
}, []);
|
const shareId = params.get('share');
|
||||||
|
if (shareId && /^\d+$/.test(shareId)) {
|
||||||
|
history.replaceState({}, '', window.location.pathname);
|
||||||
|
navigate('/forum/' + shareId);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
forumApi.listCategories().then((cs) => setCategories(cs || [])).catch(() => {});
|
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)
|
||||||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
settingsApi.getPublicSettings()
|
||||||
else setUser(null);
|
.then((s) => { setGuestLocked(s.forum_guest_visible === '0'); setSettingsLoaded(true); })
|
||||||
load(null, '');
|
.catch(() => { setSettingsLoaded(true); });
|
||||||
}, [load]);
|
if (getToken()) {
|
||||||
|
me().then(setUser).catch(() => setUser(null));
|
||||||
const selectCategory = (catId) => {
|
// 我管理的版块(admin 恒真由 role 判断;版主取真实归属),用于「管理我的版块」入口
|
||||||
setCurrentCatId(catId);
|
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
|
||||||
setFilterSub('');
|
|
||||||
load(catId, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const showAllPosts = () => {
|
|
||||||
setCurrentCatId(null);
|
|
||||||
setFilterSub('');
|
|
||||||
load(null, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 发帖按钮:未登录显示"登录发帖"并跳登录页(照 forum.js)
|
|
||||||
const handleNewPostBtn = () => {
|
|
||||||
if (user) {
|
|
||||||
setNpCategory(categories.length ? String(categories[0].id) : '');
|
|
||||||
setNpSub('');
|
|
||||||
setNpTitle('');
|
|
||||||
setNpContent('');
|
|
||||||
setNpStatus('');
|
|
||||||
setShowNewPost(true);
|
|
||||||
} else {
|
} else {
|
||||||
navigate('/login.html');
|
setUser(null);
|
||||||
}
|
}
|
||||||
};
|
// 全站公告(公开接口,无公告不渲染)
|
||||||
|
announcementsApi.listActive().then(setAnnouncements).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCategoryChange = (e) => {
|
// 版块加载:已登录或游客可见才拉取(锁定时不请求,避免后端 401 报错噪音)
|
||||||
setNpCategory(e.target.value);
|
useEffect(() => {
|
||||||
setNpSub(''); // 切换板块后重置子分类
|
if (!settingsLoaded) return;
|
||||||
};
|
if (getToken() || !guestLocked) {
|
||||||
|
forumApi.listCategories()
|
||||||
const doUpload = async (file) => {
|
.then((cs) => setCategories(cs || []))
|
||||||
try {
|
.catch((e) => { setLoadError(e.message || '加载失败'); setCategories([]); });
|
||||||
const data = await uploadFile(file);
|
|
||||||
setNpContent((c) => c + '\n' + data.tag + '\n');
|
|
||||||
setNpStatus('已插入: ' + data.tag);
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(e.message);
|
|
||||||
}
|
}
|
||||||
|
}, [settingsLoaded, guestLocked]);
|
||||||
|
|
||||||
|
// 发帖按钮:已登录整页跳转 Write 页论坛模式(统一编辑体验);未登录跳登录页
|
||||||
|
const handleNewPostBtn = () => {
|
||||||
|
if (user) window.location.href = '/write.html?type=forum';
|
||||||
|
else navigate('/login.html');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e) => {
|
// 版主/管理员 → 页头显示「管理我的版块」入口
|
||||||
const f = e.target.files && e.target.files[0];
|
const canManage = !!user && (user.role === 'admin' || moderatedIds.length > 0);
|
||||||
if (f) doUpload(f);
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitPost = async () => {
|
// 游客锁定:未登录且论坛关闭游客预览 → 登录提示卡片
|
||||||
if (!npTitle.trim() || !npContent.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
const locked = settingsLoaded && guestLocked && !getToken();
|
||||||
setSubmitting(true);
|
if (locked) {
|
||||||
try {
|
return (
|
||||||
const base = {
|
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
|
||||||
category_id: parseInt(npCategory, 10),
|
<div className="empty-icon">🔒</div>
|
||||||
title: npTitle.trim(),
|
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
|
||||||
content: npContent.trim(),
|
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见,登录后即可浏览与发帖</p>
|
||||||
sub_category: npSub,
|
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
|
||||||
use_markdown: 1,
|
</div>
|
||||||
};
|
);
|
||||||
// 验证码:captcha_forum 开启时走 showCaptcha 拿 proof / 第三方 token
|
}
|
||||||
const cap = await captchaRequired('forum');
|
|
||||||
if (cap && cap.required) {
|
|
||||||
const result = await showCaptcha('forum');
|
|
||||||
if (result === null) { setSubmitting(false); return; } // 取消
|
|
||||||
applyCaptchaResult(base, result);
|
|
||||||
}
|
|
||||||
await forumApi.createPost(base);
|
|
||||||
showSnackbar('发布成功');
|
|
||||||
setShowNewPost(false);
|
|
||||||
if (currentCatId) selectCategory(currentCatId);
|
|
||||||
else showAllPosts();
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(e.message);
|
|
||||||
}
|
|
||||||
setSubmitting(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentCat = categories.find((c) => c.id === currentCatId);
|
|
||||||
const subCats = subCatsOf(currentCat);
|
|
||||||
|
|
||||||
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
|
||||||
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="forum-layout">
|
<div className="forum-home">
|
||||||
<h1 className="sr-only">论坛</h1>
|
{announcements.length > 0 && (
|
||||||
<aside className="forum-sidebar">
|
<div className="announcement-bar">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<span className="material-icons ann-icon">campaign</span>
|
||||||
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
<span className="ann-content">
|
||||||
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
|
{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 ? '发帖' : '登录发帖'}
|
{user ? '发帖' : '登录发帖'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="forum-cat-list">
|
|
||||||
{categories.map((c) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={c.id}
|
|
||||||
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
|
|
||||||
onClick={() => selectCategory(c.id)}
|
|
||||||
>
|
|
||||||
<span className="material-icons" style={{ fontSize: 18 }}>forum</span> {c.name}
|
|
||||||
{c.announcement ? (
|
|
||||||
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
|
|
||||||
onClick={showAllPosts}
|
|
||||||
style={{ fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="forum-content">
|
|
||||||
{currentCat && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
||||||
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
|
|
||||||
<span className="chip chip-static">板块</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
|
|
||||||
{currentCat.announcement && (
|
|
||||||
<div className="announcement-bar">
|
|
||||||
<span className="material-icons ann-icon">campaign</span>
|
|
||||||
<span className="ann-content">{currentCat.announcement}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subCats.length > 0 && (
|
|
||||||
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
|
|
||||||
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
|
|
||||||
{subCats.map((s) => (
|
|
||||||
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{posts === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
|
|
||||||
{loadError && <div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败</p></div>}
|
|
||||||
{posts !== null && !loadError && posts.length === 0 && (
|
|
||||||
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
|
|
||||||
)}
|
|
||||||
{posts && posts.length > 0 && (
|
|
||||||
<div className="forum-post-list">
|
|
||||||
{posts.map((p) => {
|
|
||||||
const catName = categories.find((c) => c.id === p.category_id)?.name || '';
|
|
||||||
return (
|
|
||||||
<Link key={p.id} to={`/forum/${p.id}`} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
|
|
||||||
<div className="post-title">{p.title}</div>
|
|
||||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<span>{p.author_name || '匿名'}</span>
|
|
||||||
<span>{p.created_at}</span>
|
|
||||||
<span>{p.reply_count || 0} 回复</span>
|
|
||||||
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
|
|
||||||
<span className="chip chip-static">{catName}</span>
|
|
||||||
{p.sub_category ? (
|
|
||||||
<span className="chip chip-tonal">{p.sub_category}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 发帖弹窗 */}
|
{categories === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
|
||||||
{showNewPost && (
|
{loadError && <div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败</p></div>}
|
||||||
<div
|
{categories !== null && !loadError && categories.length === 0 && (
|
||||||
ref={npDialogRef}
|
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无版块</p></div>
|
||||||
className="dialog-overlay active"
|
)}
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
{categories !== null && categories.length > 0 && (
|
||||||
aria-label="发布新帖"
|
<div className="forum-grid">
|
||||||
style={{ display: 'flex', zIndex: 9999 }}
|
{categories.map((c) => {
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
const mods = moderatorsOf(c);
|
||||||
onKeyDown={npDialogKey}
|
const postsCount = typeof c.post_count === 'number' ? c.post_count : null;
|
||||||
>
|
const todayCount = typeof c.today_count === 'number' ? c.today_count : null;
|
||||||
<div className="dialog">
|
return (
|
||||||
<h3>发布新帖</h3>
|
<Link key={c.id} to={'/forum/c/' + c.id} className="card board-card">
|
||||||
<div className="form-group">
|
<div className="board-card-top">
|
||||||
<label>板块</label>
|
<ForumIcon icon={c.icon} name={c.name} iconColor={c.icon_color} size={44} />
|
||||||
<select value={npCategory} onChange={handleCategoryChange}>
|
<div className="board-card-title">
|
||||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
<span className="board-name">{c.name}</span>
|
||||||
</select>
|
{c.announcement ? <span className="board-badge" title="版块公告">📢</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
</div>
|
||||||
<label>帖子分类(可选)</label>
|
<p className="board-desc">{c.description || '暂无描述'}</p>
|
||||||
<select value={npSub} onChange={(e) => setNpSub(e.target.value)}>
|
<div className="board-meta">
|
||||||
<option value="">无</option>
|
<span>{postsCount != null ? `${postsCount} 帖` : ''}</span>
|
||||||
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
|
{todayCount ? <span className="board-today">今日 {todayCount}</span> : null}
|
||||||
<option key={s} value={s}>{s}</option>
|
{!postsCount && !todayCount ? <span>暂无帖子</span> : null}
|
||||||
))}
|
</div>
|
||||||
</select>
|
{mods.length > 0 && (
|
||||||
</div>
|
<div className="board-mods">版主:{mods.join('、')}</div>
|
||||||
<div className="form-group">
|
)}
|
||||||
<label>标题 *</label>
|
</Link>
|
||||||
<input type="text" value={npTitle} onChange={(e) => setNpTitle(e.target.value)} />
|
);
|
||||||
</div>
|
})}
|
||||||
<div className="form-group">
|
|
||||||
<label>内容 *</label>
|
|
||||||
<textarea value={npContent} onChange={(e) => setNpContent(e.target.value)} style={{ minHeight: 150, fontFamily: 'monospace' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
|
||||||
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
|
|
||||||
<span className="material-icons">upload</span> 上传附件
|
|
||||||
</button>
|
|
||||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
|
||||||
<span className="text-muted" style={{ fontSize: 13 }}>{npStatus}</span>
|
|
||||||
</div>
|
|
||||||
<div className="actions">
|
|
||||||
<button className="btn btn-text" onClick={() => setShowNewPost(false)}>取消</button>
|
|
||||||
<button className="btn btn-filled" onClick={submitPost} disabled={submitting}>发布</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import * as forumApi from '../api/forum.js';
|
import * as forumApi from '../api/forum.js';
|
||||||
|
import * as settingsApi from '../api/settings.js';
|
||||||
import { me } from '../api/auth.js';
|
import { me } from '../api/auth.js';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
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,SPA 内路由):
|
* 论坛帖子详情(路由 /forum/:id,L3):
|
||||||
* 标题/meta/正文(MarkdownRenderer)+ 回复列表/回复框(登录检查)+ 删除按钮(作者/admin)。
|
* 面包屑(首页/论坛/版块/标题)+ 楼主标识 + 楼层(楼主1,回复i+2)+ 置顶/加精徽章 +
|
||||||
|
* 管理操作(置顶/加精/删除,admin 或版主)+ 分享弹窗 + 回复列表/回复框。
|
||||||
* 回复无需验证码(照 v1:仅发帖走 captcha_forum)。
|
* 回复无需验证码(照 v1:仅发帖走 captcha_forum)。
|
||||||
|
* 游客预览开关:forum_guest_visible==='0' 且未登录 → 登录提示。
|
||||||
*/
|
*/
|
||||||
export default function ForumDetail() {
|
export default function ForumDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -17,10 +30,27 @@ export default function ForumDetail() {
|
|||||||
const [post, setPost] = useState(null);
|
const [post, setPost] = useState(null);
|
||||||
const [replies, setReplies] = useState([]);
|
const [replies, setReplies] = useState([]);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
const [moderatedIds, setModeratedIds] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -29,6 +59,12 @@ export default function ForumDetail() {
|
|||||||
const data = await forumApi.getPost(id);
|
const data = await forumApi.getPost(id);
|
||||||
setPost(data.post);
|
setPost(data.post);
|
||||||
setReplies(data.replies || []);
|
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) {
|
} catch (e) {
|
||||||
setError(e.message || '加载失败');
|
setError(e.message || '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,12 +73,65 @@ export default function ForumDetail() {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)+ 回复 UID 开关
|
||||||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
settingsApi.getPublicSettings()
|
||||||
else setUser(null);
|
.then((s) => {
|
||||||
}, [load]);
|
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const canDeletePost = user && post && (user.id === post.author_id || user.role === 'admin');
|
// 帖子加载:已登录或游客可见才拉取(锁定时跳过,避免后端 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 submitReply = async () => {
|
||||||
const content = replyText.trim();
|
const content = replyText.trim();
|
||||||
@@ -54,7 +143,9 @@ export default function ForumDetail() {
|
|||||||
setReplyText('');
|
setReplyText('');
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnackbar(e.message);
|
// 403:被禁言等拒绝操作 → 弹窗展示后端错误文案(含到期时间)
|
||||||
|
if (e.status === 403) setSubmitError(e.message);
|
||||||
|
else showSnackbar(e.message);
|
||||||
}
|
}
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
};
|
};
|
||||||
@@ -81,6 +172,64 @@ export default function ForumDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 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) {
|
if (loading) {
|
||||||
return <div className="loading"><div className="spinner"></div></div>;
|
return <div className="loading"><div className="spinner"></div></div>;
|
||||||
}
|
}
|
||||||
@@ -98,33 +247,95 @@ export default function ForumDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="post-detail">
|
<div className="post-detail">
|
||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
{/* 面包屑:首页 / 论坛 / 版块 / 标题 */}
|
||||||
<Link to="/forum.html" className="btn btn-text btn-sm">
|
<nav className="breadcrumbs" aria-label="面包屑">
|
||||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回
|
<Link to="/">首页</Link>
|
||||||
</Link>
|
<span className="sep">/</span>
|
||||||
{canDeletePost && (
|
<Link to="/forum.html">论坛</Link>
|
||||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 'auto' }} onClick={deletePost}>
|
<span className="sep">/</span>
|
||||||
删除
|
{post.category_id != null ? (
|
||||||
</button>
|
<>
|
||||||
)}
|
<Link to={'/forum/c/' + post.category_id}>{post.category_name || '版块'}</Link>
|
||||||
</div>
|
<span className="sep">/</span>
|
||||||
<h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
|
</>
|
||||||
|
) : 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' }}>
|
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>{post.author_name || '匿名'}</span>
|
<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>{post.created_at}</span>
|
||||||
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
|
<span className="floor-num">#1 楼</span>
|
||||||
{post.category_name || ''}
|
<span className="chip chip-static">{post.category_name || ''}</span>
|
||||||
</span>
|
|
||||||
{post.sub_category && (
|
{post.sub_category && (
|
||||||
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
|
<span className="chip chip-tonal">{post.sub_category}</span>
|
||||||
{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>
|
</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>
|
||||||
|
|
||||||
<div className="post-body">
|
<div className="post-body">
|
||||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
<MarkdownRenderer
|
||||||
|
content={post.content}
|
||||||
|
useMarkdown={post.use_markdown}
|
||||||
|
locks={locks}
|
||||||
|
unlocked={unlocked}
|
||||||
|
lockContent={lockContent}
|
||||||
|
onUnlock={unlockLock}
|
||||||
|
onGoComment={scrollToComment}
|
||||||
|
refType="forum"
|
||||||
|
refId={post.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style={{ border: 'none', borderTop: '2px solid var(--md-ref-primary-container)', margin: '24px 0', borderRadius: 2 }} />
|
<hr style={{ border: 'none', borderTop: '2px solid var(--md-ref-primary-container)', margin: '24px 0', borderRadius: 2 }} />
|
||||||
@@ -151,28 +362,81 @@ export default function ForumDetail() {
|
|||||||
{replies.length === 0 ? (
|
{replies.length === 0 ? (
|
||||||
<div className="text-muted" style={{ padding: 16 }}>暂无回复</div>
|
<div className="text-muted" style={{ padding: 16 }}>暂无回复</div>
|
||||||
) : (
|
) : (
|
||||||
replies.map((r) => {
|
replies.map((r, i) => {
|
||||||
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin');
|
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 (
|
return (
|
||||||
<div className="reply-item" key={r.id}>
|
<div className="floor-item" key={r.id}>
|
||||||
<div className="reply-meta">
|
<div className="floor-head">
|
||||||
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
|
<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 && (
|
{canDeleteReply && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="删除回复"
|
aria-label="删除回复"
|
||||||
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
className="floor-del"
|
||||||
onClick={() => deleteReply(r.id)}
|
onClick={() => deleteReply(r.id)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="reply-body">{r.content}</div>
|
<div className="floor-body">{r.content}</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { 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 { showSnackbar } from '../lib/utils.js';
|
|
||||||
|
|
||||||
const EMPTY_FORM = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
|
|
||||||
|
|
||||||
/** 单个板块的编辑卡片:名称/描述/公告/子分类(逗号分隔)/排序 */
|
|
||||||
function CategoryEditCard({ cat, onSave, onDelete }) {
|
|
||||||
const [f, setF] = useState({ ...cat });
|
|
||||||
const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value }));
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>板块名称 *</label>
|
|
||||||
<input type="text" value={f.name} onChange={set('name')} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>描述</label>
|
|
||||||
<input type="text" value={f.description} onChange={set('description')} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>板块公告</label>
|
|
||||||
<textarea value={f.announcement} onChange={set('announcement')} style={{ minHeight: 60 }} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>帖子分类(逗号分隔)</label>
|
|
||||||
<input type="text" value={f.sub_categories} onChange={set('sub_categories')} placeholder="例: 求助,分享,讨论,建议" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>排序</label>
|
|
||||||
<input type="number" value={f.sort_order} onChange={set('sort_order')} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
||||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)' }} onClick={() => onDelete(cat)}>
|
|
||||||
删除板块
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-filled btn-sm" onClick={() => onSave(f)}>保存设置</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 板块管理(admin):分类增删改 + 子分类编辑(迁移自 forum-manage.html,扩展为全板块管理) */
|
|
||||||
export default function ForumManage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [authed, setAuthed] = useState(null); // null=校验中
|
|
||||||
const [categories, setCategories] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = getToken();
|
|
||||||
if (!token) { navigate('/login.html'); return; }
|
|
||||||
me()
|
|
||||||
.then((u) => {
|
|
||||||
if (u.role !== 'admin') {
|
|
||||||
showSnackbar('需要管理员权限');
|
|
||||||
setAuthed(false);
|
|
||||||
setTimeout(() => navigate('/'), 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAuthed(true);
|
|
||||||
loadCategories();
|
|
||||||
})
|
|
||||||
.catch(() => navigate('/login.html'));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadCategories = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const cs = await forumApi.listCategories();
|
|
||||||
setCategories(cs || []);
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(e.message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCategory = async (f) => {
|
|
||||||
const data = {
|
|
||||||
name: (f.name || '').trim(),
|
|
||||||
description: f.description || '',
|
|
||||||
announcement: f.announcement || '',
|
|
||||||
sub_categories: f.sub_categories || '',
|
|
||||||
sort_order: parseInt(f.sort_order, 10) || 0,
|
|
||||||
};
|
|
||||||
if (!data.name) { showSnackbar('名称不能为空'); return; }
|
|
||||||
try {
|
|
||||||
await forumApi.updateCategory(f.id, data);
|
|
||||||
showSnackbar('保存成功');
|
|
||||||
loadCategories();
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteCategory = async (cat) => {
|
|
||||||
if (!window.confirm(`确定删除板块「${cat.name}」?板块下的帖子将一并删除`)) return;
|
|
||||||
try {
|
|
||||||
await forumApi.deleteCategory(cat.id);
|
|
||||||
showSnackbar('已删除');
|
|
||||||
loadCategories();
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCategory = async () => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
if (!data.name) { showSnackbar('名称不能为空'); return; }
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await forumApi.createCategory(data);
|
|
||||||
showSnackbar('创建成功');
|
|
||||||
setForm(EMPTY_FORM);
|
|
||||||
setShowCreate(false);
|
|
||||||
loadCategories();
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(e.message);
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (authed === null) {
|
|
||||||
return <div className="loading"><div className="spinner"></div></div>;
|
|
||||||
}
|
|
||||||
if (authed === false) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
|
||||||
<a href="/forum.html" className="btn-icon"><span className="material-icons">arrow_back</span></a>
|
|
||||||
<h1 className="page-title" style={{ margin: 0, flex: 1 }}>板块管理</h1>
|
|
||||||
<button className="btn btn-filled btn-sm" onClick={() => setShowCreate((v) => !v)}>
|
|
||||||
<span className="material-icons" style={{ fontSize: 16 }}>add</span> 新建板块
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>板块名称 *</label>
|
|
||||||
<input type="text" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>描述</label>
|
|
||||||
<input type="text" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>板块公告</label>
|
|
||||||
<textarea value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} style={{ minHeight: 60 }} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>帖子分类(逗号分隔)</label>
|
|
||||||
<input type="text" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} placeholder="例: 求助,分享,讨论,建议" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>排序</label>
|
|
||||||
<input type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
||||||
<button className="btn btn-text btn-sm" onClick={() => setShowCreate(false)}>取消</button>
|
|
||||||
<button className="btn btn-filled btn-sm" onClick={createCategory} disabled={saving}>创建</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading"><div className="spinner"></div></div>
|
|
||||||
) : categories.length === 0 ? (
|
|
||||||
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无板块</p></div>
|
|
||||||
) : (
|
|
||||||
categories.map((c) => (
|
|
||||||
<CategoryEditCard key={c.id} cat={c} onSave={saveCategory} onDelete={deleteCategory} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,28 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import * as authApi from '../api/auth.js';
|
import * as authApi from '../api/auth.js';
|
||||||
|
import * as settingsApi from '../api/settings.js';
|
||||||
import { required as captchaRequired } from '../api/captcha.js';
|
import { required as captchaRequired } from '../api/captcha.js';
|
||||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
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):
|
* 登录页(迁移自 login.html):
|
||||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||||
|
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error)
|
||||||
|
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
|
||||||
*/
|
*/
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,19 +35,69 @@ export default function Login() {
|
|||||||
const [capRequired, setCapRequired] = useState(false);
|
const [capRequired, setCapRequired] = useState(false);
|
||||||
const [capDone, setCapDone] = useState(false);
|
const [capDone, setCapDone] = useState(false);
|
||||||
const [capResult, setCapResult] = useState(null);
|
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(() => {
|
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; }
|
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')
|
captchaRequired('login')
|
||||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [navigate]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const doCaptcha = async () => {
|
const doCaptcha = async () => {
|
||||||
const result = await showCaptcha('login');
|
const result = await showCaptcha('login');
|
||||||
if (result) { setCapResult(result); setCapDone(true); }
|
if (result) { setCapResult(result); setCapDone(true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** RainID 整页跳转(loading 防重复点击) */
|
||||||
|
const handleRainId = () => {
|
||||||
|
if (oidcBusy) return;
|
||||||
|
setOidcBusy(true);
|
||||||
|
authApi.oidcLoginRedirect();
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||||
@@ -46,8 +110,13 @@ export default function Login() {
|
|||||||
const from = location.state && location.state.from;
|
const from = location.state && location.state.from;
|
||||||
navigate(from || '/');
|
navigate(from || '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录")
|
||||||
setError(e.message || '登录失败');
|
setError(e.message || '登录失败');
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
|
||||||
|
// 重置「已验证」标记允许用户重新完成验证,否则按钮保持 disabled 死锁。
|
||||||
|
setCapDone(false);
|
||||||
|
setCapResult(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +176,7 @@ export default function Login() {
|
|||||||
height: 44,
|
height: 44,
|
||||||
...(capDone
|
...(capDone
|
||||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||||
: { background: '#fff', color: '#333', border: '1px solid #333' }),
|
: { 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 className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||||
@@ -115,6 +184,18 @@ export default function Login() {
|
|||||||
</button>
|
</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}>
|
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||||||
{busy ? '登录中...' : '登录'}
|
{busy ? '登录中...' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import * as profileApi from '../api/profile.js';
|
import * as profileApi from '../api/profile.js';
|
||||||
import * as authApi from '../api/auth.js';
|
import * as authApi from '../api/auth.js';
|
||||||
|
import * as settingsApi from '../api/settings.js';
|
||||||
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
||||||
import { getToken, notifyAuthChange } from '../api/client.js';
|
import { getToken, notifyAuthChange } from '../api/client.js';
|
||||||
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||||
@@ -29,6 +30,17 @@ export default function Profile() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const fileRef = useRef(null);
|
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 [pwDialog, setPwDialog] = useState(false);
|
||||||
const [pwStep, setPwStep] = useState(1);
|
const [pwStep, setPwStep] = useState(1);
|
||||||
@@ -51,9 +63,17 @@ export default function Profile() {
|
|||||||
try {
|
try {
|
||||||
const u = await profileApi.getProfile();
|
const u = await profileApi.getProfile();
|
||||||
setUser(u);
|
setUser(u);
|
||||||
|
setQq(u.qq || ''); // 后端提供 qq 字段时预填
|
||||||
|
setBio(u.bio || ''); // 个性签名
|
||||||
|
setNickname(u.nickname || ''); // 对外昵称
|
||||||
|
setWebsite(u.website || ''); // 个人博客
|
||||||
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
|
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
|
||||||
const av = await avatarUrl(u.id);
|
const av = await avatarUrl(u.id);
|
||||||
setAvatar(av.url || '');
|
setAvatar(av.url || '');
|
||||||
|
// RainID 开关(公开设置)
|
||||||
|
settingsApi.getPublicSettings()
|
||||||
|
.then((s) => setRainidEnabled(s.rainid_enabled === '1'))
|
||||||
|
.catch(() => setRainidEnabled(false));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || '加载失败');
|
setError(e.message || '加载失败');
|
||||||
if (e.status === 401) { navigate('/login.html'); return; }
|
if (e.status === 401) { navigate('/login.html'); return; }
|
||||||
@@ -100,6 +120,20 @@ export default function Profile() {
|
|||||||
navigate('/');
|
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 () => {
|
const sendPwCode = async () => {
|
||||||
setPwBusy(true);
|
setPwBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -178,9 +212,91 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '24px 0' }} />
|
{/* 对外昵称 + 个人博客:显示在帖子/评论/个人主页 */}
|
||||||
<h4 style={{ fontWeight: 500, marginBottom: 12 }}>安全设置</h4>
|
<div className="form-group">
|
||||||
<button className="btn btn-outline" onClick={() => { setPwStep(1); setPwDialog(true); }}>修改密码</button>
|
<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>
|
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 8 }} onClick={handleLogout}>退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import * as authApi from '../api/auth.js';
|
import * as authApi from '../api/auth.js';
|
||||||
import * as emailApi from '../api/email.js';
|
import * as emailApi from '../api/email.js';
|
||||||
|
import * as settingsApi from '../api/settings.js';
|
||||||
import { required as captchaRequired } from '../api/captcha.js';
|
import { required as captchaRequired } from '../api/captcha.js';
|
||||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
import { showSnackbar } from '../lib/utils.js';
|
||||||
|
|
||||||
|
/** RainID 注册页地址(后端 rainid_register_redirect=1 时整页跳转) */
|
||||||
|
const RAINID_REGISTER_URL = 'https://rainid.rainnya.asia/register';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册页(迁移自 register.html):
|
* 注册页(迁移自 register.html):
|
||||||
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
||||||
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
||||||
|
* RainID:rainid_register_redirect=1 → 整页跳转 RainID 注册(不显示本地表单);
|
||||||
|
* 否则保留本地注册,表单下方提供「使用 RainID 登录」入口。
|
||||||
*/
|
*/
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -25,12 +31,27 @@ export default function Register() {
|
|||||||
const [capRequired, setCapRequired] = useState(false);
|
const [capRequired, setCapRequired] = useState(false);
|
||||||
const [capDone, setCapDone] = useState(false);
|
const [capDone, setCapDone] = useState(false);
|
||||||
const [capResult, setCapResult] = useState(null);
|
const [capResult, setCapResult] = useState(null);
|
||||||
|
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||||
|
const [redirecting, setRedirecting] = useState(false); // 正在跳转 RainID 注册
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||||
captchaRequired('register')
|
settingsApi.getPublicSettings()
|
||||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
.then((s) => {
|
||||||
.catch(() => {});
|
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]);
|
}, [navigate]);
|
||||||
|
|
||||||
const doCaptcha = async () => {
|
const doCaptcha = async () => {
|
||||||
@@ -38,6 +59,11 @@ export default function Register() {
|
|||||||
if (result) { setCapResult(result); setCapDone(true); }
|
if (result) { setCapResult(result); setCapDone(true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** RainID 登录入口(与登录页同逻辑) */
|
||||||
|
const handleRainId = () => {
|
||||||
|
authApi.oidcLoginRedirect();
|
||||||
|
};
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
||||||
if (password.length < 6) { setError('密码至少6位'); return; }
|
if (password.length < 6) { setError('密码至少6位'); return; }
|
||||||
@@ -60,6 +86,9 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || '注册失败');
|
setError(e.message || '注册失败');
|
||||||
|
// 与登录一致:验证码 proof 一次性消费,注册失败后重置验证状态允许重新验证
|
||||||
|
setCapDone(false);
|
||||||
|
setCapResult(null);
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
@@ -85,6 +114,21 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
||||||
@@ -167,6 +211,19 @@ export default function Register() {
|
|||||||
<span className="text-muted">已有账户?</span>
|
<span className="text-muted">已有账户?</span>
|
||||||
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
||||||
</div>
|
</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 }}>
|
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,112 +1,181 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import * as blogApi from '../api/blog.js';
|
import * as blogApi from '../api/blog.js';
|
||||||
import { uploadFile } from '../api/upload.js';
|
import * as forumApi from '../api/forum.js';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
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';
|
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 进入编辑模式):
|
* 写文章 / 发帖页(路由 /write.html):
|
||||||
* 标题/摘要/内容(Markdown)/发布开关;附件上传用 uploadFile + [image:]/[file:] 标签插入;
|
* - 默认博客模式(?edit=id 进入编辑):标题/摘要/标签/发布开关 → /api/blog
|
||||||
* 支持拖拽图片上传与预览(迁移自 write.html 内联脚本)。
|
* - ?type=forum 论坛模式(?type=forum&edit=id 进入编辑):标题 + 板块/子分类选择 → /api/forum/posts
|
||||||
|
* - 发帖走验证码(captcha_forum);编辑不需要验证码,且不改版块(下拉只读)
|
||||||
|
* 内容编辑统一用共用 MarkdownEditor(左编辑右实时预览 + 防抖刷新)。
|
||||||
*/
|
*/
|
||||||
export default function Write() {
|
export default function Write() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
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 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 [title, setTitle] = useState('');
|
||||||
const [excerpt, setExcerpt] = useState('');
|
const [excerpt, setExcerpt] = useState('');
|
||||||
const [tags, setTags] = useState('');
|
const [tags, setTags] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [published, setPublished] = useState(true);
|
const [published, setPublished] = useState(true);
|
||||||
const [preview, setPreview] = useState(false);
|
|
||||||
const [uploadStatus, setUploadStatus] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [submitError, setSubmitError] = useState(''); // 403 禁言等错误 → 弹窗
|
||||||
|
|
||||||
const fileRef = useRef(null);
|
// 论坛模式:板块 + 子分类(默认选中第一个板块)
|
||||||
const contentRef = useRef(null);
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [fpCategory, setFpCategory] = useState('');
|
||||||
|
const [fpSub, setFpSub] = useState('');
|
||||||
|
|
||||||
// 编辑模式:加载已有文章
|
// 博客编辑模式:加载已有文章
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editId) return;
|
if (!isForum && editId) {
|
||||||
blogApi.getPost(editId)
|
blogApi.getPost(editId)
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
setTitle(p.title);
|
setTitle(p.title);
|
||||||
setExcerpt(p.excerpt || '');
|
setExcerpt(p.excerpt || '');
|
||||||
setTags(p.tags || '');
|
setTags(p.tags || '');
|
||||||
setContent(p.content);
|
setContent(p.content);
|
||||||
setPublished(!!p.published);
|
setPublished(!!p.published);
|
||||||
})
|
})
|
||||||
.catch((e) => setLoadError(e.message || '加载文章失败'));
|
.catch((e) => setLoadError(e.message || '加载文章失败'));
|
||||||
}, [editId]);
|
|
||||||
|
|
||||||
const insertTag = (tag) => {
|
|
||||||
setContent((c) => c + '\n' + tag + '\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const doUpload = async (file) => {
|
|
||||||
try {
|
|
||||||
const data = await uploadFile(file);
|
|
||||||
insertTag(data.tag);
|
|
||||||
setUploadStatus('已插入: ' + data.tag);
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(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]);
|
||||||
|
|
||||||
const handleFileSelect = (e) => {
|
// 论坛模式:拉取板块列表(?category= 参数预选,无则第一个;编辑时由帖子原版块决定)
|
||||||
const f = e.target.files && e.target.files[0];
|
useEffect(() => {
|
||||||
if (f) doUpload(f);
|
if (!isForum) return;
|
||||||
e.target.value = '';
|
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 handleDrop = async (e) => {
|
const handleCategoryChange = (e) => {
|
||||||
e.preventDefault();
|
setFpCategory(e.target.value);
|
||||||
if (contentRef.current) contentRef.current.style.borderColor = '';
|
setFpSub(''); // 切换板块后重置子分类
|
||||||
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);
|
|
||||||
if (contentRef.current) contentRef.current.focus();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||||||
|
if (isForum && !fpCategory) { showSnackbar('请选择板块'); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const data = {
|
|
||||||
title: title.trim(),
|
|
||||||
content: content.trim(),
|
|
||||||
excerpt: excerpt.trim(),
|
|
||||||
tags: tags.trim(),
|
|
||||||
published,
|
|
||||||
use_markdown: 1,
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
let id = editId;
|
if (isForum) {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
await blogApi.updatePost(editId, data);
|
// 编辑帖子:不需要验证码;部分更新只提交 title/content/sub_category(版块不可改)
|
||||||
showSnackbar('已更新');
|
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 {
|
} else {
|
||||||
const created = await blogApi.createPost(data);
|
const data = {
|
||||||
id = created.id;
|
title: title.trim(),
|
||||||
showSnackbar('已发布');
|
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);
|
||||||
}
|
}
|
||||||
navigate('/blog/' + id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnackbar(e.message);
|
// 403:被禁言等拒绝操作 → 弹窗展示后端错误文案(含到期时间)
|
||||||
|
if (e.status === 403) setSubmitError(e.message);
|
||||||
|
else showSnackbar(e.message);
|
||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const heading = isForum ? (editId ? '编辑帖子' : '发帖') : (editId ? '编辑文章' : '写文章');
|
||||||
|
const backHref = isForum ? '/forum.html' : '/blog.html';
|
||||||
|
const backText = isForum ? '返回论坛' : '返回博客';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="write-page" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 16px' }}>
|
<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 }}>
|
<div className="write-header" style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>
|
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>{heading}</h1>
|
||||||
{editId ? '编辑文章' : '写文章'}
|
<a href={backHref} className="btn btn-text btn-sm">
|
||||||
</h1>
|
<span className="material-icons">arrow_back</span> {backText}
|
||||||
<a href="/blog.html" className="btn btn-text btn-sm">
|
|
||||||
<span className="material-icons">arrow_back</span> 返回博客
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,61 +187,81 @@ export default function Write() {
|
|||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label>标题 *</label>
|
<label>标题 *</label>
|
||||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="文章标题" />
|
<input
|
||||||
</div>
|
type="text"
|
||||||
<div className="form-group">
|
value={title}
|
||||||
<label>摘要</label>
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
|
placeholder={isForum ? '帖子标题' : '文章标题'}
|
||||||
</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>
|
|
||||||
<textarea
|
|
||||||
ref={contentRef}
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
style={{ minHeight: 350, fontFamily: 'monospace', fontSize: 14 }}
|
|
||||||
placeholder="支持 Markdown 语法,拖入图片自动上传"
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (contentRef.current) contentRef.current.style.borderColor = 'var(--md-ref-primary)';
|
|
||||||
}}
|
|
||||||
onDragLeave={() => { if (contentRef.current) contentRef.current.style.borderColor = ''; }}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
{!isForum && (
|
||||||
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
|
<>
|
||||||
<span className="material-icons">upload</span> 上传附件/图片
|
<div className="form-group">
|
||||||
</button>
|
<label>摘要</label>
|
||||||
<button className="btn btn-text btn-sm" onClick={() => setPreview((v) => !v)}>
|
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
|
||||||
<span className="material-icons">visibility</span> 预览
|
</div>
|
||||||
</button>
|
<div className="form-group">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto', fontSize: 14, fontWeight: 500, color: 'var(--md-ref-on-surface-variant)' }}>
|
<label>标签</label>
|
||||||
<input type="checkbox" checked={published} onChange={(e) => setPublished(e.target.checked)} /> 发布
|
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="多个标签用英文逗号分隔,如:技术,生活,随笔" />
|
||||||
</label>
|
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>多个标签用英文逗号分隔,用于标签云与标签页</div>
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
</>
|
||||||
{uploadStatus && (
|
|
||||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>{uploadStatus}</div>
|
|
||||||
)}
|
)}
|
||||||
{preview && (
|
<div className="form-group">
|
||||||
<div style={{ padding: 16, background: 'var(--md-ref-surface-container-low)', borderRadius: 12, marginBottom: 16, maxHeight: 400, overflowY: 'auto' }}>
|
<label>内容 *</label>
|
||||||
<MarkdownRenderer content={content} useMarkdown />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-filled" onClick={submit} disabled={saving}>
|
<button className="btn btn-filled" onClick={submit} disabled={saving}>
|
||||||
{editId ? '保存修改' : '发布文章'}
|
{isForum ? (editId ? '保存修改' : '发布') : (editId ? '保存修改' : '发布文章')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 403 禁言等拒绝操作提示 */}
|
||||||
|
<ErrorDialog open={!!submitError} message={submitError} onClose={() => setSubmitError('')} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { applyThemeTokens } from './themeTokens.js';
|
||||||
|
|
||||||
// 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制
|
// 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制
|
||||||
// (public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类)
|
// (public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类)
|
||||||
const THEME_KEY = 'theme';
|
const THEME_KEY = 'theme';
|
||||||
const ThemeContext = createContext(null);
|
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) {
|
function applyTheme(theme) {
|
||||||
if (theme === 'dark') {
|
if (theme === 'dark') {
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
@@ -14,21 +21,22 @@ function applyTheme(theme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }) {
|
export function ThemeProvider({ children }) {
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setThemeState] = useState(getThemePreference);
|
||||||
const saved = localStorage.getItem(THEME_KEY);
|
const setTheme = useCallback((nextTheme, { persist = true } = {}) => {
|
||||||
if (saved === 'dark' || saved === 'light') return saved;
|
if (nextTheme !== 'dark' && nextTheme !== 'light') return;
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
if (persist) localStorage.setItem(THEME_KEY, nextTheme);
|
||||||
});
|
setThemeState(nextTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
localStorage.setItem(THEME_KEY, theme);
|
applyThemeTokens({ mode: theme });
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
toggleTheme: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
|
toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
|
||||||
setTheme,
|
setTheme,
|
||||||
}),
|
}),
|
||||||
[theme]
|
[theme]
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
const DEFAULT_PRIMARY_COLOR = '#6750a4';
|
||||||
|
const DEFAULT_GLASS_OPACITY = 0.6;
|
||||||
|
const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
||||||
|
|
||||||
|
function clamp(value, min = 0, max = 255) {
|
||||||
|
return Math.min(max, Math.max(min, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHexColor(value, fallback = DEFAULT_PRIMARY_COLOR) {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
const safeFallback = HEX_COLOR_RE.test(String(fallback).trim()) ? String(fallback).trim() : DEFAULT_PRIMARY_COLOR;
|
||||||
|
const color = HEX_COLOR_RE.test(raw) ? raw : safeFallback;
|
||||||
|
const hex = color.slice(1).toLowerCase();
|
||||||
|
return `#${hex.length === 3 ? hex.split('').map((part) => part + part).join('') : hex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const value = normalizeHexColor(hex).slice(1);
|
||||||
|
const number = parseInt(value, 16);
|
||||||
|
return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(red, green, blue) {
|
||||||
|
const toHex = (value) => clamp(value).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(first, second, weight) {
|
||||||
|
const a = hexToRgb(first);
|
||||||
|
const b = hexToRgb(second);
|
||||||
|
return rgbToHex(
|
||||||
|
a[0] + (b[0] - a[0]) * weight,
|
||||||
|
a[1] + (b[1] - a[1]) * weight,
|
||||||
|
a[2] + (b[2] - a[2]) * weight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToHsl(hex) {
|
||||||
|
const [red, green, blue] = hexToRgb(hex).map((value) => value / 255);
|
||||||
|
const max = Math.max(red, green, blue);
|
||||||
|
const min = Math.min(red, green, blue);
|
||||||
|
const lightness = (max + min) / 2;
|
||||||
|
let hue = 0;
|
||||||
|
let saturation = 0;
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
const delta = max - min;
|
||||||
|
saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||||
|
if (max === red) hue = (green - blue) / delta + (green < blue ? 6 : 0);
|
||||||
|
else if (max === green) hue = (blue - red) / delta + 2;
|
||||||
|
else hue = (red - green) / delta + 4;
|
||||||
|
hue *= 60;
|
||||||
|
}
|
||||||
|
return [hue, saturation, lightness];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToHex(hue, saturation, lightness) {
|
||||||
|
const normalizedHue = ((hue % 360) + 360) % 360;
|
||||||
|
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||||
|
const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
|
||||||
|
const match = lightness - chroma / 2;
|
||||||
|
let rgb;
|
||||||
|
if (normalizedHue < 60) rgb = [chroma, x, 0];
|
||||||
|
else if (normalizedHue < 120) rgb = [x, chroma, 0];
|
||||||
|
else if (normalizedHue < 180) rgb = [0, chroma, x];
|
||||||
|
else if (normalizedHue < 240) rgb = [0, x, chroma];
|
||||||
|
else if (normalizedHue < 300) rgb = [x, 0, chroma];
|
||||||
|
else rgb = [chroma, 0, x];
|
||||||
|
return rgbToHex((rgb[0] + match) * 255, (rgb[1] + match) * 255, (rgb[2] + match) * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
function grayscale(hex) {
|
||||||
|
const [red, green, blue] = hexToRgb(hex);
|
||||||
|
const value = 0.299 * red + 0.587 * green + 0.114 * blue;
|
||||||
|
return rgbToHex(value, value, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(seed, value) {
|
||||||
|
if (value >= 40) return mix(seed, '#ffffff', (value - 40) / 60);
|
||||||
|
return mix(seed, '#000000', (40 - value) / 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tonal(seed, value, { hue = 0, saturation = 1 } = {}) {
|
||||||
|
let base = seed;
|
||||||
|
if (hue || saturation < 1) {
|
||||||
|
const [currentHue, currentSaturation, lightness] = hexToHsl(seed);
|
||||||
|
base = hslToHex(currentHue + hue, currentSaturation * saturation, lightness);
|
||||||
|
}
|
||||||
|
return tone(base, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeLuminance(hex) {
|
||||||
|
return hexToRgb(hex).reduce((total, channel, index) => {
|
||||||
|
const value = channel / 255;
|
||||||
|
const linear = value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
return total + linear * [0.2126, 0.7152, 0.0722][index];
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastText(background) {
|
||||||
|
const luminance = relativeLuminance(background);
|
||||||
|
const whiteContrast = 1.05 / (luminance + 0.05);
|
||||||
|
const darkContrast = (luminance + 0.05) / 0.05;
|
||||||
|
return whiteContrast >= darkContrast ? '#ffffff' : '#1c1b1f';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeOpacity(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number)
|
||||||
|
? Math.min(0.95, Math.max(0.1, number))
|
||||||
|
: DEFAULT_GLASS_OPACITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(hex, alpha) {
|
||||||
|
const [red, green, blue] = hexToRgb(hex);
|
||||||
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIGHT_SURFACES = {
|
||||||
|
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_SURFACES = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LIGHT_PRIMARY = {
|
||||||
|
primary: DEFAULT_PRIMARY_COLOR,
|
||||||
|
onPrimary: '#ffffff',
|
||||||
|
primaryContainer: '#eadfff',
|
||||||
|
onPrimaryContainer: '#21005d',
|
||||||
|
secondary: '#625b71',
|
||||||
|
onSecondary: '#ffffff',
|
||||||
|
secondaryContainer: '#e8def8',
|
||||||
|
onSecondaryContainer: '#1d192b',
|
||||||
|
tertiary: '#7d5260',
|
||||||
|
onTertiary: '#ffffff',
|
||||||
|
tertiaryContainer: '#ffd8e4',
|
||||||
|
onTertiaryContainer: '#31111d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DARK_PRIMARY = {
|
||||||
|
primary: '#d0bcff',
|
||||||
|
onPrimary: '#381e72',
|
||||||
|
primaryContainer: '#4f378b',
|
||||||
|
onPrimaryContainer: '#eadfff',
|
||||||
|
secondary: '#ccc2dc',
|
||||||
|
onSecondary: '#332d41',
|
||||||
|
secondaryContainer: '#4a4458',
|
||||||
|
onSecondaryContainer: '#e8def8',
|
||||||
|
tertiary: '#efb8c8',
|
||||||
|
onTertiary: '#492532',
|
||||||
|
tertiaryContainer: '#61343f',
|
||||||
|
onTertiaryContainer: '#ffd8e4',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildPalette(seed, dark) {
|
||||||
|
if (seed === DEFAULT_PRIMARY_COLOR) {
|
||||||
|
return { ...(dark ? DEFAULT_DARK_PRIMARY : DEFAULT_LIGHT_PRIMARY), ...(dark ? DARK_SURFACES : LIGHT_SURFACES) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const surfaces = dark ? DARK_SURFACES : LIGHT_SURFACES;
|
||||||
|
const primary = tone(seed, dark ? 80 : 40);
|
||||||
|
const primaryContainer = tone(seed, dark ? 30 : 90);
|
||||||
|
const secondary = tonal(seed, dark ? 80 : 40, { saturation: 0.55 });
|
||||||
|
const secondaryContainer = tonal(seed, dark ? 30 : 90, { saturation: 0.55 });
|
||||||
|
const tertiary = tonal(seed, dark ? 80 : 40, { hue: 32 });
|
||||||
|
const tertiaryContainer = tonal(seed, dark ? 30 : 90, { hue: 32 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...surfaces,
|
||||||
|
primary,
|
||||||
|
onPrimary: dark ? tone(seed, 20) : contrastText(primary),
|
||||||
|
primaryContainer,
|
||||||
|
onPrimaryContainer: dark ? tone(seed, 90) : contrastText(primaryContainer),
|
||||||
|
secondary,
|
||||||
|
onSecondary: dark ? tone(seed, 20) : contrastText(secondary),
|
||||||
|
secondaryContainer,
|
||||||
|
onSecondaryContainer: dark ? tone(seed, 90) : contrastText(secondaryContainer),
|
||||||
|
tertiary,
|
||||||
|
onTertiary: dark ? tone(seed, 20) : contrastText(tertiary),
|
||||||
|
tertiaryContainer,
|
||||||
|
onTertiaryContainer: dark ? tone(seed, 90) : contrastText(tertiaryContainer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMode(options) {
|
||||||
|
const requested = options.mode || options.lightDark || options.light_dark || options['light-dark'];
|
||||||
|
return requested === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildThemeTokens(options = {}) {
|
||||||
|
const mode = resolveMode(options);
|
||||||
|
const dark = mode === 'dark';
|
||||||
|
const primaryColor = normalizeHexColor(options.primaryColor ?? options.primary_color);
|
||||||
|
const glassOpacity = safeOpacity(options.glassOpacity ?? options.glass_opacity);
|
||||||
|
const palette = buildPalette(primaryColor, dark);
|
||||||
|
const primaryRgb = hexToRgb(palette.primary).join(', ');
|
||||||
|
const primaryColorRgb = hexToRgb(primaryColor).join(', ');
|
||||||
|
const glassSurface = palette.surface;
|
||||||
|
const glassHeavyOpacity = Math.min(0.98, glassOpacity + 0.18);
|
||||||
|
const glassWallpaperOpacity = Math.min(0.98, glassOpacity + 0.22);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...palette,
|
||||||
|
mode,
|
||||||
|
primaryColor,
|
||||||
|
primary_color: primaryColor,
|
||||||
|
primaryRgb,
|
||||||
|
primary_rgb: primaryRgb,
|
||||||
|
primaryColorRgb,
|
||||||
|
background: palette.surface,
|
||||||
|
foreground: palette.onSurface,
|
||||||
|
card: palette.surfaceContainerLow,
|
||||||
|
cardForeground: palette.onSurface,
|
||||||
|
muted: palette.surfaceContainer,
|
||||||
|
mutedForeground: palette.onSurfaceVariant,
|
||||||
|
accent: palette.primaryContainer,
|
||||||
|
accentForeground: palette.onPrimaryContainer,
|
||||||
|
border: palette.outlineVariant,
|
||||||
|
input: palette.outlineVariant,
|
||||||
|
ring: palette.primary,
|
||||||
|
destructive: palette.error,
|
||||||
|
destructiveForeground: palette.onError,
|
||||||
|
errorContainer: dark ? '#93000a' : '#f9dedc',
|
||||||
|
onErrorContainer: dark ? '#ffdad6' : '#410e0b',
|
||||||
|
success: dark ? '#81c784' : '#2e7d32',
|
||||||
|
onSuccess: dark ? '#123d16' : '#ffffff',
|
||||||
|
successContainer: dark ? '#214d29' : '#d9f2df',
|
||||||
|
onSuccessContainer: dark ? '#b8efbd' : '#1b5e20',
|
||||||
|
warning: dark ? '#ffdda0' : '#7a5900',
|
||||||
|
onWarning: '#ffffff',
|
||||||
|
warningContainer: dark ? '#5a420d' : '#fff1d6',
|
||||||
|
onWarningContainer: dark ? '#ffdda0' : '#6b4300',
|
||||||
|
info: dark ? '#a8c7fa' : '#245b9e',
|
||||||
|
glassOpacity,
|
||||||
|
glass_opacity: String(glassOpacity),
|
||||||
|
glassBg: rgba(glassSurface, glassOpacity),
|
||||||
|
glassBgHeavy: rgba(glassSurface, glassHeavyOpacity),
|
||||||
|
glassBgWallpaper: rgba(glassSurface, glassWallpaperOpacity),
|
||||||
|
glassBgHeavyWallpaper: rgba(glassSurface, Math.min(0.99, glassHeavyOpacity + 0.04)),
|
||||||
|
glassBorder: rgba(dark ? '#ffffff' : '#ffffff', dark ? 0.1 : 0.2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokensToCssVars(tokens) {
|
||||||
|
const safeTokens = buildThemeTokens(tokens || {});
|
||||||
|
const vars = {
|
||||||
|
'--md-source': safeTokens.primaryColor,
|
||||||
|
'--md-ref-primary': safeTokens.primary,
|
||||||
|
'--md-ref-on-primary': safeTokens.onPrimary,
|
||||||
|
'--md-ref-primary-container': safeTokens.primaryContainer,
|
||||||
|
'--md-ref-on-primary-container': safeTokens.onPrimaryContainer,
|
||||||
|
'--md-ref-secondary': safeTokens.secondary,
|
||||||
|
'--md-ref-on-secondary': safeTokens.onSecondary,
|
||||||
|
'--md-ref-secondary-container': safeTokens.secondaryContainer,
|
||||||
|
'--md-ref-on-secondary-container': safeTokens.onSecondaryContainer,
|
||||||
|
'--md-ref-tertiary': safeTokens.tertiary,
|
||||||
|
'--md-ref-on-tertiary': safeTokens.onTertiary,
|
||||||
|
'--md-ref-tertiary-container': safeTokens.tertiaryContainer,
|
||||||
|
'--md-ref-on-tertiary-container': safeTokens.onTertiaryContainer,
|
||||||
|
'--md-ref-surface': safeTokens.surface,
|
||||||
|
'--md-ref-on-surface': safeTokens.onSurface,
|
||||||
|
'--md-ref-surface-variant': safeTokens.surfaceVariant,
|
||||||
|
'--md-ref-on-surface-variant': safeTokens.onSurfaceVariant,
|
||||||
|
'--md-ref-outline': safeTokens.outline,
|
||||||
|
'--md-ref-outline-variant': safeTokens.outlineVariant,
|
||||||
|
'--md-ref-error': safeTokens.error,
|
||||||
|
'--md-ref-on-error': safeTokens.onError,
|
||||||
|
'--md-ref-error-container': safeTokens.errorContainer,
|
||||||
|
'--md-ref-on-error-container': safeTokens.onErrorContainer,
|
||||||
|
'--md-ref-success': safeTokens.success,
|
||||||
|
'--md-ref-on-success': safeTokens.onSuccess,
|
||||||
|
'--md-ref-success-container': safeTokens.successContainer,
|
||||||
|
'--md-ref-on-success-container': safeTokens.onSuccessContainer,
|
||||||
|
'--md-ref-warning': safeTokens.warning,
|
||||||
|
'--md-ref-on-warning': safeTokens.onWarning,
|
||||||
|
'--md-ref-warning-container': safeTokens.warningContainer,
|
||||||
|
'--md-ref-on-warning-container': safeTokens.onWarningContainer,
|
||||||
|
'--md-ref-background': safeTokens.background,
|
||||||
|
'--md-ref-on-background': safeTokens.foreground,
|
||||||
|
'--md-ref-surface-container-low': safeTokens.surfaceContainerLow,
|
||||||
|
'--md-ref-surface-container': safeTokens.surfaceContainer,
|
||||||
|
'--md-ref-surface-container-high': safeTokens.surfaceContainerHigh,
|
||||||
|
'--md-ref-inverse-surface': safeTokens.inverseSurface,
|
||||||
|
'--md-ref-inverse-on-surface': safeTokens.inverseOnSurface,
|
||||||
|
'--md-card-bg': safeTokens.card,
|
||||||
|
'--md-ref-primary-rgb': safeTokens.primaryRgb,
|
||||||
|
'--primary-color': safeTokens.primaryColor,
|
||||||
|
'--primary-rgb': safeTokens.primaryRgb,
|
||||||
|
'--primary-color-rgb': safeTokens.primaryColorRgb,
|
||||||
|
'--background': safeTokens.background,
|
||||||
|
'--foreground': safeTokens.foreground,
|
||||||
|
'--card': safeTokens.card,
|
||||||
|
'--card-foreground': safeTokens.cardForeground,
|
||||||
|
'--muted': safeTokens.muted,
|
||||||
|
'--muted-foreground': safeTokens.mutedForeground,
|
||||||
|
'--accent': safeTokens.accent,
|
||||||
|
'--accent-foreground': safeTokens.accentForeground,
|
||||||
|
'--border': safeTokens.border,
|
||||||
|
'--input': safeTokens.input,
|
||||||
|
'--ring': safeTokens.ring,
|
||||||
|
'--destructive': safeTokens.destructive,
|
||||||
|
'--destructive-foreground': safeTokens.destructiveForeground,
|
||||||
|
'--glass-opacity': String(safeTokens.glassOpacity),
|
||||||
|
'--glass-opacity-heavy': String(Math.min(0.98, safeTokens.glassOpacity + 0.18)),
|
||||||
|
'--glass-bg': safeTokens.glassBg,
|
||||||
|
'--glass-bg-heavy': safeTokens.glassBgHeavy,
|
||||||
|
'--glass-bg-wallpaper': safeTokens.glassBgWallpaper,
|
||||||
|
'--glass-bg-heavy-wallpaper': safeTokens.glassBgHeavyWallpaper,
|
||||||
|
'--glass-border': safeTokens.glassBorder,
|
||||||
|
'--theme-primary-gradient': `linear-gradient(135deg, ${safeTokens.primary}, ${safeTokens.primaryContainer})`,
|
||||||
|
'--theme-primary-gradient-wide': `linear-gradient(90deg, ${safeTokens.primary}, ${safeTokens.primaryContainer}, ${safeTokens.tertiaryContainer})`,
|
||||||
|
'--theme-primary-gradient-soft': `linear-gradient(135deg, color-mix(in srgb, ${safeTokens.primary} 16%, transparent), color-mix(in srgb, ${safeTokens.secondary} 9%, transparent) 50%, color-mix(in srgb, ${safeTokens.tertiary} 10%, transparent))`,
|
||||||
|
'--theme-primary-shadow': `0 0 8px rgba(${safeTokens.primaryRgb}, 0.7)`,
|
||||||
|
'--theme-primary-glow': `0 0 16px rgba(${safeTokens.primaryRgb}, 0.45), 0 0 36px rgba(${safeTokens.primaryRgb}, 0.25)`,
|
||||||
|
};
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_SETTINGS_EVENT = 'rainweb:theme-update';
|
||||||
|
export const THEME_SETTINGS_SYNC_KEY = 'rainweb:theme-settings';
|
||||||
|
|
||||||
|
/** 将设置派生出的 token 写入前台根节点,供 CSS 与非 React 内容共同使用。 */
|
||||||
|
export function applyThemeTokens(options = {}) {
|
||||||
|
const tokens = buildThemeTokens(options);
|
||||||
|
if (typeof document === 'undefined') return tokens;
|
||||||
|
Object.entries(tokensToCssVars(tokens)).forEach(([name, value]) => {
|
||||||
|
document.documentElement.style.setProperty(name, value);
|
||||||
|
});
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY };
|
||||||
@@ -39,10 +39,10 @@ import NotesView from './components/NoteEditor.jsx';
|
|||||||
import VaultView from './components/VaultDrawer.jsx';
|
import VaultView from './components/VaultDrawer.jsx';
|
||||||
import SidebarPane from './components/SidebarPane.jsx';
|
import SidebarPane from './components/SidebarPane.jsx';
|
||||||
import ActivityBar from './components/ActivityBar.jsx';
|
import ActivityBar from './components/ActivityBar.jsx';
|
||||||
|
import CollapseArrow from './components/CollapseArrow.jsx';
|
||||||
import ToolWindow from './components/ToolWindow.jsx';
|
import ToolWindow from './components/ToolWindow.jsx';
|
||||||
import TabStrip from './components/TabStrip.jsx';
|
import TabStrip from './components/TabStrip.jsx';
|
||||||
import ContentView from './components/ContentView.jsx';
|
import ContentView from './components/ContentView.jsx';
|
||||||
import TerminalPanel from './components/TerminalPanel.jsx';
|
|
||||||
import StatusBar from './components/StatusBar.jsx';
|
import StatusBar from './components/StatusBar.jsx';
|
||||||
import PanelFrame from './components/PanelFrame.jsx';
|
import PanelFrame from './components/PanelFrame.jsx';
|
||||||
import AddPanelDialog from './components/AddPanelDialog.jsx';
|
import AddPanelDialog from './components/AddPanelDialog.jsx';
|
||||||
@@ -61,6 +61,21 @@ import SnackHost, { showSnack } from '../../admin/snack.jsx';
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
const LAYOUT_KEY = 'workbench.layout';
|
const LAYOUT_KEY = 'workbench.layout';
|
||||||
|
const MAIN_ACTIVE_KEY = 'workbench.mainActive'; // 主区激活标签(刷新后恢复上次激活项)
|
||||||
|
|
||||||
|
/** 侧边栏默认宽度(px):箭头/活动栏图标展开时的目标宽度 */
|
||||||
|
const SIDEBAR_DEFAULT_WIDTH = { left: 240, right: 300 };
|
||||||
|
/** 视为「已折叠」的宽度阈值(px)。RRP v4 isCollapsed 用零容差比较(尺寸必须精确等于 0),
|
||||||
|
* 布局归一化把面板挤到亚像素宽度时会误判为「未折叠」,导致 expand() 失效、侧栏卡死。
|
||||||
|
* 右侧栏是布局末尾的柔性面板最易被挤压,因此用阈值 + resize() 兜底。 */
|
||||||
|
const SIDEBAR_COLLAPSED_PX = 24;
|
||||||
|
|
||||||
|
function persistMainActive(key) {
|
||||||
|
try {
|
||||||
|
if (key) localStorage.setItem(MAIN_ACTIVE_KEY, key);
|
||||||
|
else localStorage.removeItem(MAIN_ACTIVE_KEY);
|
||||||
|
} catch { /* 隐私模式忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
|
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
|
||||||
function readLayout() {
|
function readLayout() {
|
||||||
@@ -196,23 +211,38 @@ function WorkbenchContent() {
|
|||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [mainRefresh, setMainRefresh] = useState(0);
|
const [mainRefresh, setMainRefresh] = useState(0);
|
||||||
const [modalRefresh, setModalRefresh] = useState(0);
|
const [modalRefresh, setModalRefresh] = useState(0);
|
||||||
const [activeMainKey, setActiveMainKey] = useState(null);
|
// 主区激活标签:同步从 localStorage 恢复(workbench.mainActive),刷新后回到上次激活项
|
||||||
|
const mainActiveInit = useRef(null);
|
||||||
|
const [activeMainKey, setActiveMainKey] = useState(() => {
|
||||||
|
let v = null;
|
||||||
|
try { v = localStorage.getItem(MAIN_ACTIVE_KEY); } catch { /* ignore */ }
|
||||||
|
mainActiveInit.current = v;
|
||||||
|
return v;
|
||||||
|
});
|
||||||
const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
|
const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
|
||||||
const searchRef = useRef(null);
|
const searchRef = useRef(null);
|
||||||
|
|
||||||
// ---- react-resizable-panels 引用 ----
|
// ---- react-resizable-panels 引用 ----
|
||||||
const groupRef = useRef(null);
|
const groupRef = useRef(null);
|
||||||
|
const mainRef = useRef(null); // 主面板(始终 ≥minSize 可见),用作 setLayout 像素↔百分比换算探针
|
||||||
const leftRef = useRef(null);
|
const leftRef = useRef(null);
|
||||||
const rightRef = useRef(null);
|
const rightRef = useRef(null);
|
||||||
const termRef = useRef(null);
|
|
||||||
|
|
||||||
const savedLayout = useRef(readLayout());
|
const savedLayout = useRef(readLayout());
|
||||||
|
// 侧边栏折叠状态(供折叠箭头方向/语义;拖拽折叠也会经 onResize 同步)
|
||||||
|
const [leftCollapsed, setLeftCollapsed] = useState(
|
||||||
|
() => !!savedLayout.current && savedLayout.current.left === 0
|
||||||
|
);
|
||||||
|
const [rightCollapsed, setRightCollapsed] = useState(
|
||||||
|
() => !!savedLayout.current && savedLayout.current.right === 0
|
||||||
|
);
|
||||||
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
|
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
|
||||||
const defaultLayout = savedLayout.current
|
const defaultLayout = savedLayout.current
|
||||||
? {
|
? {
|
||||||
'activity-l': 3,
|
'activity-l': 3,
|
||||||
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
|
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
|
||||||
main: Math.max(5, Math.min(90, savedLayout.current.main)),
|
// main 上限 75%:防止恢复时主区过大挤压两侧栏(右侧栏在布局末尾最易被挤到亚像素卡死)
|
||||||
|
main: Math.max(20, Math.min(75, savedLayout.current.main)),
|
||||||
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
|
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
|
||||||
'activity-r': 3,
|
'activity-r': 3,
|
||||||
}
|
}
|
||||||
@@ -238,13 +268,44 @@ function WorkbenchContent() {
|
|||||||
|
|
||||||
useEffect(() => () => clearTimeout(layoutTimer.current), []);
|
useEffect(() => () => clearTimeout(layoutTimer.current), []);
|
||||||
|
|
||||||
|
/** 程序化调整侧边栏宽度:走 groupRef.setLayout() 整体替换布局。
|
||||||
|
* 不能再依赖 ref.collapse()/resize()/expand()——RRP 命令式 API 按 pivot 相邻面板分配
|
||||||
|
* delta,右侧栏的右邻 activity-r 是刚性面板(minSize=maxSize=48px),delta 被其 clamp
|
||||||
|
* 全部吸收 → 布局不变 → 静默 no-op;拖拽走 Separator(邻面板 main 柔性)所以只有拖拽有效。
|
||||||
|
* setLayout() 不走 pivot:K() 直接归一化 + 逐面板 clamp,目标尺寸落在目标面板上。 */
|
||||||
|
const applySidebarSize = useCallback((side, targetPx) => {
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group) return;
|
||||||
|
const cur = group.getLayout();
|
||||||
|
// defaultLayoutDeferred 期间 getLayout() 返回 {},setLayout 也不应用——直接跳过
|
||||||
|
if (!cur || Object.keys(cur).length === 0) return;
|
||||||
|
// 用任一可见面板换算组宽度:groupPx = inPixels / (asPercentage/100)
|
||||||
|
let groupPx = 0;
|
||||||
|
for (const probe of [mainRef.current, rightRef.current, leftRef.current]) {
|
||||||
|
try {
|
||||||
|
const g = probe?.getSize();
|
||||||
|
if (g && g.inPixels > 0 && g.asPercentage > 0) {
|
||||||
|
groupPx = g.inPixels / (g.asPercentage / 100);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { /* 面板尚未就绪 */ }
|
||||||
|
}
|
||||||
|
const id = side === 'left' ? 'sidebar-l' : 'sidebar-r';
|
||||||
|
if (groupPx > 0) {
|
||||||
|
group.setLayout({ ...cur, [id]: (targetPx / groupPx) * 100 });
|
||||||
|
} else if (targetPx === 0) {
|
||||||
|
group.setLayout({ ...cur, [id]: 0 }); // 折叠:无法换算也直接置 0
|
||||||
|
} else {
|
||||||
|
group.setLayout({ ...cur, [id]: side === 'left' ? 20 : 25 }); // 退化固定百分比,K 的 clamp 兜底
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleSidebar = useCallback((side) => {
|
const toggleSidebar = useCallback((side) => {
|
||||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
// 用状态(阈值判定)而非 ref.isCollapsed():RRP 零容差比较在亚像素宽度下会误判
|
||||||
if (!ref) return;
|
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||||
if (ref.isCollapsed()) ref.expand();
|
applySidebarSize(side, collapsed ? SIDEBAR_DEFAULT_WIDTH[side] : 0);
|
||||||
else ref.collapse();
|
setTimeout(saveLayoutNow, 0); // setLayout 的 onLayoutChanged 不带 isUserInteraction,须手动兜底写盘
|
||||||
setTimeout(saveLayoutNow, 0);
|
}, [leftCollapsed, rightCollapsed, applySidebarSize, saveLayoutNow]);
|
||||||
}, [saveLayoutNow]);
|
|
||||||
|
|
||||||
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
|
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
|
||||||
const leftActive = layout.activeViewOf('left');
|
const leftActive = layout.activeViewOf('left');
|
||||||
@@ -318,20 +379,27 @@ function WorkbenchContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mainTabs = buildMainTabs();
|
const mainTabs = buildMainTabs();
|
||||||
// 活动主标签:优先记住的用户选择,失效则回退到第一个
|
// 活动主标签:优先记住的用户选择(含持久化的上次激活项),失效则回退到第一个
|
||||||
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
|
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
|
||||||
? activeMainKey
|
? activeMainKey
|
||||||
: (mainTabs[0]?.key ?? null);
|
: (mainTabs[0]?.key ?? null);
|
||||||
|
|
||||||
// 打开面板 / 历史导航时自动切到对应 iframe 标签
|
// 打开面板 / 历史导航时自动切到对应 iframe 标签。
|
||||||
|
// 首次挂载若已有持久化激活项(可能指向 tab 模式视图)则尊重它,只消费一次;
|
||||||
|
// 之后每次 activeId 变化都同步并持久化。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeId != null) setActiveMainKey('panel:' + activeId);
|
if (activeId == null) return;
|
||||||
|
if (mainActiveInit.current) { mainActiveInit.current = null; return; }
|
||||||
|
const key = 'panel:' + activeId;
|
||||||
|
setActiveMainKey(key);
|
||||||
|
persistMainActive(key);
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
|
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
|
||||||
|
|
||||||
const handleSelectTab = (key) => {
|
const handleSelectTab = (key) => {
|
||||||
setActiveMainKey(key);
|
setActiveMainKey(key);
|
||||||
|
persistMainActive(key);
|
||||||
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
|
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
|
||||||
};
|
};
|
||||||
const handleCloseTab = (key) => {
|
const handleCloseTab = (key) => {
|
||||||
@@ -339,6 +407,14 @@ function WorkbenchContent() {
|
|||||||
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
|
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 侧边栏 Panel onResize:阈值判定折叠状态(拖拽折叠/亚像素挤压都覆盖) */
|
||||||
|
const onSidebarResize = useCallback((side) => (size) => {
|
||||||
|
const px = size && typeof size === 'object' ? size.inPixels : size;
|
||||||
|
const collapsed = px < SIDEBAR_COLLAPSED_PX;
|
||||||
|
if (side === 'left') setLeftCollapsed(collapsed);
|
||||||
|
else setRightCollapsed(collapsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
|
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
|
||||||
|
|
||||||
const handleOpenExternal = () => {
|
const handleOpenExternal = () => {
|
||||||
@@ -371,10 +447,11 @@ function WorkbenchContent() {
|
|||||||
mode={layout.mode[viewId]}
|
mode={layout.mode[viewId]}
|
||||||
onSetMode={(m) => setViewModeSafely(viewId, m)}
|
onSetMode={(m) => setViewModeSafely(viewId, m)}
|
||||||
onFloat={() => showSnack('浮动模式即将上线', 'info')}
|
onFloat={() => showSnack('浮动模式即将上线', 'info')}
|
||||||
|
closeTitle="关闭侧边栏"
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
// X = 关闭整个侧边栏(折叠到 0 宽度),活动栏图标仍可重新展开
|
||||||
const side = layout.sideOf[viewId];
|
const side = layout.sideOf[viewId];
|
||||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
toggleSidebar(side);
|
||||||
ref?.collapse();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -401,17 +478,16 @@ function WorkbenchContent() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起 */
|
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起(VSCode 语义) */
|
||||||
const handleActivateView = (viewId, side) => {
|
const handleActivateView = (viewId, side) => {
|
||||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||||
const alreadyActive = layout.activeViewOf(side) === viewId;
|
const alreadyActive = layout.activeViewOf(side) === viewId;
|
||||||
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
|
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
|
||||||
layout.activateView(viewId);
|
layout.activateView(viewId);
|
||||||
if (ref) {
|
// setLayout 整体替换:展开到默认宽度 / 折叠到 0,绕开 pivot 刚性邻居吸收问题
|
||||||
if (alreadyActive && !ref.isCollapsed()) ref.collapse();
|
if (alreadyActive && !collapsed) applySidebarSize(side, 0);
|
||||||
else ref.expand();
|
else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
|
||||||
setTimeout(saveLayoutNow, 0);
|
setTimeout(saveLayoutNow, 0);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 菜单栏配置 ----
|
// ---- 菜单栏配置 ----
|
||||||
@@ -444,8 +520,8 @@ function WorkbenchContent() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '终端', items: [
|
label: '终端', items: [
|
||||||
{ label: '切换终端面板', Icon: TerminalIcon, action: () => layout.toggleTerminal() },
|
// 终端功能临时下线(TerminalPanel 已归档到 archive/),保留占位提示,后续恢复
|
||||||
{ label: '新建终端会话', Icon: AddIcon, action: () => { layout.openTerminal(); termRef.current?.newSession(); } },
|
{ label: '终端功能即将回归', Icon: TerminalIcon },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -579,7 +655,7 @@ function WorkbenchContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
<ResizeSeparator disabled />
|
<ResizeSeparator disabled />
|
||||||
{/* 左侧侧边栏 */}
|
{/* 左侧侧边栏 */}
|
||||||
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef}>
|
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef} onResize={onSidebarResize('left')}>
|
||||||
<SidebarPane
|
<SidebarPane
|
||||||
side="left"
|
side="left"
|
||||||
views={leftPaneViews}
|
views={leftPaneViews}
|
||||||
@@ -590,8 +666,10 @@ function WorkbenchContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
<ResizeSeparator />
|
<ResizeSeparator />
|
||||||
{/* 主区:标签条 + 内容 + 底部终端 */}
|
{/* 主区:标签条 + 内容 + 底部终端 */}
|
||||||
<Panel id="main" minSize={320}>
|
<Panel id="main" minSize={320} panelRef={mainRef}>
|
||||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
{/* 注意:RRP Panel 内容容器是 block 布局(maxHeight:100% + flexGrow:1),
|
||||||
|
这里必须用 height:'100%' 而非 flex:1,否则高度塌陷导致内容挤顶部 */}
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden', position: 'relative' }}>
|
||||||
{mainTabs.length > 0 && (
|
{mainTabs.length > 0 && (
|
||||||
<TabStrip
|
<TabStrip
|
||||||
tabs={mainTabs}
|
tabs={mainTabs}
|
||||||
@@ -604,23 +682,15 @@ function WorkbenchContent() {
|
|||||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
|
||||||
{mainBody}
|
{mainBody}
|
||||||
</Box>
|
</Box>
|
||||||
{/* 底部终端:默认隐藏,keep-alive 不销毁会话 */}
|
{/* 侧边栏折叠箭头:贴主区整高的边缘(即侧边栏与主区 Separator 位置)垂直居中,
|
||||||
<Box
|
折叠为 0 后主区仍在 → 箭头始终可点,支持再点击展开 */}
|
||||||
sx={{
|
<CollapseArrow side="left" collapsed={leftCollapsed} onToggle={() => toggleSidebar('left')} />
|
||||||
height: layout.terminalOpen ? 240 : 0,
|
<CollapseArrow side="right" collapsed={rightCollapsed} onToggle={() => toggleSidebar('right')} />
|
||||||
flexShrink: 0, overflow: 'hidden',
|
|
||||||
transition: 'height .18s ease',
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
borderTop: layout.terminalOpen ? 1 : 0, borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalPanel ref={termRef} open={layout.terminalOpen} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Panel>
|
</Panel>
|
||||||
<ResizeSeparator />
|
<ResizeSeparator />
|
||||||
{/* 右侧侧边栏 */}
|
{/* 右侧侧边栏 */}
|
||||||
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef}>
|
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef} onResize={onSidebarResize('right')}>
|
||||||
<SidebarPane
|
<SidebarPane
|
||||||
side="right"
|
side="right"
|
||||||
views={rightPaneViews}
|
views={rightPaneViews}
|
||||||
@@ -637,8 +707,6 @@ function WorkbenchContent() {
|
|||||||
views={rightBarViews}
|
views={rightBarViews}
|
||||||
activeId={rightActive}
|
activeId={rightActive}
|
||||||
onActivate={(id) => handleActivateView(id, 'right')}
|
onActivate={(id) => handleActivateView(id, 'right')}
|
||||||
terminalOpen={layout.terminalOpen}
|
|
||||||
onToggleTerminal={layout.toggleTerminal}
|
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -415,23 +415,37 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
|||||||
if (!inst || !el) return;
|
if (!inst || !el) return;
|
||||||
if (inst.term) {
|
if (inst.term) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { inst.fit?.fit(); manager.current.sendResize(id); } catch {}
|
try {
|
||||||
|
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit?.fit(); manager.current.sendResize(id); }
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const term = new Terminal({
|
let term;
|
||||||
fontSize: 13,
|
let fit = null;
|
||||||
fontFamily: FONT,
|
try {
|
||||||
lineHeight: 1.35,
|
term = new Terminal({
|
||||||
cursorBlink: true,
|
fontSize: 13,
|
||||||
scrollback: 3000,
|
fontFamily: FONT,
|
||||||
theme: themeObj,
|
lineHeight: 1.35,
|
||||||
});
|
cursorBlink: true,
|
||||||
const fit = new FitAddon();
|
scrollback: 3000,
|
||||||
term.loadAddon(fit);
|
theme: themeObj,
|
||||||
term.loadAddon(new Unicode11Addon());
|
});
|
||||||
term.unicode.activeVersion = '11';
|
fit = new FitAddon();
|
||||||
term.open(el);
|
term.loadAddon(fit);
|
||||||
|
term.loadAddon(new Unicode11Addon());
|
||||||
|
term.unicode.activeVersion = '11';
|
||||||
|
term.open(el);
|
||||||
|
inst.term = term;
|
||||||
|
inst.fit = fit;
|
||||||
|
} catch (e) {
|
||||||
|
// xterm 初始化异常:显式报错而非白屏/整树崩溃
|
||||||
|
console.error('[TerminalPanel] xterm 初始化失败:', e);
|
||||||
|
patchStatus(id, 'error');
|
||||||
|
setBanner({ type: 'error', text: `终端初始化失败:${e?.message || '未知错误'}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
term.onData((d) => {
|
term.onData((d) => {
|
||||||
const ws = inst.ws;
|
const ws = inst.ws;
|
||||||
if (!ws || ws.readyState !== 1) return;
|
if (!ws || ws.readyState !== 1) return;
|
||||||
@@ -449,11 +463,12 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
inst.term = term;
|
|
||||||
inst.fit = fit;
|
|
||||||
inst.ro = ro;
|
inst.ro = ro;
|
||||||
|
// rAF fit 同样做尺寸守卫:容器零尺寸时跳过,避免 0 行列渲染成空白
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { fit.fit(); manager.current.sendResize(id); } catch {}
|
try {
|
||||||
|
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit.fit(); manager.current.sendResize(id); }
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
manager.current.connectSession(id);
|
manager.current.connectSession(id);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -672,6 +687,26 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
|
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
|
||||||
|
{/* 未连接状态条:防止"白屏"——始终可见的连接进度/错误提示(banner 可被关掉) */}
|
||||||
|
{s.status !== 'connected' && s.status !== 'ended' && (
|
||||||
|
<Box
|
||||||
|
role="status"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5,
|
||||||
|
bgcolor: s.status === 'error' ? 'error.main' : 'warning.main',
|
||||||
|
color: s.status === 'error' ? 'error.contrastText' : 'warning.contrastText',
|
||||||
|
borderTop: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.status !== 'error' && <CircularProgress size={12} color="inherit" />}
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||||
|
{s.status === 'connecting' ? '正在连接终端服务…'
|
||||||
|
: s.status === 'reconnecting' ? '连接中断,正在重试…'
|
||||||
|
: '终端服务不可用'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
|
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
|
||||||
{s.status === 'ended' && (
|
{s.status === 'ended' && (
|
||||||
<Box
|
<Box
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-
|
|||||||
* - 无障碍:role=toolbar,仅活动项 tabIndex=0(roving),
|
* - 无障碍:role=toolbar,仅活动项 tabIndex=0(roving),
|
||||||
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
|
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
|
||||||
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane)
|
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane)
|
||||||
* - 右侧底部固定「终端」呼出按钮(不参与拖拽)
|
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
|
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
|
||||||
@@ -73,33 +71,8 @@ function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TerminalButton({ open, onToggle }) {
|
|
||||||
return (
|
|
||||||
<Tooltip title="终端" placement="right">
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
aria-label="终端"
|
|
||||||
aria-pressed={open}
|
|
||||||
sx={{
|
|
||||||
width: 48, height: 48, flexShrink: 0, border: 'none', background: 'transparent',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
|
||||||
color: open ? 'primary.main' : 'text.secondary',
|
|
||||||
bgcolor: open ? 'action.selected' : 'transparent',
|
|
||||||
'&:hover': { bgcolor: open ? 'action.selected' : 'action.hover' },
|
|
||||||
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalIcon sx={{ fontSize: 22 }} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ActivityBar({
|
export default function ActivityBar({
|
||||||
side, views, activeId, onActivate,
|
side, views, activeId, onActivate,
|
||||||
terminalOpen = false, onToggleTerminal,
|
|
||||||
}) {
|
}) {
|
||||||
const right = side === 'right';
|
const right = side === 'right';
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
|
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
|
||||||
@@ -146,7 +119,6 @@ export default function ActivityBar({
|
|||||||
</Box>
|
</Box>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
<Box sx={{ flex: 1 }} />
|
<Box sx={{ flex: 1 }} />
|
||||||
{right && <TerminalButton open={terminalOpen} onToggle={onToggleTerminal} />}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePan
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* AddPanelDialog:添加面板
|
* AddPanelDialog:添加面板
|
||||||
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
|
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
|
||||||
* + 嵌入 URL + 代理开关;保存到 admin_links(复用现有 API)
|
* + 嵌入 URL + 代理开关 + 信任模式 + 缩放 + 权限委托;
|
||||||
|
* 保存到 admin_links(复用现有 API,trusted/scale/permissions 为代理新字段)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
const MODE_META = [
|
const MODE_META = [
|
||||||
@@ -30,6 +31,19 @@ const MODE_META = [
|
|||||||
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
|
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* iframe 委托权限候选(对应 Permissions Policy 的 allow 属性 token) */
|
||||||
|
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: '通知' },
|
||||||
|
];
|
||||||
|
|
||||||
function normalizeUrl(raw) {
|
function normalizeUrl(raw) {
|
||||||
const s = (raw || '').trim();
|
const s = (raw || '').trim();
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -40,9 +54,16 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
|||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
|
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
|
||||||
use_proxy: false, embed_url: '',
|
use_proxy: false, embed_url: '',
|
||||||
|
trusted: true, scale: '1', permissions: [],
|
||||||
});
|
});
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const resetForm = () => setForm({
|
||||||
|
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
|
||||||
|
use_proxy: false, embed_url: '',
|
||||||
|
trusted: true, scale: '1', permissions: [],
|
||||||
|
});
|
||||||
|
|
||||||
const host = useMemo(() => {
|
const host = useMemo(() => {
|
||||||
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
|
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
|
||||||
}, [form.url]);
|
}, [form.url]);
|
||||||
@@ -61,6 +82,9 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
|||||||
embed_url: form.embed_url.trim(),
|
embed_url: form.embed_url.trim(),
|
||||||
category: form.category.trim() || '默认',
|
category: form.category.trim() || '默认',
|
||||||
use_proxy: form.use_proxy ? 1 : 0,
|
use_proxy: form.use_proxy ? 1 : 0,
|
||||||
|
trusted: form.trusted ? 1 : 0,
|
||||||
|
scale: parseFloat(form.scale) || 1,
|
||||||
|
permissions: JSON.stringify(form.permissions),
|
||||||
description: '',
|
description: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
@@ -68,7 +92,7 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
|||||||
});
|
});
|
||||||
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
|
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
|
||||||
setErr('');
|
setErr('');
|
||||||
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
|
resetForm();
|
||||||
onSaved(panel);
|
onSaved(panel);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e.message || '保存失败');
|
setErr(e.message || '保存失败');
|
||||||
@@ -176,6 +200,53 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
|||||||
margin="dense"
|
margin="dense"
|
||||||
placeholder="留空则使用上面的链接"
|
placeholder="留空则使用上面的链接"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 代理新字段:信任模式 / 缩放 / 权限委托 */}
|
||||||
|
<Box sx={{ mt: 2, pt: 1.5, borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: 'text.secondary' }}>
|
||||||
|
信任模式(影响 iframe 沙箱)
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={form.trusted ? 'trusted' : 'safe'}
|
||||||
|
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, trusted: v === 'trusted' })); }}
|
||||||
|
aria-label="信任模式"
|
||||||
|
>
|
||||||
|
<ToggleButton value="trusted" sx={{ py: 0.75 }}>可信(保留登录态)</ToggleButton>
|
||||||
|
<ToggleButton value="safe" sx={{ py: 0.75 }}>安全(隔离沙箱)</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
|
||||||
|
{form.trusted
|
||||||
|
? '可信:iframe 保留 allow-same-origin,目标站 cookie 登录态可用'
|
||||||
|
: '安全:剥离 allow-same-origin(opaque origin),被代理页无法访问本站'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="缩放(0.1–3,默认 1)"
|
||||||
|
type="number"
|
||||||
|
value={form.scale}
|
||||||
|
onChange={set('scale')}
|
||||||
|
margin="dense"
|
||||||
|
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
|
||||||
|
helperText="小于 1 缩小(看得更多),大于 1 放大(看得更清)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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="委托权限(可选)" margin="dense" placeholder="选择 iframe 可调用的设备权限" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* CollapseArrow:侧边栏折叠箭头(VSCode 风格)
|
||||||
|
*
|
||||||
|
* - 由父级绝对定位到主区边缘(即侧边栏与主区的 Separator 位置)垂直居中;
|
||||||
|
* 侧边栏折叠为 0 后主区仍在,箭头始终可见可点,支持"再点击展开"。
|
||||||
|
* - 箭头始终指向栏的方向:展开时指栏(点击收起),折叠后反向(点击展开)。
|
||||||
|
* - 可见性:**常驻可见**——不透明背景(background.paper)+ 实心边框 + 阴影,
|
||||||
|
* opacity 展开 0.9 / 折叠 1,hover 全亮加深阴影;不再半透明叠加 iframe 混色。
|
||||||
|
* - 28x28 触控目标,zIndex 12 盖在 iframe 内容之上。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function CollapseArrow({ side, collapsed = false, onToggle }) {
|
||||||
|
const right = side === 'right';
|
||||||
|
const label = collapsed ? `展开${right ? '右' : '左'}侧边栏` : `折叠${right ? '右' : '左'}侧边栏`;
|
||||||
|
// 展开 → 指向栏(收起动作);折叠 → 反向(展开动作)
|
||||||
|
const Icon = collapsed
|
||||||
|
? (right ? ChevronLeftIcon : ChevronRightIcon)
|
||||||
|
: (right ? ChevronRightIcon : ChevronLeftIcon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={label} placement={right ? 'left' : 'right'}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onToggle}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', top: '50%', [right ? 'right' : 'left']: 2,
|
||||||
|
transform: 'translateY(-50%)', zIndex: 12,
|
||||||
|
width: 28, height: 28, minWidth: 28, p: 0,
|
||||||
|
opacity: collapsed ? 1 : 0.9,
|
||||||
|
bgcolor: 'background.paper', border: 1, borderColor: 'divider',
|
||||||
|
boxShadow: (t) => t.shadows[2],
|
||||||
|
transition: 'opacity .12s ease, background-color .12s ease, box-shadow .12s ease',
|
||||||
|
'&:hover': { opacity: 1, bgcolor: 'action.hover', boxShadow: (t) => t.shadows[4] },
|
||||||
|
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
@@ -17,20 +17,33 @@ import { getToken } from '../../../api/client.js';
|
|||||||
* - 懒加载:首次激活才创建 iframe DOM,之后 display:none 保留状态
|
* - 懒加载:首次激活才创建 iframe DOM,之后 display:none 保留状态
|
||||||
* - 超时 + load 双保险:加载中显示覆盖层;超时(X-Frame-Options
|
* - 超时 + load 双保险:加载中显示覆盖层;超时(X-Frame-Options
|
||||||
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
|
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
|
||||||
* - proxy 模式:use_proxy=1 或 HTTPS 页内嵌 HTTP 目标时走
|
* - 代理模式(use_proxy=1 或 HTTPS 页内嵌 HTTP 目标):走
|
||||||
* /api/proxy/fetch?url=&token=(proxy 剥 X-Frame-Options/CSP)。
|
* /proxy/{slug}/ 前缀代理(后端 HTML 改写 + shim 注入 + WebSocket
|
||||||
* token 用 /api/proxy/token 签发的 5 分钟短 TTL JWT,避免把 7 天
|
* + 全方法透传)。鉴权由后端处理:首次加载用 Authorization header
|
||||||
* 主 token 暴露在 iframe URL 中(模块级缓存,4 分钟刷新一次)。
|
* 预热一次(触发下发 HttpOnly 的 rwp_{slug} 面板 cookie,Path 限定
|
||||||
* - 站内 URL(/ 开头)为同源内容:剥离 allow-same-origin 防其访问
|
* /proxy/{slug}/,12h 有效),iframe src 本身不带任何 token,
|
||||||
* 父页面 DOM;跨源面板保留 allow-same-origin(多数站点依赖)。
|
* 被代理页脚本读不到凭据。旧面板无 slug 时回退 /api/proxy/fetch
|
||||||
|
* 单 URL 透传(保留兼容,短 TTL token 5 分钟)。
|
||||||
|
* - 信任模型(admin_links.trusted)→ sandbox:
|
||||||
|
* trusted=1(默认):保留 allow-same-origin(保目标站 cookie 登录态),
|
||||||
|
* 追加 allow-modals / allow-downloads;
|
||||||
|
* trusted=0:剥离 allow-same-origin(opaque origin 安全模式)。
|
||||||
|
* 站内 URL(/ 开头)直嵌一律剥离 allow-same-origin——本站同源伺服,
|
||||||
|
* 保留即等同 sandbox 逃逸,与信任开关无关。
|
||||||
|
* - permissions 委托:面板配置的权限数组 → iframe allow 属性
|
||||||
|
* - scale 缩放:外盒 1/scale + transform: scale 实现任意缩放(0.1-3)
|
||||||
* - LRU 由父级 Workbench 控制挂载/卸载(此组件不自行卸载)
|
* - LRU 由父级 Workbench 控制挂载/卸载(此组件不自行卸载)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
const LOAD_TIMEOUT_MS = 15000;
|
const LOAD_TIMEOUT_MS = 15000;
|
||||||
const TOKEN_TTL_MS = 4 * 60 * 1000; // 缓存 4 分钟(短 token 5 分钟有效)
|
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
|
||||||
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
|
const WARM_TTL_MS = 10 * 60 * 60 * 1000; // 面板 cookie 12h 有效,预热缓存 10h 内复用
|
||||||
|
const TOKEN_TTL_MS = 4 * 60 * 1000; // 旧 /fetch 兼容:短 token 缓存 4 分钟
|
||||||
|
|
||||||
/* 短 TTL proxy token 的模块级缓存:多个 PanelFrame 共享,避免每帧都请求 */
|
/* 面板预热缓存:slug -> { p, at }(并发去重 + 定时过期) */
|
||||||
|
const warmCache = new Map();
|
||||||
|
|
||||||
|
/* 短 TTL proxy token 的模块级缓存(仅旧 /api/proxy/fetch 兼容回退使用) */
|
||||||
let proxyTokenCache = { promise: null, expiresAt: 0 };
|
let proxyTokenCache = { promise: null, expiresAt: 0 };
|
||||||
|
|
||||||
function fetchProxyToken() {
|
function fetchProxyToken() {
|
||||||
@@ -53,6 +66,36 @@ function fetchProxyToken() {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 预热 /proxy/{slug}/:带主 token 的 fetch 触发后端下发 rwp_{slug} 面板 cookie
|
||||||
|
* (HttpOnly,面板脚本不可读),之后 iframe 同源加载自动携带该 cookie 鉴权。 */
|
||||||
|
function warmProxy(slug) {
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = warmCache.get(slug);
|
||||||
|
if (hit && now - hit.at < WARM_TTL_MS) return hit.p;
|
||||||
|
const p = fetch('/proxy/' + slug + '/', {
|
||||||
|
headers: { Authorization: 'Bearer ' + (getToken() || '') },
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error('面板代理鉴权失败');
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch((e) => { warmCache.delete(slug); throw e; });
|
||||||
|
warmCache.set(slug, { p, at: now });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 [];
|
||||||
|
}
|
||||||
|
|
||||||
export default function PanelFrame({ panel, active = false, refreshNonce = 0 }) {
|
export default function PanelFrame({ panel, active = false, refreshNonce = 0 }) {
|
||||||
const [mounted, setMounted] = useState(active);
|
const [mounted, setMounted] = useState(active);
|
||||||
const [status, setStatus] = useState('loading'); // loading | loaded | timeout
|
const [status, setStatus] = useState('loading'); // loading | loaded | timeout
|
||||||
@@ -66,31 +109,65 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
|||||||
const targetUrl = panel.embed_url || panel.url || '';
|
const targetUrl = panel.embed_url || panel.url || '';
|
||||||
const isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
const isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||||
const needsProxy = !!panel.use_proxy || (isHttpsPage && targetUrl.startsWith('http:'));
|
const needsProxy = !!panel.use_proxy || (isHttpsPage && targetUrl.startsWith('http:'));
|
||||||
// 站内 URL(同源内容):沙箱剥离 allow-same-origin,内容无法操作父页面 DOM
|
|
||||||
const isInternal = targetUrl.startsWith('/');
|
|
||||||
const sandbox = isInternal
|
|
||||||
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
|
|
||||||
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
|
|
||||||
|
|
||||||
// proxy 模式:先取 5 分钟短 TTL token 再拼 iframe URL(失败进入超时回退态)
|
// slug:后端自动生成(小写 [a-z0-9-],≤32)。旧库行可能为空 → 回退旧 /fetch
|
||||||
|
const slug = String(panel.slug || '').toLowerCase();
|
||||||
|
const hasSlug = /^[a-z0-9-]{1,32}$/.test(slug);
|
||||||
|
const proxyPrefix = hasSlug ? '/proxy/' + slug + '/' : '';
|
||||||
|
|
||||||
|
// 信任模型:admin_links.trusted(1=可信,0=安全)。缺失按可信处理
|
||||||
|
const trusted = !(panel.trusted === 0 || panel.trusted === '0');
|
||||||
|
|
||||||
|
// ── sandbox 按信任模型配置 ────────────────────────────────
|
||||||
|
// 代理模式:trusted=1 保 allow-same-origin(保目标站 cookie 登录态);
|
||||||
|
// trusted=0 剥离(opaque origin,被代理页无法以本站身份发请求/读 DOM)
|
||||||
|
// 站内直嵌(/ 开头,本站同源伺服):一律剥离(同源逃逸风险,与信任无关)
|
||||||
|
// 外部直嵌:跨源天然隔离,trusted=1 保 allow-same-origin(站点 cookie 依赖)
|
||||||
|
const stripSameOrigin = needsProxy
|
||||||
|
? !trusted
|
||||||
|
: (targetUrl.startsWith('/') || !trusted);
|
||||||
|
const sandbox = stripSameOrigin
|
||||||
|
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
|
||||||
|
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads';
|
||||||
|
|
||||||
|
// ── permissions 委托:默认能力 + 面板配置的权限 → allow 属性 ──
|
||||||
|
const baseAllow = 'fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
||||||
|
const delegated = parsePermissions(panel.permissions);
|
||||||
|
const allowAttr = delegated.length ? [baseAllow, ...delegated].join('; ') : baseAllow;
|
||||||
|
|
||||||
|
// ── scale 缩放(0.1-3,默认 1):1/scale 外盒 + transform scale ──
|
||||||
|
const scale = (() => {
|
||||||
|
const s = parseFloat(panel.scale);
|
||||||
|
return Number.isFinite(s) ? Math.min(3, Math.max(0.1, s)) : 1;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 代理模式启动:预热(slug 模式)或取短 token(旧模式),成功后就绪 src。
|
||||||
|
// alive 守卫避免卸载后 setState;预热失败进入超时回退态。
|
||||||
|
const bootProxy = useCallback((alive = () => true) => {
|
||||||
|
if (!needsProxy) { setProxySrc(targetUrl); return; }
|
||||||
|
setProxySrc('');
|
||||||
|
if (hasSlug) {
|
||||||
|
warmProxy(slug)
|
||||||
|
.then(() => { if (alive()) setProxySrc(proxyPrefix); })
|
||||||
|
.catch(() => {
|
||||||
|
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fetchProxyToken()
|
||||||
|
.then((tok) => {
|
||||||
|
if (alive()) setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [needsProxy, hasSlug, slug, proxyPrefix, targetUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
|
bootProxy(() => alive);
|
||||||
setProxySrc('');
|
|
||||||
fetchProxyToken()
|
|
||||||
.then((tok) => {
|
|
||||||
if (alive) {
|
|
||||||
setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (alive) {
|
|
||||||
statusRef.current = 'timeout';
|
|
||||||
setStatus('timeout');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [needsProxy, targetUrl]);
|
}, [bootProxy]);
|
||||||
|
|
||||||
const src = needsProxy ? proxySrc : targetUrl;
|
const src = needsProxy ? proxySrc : targetUrl;
|
||||||
|
|
||||||
@@ -119,18 +196,30 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
|||||||
return () => clearTimeout(timerRef.current);
|
return () => clearTimeout(timerRef.current);
|
||||||
}, [mounted, src]);
|
}, [mounted, src]);
|
||||||
|
|
||||||
// 刷新信号(工具栏 ↻):仅对激活面板 reload,且重新进入加载态
|
// 统一刷新入口:从未就绪(预热/token 失败)时重新走启动路径;
|
||||||
|
// 已就绪则先重新预热(cookie 过期时刷新也能恢复),再 reload。
|
||||||
|
const performReload = () => {
|
||||||
|
statusRef.current = 'loading';
|
||||||
|
setStatus('loading');
|
||||||
|
setShowHint(false);
|
||||||
|
startTimer();
|
||||||
|
if (!src) { bootProxy(); return; }
|
||||||
|
const reloadNow = () => {
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
if (!iframe || !src) return;
|
||||||
|
try { iframe.contentWindow.location.reload(); }
|
||||||
|
catch { iframe.src = iframe.src; }
|
||||||
|
};
|
||||||
|
if (needsProxy && hasSlug) warmProxy(slug).then(reloadNow).catch(reloadNow);
|
||||||
|
else reloadNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新信号(工具栏 ↻):仅对激活面板执行统一刷新
|
||||||
const lastNonce = useRef(refreshNonce);
|
const lastNonce = useRef(refreshNonce);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || refreshNonce === lastNonce.current) return;
|
if (!active || refreshNonce === lastNonce.current) return;
|
||||||
lastNonce.current = refreshNonce;
|
lastNonce.current = refreshNonce;
|
||||||
const iframe = iframeRef.current;
|
performReload();
|
||||||
if (!iframe || !src) return;
|
|
||||||
statusRef.current = 'loading';
|
|
||||||
setStatus('loading');
|
|
||||||
startTimer();
|
|
||||||
try { iframe.contentWindow.location.reload(); }
|
|
||||||
catch { iframe.src = iframe.src; }
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [refreshNonce]);
|
}, [refreshNonce]);
|
||||||
|
|
||||||
@@ -148,13 +237,7 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
const iframe = iframeRef.current;
|
performReload();
|
||||||
if (!iframe || !src) return;
|
|
||||||
statusRef.current = 'loading';
|
|
||||||
setStatus('loading');
|
|
||||||
startTimer();
|
|
||||||
try { iframe.contentWindow.location.reload(); }
|
|
||||||
catch { iframe.src = iframe.src; }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
|
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
|
||||||
@@ -167,15 +250,25 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<iframe
|
/* scale 缩放:外盒宽高取 1/scale(相对容器),transform scale(scale) 后
|
||||||
ref={iframeRef}
|
* 恰好铺满容器;scale=1 时恒等,无副作用 */
|
||||||
title={panel.title || '面板'}
|
<Box
|
||||||
src={src}
|
sx={{
|
||||||
onLoad={handleLoad}
|
position: 'absolute', top: 0, left: 0,
|
||||||
sandbox={sandbox}
|
width: `${100 / scale}%`, height: `${100 / scale}%`,
|
||||||
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
transform: `scale(${scale})`, transformOrigin: 'top left',
|
||||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
|
}}
|
||||||
/>
|
>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title={panel.title || '面板'}
|
||||||
|
src={src}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
sandbox={sandbox}
|
||||||
|
allow={allowAttr}
|
||||||
|
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'loading' && (
|
{status === 'loading' && (
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 面板图标:Google favicon 服务 + 失败回退(首字母块 / 地球图标)
|
* 面板图标:Google favicon 服务 + 失败回退(站点首字母 + 确定性 MD3 容器色)
|
||||||
|
*
|
||||||
|
* - 成功:显示 favicon(不变)
|
||||||
|
* - 失败/无 host:显示首字母色块(类似 Google Avatar)——
|
||||||
|
* 背景色从 MD3 容器色(primary/secondary/tertiary.container)按
|
||||||
|
* 域名/标题哈希确定性选取,比默认地球图标更可辨识。
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 确定性回退色板:MD3 token(不硬编码 hex),按哈希循环选取 */
|
||||||
|
const FALLBACK_PALETTE = [
|
||||||
|
{ bg: 'primary.container', fg: 'primary.onContainer' },
|
||||||
|
{ bg: 'secondary.container', fg: 'secondary.onContainer' },
|
||||||
|
{ bg: 'tertiary.container', fg: 'tertiary.onContainer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashString(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
function hostOf(url) {
|
function hostOf(url) {
|
||||||
try {
|
try {
|
||||||
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
|
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
|
||||||
@@ -17,19 +34,24 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const host = hostOf(url);
|
const host = hostOf(url);
|
||||||
|
|
||||||
if (!host) {
|
// 首字母 + 确定性颜色
|
||||||
|
const letter = ((title && title[0]) || (host && host[0]) || '?').toUpperCase();
|
||||||
|
const { bg, fg } = FALLBACK_PALETTE[hashString(host || title || '?') % FALLBACK_PALETTE.length];
|
||||||
|
|
||||||
|
// 无 host(内部地址/空)或 favicon 加载失败(404/API 不可达)→ 首字母色块
|
||||||
|
if (!host || error) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
aria-hidden
|
aria-hidden
|
||||||
sx={{
|
sx={{
|
||||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
width: size, height: size, borderRadius: size / 4, flexShrink: 0,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
bgcolor: 'primary.container', color: 'primary.onContainer',
|
bgcolor: bg, color: fg,
|
||||||
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(title[0] || '?').toUpperCase()}
|
{letter}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,19 +65,15 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
|||||||
bgcolor: 'background.paper', ...sx,
|
bgcolor: 'background.paper', ...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error ? (
|
<img
|
||||||
<LanguageIcon sx={{ fontSize: size * 0.7, color: 'text.disabled' }} />
|
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
||||||
) : (
|
alt=""
|
||||||
<img
|
width={size}
|
||||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
height={size}
|
||||||
alt=""
|
loading="lazy"
|
||||||
width={size}
|
onError={() => setError(true)}
|
||||||
height={size}
|
style={{ display: 'block' }}
|
||||||
loading="lazy"
|
/>
|
||||||
onError={() => setError(true)}
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ export default function PanelsView({
|
|||||||
return m;
|
return m;
|
||||||
}, [links]);
|
}, [links]);
|
||||||
|
|
||||||
const pinnedPanels = links.filter((p) => pinned.includes(p.id));
|
const pinnedPanels = links.filter((p) => pinned.includes(String(p.id)));
|
||||||
|
|
||||||
const renderItem = (p) => {
|
const renderItem = (p) => {
|
||||||
const selected = String(p.id) === String(activeId);
|
const selected = String(p.id) === String(activeId);
|
||||||
const isPinned = pinned.includes(p.id);
|
const isPinned = pinned.includes(String(p.id));
|
||||||
const mode = modes[p.id] || OPEN_MODE_EMBED;
|
const mode = modes[p.id] || OPEN_MODE_EMBED;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js
|
|||||||
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入
|
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, actions = null, children }) {
|
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, closeTitle = '关闭', actions = null, children }) {
|
||||||
const modeBtn = (m, label, MIcon) => (
|
const modeBtn = (m, label, MIcon) => (
|
||||||
<Tooltip key={m} title={label}>
|
<Tooltip key={m} title={label}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -65,11 +65,11 @@ export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onCl
|
|||||||
)}
|
)}
|
||||||
{actions}
|
{actions}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<Tooltip title="关闭">
|
<Tooltip title={closeTitle}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="关闭"
|
aria-label={closeTitle}
|
||||||
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
||||||
>
|
>
|
||||||
<CloseIcon sx={{ fontSize: 15 }} />
|
<CloseIcon sx={{ fontSize: 15 }} />
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import PanelIcon from './PanelIcon.jsx';
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* Toolbar:VSCode 风格顶栏(高度 56px)
|
* Toolbar:VSCode 风格顶栏(高度 56px)
|
||||||
*
|
*
|
||||||
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 缩小地址栏(只读) | 搜索 | 返回
|
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 地址栏(自适应填满) | 搜索 | 返回
|
||||||
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签
|
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签;
|
||||||
|
* flex:1 填满顶栏剩余空间,左侧内容变宽时自然缩减
|
||||||
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
|
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export default function Toolbar({
|
|||||||
component="span"
|
component="span"
|
||||||
aria-label="Rain Work"
|
aria-label="Rain Work"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 15, fontWeight: 700, letterSpacing: 0.3, mr: 0.75, flexShrink: 0,
|
fontSize: 19.5, fontWeight: 700, letterSpacing: 0.4, mr: 1, flexShrink: 0,
|
||||||
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
|
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
|
||||||
WebkitTextFillColor: 'transparent', color: 'transparent',
|
WebkitTextFillColor: 'transparent', color: 'transparent',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -75,12 +76,13 @@ export default function Toolbar({
|
|||||||
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
|
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* 缩小地址栏(只读) */}
|
{/* 地址栏(只读,flex:1 自适应填满顶栏剩余空间;无 maxWidth 上限,
|
||||||
|
左侧菜单/导航变宽时自然缩减,顶栏始终占满) */}
|
||||||
<Box
|
<Box
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-label="当前面板地址"
|
aria-label="当前面板地址"
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1, minWidth: 120, maxWidth: 360, display: 'flex', alignItems: 'center', gap: 0.75,
|
flex: 1, minWidth: 120, maxWidth: 'none', display: 'flex', alignItems: 'center', gap: 0.75,
|
||||||
mx: 0.75, height: 34, px: 1.25,
|
mx: 0.75, height: 34, px: 1.25,
|
||||||
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
|
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
|
||||||
'&:focus-within': { borderColor: 'primary.main' },
|
'&:focus-within': { borderColor: 'primary.main' },
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ export default function useNotes() {
|
|||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
// ---- 保存(PUT) ----
|
||||||
|
// 注意:必须声明在引用它的 useEffect(会话恢复 / save-request 监听)之前,
|
||||||
|
// 否则渲染期求值依赖数组会触发 TDZ(Cannot access before initialization)。
|
||||||
|
const saveContent = useCallback((name, text) => {
|
||||||
|
setSaving(true);
|
||||||
|
return notesApi.updateNote(name, text)
|
||||||
|
.then((res) => {
|
||||||
|
setSavedContent(text);
|
||||||
|
sessionCache.text = text;
|
||||||
|
setNotes((prev) => (prev
|
||||||
|
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
|
||||||
|
: prev));
|
||||||
|
})
|
||||||
|
.catch((e) => showSnack(e.message, 'error'))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
|
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
|
||||||
// 无缓存但记住了文件名→从服务端读取 ----
|
// 无缓存但记住了文件名→从服务端读取 ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,21 +111,6 @@ export default function useNotes() {
|
|||||||
}
|
}
|
||||||
}, [activeName]);
|
}, [activeName]);
|
||||||
|
|
||||||
// ---- 保存(PUT) ----
|
|
||||||
const saveContent = useCallback((name, text) => {
|
|
||||||
setSaving(true);
|
|
||||||
return notesApi.updateNote(name, text)
|
|
||||||
.then((res) => {
|
|
||||||
setSavedContent(text);
|
|
||||||
sessionCache.text = text;
|
|
||||||
setNotes((prev) => (prev
|
|
||||||
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
|
|
||||||
: prev));
|
|
||||||
})
|
|
||||||
.catch((e) => showSnack(e.message, 'error'))
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ---- 打开文件 ----
|
// ---- 打开文件 ----
|
||||||
const open = useCallback((name) => {
|
const open = useCallback((name) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { listAdminLinks } from '../../../api/adminLinks.js';
|
|||||||
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
|
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
|
||||||
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
|
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
|
||||||
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStorage(key: workbench.*)。
|
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStorage(key: workbench.*)。
|
||||||
|
*
|
||||||
|
* 面板对象直接来自后端 admin_links 全行(SELECT *),已含代理新字段:
|
||||||
|
* slug(唯一标识)/ trusted(信任模型 0|1)/ permissions(JSON 数组字符串)/
|
||||||
|
* scale(缩放 0.1-3)。加载与 panelOverride 场景统一经 normalizePanel 归一化,
|
||||||
|
* 兜底旧库行缺失字段,保证 PanelFrame 拿到的字段类型稳定。
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
export const OPEN_MODE_EMBED = 'embed'; // 内嵌 iframe
|
export const OPEN_MODE_EMBED = 'embed'; // 内嵌 iframe
|
||||||
@@ -33,17 +38,48 @@ function write(key, value) {
|
|||||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式等场景忽略 */ }
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式等场景忽略 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 面板字段归一化:旧库行可能缺代理新字段,统一兜底默认值
|
||||||
|
* (slug 空串、trusted 默认 1、permissions 原样保留、scale 默认 1) */
|
||||||
|
function normalizePanel(p) {
|
||||||
|
if (!p || typeof p !== 'object') return p;
|
||||||
|
const scale = parseFloat(p.scale);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
slug: String(p.slug || ''),
|
||||||
|
trusted: p.trusted === 0 || p.trusted === '0' ? 0 : 1,
|
||||||
|
permissions: p.permissions || '',
|
||||||
|
scale: Number.isFinite(scale) ? Math.min(3, Math.max(0.1, scale)) : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function usePanels() {
|
export default function usePanels() {
|
||||||
const [links, setLinks] = useState(null); // null = 加载中
|
const [links, setLinks] = useState(null); // null = 加载中
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [openIds, setOpenIds] = useState(() => read(STORE.open, []));
|
// 初始化即清洗(治愈已污染的 localStorage 存量数据):
|
||||||
const [activeId, setActiveId] = useState(() => read(STORE.active, null));
|
// 面板 id 统一为字符串 + 去重。此前 openPanel 被传入数字(p.id)与字符串
|
||||||
|
// (handleSelectTab 的 key.slice) 两种类型,prev.includes 严格比较漏判 →
|
||||||
|
// 同一面板以两份加入 openIds → 双 iframe 并排"分屏"、closePanel 关不掉。
|
||||||
|
const [openIds, setOpenIds] = useState(() => {
|
||||||
|
const a = read(STORE.open, []);
|
||||||
|
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
|
||||||
|
});
|
||||||
|
const [activeId, setActiveId] = useState(() => {
|
||||||
|
const v = read(STORE.active, null);
|
||||||
|
return v == null ? null : String(v);
|
||||||
|
});
|
||||||
const [history, setHistory] = useState(() => {
|
const [history, setHistory] = useState(() => {
|
||||||
const h = read(STORE.history, null);
|
const h = read(STORE.history, null);
|
||||||
return (h && Array.isArray(h.ids)) ? h : { ids: [], index: -1 };
|
if (h && Array.isArray(h.ids)) {
|
||||||
|
const ids = [...new Set(h.ids.map(String))];
|
||||||
|
return { ids, index: Math.min(Number(h.index) || -1, ids.length - 1) };
|
||||||
|
}
|
||||||
|
return { ids: [], index: -1 };
|
||||||
});
|
});
|
||||||
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
|
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
|
||||||
const [pinned, setPinned] = useState(() => read(STORE.pinned, []));
|
const [pinned, setPinned] = useState(() => {
|
||||||
|
const a = read(STORE.pinned, []);
|
||||||
|
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
|
||||||
|
});
|
||||||
const [modes, setModes] = useState(() => read(STORE.modes, {}));
|
const [modes, setModes] = useState(() => read(STORE.modes, {}));
|
||||||
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
|
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -65,14 +101,15 @@ export default function usePanels() {
|
|||||||
setLinks(null);
|
setLinks(null);
|
||||||
return listAdminLinks()
|
return listAdminLinks()
|
||||||
.then((ls) => {
|
.then((ls) => {
|
||||||
const arr = Array.isArray(ls) ? ls : [];
|
const arr = Array.isArray(ls) ? ls.map(normalizePanel) : [];
|
||||||
setLinks(arr);
|
setLinks(arr);
|
||||||
const valid = new Set(arr.map((p) => String(p.id)));
|
const valid = new Set(arr.map((p) => String(p.id)));
|
||||||
setOpenIds((prev) => prev.filter((id) => valid.has(id)));
|
// String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
|
||||||
setPinned((prev) => prev.filter((id) => valid.has(id)));
|
setOpenIds((prev) => prev.map(String).filter((id) => valid.has(id)));
|
||||||
|
setPinned((prev) => prev.map(String).filter((id) => valid.has(id)));
|
||||||
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
|
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const ids = prev.ids.filter((id) => valid.has(id));
|
const ids = prev.ids.map(String).filter((id) => valid.has(id));
|
||||||
return { ids, index: Math.min(prev.index, ids.length - 1) };
|
return { ids, index: Math.min(prev.index, ids.length - 1) };
|
||||||
});
|
});
|
||||||
return arr;
|
return arr;
|
||||||
@@ -99,7 +136,8 @@ export default function usePanels() {
|
|||||||
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
|
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
|
||||||
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
||||||
const openPanel = useCallback((id, panelOverride) => {
|
const openPanel = useCallback((id, panelOverride) => {
|
||||||
const panel = panelOverride || (links && links.find((p) => String(p.id) === String(id)));
|
id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
|
||||||
|
const panel = normalizePanel(panelOverride || (links && links.find((p) => String(p.id) === id)));
|
||||||
if (!panel) return false;
|
if (!panel) return false;
|
||||||
touch(id);
|
touch(id);
|
||||||
const mode = modes[id] || OPEN_MODE_EMBED;
|
const mode = modes[id] || OPEN_MODE_EMBED;
|
||||||
@@ -108,7 +146,7 @@ export default function usePanels() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
|
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
|
||||||
if (String(activeId) === String(id)) return false;
|
if (activeId === id) return false;
|
||||||
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const ids = prev.ids.slice(0, prev.index + 1);
|
const ids = prev.ids.slice(0, prev.index + 1);
|
||||||
@@ -143,6 +181,7 @@ export default function usePanels() {
|
|||||||
|
|
||||||
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
|
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
|
||||||
const closePanel = useCallback((id) => {
|
const closePanel = useCallback((id) => {
|
||||||
|
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
|
||||||
const nextOpen = openIds.filter((x) => x !== id);
|
const nextOpen = openIds.filter((x) => x !== id);
|
||||||
setOpenIds(nextOpen);
|
setOpenIds(nextOpen);
|
||||||
delete recency.current[id];
|
delete recency.current[id];
|
||||||
@@ -157,6 +196,7 @@ export default function usePanels() {
|
|||||||
}, [history, openIds]);
|
}, [history, openIds]);
|
||||||
|
|
||||||
const togglePin = useCallback((id) => {
|
const togglePin = useCallback((id) => {
|
||||||
|
id = String(id); // 防止固定集合再次被数字污染
|
||||||
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -49,17 +49,48 @@ function write(key, value) {
|
|||||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧 */
|
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
|
||||||
function sanitizeOrder(saved) {
|
function sanitizeOrder(saved) {
|
||||||
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
|
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const clean = (arr) => arr.filter((id) => VIEW_IDS.includes(id) && !seen.has((seen.add(id), id)));
|
const clean = (arr) => {
|
||||||
|
const out = [];
|
||||||
|
arr.forEach((id) => {
|
||||||
|
if (!VIEW_IDS.includes(id) || seen.has(id)) return; // 先查重,后登记
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
const left = clean(saved.left);
|
const left = clean(saved.left);
|
||||||
const right = clean(saved.right);
|
const right = clean(saved.right);
|
||||||
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
|
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
|
||||||
|
// 兜底:持久化可能把视图全挪到一侧(或空数组),刷新后另一侧空白打不开。
|
||||||
|
// 保证每侧至少一个视图(panels 优先左侧,notes 次之)。
|
||||||
|
if (left.length === 0 && right.length > 0) {
|
||||||
|
const preferred = ['panels', 'notes'].find((id) => right.includes(id));
|
||||||
|
const move = preferred || right[0];
|
||||||
|
left.push(move);
|
||||||
|
right.splice(right.indexOf(move), 1);
|
||||||
|
}
|
||||||
|
if (right.length === 0 && left.length > 0) {
|
||||||
|
const preferred = ['notes', 'vault'].find((id) => left.includes(id));
|
||||||
|
const move = preferred || left[0];
|
||||||
|
right.push(move);
|
||||||
|
left.splice(left.indexOf(move), 1);
|
||||||
|
}
|
||||||
return { left, right };
|
return { left, right };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验 active:指向的视图必须仍在本侧,否则回退到该侧第一个 */
|
||||||
|
function sanitizeActive(saved, order) {
|
||||||
|
if (!saved || typeof saved !== 'object') return { ...DEFAULT_ACTIVE };
|
||||||
|
const pick = (side, fallback) => (
|
||||||
|
order[side].includes(saved[side]) ? saved[side] : (order[side][0] || fallback)
|
||||||
|
);
|
||||||
|
return { left: pick('left', 'panels'), right: pick('right', 'notes') };
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验 mode:只接受 sidebar/tab(float 为保留值,不持久化激活) */
|
/** 校验 mode:只接受 sidebar/tab(float 为保留值,不持久化激活) */
|
||||||
function sanitizeMode(saved) {
|
function sanitizeMode(saved) {
|
||||||
const out = { ...DEFAULT_MODE };
|
const out = { ...DEFAULT_MODE };
|
||||||
@@ -73,10 +104,7 @@ function sanitizeMode(saved) {
|
|||||||
|
|
||||||
export default function useWorkbenchLayout() {
|
export default function useWorkbenchLayout() {
|
||||||
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
|
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
|
||||||
const [active, setActive] = useState(() => {
|
const [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
|
||||||
const saved = read(STORE.active, null);
|
|
||||||
return saved && saved.left && saved.right ? saved : DEFAULT_ACTIVE;
|
|
||||||
});
|
|
||||||
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
||||||
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export { default as SidebarPane } from './components/SidebarPane.jsx';
|
|||||||
export { default as ToolWindow } from './components/ToolWindow.jsx';
|
export { default as ToolWindow } from './components/ToolWindow.jsx';
|
||||||
export { default as TabStrip } from './components/TabStrip.jsx';
|
export { default as TabStrip } from './components/TabStrip.jsx';
|
||||||
export { default as ContentView } from './components/ContentView.jsx';
|
export { default as ContentView } from './components/ContentView.jsx';
|
||||||
export { default as TerminalPanel } from './components/TerminalPanel.jsx';
|
// 终端面板临时下线:组件归档到 archive/(前端入口已移除,恢复时改回 components/ 路径)
|
||||||
|
export { default as TerminalPanel } from './archive/TerminalPanel.jsx';
|
||||||
export { default as StatusBar } from './components/StatusBar.jsx';
|
export { default as StatusBar } from './components/StatusBar.jsx';
|
||||||
export { default as MenuBar } from './components/MenuBar.jsx';
|
export { default as MenuBar } from './components/MenuBar.jsx';
|
||||||
export { default as useNotes } from './hooks/useNotes.js';
|
export { default as useNotes } from './hooks/useNotes.js';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// lib/author.js —— 作者显示信息组装(论坛/博客/评论/公开主页共用)
|
||||||
|
//
|
||||||
|
// SQL 侧需为作者 JOIN 补选(见 routes/forum.js / routes/blog.js):
|
||||||
|
// u.username AS author_name, u.nickname AS author_nickname, u.title AS author_title,
|
||||||
|
// u.title_color AS author_title_color, u.role AS author_role
|
||||||
|
//(u.title 用 AS 别名,避免与帖子/文章的 title 列冲突)
|
||||||
|
// 前端最终拿到的作者字段:author_id, author_name(=nickname||username),
|
||||||
|
// author_username(原始登录名,恒存在——站长帖判断等需用它), author_avatar,
|
||||||
|
// author_title, author_title_color, author_role。
|
||||||
|
|
||||||
|
const { authorAvatar } = require('./avatar');
|
||||||
|
|
||||||
|
// 在 JOIN 行上直接写入作者显示字段(幂等:已有 author_avatar / author_username 则保留)
|
||||||
|
function attachAuthor(p) {
|
||||||
|
if (!p) return p;
|
||||||
|
if (p.author_avatar === undefined) p.author_avatar = authorAvatar(p);
|
||||||
|
// 覆盖 author_name 前先保存原始登录名(前端「站长帖」判断等依赖原始 username)
|
||||||
|
if (p.author_username === undefined) p.author_username = String(p.author_name || '').trim();
|
||||||
|
p.author_name = String(p.author_nickname || '').trim() || p.author_name || p.username || '';
|
||||||
|
p.author_title = String(p.author_title || '').trim();
|
||||||
|
p.author_title_color = String(p.author_title_color || '').trim();
|
||||||
|
p.author_role = p.author_role || 'user';
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装作者显示信息(返回独立对象,供测试或需要独立作者对象的场景)
|
||||||
|
function authorInfo(p) {
|
||||||
|
if (!p) return null;
|
||||||
|
return {
|
||||||
|
id: p.author_id != null ? p.author_id : p.uid,
|
||||||
|
username: String(p.author_username || p.author_name || '').trim(),
|
||||||
|
name: String(p.author_nickname || '').trim() || p.author_name || p.username || '',
|
||||||
|
avatar: p.author_avatar || '',
|
||||||
|
title: String(p.author_title || '').trim(),
|
||||||
|
title_color: String(p.author_title_color || '').trim(),
|
||||||
|
role: p.author_role || 'user',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { attachAuthor, authorInfo };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// lib/avatar.js —— 用户头像解析(博客/论坛/评论等各处共用)
|
||||||
|
//
|
||||||
|
// 优先级(用户确认):
|
||||||
|
// 1. RainWeb 自己上传的头像(users.avatar 以 /uploads/ 开头)
|
||||||
|
// 2. QQ 头像(users.qq 字段 → https://q1.qlogo.cn/g?b=qq&nk=<QQ号>&s=640)
|
||||||
|
// 3. QQ 邮箱前缀(users.qq_from_email —— SQL 内 CASE 提取的数字前缀,见 routes/forum.js、routes/blog.js)
|
||||||
|
// 4. @qq.com 邮箱匹配(users.email 本体,仅本地直查场景可用)
|
||||||
|
// 5. RainID 获取的头像(users.avatar 里的 http(s) URL)
|
||||||
|
// 6. 均无 → 空串(前端显示默认头像)
|
||||||
|
|
||||||
|
function resolveAvatar(user) {
|
||||||
|
if (!user) return '';
|
||||||
|
if (user.avatar && String(user.avatar).startsWith('/uploads/')) return user.avatar;
|
||||||
|
// QQ 头像:users.qq 字段 → qq_from_email(SQL 提取)→ @qq.com 邮箱前缀
|
||||||
|
let qq = String(user.qq || '').trim();
|
||||||
|
if (!qq && user.qq_from_email) qq = String(user.qq_from_email).trim();
|
||||||
|
if (!qq && user.email) {
|
||||||
|
const m = String(user.email).toLowerCase().match(/^(\d{5,12})@qq\.com$/);
|
||||||
|
if (m) qq = m[1];
|
||||||
|
}
|
||||||
|
if (qq) return 'https://q1.qlogo.cn/g?b=qq&nk=' + qq + '&s=640';
|
||||||
|
if (user.avatar && /^https?:/i.test(user.avatar)) return user.avatar;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容缺行(匿名作者/已注销用户等 author_id 为空的行)
|
||||||
|
function authorAvatar(authorRow) {
|
||||||
|
return resolveAvatar(authorRow || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveAvatar, authorAvatar };
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// lib/locks.js —— markdown [lock:] 成对块锁定核心共享模块(博客/论坛共用)
|
||||||
|
//
|
||||||
|
// 语法(多类型):
|
||||||
|
// [lock:login]...[/lock] 需登录
|
||||||
|
// [lock:reply]...[/lock] 评论后解锁(含待审核评论)
|
||||||
|
// [lock:password 密码]...[/lock] 密码解锁(bcrypt 成本 10 存储校验)
|
||||||
|
// [lock]...[/lock] 默认 login
|
||||||
|
//
|
||||||
|
// 真锁原则:未解锁内容不进 HTML/API——剥离的块整体替换为 @@LOCK<index>@@ 占位符,
|
||||||
|
// 块内文本与 [image:]/[file:] 附件标签一律不输出;password 块内容必须经 unlock 接口
|
||||||
|
// bcrypt 校验后才返回。未闭合 [lock: 不匹配正则,按普通文本原样保留(不破坏文档)。
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { SECRET } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const LOCK_RE = /\[lock(?::([^\]]*))?\]([\s\S]*?)\[\/lock\]/gi;
|
||||||
|
const PASSWORD_TAG_RE = /\[lock:password\s*[::\s]\s*([^\]]*)\]/gi;
|
||||||
|
|
||||||
|
// 解锁接口限流:15 分钟窗口内每 IP 最多 10 次(照 loginLimiter 模式,防密码爆破)
|
||||||
|
const unlockLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: '尝试次数过多,请 15 分钟后再试' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 参数解析:空 → login;login/reply → 对应;'password:<密码>'(冒号分隔,兼容 'password 密码')→ password
|
||||||
|
// 冒号分隔优于空格:密码本身可含空格(如 "my secret")
|
||||||
|
function parseLockParams(param) {
|
||||||
|
const p = String(param || '').trim();
|
||||||
|
if (!p || p === 'login') return { type: 'login' };
|
||||||
|
if (p === 'reply') return { type: 'reply' };
|
||||||
|
const m = p.match(/^password\s*[::\s]\s*(.+)$/i) || p.match(/^password$/i);
|
||||||
|
if (m && m[1] && m[1].trim()) return { type: 'password', plain: m[1].trim() };
|
||||||
|
if (p.toLowerCase() === 'password') return { type: 'password', plain: '' }; // 无密码参数按 login 兜底在下方
|
||||||
|
// 未知参数容错按 login 处理(不破坏文档)
|
||||||
|
return { type: 'login' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取锁定块:index=解析顺序(0 起);raw=块原文含标签;inner=块内原文。
|
||||||
|
// 注意:本函数不做 bcrypt 计算(读路径零开销)——hash 由 saveLocks 落库、
|
||||||
|
// unlock 接口取库内 hash 校验,两块 index 顺序一致(同一正则遍历)。
|
||||||
|
function parseLocks(content) {
|
||||||
|
const blocks = [];
|
||||||
|
const re = new RegExp(LOCK_RE.source, 'gi');
|
||||||
|
String(content || '').replace(re, (m, param, inner) => {
|
||||||
|
const { type, plain } = parseLockParams(param);
|
||||||
|
blocks.push({ index: blocks.length, type, plain, raw: m, inner });
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
return { blocks, stripped: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 依据 viewer 视角剥离锁定块,返回 { stripped, unlocked }:
|
||||||
|
// stripped 中保留块原样输出(含 [lock:] 标签),剥离块替换为 @@LOCK<index>@@ 占位符;
|
||||||
|
// unlocked = 本次视角已解锁的块索引数组。
|
||||||
|
// viewer: { userId, isAdmin, isAuthor, hasCommented }
|
||||||
|
// login 块:任何登录用户可见;reply 块:已评论或 admin/作者;
|
||||||
|
// password 块:仅 admin/作者可见(其余一律剥离,必须经 unlock 接口 bcrypt 校验;
|
||||||
|
// unlockedSet 参数不豁免 password 块,防止前端伪造解锁)。
|
||||||
|
// maskPasswords:对输出中残留的 [lock:password 明文] 参数打码为 ***(SSR 用,
|
||||||
|
// 覆盖未闭合/异常块按文本输出时明文进 HTML 的路径)。
|
||||||
|
function stripLocks(content, opts = {}) {
|
||||||
|
const { viewer = {}, unlockedSet = [], maskPasswords = false } = opts;
|
||||||
|
const blocks = parseLocks(content).blocks;
|
||||||
|
const unlocked = [];
|
||||||
|
|
||||||
|
let matchIdx = -1;
|
||||||
|
const stripped = String(content || '').replace(new RegExp(LOCK_RE.source, 'gi'), (m) => {
|
||||||
|
matchIdx++;
|
||||||
|
const b = blocks[matchIdx];
|
||||||
|
const extra = Array.isArray(unlockedSet) && unlockedSet.map(Number).includes(b.index);
|
||||||
|
// 判定:admin/作者恒全解锁(password 含明文也仅限作者可见);login 登录即见;
|
||||||
|
// reply 已评论即见;password 非作者一律剥离(unlockedSet 不豁免)
|
||||||
|
const keep = !!(
|
||||||
|
viewer.isAdmin || viewer.isAuthor ||
|
||||||
|
(b.type === 'login' && !!viewer.userId) ||
|
||||||
|
(b.type === 'reply' && (viewer.hasCommented || extra)) ||
|
||||||
|
(b.type === 'password' && extra && (viewer.isAdmin || viewer.isAuthor))
|
||||||
|
);
|
||||||
|
if (keep) { unlocked.push(b.index); return m; }
|
||||||
|
return '@@LOCK' + b.index + '@@';
|
||||||
|
});
|
||||||
|
|
||||||
|
let out = stripped;
|
||||||
|
if (maskPasswords) {
|
||||||
|
out = out.replace(PASSWORD_TAG_RE, (m, p) => '[lock:password ' + (p.trim() ? '***' : '') + ']');
|
||||||
|
}
|
||||||
|
return { stripped: out, unlocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存锁定元数据:parseLocks → JSON.stringify(blocks.map(b => ({type, hash})))(不含 raw/inner)。
|
||||||
|
// password 块在此处 bcrypt 哈希(成本 10,全站一致);login/reply 块无 hash 字段。
|
||||||
|
function saveLocks(content) {
|
||||||
|
const { blocks } = parseLocks(content);
|
||||||
|
return JSON.stringify(blocks.map(b => ({
|
||||||
|
type: b.type,
|
||||||
|
...(b.type === 'password' ? { hash: bcrypt.hashSync(b.plain, 10) } : {}),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解锁校验(blog/forum 共用):
|
||||||
|
// blocks=parseLocks(content).blocks;locksMeta=库内 JSON(取 password hash);
|
||||||
|
// viewer={userId,isAdmin,isAuthor};replyQualified=是否已评论。
|
||||||
|
// 返回 { ok, status, inner? };失败统一 401(密码错/块不存在不区分,防探测);
|
||||||
|
// reply 未评论返回 403。
|
||||||
|
function verifyUnlock({ blocks, locksMeta = [], index, password, viewer = {}, replyQualified = false }) {
|
||||||
|
const b = (blocks || []).find(x => x.index === index);
|
||||||
|
if (!b) return { ok: false, status: 401 };
|
||||||
|
if (viewer.isAdmin || viewer.isAuthor) return { ok: true, status: 200, inner: b.inner };
|
||||||
|
if (b.type === 'login') {
|
||||||
|
if (!viewer.userId) return { ok: false, status: 401 };
|
||||||
|
return { ok: true, status: 200, inner: b.inner };
|
||||||
|
}
|
||||||
|
if (b.type === 'reply') {
|
||||||
|
if (!replyQualified) return { ok: false, status: 403 };
|
||||||
|
return { ok: true, status: 200, inner: b.inner };
|
||||||
|
}
|
||||||
|
if (b.type === 'password') {
|
||||||
|
const meta = locksMeta[index] || null;
|
||||||
|
const hash = meta && meta.hash;
|
||||||
|
if (!hash || !password || !bcrypt.compareSync(String(password), hash)) {
|
||||||
|
return { ok: false, status: 401 };
|
||||||
|
}
|
||||||
|
return { ok: true, status: 200, inner: b.inner };
|
||||||
|
}
|
||||||
|
return { ok: false, status: 401 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解锁附件 token:详情接口对已解锁块、unlock 接口对解锁成功块签发,
|
||||||
|
// 前端用它加载块内 [image:]/[file:] 附件(/api/upload/locked 鉴权)。
|
||||||
|
// 有效期 1h;payload 绑定 refType/refId/blockIndex——只能取该帖该块的附件。
|
||||||
|
function makeLockToken(refType, refId, blockIndex) {
|
||||||
|
return jwt.sign({ purpose: 'lock-attachment', refType, refId, blockIndex }, SECRET, { expiresIn: '1h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验解锁附件 token:合法且 purpose 匹配返回 payload,否则 null
|
||||||
|
function verifyLockToken(token) {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, SECRET);
|
||||||
|
if (!payload || payload.purpose !== 'lock-attachment') return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseLocks, stripLocks, saveLocks, verifyUnlock, unlockLimiter, makeLockToken, verifyLockToken };
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// RainID OIDC 接入共享逻辑(routes/auth.js ROPC 密码登录 / routes/oidc.js 授权码流共用)
|
||||||
|
// 安全要点:
|
||||||
|
// - client_secret 双通道:优先 .env.json(gitignored),回退后台站点设置
|
||||||
|
// site_settings.rainid_client_secret(routes/settings.js ALLOWED_SET 可写、GET 不返回,不泄露)
|
||||||
|
// - rainid_enabled='1' 但 client_id/secret 任一通道均缺失 → enabled=false(fail-closed,本地登录不受影响)
|
||||||
|
// - 影子账号按 sub 绑定(唯一索引兜底);email_verified 是自动绑定闸门(防撞绑)
|
||||||
|
// - 影子账号随机不可登录密码,禁止本地密码登入
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const openidClient = require('openid-client'); // v6 函数式 API(Node >=23 支持 require(esm))
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
|
||||||
|
|
||||||
|
// 机密 client_secret 双通道读取(优先级从高到低):
|
||||||
|
// ① 环境变量 RAINID_CLIENT_SECRET(server.js 启动时从 .env.json 的 cfg.rainid_client_secret 读入)
|
||||||
|
// ② .env.json 直接读取
|
||||||
|
// ③ 后台站点设置 site_settings.rainid_client_secret(可写不可读,GET /api/settings 不返回)
|
||||||
|
function getClientSecret() {
|
||||||
|
if (process.env.RAINID_CLIENT_SECRET) return process.env.RAINID_CLIENT_SECRET;
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '.env.json'), 'utf8'));
|
||||||
|
if (cfg.rainid_client_secret) return cfg.rainid_client_secret;
|
||||||
|
} catch { /* .env.json 缺失/损坏 → 走 settings 回退 */ }
|
||||||
|
return db.getSetting('rainid_client_secret') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOidcSettings() {
|
||||||
|
const clientId = db.getSetting('rainid_client_id');
|
||||||
|
const clientSecret = getClientSecret();
|
||||||
|
return {
|
||||||
|
// fail-closed:enabled 声明为 true 仅当三项齐全(rainid_enabled + client_id + secret)
|
||||||
|
enabled: db.getSetting('rainid_enabled') === '1' && !!clientId && !!clientSecret,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
discoveryUrl: db.getSetting('rainid_discovery_url') || DISCOVERY_DEFAULT,
|
||||||
|
registerRedirect: db.getSetting('rainid_register_redirect') === '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// discovery 结果缓存(RainID JWKS 多 key 轮换由 SDK 自动处理;设置变更需重启进程生效)
|
||||||
|
let configCache = null;
|
||||||
|
async function getOidcConfig() {
|
||||||
|
if (configCache) return configCache;
|
||||||
|
const s = getOidcSettings();
|
||||||
|
if (!s.clientId || !s.clientSecret) {
|
||||||
|
const err = new Error('RainID 客户端未配置(rainid_client_id / rainid_client_secret)');
|
||||||
|
err.code = 'RAINID_NOT_CONFIGURED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// v6 的 discovery() 第一个参数必须是 URL 实例(传字符串会抛 "server must be an instance of URL")
|
||||||
|
configCache = await openidClient.discovery(new URL(s.discoveryUrl), s.clientId, s.clientSecret);
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站点基址:redirect_uri / 回跳前端页面用。优先 site_url 设置,回退请求头(反代注意 X-Forwarded-Proto)
|
||||||
|
function siteBase(req) {
|
||||||
|
const u = db.getSetting('site_url');
|
||||||
|
if (u) return String(u).replace(/\/+$/, '');
|
||||||
|
// M7:Host 头不可信——攻击者可构造 Host: evil.com 把 redirect_uri 变成开放重定向/授权回调投毒。
|
||||||
|
// 仅接受合法域名/IP 格式(允许字母数字、点、连字符、冒号、方括号、下划线),
|
||||||
|
// 拒绝含 @、/、空白、控制字符的 Host;缺失或异常时回退固定值 localhost。
|
||||||
|
const host = req.get('host');
|
||||||
|
const valid = !!host && host.length <= 255
|
||||||
|
&& !/[@\s\x00-\x1f\x7f]/.test(host)
|
||||||
|
&& /^[a-zA-Z0-9._\-:\[\]]+$/.test(host);
|
||||||
|
return (req.protocol || 'http') + '://' + (valid ? host : 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 影子账号 查/建(sub 为 OIDC 稳定绑定键):
|
||||||
|
// 1. rainid_user_id = sub 命中 → 直接返回
|
||||||
|
// 2. email_verified=true 且本地存在同 email 且未绑定 → 自动绑定(防撞绑:仅 RainID 已验证邮箱可绑)
|
||||||
|
// 3. 否则创建影子账号(username=preferred_username 或 rainid_<sub前8>,冲突加后缀;
|
||||||
|
// password=随机不可登录;email_verified=1;全站无 admin 时首个用户为 admin)
|
||||||
|
// 4. 并发兜底:唯一索引冲突 → 重查按 sub 返回
|
||||||
|
function findOrCreateRainidUser(sub, profile) {
|
||||||
|
if (!sub) {
|
||||||
|
const e = new Error('RainID userinfo 缺少 sub');
|
||||||
|
e.code = 'RAINID_NO_SUB';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const email = String(profile.email || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
// 1) 已绑定
|
||||||
|
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
// 2) 同 email 自动绑定(条件 UPDATE:仅绑定未绑定的本地号,防并发/防覆盖其他 sub)
|
||||||
|
if (email && profile.email_verified) {
|
||||||
|
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||||||
|
if (local && !local.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);
|
||||||
|
}
|
||||||
|
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ROPC 密码登录:RainID token 端点 grant_type=password(openid-client genericGrantRequest 自动带 client 认证)
|
||||||
|
// 成功 → 影子账号 → 返回 { ok:true, user };失败 → { ok:false, status, error }
|
||||||
|
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) {
|
||||||
|
// 区分:配置缺失 vs discovery 失败(端点协议/不可达——RainID 端点必须是 https)
|
||||||
|
if (e && e.code === 'RAINID_NOT_CONFIGURED') {
|
||||||
|
return { ok: false, status: 500, error: 'RainID 客户端未配置(client_id / client_secret)' };
|
||||||
|
}
|
||||||
|
console.error('[RainID] discovery 失败:', e && e.message);
|
||||||
|
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 声明(签名/iss/aud 校验由 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 状态 + 文案(RainID 对接文档 §5.3 / §12 错误码总表)
|
||||||
|
function mapOidcError(err) {
|
||||||
|
const code = err && err.error;
|
||||||
|
const desc = (err && err.error_description) || '';
|
||||||
|
switch (code) {
|
||||||
|
case 'invalid_grant':
|
||||||
|
// 2FA 用户 ROPC 被拒 → 固定文案;其余统一"用户名/邮箱或密码错误"(防枚举)
|
||||||
|
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 服务暂不可用,请稍后再试' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOidcSettings,
|
||||||
|
getOidcConfig,
|
||||||
|
getClientSecret,
|
||||||
|
siteBase,
|
||||||
|
findOrCreateRainidUser,
|
||||||
|
rainidRopcLogin,
|
||||||
|
mapOidcError,
|
||||||
|
};
|
||||||
@@ -3,6 +3,20 @@ const db = require('../db');
|
|||||||
|
|
||||||
const SECRET = process.env.JWT_SECRET;
|
const SECRET = process.env.JWT_SECRET;
|
||||||
|
|
||||||
|
// 最后活跃记录:模块级缓存 <userId, 'YYYY-MM-DD'>,仅当用户上次记录日期 ≠ 今天时
|
||||||
|
// 才写库(避免每个请求都执行 UPDATE)。写入失败静默忽略(best-effort,不影响鉴权)。
|
||||||
|
const activeDates = new Map();
|
||||||
|
|
||||||
|
function touchLastActive(userId) {
|
||||||
|
if (!userId) return;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
if (activeDates.get(userId) === today) return;
|
||||||
|
activeDates.set(userId, today);
|
||||||
|
try {
|
||||||
|
db.run("UPDATE users SET last_active_at = datetime('now') WHERE id = ?", [userId]);
|
||||||
|
} catch { /* 忽略活跃记录失败 */ }
|
||||||
|
}
|
||||||
|
|
||||||
function authMiddleware(req, res, next) {
|
function authMiddleware(req, res, next) {
|
||||||
const header = req.headers.authorization;
|
const header = req.headers.authorization;
|
||||||
if (!header || !header.startsWith('Bearer ')) {
|
if (!header || !header.startsWith('Bearer ')) {
|
||||||
@@ -11,6 +25,8 @@ function authMiddleware(req, res, next) {
|
|||||||
try {
|
try {
|
||||||
const payload = jwt.verify(header.slice(7), SECRET);
|
const payload = jwt.verify(header.slice(7), SECRET);
|
||||||
req.user = payload;
|
req.user = payload;
|
||||||
|
// 记录最后活跃(跨天节流)
|
||||||
|
touchLastActive(payload.id);
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(401).json({ error: '登录已过期' });
|
return res.status(401).json({ error: '登录已过期' });
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -19,16 +20,19 @@
|
|||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.6.2",
|
"express-rate-limit": "^8.6.2",
|
||||||
|
"http-proxy-middleware": "^4.2.0",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-resizable-panels": "^4.12.2",
|
"react-resizable-panels": "^4.12.2",
|
||||||
@@ -567,7 +571,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/utils": {
|
"node_modules/@emotion/utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -1322,6 +1326,24 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
@@ -1392,6 +1414,90 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/undici": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -1402,7 +1508,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
"engines": [
|
"engines": [
|
||||||
@@ -1500,6 +1606,22 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
@@ -1513,6 +1635,18 @@
|
|||||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1562,7 +1696,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1600,6 +1734,59 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dom-serializer/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.13",
|
"version": "3.4.13",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||||
@@ -1609,6 +1796,20 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1639,7 +1840,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1647,6 +1848,31 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
@@ -1831,6 +2057,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
@@ -1997,6 +2235,37 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -2017,6 +2286,45 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-proxy-middleware": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"httpxy": "^0.5.4",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"is-plain-obj": "^4.1.0",
|
||||||
|
"micromatch": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.15.0 || ^24.0.0 || >=26.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-proxy-middleware/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/httpxy": {
|
||||||
|
"version": "0.5.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/httpxy/-/httpxy-0.5.5.tgz",
|
||||||
|
"integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
@@ -2090,12 +2398,63 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-potential-custom-element-name": {
|
"node_modules/is-potential-custom-element-name": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
|
||||||
|
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -2155,7 +2514,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-parse-even-better-errors": {
|
"node_modules/json-parse-even-better-errors": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -2605,6 +2964,31 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime": {
|
"node_modules/mime": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
@@ -2639,13 +3023,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2725,6 +3109,27 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
||||||
|
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -2758,6 +3163,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "6.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
|
||||||
|
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"oauth4webapi": "^3.8.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -2800,6 +3218,79 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -2909,7 +3400,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3434,6 +3925,18 @@
|
|||||||
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
|
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -3633,6 +4136,31 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-mimetype": {
|
"node_modules/whatwg-mimetype": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
@@ -3687,7 +4215,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xmlchars": {
|
"node_modules/xmlchars": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.1.0",
|
"version": "2.3.2",
|
||||||
"description": "链接聚合管理平台",
|
"description": "链接聚合管理平台",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,16 +22,19 @@
|
|||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.6.2",
|
"express-rate-limit": "^8.6.2",
|
||||||
|
"http-proxy-middleware": "^4.2.0",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-resizable-panels": "^4.12.2",
|
"react-resizable-panels": "^4.12.2",
|
||||||
@@ -41,5 +44,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"vite": "^8.2.1"
|
"vite": "^8.2.1"
|
||||||
}
|
},
|
||||||
|
"license": "SEE LICENSE IN LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#120A22"/>
|
||||||
|
<text x="32" y="46" text-anchor="middle" font-family="Anybody, 'Arial Black', Arial, sans-serif" font-weight="900" font-style="italic" font-size="42" fill="#F4F1EB">R</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553242408" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8564" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M306.005333 117.632L444.330667 256h135.296l138.368-138.325333a42.666667 42.666667 0 1 1 60.373333 60.373333l-78.037333 77.952L789.333333 256A149.333333 149.333333 0 0 1 938.666667 405.333333v341.333334a149.333333 149.333333 0 0 1-149.333334 149.333333h-554.666666A149.333333 149.333333 0 0 1 85.333333 746.666667v-341.333334A149.333333 149.333333 0 0 1 234.666667 256h88.96L245.632 177.962667a42.666667 42.666667 0 0 1 60.373333-60.373334zM789.333333 341.333333h-554.666666a64 64 0 0 0-63.701334 57.856L170.666667 405.333333v341.333334a64 64 0 0 0 57.856 63.701333L234.666667 810.666667h554.666666a64 64 0 0 0 63.701334-57.813334L853.333333 746.666667v-341.333334A64 64 0 0 0 789.333333 341.333333zM341.333333 469.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v85.333333a42.666667 42.666667 0 1 1-85.333333 0v-85.333333a42.666667 42.666667 0 0 1 42.666666-42.666667z m341.333334 0a42.666667 42.666667 0 0 1 42.666666 42.666667v85.333333a42.666667 42.666667 0 1 1-85.333333 0v-85.333333a42.666667 42.666667 0 0 1 42.666667-42.666667z" p-id="8565"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553278064" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11557" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M178.602667 231.296C99.882667 231.125333-5.546667 281.173333 0.256 406.656c9.088 196.010667 209.92 214.186667 290.176 215.765333 8.789333 36.778667 103.253333 163.584 173.184 170.24h306.346667c183.722667-12.202667 321.28-555.605333 219.306666-557.653333-168.661333 7.936-268.629333 11.946667-354.346666 12.629333v169.6l-26.709334-11.818666-0.170666-157.696c-98.389333-0.042667-185.002667-4.608-349.397334-12.714667-20.565333-0.128-49.237333-3.626667-80.042666-3.712z m11.136 69.333333h9.386666c11.178667 100.48 29.354667 159.232 66.133334 249.002667-93.866667-11.093333-173.738667-38.357333-188.416-140.16-7.594667-52.693333 18.005333-107.690667 112.896-108.885333z m365.098666 98.773334c6.4 0.085333 12.928 1.28 19.072 4.096l31.957334 13.781333-22.912 41.770667a28.672 25.472 0 0 0-10.282667 1.621333 28.672 25.472 0 0 0-17.28 32.597333 28.672 25.472 0 0 0 4.778667 7.424l-39.509334 71.936a28.672 25.472 0 0 0-9.472 1.621334 28.672 25.472 0 0 0-17.28 32.597333 28.672 25.472 0 0 0 36.693334 15.36 28.672 25.472 0 0 0 17.237333-32.64 28.672 25.472 0 0 0-6.741333-9.386667l38.485333-70.058666a28.672 25.472 0 0 0 12.501333-1.28 28.672 25.472 0 0 0 9.088-4.778667c14.848 6.229333 27.008 11.306667 35.754667 15.616 13.141333 6.485333 17.792 10.794667 19.2 15.573333 1.408 4.693333-0.128 13.738667-7.552 29.610667-5.546667 11.818667-14.72 28.586667-25.557333 48.341333a28.672 25.472 0 0 0-10.709334 1.621334 28.672 25.472 0 0 0-17.28 32.597333 28.672 25.472 0 0 0 36.693334 15.36 28.672 25.472 0 0 0 17.237333-32.597333 28.672 25.472 0 0 0-5.845333-8.618667c10.709333-19.541333 19.925333-36.352 25.856-48.981333 8.021333-17.152 12.202667-29.909333 8.533333-42.24-3.669333-12.330667-14.933333-20.352-29.866667-27.733334-9.813333-4.821333-22.058667-9.941333-36.693333-16.085333a28.672 25.472 0 0 0-1.621333-10.197333 28.672 25.472 0 0 0-6.186667-8.917334l22.528-41.088 124.757333 53.888c22.528 9.770667 31.829333 33.706667 20.906667 53.76l-85.76 157.013334c-10.965333 20.010667-37.888 28.288-60.416 18.56l-176.512-76.288c-22.528-9.728-31.872-33.706667-20.906667-53.76l85.76-156.970667c7.509333-13.781333 22.613333-21.973333 38.613334-22.613333h2.730666z" p-id="11558"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553263659" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10560" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M511.6 76.3C264.3 76.2 64 276.4 64 523.5 64 718.9 189.3 885 363.8 946c23.5 5.9 19.9-10.8 19.9-22.2v-77.5c-135.7 15.9-141.2-73.9-150.3-88.9C215 726 171.5 718 184.5 703c30.9-15.9 62.4 4 98.9 57.9 26.4 39.1 77.9 32.5 104 26 5.7-23.5 17.9-44.5 34.7-60.8-140.6-25.2-199.2-111-199.2-213 0-49.5 16.3-95 48.3-131.7-20.4-60.5 1.9-112.3 4.9-120 58.1-5.2 118.5 41.6 123.2 45.3 33-8.9 70.7-13.6 112.9-13.6 42.4 0 80.2 4.9 113.5 13.9 11.3-8.6 67.3-48.8 121.3-43.9 2.9 7.7 24.7 58.3 5.5 118 32.4 36.8 48.9 82.7 48.9 132.3 0 102.2-59 188.1-200 212.9 23.5 23.2 38.1 55.4 38.1 91v112.5c0.8 9 0 17.9 15 17.9 177.1-59.7 304.6-227 304.6-424.1 0-247.2-200.4-447.3-447.5-447.3z" p-id="10561"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1017 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553215331" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7559" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M824.8 613.2c-16-51.4-34.4-94.6-62.7-165.3C766.5 262.2 689.3 112 511.5 112 331.7 112 256.2 265.2 261 447.9c-28.4 70.8-46.7 113.7-62.7 165.3-34 109.5-23 154.8-14.6 155.8 18 2.2 70.1-82.4 70.1-82.4 0 49 25.2 112.9 79.8 159-26.4 8.1-85.7 29.9-71.6 53.8 11.4 19.3 196.2 12.3 249.5 6.3 53.3 6 238.1 13 249.5-6.3 14.1-23.8-45.3-45.7-71.6-53.8 54.6-46.2 79.8-110.1 79.8-159 0 0 52.1 84.6 70.1 82.4 8.5-1.1 19.5-46.4-14.5-155.8z" p-id="7560"></path></svg>
|
||||||
|
After Width: | Height: | Size: 780 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553254765" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9589" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M822.909 852.197l134.083-652.039c13.426-54.928-20.117-82.37-60.351-68.672L105.164 440.35c-53.706 20.615-53.706 54.928-6.736 68.626l201.26 68.672L775.94 268.784c20.117-13.743 40.234-6.871 26.853 6.827L420.39 632.576l-13.427 212.795c20.117 0 33.544-6.872 40.234-20.615l100.63-96.068 207.95 157.869c33.544 20.57 60.35 6.826 67.087-34.313l0.045-0.047z" fill="#999999" p-id="9590"></path></svg>
|
||||||
|
After Width: | Height: | Size: 722 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553089135" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6536" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z" p-id="6537"></path><path d="M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z" p-id="6538"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
|
||||||
|
<rect width="1200" height="630" fill="#120A22"/>
|
||||||
|
<text x="600" y="385" text-anchor="middle" font-family="Anybody, 'Arial Black', Arial, sans-serif" font-weight="900" font-style="italic" font-size="150" fill="#F4F1EB" letter-spacing="4">RAINBLOG</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 327 B |
@@ -4,6 +4,89 @@ const { authMiddleware, adminOnly } = require('../middleware/auth');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// slug 生成:name 小写、空格转连字符、去特殊字符(仅 [a-z0-9-]),空则随机后缀
|
||||||
|
function makeSlug(name) {
|
||||||
|
const base = String(name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 32);
|
||||||
|
return base || ('panel-' + Math.random().toString(36).slice(2, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 slug 唯一:存在同名则追加 -2/-3/...
|
||||||
|
function uniqueSlug(slug, excludeId) {
|
||||||
|
let s = slug;
|
||||||
|
let i = 1;
|
||||||
|
const exists = (x) => {
|
||||||
|
if (excludeId) return db.get('SELECT id FROM admin_links WHERE slug = ? AND id != ?', [x, excludeId]);
|
||||||
|
return db.get('SELECT id FROM admin_links WHERE slug = ?', [x]);
|
||||||
|
};
|
||||||
|
while (exists(s)) { s = slug + '-' + (++i); }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验面板代理配置字段(返回 { ok, data?, error? })
|
||||||
|
function validateProxyFields(body, current) {
|
||||||
|
const data = {};
|
||||||
|
// proxy_headers:合法 JSON 对象(空串允许)
|
||||||
|
if (body.proxy_headers !== undefined) {
|
||||||
|
const raw = String(body.proxy_headers);
|
||||||
|
if (raw.trim() !== '') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, error: 'proxy_headers 需为 JSON 对象' };
|
||||||
|
data.proxy_headers = raw;
|
||||||
|
} catch { return { ok: false, error: 'proxy_headers 不是合法 JSON' }; }
|
||||||
|
} else {
|
||||||
|
data.proxy_headers = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// permissions:JSON 数组
|
||||||
|
if (body.permissions !== undefined) {
|
||||||
|
const raw = String(body.permissions);
|
||||||
|
if (raw.trim() !== '') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return { ok: false, error: 'permissions 需为 JSON 数组' };
|
||||||
|
data.permissions = raw;
|
||||||
|
} catch { return { ok: false, error: 'permissions 不是合法 JSON' }; }
|
||||||
|
} else {
|
||||||
|
data.permissions = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// scale:0.1-3
|
||||||
|
if (body.scale !== undefined) {
|
||||||
|
const s = parseFloat(body.scale);
|
||||||
|
if (!Number.isFinite(s) || s < 0.1 || s > 3) return { ok: false, error: 'scale 需在 0.1-3 之间' };
|
||||||
|
data.scale = s;
|
||||||
|
}
|
||||||
|
// trusted:0/1
|
||||||
|
if (body.trusted !== undefined) {
|
||||||
|
const t = Number(body.trusted);
|
||||||
|
if (t !== 0 && t !== 1) return { ok: false, error: 'trusted 需为 0 或 1' };
|
||||||
|
data.trusted = t;
|
||||||
|
}
|
||||||
|
// proxy_skip_tls_verify:0/1
|
||||||
|
if (body.proxy_skip_tls_verify !== undefined) {
|
||||||
|
data.proxy_skip_tls_verify = Number(body.proxy_skip_tls_verify) ? 1 : 0;
|
||||||
|
}
|
||||||
|
// slug:唯一([a-z0-9-]),空则自动生成
|
||||||
|
if (body.slug !== undefined) {
|
||||||
|
const raw = String(body.slug || '').trim().toLowerCase();
|
||||||
|
if (raw !== '') {
|
||||||
|
if (!/^[a-z0-9-]{1,32}$/.test(raw)) return { ok: false, error: 'slug 仅限小写字母/数字/连字符,≤32 字符' };
|
||||||
|
if (['token', 'fetch'].includes(raw)) return { ok: false, error: 'slug 为保留名,请更换' };
|
||||||
|
data.slug = uniqueSlug(raw, current && current.id);
|
||||||
|
} else {
|
||||||
|
data.slug = uniqueSlug(makeSlug((body.title || (current && current.title) || '')), current && current.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', authMiddleware, adminOnly, (req, res) => {
|
router.get('/', authMiddleware, adminOnly, (req, res) => {
|
||||||
res.json(db.all('SELECT * FROM admin_links ORDER BY sort_order ASC, id ASC'));
|
res.json(db.all('SELECT * FROM admin_links ORDER BY sort_order ASC, id ASC'));
|
||||||
});
|
});
|
||||||
@@ -11,18 +94,36 @@ router.get('/', authMiddleware, adminOnly, (req, res) => {
|
|||||||
router.post('/', authMiddleware, adminOnly, (req, res) => {
|
router.post('/', authMiddleware, adminOnly, (req, res) => {
|
||||||
const { title, url, embed_url, description, icon, category, sort_order, use_proxy, version } = req.body;
|
const { title, url, embed_url, description, icon, category, sort_order, use_proxy, version } = req.body;
|
||||||
if (!title || !url) return res.status(400).json({ error: '标题和链接不能为空' });
|
if (!title || !url) return res.status(400).json({ error: '标题和链接不能为空' });
|
||||||
|
const vf = validateProxyFields(req.body);
|
||||||
|
if (!vf.ok) return res.status(400).json({ error: vf.error });
|
||||||
|
const d = vf.data;
|
||||||
const id = db.run(
|
const id = db.run(
|
||||||
'INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order, use_proxy, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
`INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order, use_proxy, version,
|
||||||
[title, url, embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '']);
|
proxy_headers, proxy_skip_tls_verify, permissions, scale, trusted, slug) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[title, url, embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '',
|
||||||
|
d.proxy_headers || '', d.proxy_skip_tls_verify || 0, d.permissions || '', d.scale !== undefined ? d.scale : 1.0, d.trusted !== undefined ? d.trusted : 1,
|
||||||
|
d.slug || uniqueSlug(makeSlug(title))]);
|
||||||
res.json(db.get('SELECT * FROM admin_links WHERE id = ?', [id]));
|
res.json(db.get('SELECT * FROM admin_links WHERE id = ?', [id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authMiddleware, adminOnly, (req, res) => {
|
router.put('/:id', authMiddleware, adminOnly, (req, res) => {
|
||||||
if (!db.get('SELECT id FROM admin_links WHERE id = ?', [req.params.id]))
|
const existing = db.get('SELECT * FROM admin_links WHERE id = ?', [req.params.id]);
|
||||||
return res.status(404).json({ error: '链接不存在' });
|
if (!existing) return res.status(404).json({ error: '链接不存在' });
|
||||||
const { title, url, embed_url, description, icon, category, sort_order, use_proxy, version } = req.body;
|
const { title, url, embed_url, description, icon, category, sort_order, use_proxy, version } = req.body;
|
||||||
db.run('UPDATE admin_links SET title=?, url=?, embed_url=?, description=?, icon=?, category=?, sort_order=?, use_proxy=?, version=? WHERE id=?',
|
const vf = validateProxyFields(req.body, existing);
|
||||||
[title || '', url || '', embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '', req.params.id]);
|
if (!vf.ok) return res.status(400).json({ error: vf.error });
|
||||||
|
const d = vf.data;
|
||||||
|
db.run(
|
||||||
|
`UPDATE admin_links SET title=?, url=?, embed_url=?, description=?, icon=?, category=?, sort_order=?, use_proxy=?, version=?,
|
||||||
|
proxy_headers=?, proxy_skip_tls_verify=?, permissions=?, scale=?, trusted=?, slug=? WHERE id=?`,
|
||||||
|
[title || '', url || '', embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '',
|
||||||
|
d.proxy_headers !== undefined ? d.proxy_headers : existing.proxy_headers,
|
||||||
|
d.proxy_skip_tls_verify !== undefined ? d.proxy_skip_tls_verify : existing.proxy_skip_tls_verify,
|
||||||
|
d.permissions !== undefined ? d.permissions : existing.permissions,
|
||||||
|
d.scale !== undefined ? d.scale : existing.scale,
|
||||||
|
d.trusted !== undefined ? d.trusted : existing.trusted,
|
||||||
|
d.slug !== undefined ? d.slug : (existing.slug || uniqueSlug(makeSlug(existing.title))),
|
||||||
|
req.params.id]);
|
||||||
res.json(db.get('SELECT * FROM admin_links WHERE id = ?', [req.params.id]));
|
res.json(db.get('SELECT * FROM admin_links WHERE id = ?', [req.params.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||