Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -9,10 +9,13 @@ npm start # 生产:node server.js(serve 构建产物 + /api)
|
||||
npm run dev # 开发:vite dev server(5173,/api 与 /uploads 代理到 3101)
|
||||
npm run build # 构建 React 前端 → public/dist/(index.html + admin.html + assets)
|
||||
npm run preview # 预览构建产物
|
||||
node cli.js <cmd> # status | 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),后端端口改了就同步改。
|
||||
- CLI:`status`、`health`、`db-check`、`config`、`backup list` 支持 `--json`;`health`/`db-check` 检查失败返回非 0。`backup prune` 必须明确 `--keep N`,默认只预览,只有 `--yes` 才删除;`backup restore <file>` 必须服务停止并带 `--yes`,不会默认覆盖。
|
||||
- CLI 的 `start/stop/restart` 只操作经过 cwd 与命令行双重校验、确认属于本项目 `server.js` 的 PID,停止会先优雅等待。`upgrade` 仅供自托管机器本地执行:升级前备份,要求 git 工作区干净,执行 `git pull --ff-only`、`npm install`、`npm run build`,服务原本运行时重启并健康检查;失败返回非 0。
|
||||
- CLI 密码命令默认交互输入;`config` 对密码、secret、token、JWT、私钥和 API key 等敏感配置统一脱敏。验证码参数需使用受支持的类型和值。
|
||||
- 验证方式:后端改动启动后 `curl` 接口;前端改动 `npm run dev` 热更新,或 `npm run build` 后刷新验证(需强刷一次,见下)。
|
||||
- **部署后浏览器必须拿新 HTML**:`serveIndex` 响应带 `Cache-Control: no-store`;构建产物 assets 文件名带 hash,经 `/assets` 挂载长期缓存(immutable)。若用户白屏且 console 报 "MIME type text/html",先查是否 `npm run build` 缺失/旧产物。
|
||||
- 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete` 用 `setup_complete` 门禁,非 adminOnly——新装无 token)。
|
||||
@@ -52,7 +55,7 @@ node cli.js <cmd> # status | start | stop | restart | port [N] | password [pw
|
||||
## 版本与更新
|
||||
|
||||
- 版本号在根目录 `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 入库)。
|
||||
|
||||
## 约定
|
||||
|
||||
@@ -1,21 +1,51 @@
|
||||
MIT License
|
||||
# RainWeb 软件授权协议(定制版)
|
||||
|
||||
Copyright (c) 2024 Xianyunah
|
||||
**版本 1.0 · 2026-08-12**
|
||||
**原作者(Initial Author):Xianyunah(Rainnya 家族)**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
## 一、授权范围
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
本协议授予任何获得本软件副本(含源码、文档、构建产物)的个人或组织(下称"被授权人")以下权利:
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1. **使用**:允许安装、运行、复制本软件,用于个人学习、研究、内部开发。
|
||||
2. **修改**:允许对本软件源码进行修改、扩展、二次开发。
|
||||
3. **分发**:允许分发本软件的原始版本或修改后的衍生版本。
|
||||
|
||||
## 二、禁止事项
|
||||
|
||||
被授权人**不得**:
|
||||
|
||||
1. **商业使用**:不得将本软件或其衍生版本用于任何商业目的,包括但不限于:
|
||||
- 直接销售本软件或其衍生版本
|
||||
- 提供收费的托管服务(SaaS/PaaS)
|
||||
- 将本软件作为商业产品的组成部分
|
||||
- 通过本软件获取直接或间接的商业收益
|
||||
2. **闭源**:本软件及其衍生版本必须保持源代码公开可用,不得以任何形式闭源或隐藏源代码。
|
||||
3. **使用品牌**:不得使用「Rainnya」「雨喵」「RainWeb」名称、logo、视觉标识(含紫色雨夜视觉主题)对本软件的衍生版本进行品牌宣传、背书或暗示官方身份,除非获得原作者书面授权。
|
||||
|
||||
## 三、义务条款(Copyleft)
|
||||
|
||||
1. **衍生版本必须开源**:任何基于本软件的修改、扩展、整合版本,其源代码必须遵循本协议开源发布,且必须包含本 LICENSE 文件。
|
||||
2. **同协议授权**:衍生版本必须使用与本协议相同或更严格(限制更多)的授权条款。
|
||||
3. **署名**:任何二次分发(原始或衍生版本)必须保留本 LICENSE 文件,并显著标明:
|
||||
- 最初作者:**Xianyunah(Rainnya 家族)**
|
||||
- 原始项目名称与原始仓库地址
|
||||
- 若为修改版,须明确标注"基于 RainWeb 修改"并说明修改内容
|
||||
|
||||
## 四、免责声明
|
||||
|
||||
本软件按"现状"(AS IS)提供,不附带任何明示或暗示的担保,包括但不限于适销性、特定用途适用性与非侵权保证。原作者对使用本软件产生的任何损害概不负责,无论基于何种法律理论(合同、侵权或其他)。
|
||||
|
||||
## 五、协议终止
|
||||
|
||||
被授权人违反本协议任何条款,其本协议项下的所有权利自动终止,且须立即停止使用、分发并销毁全部副本。
|
||||
|
||||
## 六、其他
|
||||
|
||||
- 本协议与 GPL-3.0 的精神一致(copyleft),但额外附加了非商业与品牌保留条款,属"源代码可用(source-available)"授权,**非 OSI 认证的开源协议**。
|
||||
- 若本协议任一条款被判定无效,不影响其余条款效力。
|
||||
- 授权事宜咨询:rainnya.asia
|
||||
|
||||
---
|
||||
|
||||
**版权 © 2024 Xianyunah(Rainnya 家族)· 保留所有权利**
|
||||
|
||||
@@ -106,15 +106,23 @@ npm run cli -- <command>
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `status` | 查看运行状态 |
|
||||
| `start` | 启动服务器 |
|
||||
| `stop` | 停止服务器 |
|
||||
| `restart` | 重启服务器 |
|
||||
| `status [--json]` | 查看版本、服务、数据库和备份状态 |
|
||||
| `health [--json]` | HTTP 健康检查和 SQLite 完整性检查;不健康时返回非 0 |
|
||||
| `db-check [--json]` | 只读检查数据库完整性和关键表 |
|
||||
| `start` | 校验 PID 后启动服务器,已运行时不会重复启动 |
|
||||
| `stop` | 只停止确认属于本项目的 `server.js`,优雅等待退出 |
|
||||
| `restart` | 优雅停止并等待后启动服务器 |
|
||||
| `port [number]` | 查看/修改端口 |
|
||||
| `password [new-pass]` | 修改管理员密码 |
|
||||
| `captcha` | 交互式配置验证码 |
|
||||
| `config` | 查看所有配置 |
|
||||
| `upgrade` | 一键升级(git pull + npm install + 重启) |
|
||||
| `password` | 交互式修改管理员密码(兼容旧的末尾参数写法) |
|
||||
| `captcha` | 交互式配置验证码;也支持 `--type`、`--login`、`--register`、`--forum`、`--failed`、`--threshold` |
|
||||
| `config [--json]` | 查看配置,敏感值统一脱敏 |
|
||||
| `backup` | 创建数据库备份并立即做完整性检查 |
|
||||
| `backup list [--json]` | 列出备份并检查 SQLite 完整性 |
|
||||
| `backup prune --keep N [--yes]` | 预览清理旧备份;只有显式 `--yes` 才删除 |
|
||||
| `backup restore <file> --yes` | 服务停止后恢复指定备份;显式确认才覆盖,旧库会保留 |
|
||||
| `upgrade` | 升级前备份,检查干净工作区,`git pull --ff-only`、安装、构建并在服务原本运行时重启验活 |
|
||||
|
||||
通用选项:`--yes` 跳过确认,`--json` 输出 JSON,HTTP 检查可用 `--timeout 100-60000` 设置毫秒超时。密码默认交互输入,不建议在命令行中传递明文密码。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -214,16 +222,18 @@ npm start
|
||||
## 升级
|
||||
|
||||
```bash
|
||||
# 方法1: 一键升级(推荐)
|
||||
# 方法1:一键升级(推荐;仅限自托管机器本地执行)
|
||||
node cli.js upgrade
|
||||
|
||||
# 方法2: 手动
|
||||
git pull
|
||||
# 方法2:手动
|
||||
git pull --ff-only
|
||||
npm install
|
||||
npm run build
|
||||
node cli.js restart
|
||||
```
|
||||
|
||||
`upgrade` 要求 git 工作区干净,只执行 fast-forward 更新;失败会返回非 0,不会假报成功。`backup prune` 默认只预览不删除,`backup restore` 必须同时满足服务已停止和 `--yes`。
|
||||
|
||||
## 版本
|
||||
|
||||
当前版本记录在项目根目录的 `VERSION` 文件中,导航栏标题右侧会显示当前版本号。
|
||||
|
||||
@@ -1,328 +1,632 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync, spawn } = require('child_process');
|
||||
const { execFileSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const http = require('http');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const 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 db = require('./db');
|
||||
|
||||
// 只读命令不加载 db.js,避免检查状态时意外触发数据库迁移或写入。
|
||||
let writableDb = null;
|
||||
function getWritableDb() {
|
||||
if (!writableDb) writableDb = require('./db');
|
||||
return writableDb;
|
||||
}
|
||||
|
||||
const HELP = `
|
||||
RainWeb CLI v${PKG.version}
|
||||
Usage: node cli.js <command> [options]
|
||||
用法:node cli.js <命令> [参数] [选项]
|
||||
|
||||
Commands:
|
||||
status Show server and system status
|
||||
start Start the server
|
||||
restart Restart the server
|
||||
stop Stop the server
|
||||
port [number] Show or change listen port (default: 3001)
|
||||
password [new-pass] Change admin password (leave empty for prompt)
|
||||
captcha Interactive captcha rule configuration
|
||||
config Show all current settings
|
||||
backup Backup database to backups/ (readonly, safe to run while running)
|
||||
upgrade Git pull + npm install + restart (one-click upgrade)
|
||||
help Show this help
|
||||
命令:
|
||||
status 查看版本、服务、数据库和备份状态
|
||||
health HTTP 健康检查和 SQLite 完整性检查
|
||||
db-check 只读检查 SQLite 完整性和关键表
|
||||
start 启动服务器(防重复启动)
|
||||
stop 优雅停止本项目的 server.js
|
||||
restart 停止后等待并启动服务器
|
||||
port [端口] 查看或修改监听端口
|
||||
password 交互式修改管理员密码
|
||||
captcha 交互式配置验证码规则
|
||||
config 查看配置(敏感值统一脱敏)
|
||||
backup 创建数据库备份
|
||||
backup list 列出并检查备份完整性
|
||||
backup prune --keep <数量> 预览清理旧备份;加 --yes 才会删除
|
||||
backup restore <文件> --yes 服务停止后恢复备份,显式确认才覆盖
|
||||
upgrade 备份、检查工作区、快进升级、构建并验活
|
||||
help 显示帮助
|
||||
|
||||
Examples:
|
||||
node cli.js status
|
||||
node cli.js port 8080
|
||||
node cli.js password MyNewP@ss123
|
||||
node cli.js captcha
|
||||
node cli.js backup
|
||||
node cli.js upgrade
|
||||
通用选项:
|
||||
--yes 跳过需要确认的操作
|
||||
--json 以 JSON 输出(status/health/config/backup list 等支持)
|
||||
--timeout <毫秒> 设置 HTTP 检查超时(100-60000)
|
||||
|
||||
说明:
|
||||
password 默认交互输入;为兼容旧脚本仍接受末尾明文参数,但不在帮助示例中推荐。
|
||||
upgrade 仅用于自托管机器上的本地操作,不会修改云端或远程服务。
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const cmd = process.argv[2] || 'help';
|
||||
|
||||
switch (cmd) {
|
||||
case 'status': return cmdStatus();
|
||||
case 'start': return cmdStart();
|
||||
case 'restart': return cmdRestart();
|
||||
case 'stop': return cmdStop();
|
||||
case 'port': return cmdPort();
|
||||
case 'password': return cmdPassword();
|
||||
case 'captcha': return cmdCaptcha();
|
||||
case 'config': return cmdConfig();
|
||||
case 'backup': return cmdBackup();
|
||||
case 'upgrade': return cmdUpgrade();
|
||||
case 'help':
|
||||
default:
|
||||
console.log(HELP);
|
||||
class CliError extends Error {
|
||||
constructor(message, code = 1) {
|
||||
super(message);
|
||||
this.name = 'CliError';
|
||||
this.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
// === Status ===
|
||||
async function cmdStatus() {
|
||||
console.log(`RainWeb v${PKG.version}`);
|
||||
console.log(`Node.js: ${process.version}`);
|
||||
console.log(`Platform: ${process.platform}`);
|
||||
console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`);
|
||||
|
||||
// 最近一次备份时间
|
||||
const latestBackup = getLatestBackup();
|
||||
console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份'));
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
await httpGet('http://localhost:' + (getConfigPort()));
|
||||
console.log('Server: RUNNING');
|
||||
} catch {
|
||||
console.log('Server: STOPPED');
|
||||
function parseArgs(argv) {
|
||||
const positionals = [];
|
||||
const options = { yes: false, json: false };
|
||||
const valueOptions = new Set(['keep', 'timeout', 'type', 'login', 'register', 'forum', 'failed', 'threshold']);
|
||||
const booleanOptions = new Set(['yes', 'json']);
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === '--') {
|
||||
positionals.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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 {}
|
||||
function readJson(file, fallback = {}) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
||||
}
|
||||
|
||||
// === Port ===
|
||||
function getConfigPort() {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
return config.port || 3001;
|
||||
} catch { return 3001; }
|
||||
const config = readJson(CONFIG_PATH);
|
||||
const value = Number(process.env.PORT || config.port || 3001);
|
||||
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : 3001;
|
||||
}
|
||||
|
||||
function setConfigPort(port) {
|
||||
let config = {};
|
||||
try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {}
|
||||
const config = readJson(CONFIG_PATH);
|
||||
config.port = port;
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function cmdPort() {
|
||||
const arg = process.argv[3];
|
||||
if (arg) {
|
||||
const port = parseInt(arg);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
console.error('Invalid port number. Use 1-65535.');
|
||||
process.exit(1);
|
||||
}
|
||||
setConfigPort(port);
|
||||
console.log(`Port set to ${port}. Restart to apply.`);
|
||||
} else {
|
||||
console.log(`Current port: ${getConfigPort()}`);
|
||||
}
|
||||
function dbExists() { return fs.existsSync(DB_PATH); }
|
||||
|
||||
function openReadonlyDatabase(file = DB_PATH) {
|
||||
if (!fs.existsSync(file)) throw new CliError(`数据库不存在:${file}`);
|
||||
try { return new Database(file, { readonly: true, fileMustExist: true }); }
|
||||
catch (error) { throw new CliError(`无法打开数据库:${error.message}`); }
|
||||
}
|
||||
|
||||
// === Password ===
|
||||
async function cmdPassword() {
|
||||
await warnIfServerRunning();
|
||||
let newPass = process.argv[3];
|
||||
if (!newPass) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
newPass = await new Promise(resolve => {
|
||||
rl.question('New admin password (min 6 chars): ', resolve);
|
||||
});
|
||||
rl.close();
|
||||
}
|
||||
if (!newPass || newPass.length < 6) {
|
||||
console.error('Password must be at least 6 characters.');
|
||||
process.exit(1);
|
||||
function inspectDatabase(file = DB_PATH) {
|
||||
const connection = openReadonlyDatabase(file);
|
||||
try {
|
||||
const quickCheck = connection.pragma('quick_check', { simple: true });
|
||||
const foreignKeyErrors = connection.prepare('PRAGMA foreign_key_check').all();
|
||||
const tables = connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all().map((row) => row.name);
|
||||
const requiredTables = ['users', 'site_settings', 'tickets', 'ticket_messages', 'ticket_events'];
|
||||
const missingTables = requiredTables.filter((name) => !tables.includes(name));
|
||||
return {
|
||||
file,
|
||||
size: fs.statSync(file).size,
|
||||
userVersion: connection.pragma('user_version', { simple: true }),
|
||||
quickCheck,
|
||||
foreignKeyErrors,
|
||||
missingTables,
|
||||
valid: quickCheck === 'ok' && foreignKeyErrors.length === 0 && missingTables.length === 0,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new CliError(`数据库完整性检查失败:${error.message}`);
|
||||
} finally { connection.close(); }
|
||||
}
|
||||
|
||||
await db.getDb();
|
||||
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
|
||||
if (!admin) { console.error('No admin user found.'); process.exit(1); }
|
||||
|
||||
const hash = bcrypt.hashSync(newPass, 10);
|
||||
db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
||||
console.log('Admin password updated successfully.');
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes)) return '未知';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
||||
}
|
||||
|
||||
// === Captcha ===
|
||||
async function cmdCaptcha() {
|
||||
await warnIfServerRunning();
|
||||
await db.getDb();
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const q = (q) => new Promise(resolve => rl.question(q, resolve));
|
||||
|
||||
console.log('=== Captcha Rule Configuration ===\n');
|
||||
console.log('Current settings:');
|
||||
['login','register','forum','failed'].forEach(k => {
|
||||
const v = 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']);
|
||||
function formatMtime(date) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
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]);
|
||||
function print(value, args) {
|
||||
if (args.json) console.log(JSON.stringify(value, null, 2));
|
||||
else console.log(value);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
console.log('\nCaptcha rules updated.');
|
||||
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)}`;
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
async function cmdConfig() {
|
||||
await db.getDb();
|
||||
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()}`);
|
||||
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);
|
||||
}
|
||||
|
||||
// === Start / Stop / Restart ===
|
||||
function findPidFile() { return path.join(__dirname, 'server.pid'); }
|
||||
|
||||
function isRunning(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function cmdStop() {
|
||||
const pidFile = findPidFile();
|
||||
if (fs.existsSync(pidFile)) {
|
||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'));
|
||||
if (isRunning(pid)) {
|
||||
try { process.kill(pid); console.log('Server stopped (PID: ' + pid + ')'); } catch { console.log('Could not stop process.'); }
|
||||
} else { console.log('Server not running.'); }
|
||||
fs.unlinkSync(pidFile);
|
||||
} else {
|
||||
// Try to find node process
|
||||
console.log('No PID file found. Try: taskkill /F /IM node.exe (Windows) or pkill node (Linux/Mac)');
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdStart() {
|
||||
const port = getConfigPort();
|
||||
const proc = spawn('node', ['server.js'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
detached: true,
|
||||
});
|
||||
proc.unref();
|
||||
fs.writeFileSync(findPidFile(), String(proc.pid));
|
||||
console.log(`Server starting on port ${port} (PID: ${proc.pid})`);
|
||||
}
|
||||
|
||||
async function cmdRestart() {
|
||||
await cmdStop();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await cmdStart();
|
||||
}
|
||||
|
||||
// === 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 });
|
||||
function readProcessDetails(pid) {
|
||||
if (!isRunning(pid)) return null;
|
||||
let cwd = '';
|
||||
let command = '';
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
await src.backup(destPath);
|
||||
} finally {
|
||||
src.close();
|
||||
}
|
||||
|
||||
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 再执行');
|
||||
const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ' + $args[0]; if ($p) { $p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress }";
|
||||
const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script, String(pid)], { encoding: 'utf8' }).trim();
|
||||
const processInfo = output ? JSON.parse(output) : null;
|
||||
command = processInfo && processInfo.CommandLine ? processInfo.CommandLine : '';
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
cwd = fs.realpathSync(`/proc/${pid}/cwd`);
|
||||
command = fs.readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim();
|
||||
}
|
||||
} catch {}
|
||||
if (!command && process.platform !== 'win32') {
|
||||
try { command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim(); } catch {}
|
||||
}
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); })
|
||||
.on('error', reject);
|
||||
function listCandidatePids() {
|
||||
try { return fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)).map(Number); }
|
||||
catch {
|
||||
try { return execFileSync('ps', ['-A', '-o', 'pid='], { encoding: 'utf8' }).split(/\s+/).filter(Boolean).map(Number); }
|
||||
catch { return []; }
|
||||
}
|
||||
}
|
||||
|
||||
function findOwnedServers() {
|
||||
return listCandidatePids().filter((pid) => pid !== process.pid).map(readProcessDetails).filter((details) => details && details.owned);
|
||||
}
|
||||
|
||||
function getOwnedServer() {
|
||||
const pidInfo = getPidFromFile();
|
||||
if (pidInfo && pidInfo.invalid) throw new CliError(`PID 文件格式错误:${PID_PATH}`);
|
||||
if (Number.isInteger(pidInfo)) {
|
||||
if (!isRunning(pidInfo)) {
|
||||
try { fs.unlinkSync(PID_PATH); } catch {}
|
||||
} else {
|
||||
const details = readProcessDetails(pidInfo);
|
||||
if (!details || !details.owned) throw new CliError(`拒绝操作:${PID_PATH} 中的 PID ${pidInfo} 不属于本项目的 server.js`);
|
||||
return details;
|
||||
}
|
||||
}
|
||||
const servers = findOwnedServers();
|
||||
if (servers.length > 1) throw new CliError(`发现多个本项目 server.js 进程(${servers.map((item) => item.pid).join(', ')}),请先人工处理`);
|
||||
return servers[0] || null;
|
||||
}
|
||||
|
||||
async function waitForExit(pid, timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isRunning(pid)) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return !isRunning(pid);
|
||||
}
|
||||
|
||||
function probeHttp(port, timeoutMs = 3000) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const request = http.get({ hostname: '127.0.0.1', port, path: '/', timeout: timeoutMs }, (response) => {
|
||||
response.resume();
|
||||
response.on('end', () => resolve({ reachable: true, healthy: response.statusCode >= 200 && response.statusCode < 400, statusCode: response.statusCode, latencyMs: Date.now() - started }));
|
||||
});
|
||||
request.on('timeout', () => request.destroy(new Error('请求超时')));
|
||||
request.on('error', (error) => resolve({ reachable: false, healthy: false, error: error.message, latencyMs: Date.now() - started }));
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
function getTimeout(args) {
|
||||
const value = args.timeout === undefined ? 3000 : Number(args.timeout);
|
||||
if (!Number.isInteger(value) || value < 100 || value > 60000) throw new CliError('--timeout 必须是 100-60000 的整数(毫秒)');
|
||||
return value;
|
||||
}
|
||||
|
||||
async function waitForStartedServer(pid, port, timeoutMs = 12000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last = null;
|
||||
while (Date.now() < deadline) {
|
||||
const details = readProcessDetails(pid);
|
||||
if (!details || !details.owned) throw new CliError(`新启动的 PID ${pid} 未能确认属于本项目 server.js`);
|
||||
last = await probeHttp(port, 1000);
|
||||
if (last.healthy) return last;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new CliError(`服务器启动后健康检查失败:${last && last.error ? last.error : `HTTP ${last && last.statusCode ? last.statusCode : '无响应'}`}`);
|
||||
}
|
||||
|
||||
async function stopServer({ quiet = false } = {}) {
|
||||
const server = getOwnedServer();
|
||||
if (!server) {
|
||||
if (!quiet) console.log('服务器当前未运行。');
|
||||
return { stopped: false, pid: null };
|
||||
}
|
||||
process.kill(server.pid, 'SIGTERM');
|
||||
let exited = await waitForExit(server.pid, 10000);
|
||||
if (!exited) {
|
||||
const stillOwned = readProcessDetails(server.pid);
|
||||
if (!stillOwned || !stillOwned.owned) throw new CliError(`PID ${server.pid} 在等待期间不再确认属于本项目,未强制结束`);
|
||||
process.kill(server.pid, 'SIGKILL');
|
||||
exited = await waitForExit(server.pid, 3000);
|
||||
}
|
||||
if (!exited) throw new CliError(`服务器未能退出(PID: ${server.pid})`);
|
||||
if (fs.existsSync(PID_PATH) && getPidFromFile() === server.pid) fs.unlinkSync(PID_PATH);
|
||||
if (!quiet) console.log(`服务器已停止(PID: ${server.pid})。`);
|
||||
return { stopped: true, pid: server.pid };
|
||||
}
|
||||
|
||||
async function startServer({ quiet = false } = {}) {
|
||||
const existing = getOwnedServer();
|
||||
if (existing) {
|
||||
if (!quiet) console.log(`服务器已经运行(PID: ${existing.pid})。`);
|
||||
return { started: false, alreadyRunning: true, pid: existing.pid, port: getConfigPort() };
|
||||
}
|
||||
const port = getConfigPort();
|
||||
const child = spawn(process.execPath, [SERVER_PATH], { cwd: PROJECT_ROOT, stdio: 'ignore', detached: true, env: { ...process.env, PORT: String(port) } });
|
||||
if (!child.pid) throw new CliError('无法启动服务器进程');
|
||||
fs.writeFileSync(PID_PATH, `${child.pid}\n`, { mode: 0o644 });
|
||||
try { await waitForStartedServer(child.pid, port); }
|
||||
catch (error) {
|
||||
if (isRunning(child.pid)) {
|
||||
const details = readProcessDetails(child.pid);
|
||||
if (details && details.owned) { process.kill(child.pid, 'SIGTERM'); await waitForExit(child.pid, 3000); }
|
||||
}
|
||||
if (fs.existsSync(PID_PATH) && getPidFromFile() === child.pid) fs.unlinkSync(PID_PATH);
|
||||
throw error;
|
||||
}
|
||||
child.unref();
|
||||
if (!quiet) console.log(`服务器已启动(端口: ${port},PID: ${child.pid})。`);
|
||||
return { started: true, alreadyRunning: false, pid: child.pid, port };
|
||||
}
|
||||
|
||||
function listBackupFiles() {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return [];
|
||||
return fs.readdirSync(BACKUP_DIR).filter((file) => /^rainweb-.+\.db$/.test(file)).map((file) => path.join(BACKUP_DIR, file)).filter((file) => fs.statSync(file).isFile()).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
}
|
||||
|
||||
function inspectBackup(file) {
|
||||
try {
|
||||
const result = inspectDatabase(file);
|
||||
const integrityValid = result.quickCheck === 'ok' && result.foreignKeyErrors.length === 0;
|
||||
return { file: path.basename(file), size: result.size, modifiedAt: formatMtime(fs.statSync(file).mtime), valid: integrityValid, schemaReady: result.missingTables.length === 0, quickCheck: result.quickCheck, foreignKeyErrors: result.foreignKeyErrors.length, missingTables: result.missingTables, userVersion: result.userVersion };
|
||||
} catch (error) {
|
||||
return { file: path.basename(file), size: fs.existsSync(file) ? fs.statSync(file).size : 0, modifiedAt: fs.existsSync(file) ? formatMtime(fs.statSync(file).mtime) : '', valid: false, schemaReady: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
if (!dbExists()) throw new CliError(`数据库不存在:${DB_PATH}`);
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
const now = new Date();
|
||||
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}${String(now.getMilliseconds()).padStart(3, '0')}`;
|
||||
const destination = path.join(BACKUP_DIR, `rainweb-${stamp}.db`);
|
||||
const source = new Database(DB_PATH, { readonly: true, fileMustExist: true });
|
||||
try { await source.backup(destination); } finally { source.close(); }
|
||||
const check = inspectBackup(destination);
|
||||
if (!check.valid) { try { fs.unlinkSync(destination); } catch {} throw new CliError(`备份已生成但完整性检查失败:${check.error || check.quickCheck}`); }
|
||||
return { path: destination, ...check };
|
||||
}
|
||||
|
||||
function resolveBackupFile(input) {
|
||||
if (!input) throw new CliError('请指定备份文件名,例如 backup restore rainweb-YYYYMMDD-HHmmss.db');
|
||||
const candidate = path.resolve(BACKUP_DIR, input);
|
||||
if (path.dirname(candidate) !== path.resolve(BACKUP_DIR)) throw new CliError('备份文件必须位于 backups/ 目录内');
|
||||
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) throw new CliError(`备份文件不存在:${candidate}`);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readSettingRows() {
|
||||
if (!dbExists()) return [];
|
||||
const connection = openReadonlyDatabase();
|
||||
try { return connection.prepare('SELECT key, value FROM site_settings ORDER BY key').all(); } finally { connection.close(); }
|
||||
}
|
||||
|
||||
function normalizeSwitch(value, label) {
|
||||
const normalized = String(value || '').toLowerCase();
|
||||
if (['1', 'on', 'yes', 'y', 'true'].includes(normalized)) return '1';
|
||||
if (['0', 'off', 'no', 'n', 'false'].includes(normalized)) return '0';
|
||||
throw new CliError(`${label} 必须是 on/off、yes/no 或 1/0`);
|
||||
}
|
||||
|
||||
function ask(rl, question) { return new Promise((resolve) => rl.question(question, resolve)); }
|
||||
|
||||
async function cmdStatus(args) {
|
||||
const port = getConfigPort();
|
||||
const httpState = await probeHttp(port, getTimeout(args));
|
||||
let server = null;
|
||||
let serverError = null;
|
||||
try { server = getOwnedServer(); } catch (error) { serverError = error.message; }
|
||||
let database = null;
|
||||
if (dbExists()) { try { database = inspectDatabase(); } catch (error) { database = { valid: false, error: error.message }; } }
|
||||
const latest = listBackupFiles()[0];
|
||||
const result = { version: PKG.version, node: process.version, platform: process.platform, port, server: { running: !!server, pid: server ? server.pid : null, error: serverError, http: httpState }, database: database || { valid: false, error: '数据库不存在' }, latestBackup: latest ? inspectBackup(latest) : null };
|
||||
if (args.json) return print(result, args);
|
||||
console.log(`RainWeb v${result.version}`);
|
||||
console.log(`Node.js:${result.node}`);
|
||||
console.log(`平台:${result.platform}`);
|
||||
console.log(`端口:${result.port}`);
|
||||
console.log(`服务:${result.server.running ? `运行中(PID: ${result.server.pid})` : result.server.error || '未运行'}`);
|
||||
console.log(`HTTP:${httpState.healthy ? `正常(${httpState.statusCode},${httpState.latencyMs} ms)` : '不可用'}`);
|
||||
console.log(`数据库:${result.database.valid ? '完整性正常' : result.database.error || '检查失败'}`);
|
||||
console.log(`最近备份:${result.latestBackup ? `${result.latestBackup.file}(${formatBytes(result.latestBackup.size)})` : '无'}`);
|
||||
}
|
||||
|
||||
async function cmdHealth(args) {
|
||||
const port = getConfigPort();
|
||||
const [httpState, database] = await Promise.all([probeHttp(port, getTimeout(args)), Promise.resolve().then(() => (dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在' }))]);
|
||||
const result = { healthy: httpState.healthy && database.valid, port, http: httpState, database };
|
||||
print(args.json ? result : `HTTP:${httpState.healthy ? '正常' : `失败(${httpState.error || httpState.statusCode || '无响应'})`}\n数据库:${database.valid ? '完整性正常' : database.error || '检查失败'}\n总体:${result.healthy ? '健康' : '不健康'}`, args);
|
||||
return result.healthy ? 0 : 2;
|
||||
}
|
||||
|
||||
async function cmdDbCheck(args) {
|
||||
const result = dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在', file: DB_PATH };
|
||||
const foreignKeyCount = Array.isArray(result.foreignKeyErrors) ? result.foreignKeyErrors.length : result.foreignKeyErrors;
|
||||
print(args.json ? result : `数据库:${result.file}\n版本:${result.userVersion ?? '未知'}\nquick_check:${result.quickCheck || '未执行'}\n外键错误:${foreignKeyCount ?? '未知'}\n关键表缺失:${result.missingTables ? result.missingTables.join(', ') || '无' : '未知'}\n结果:${result.valid ? '通过' : result.error || '失败'}`, args);
|
||||
return result.valid ? 0 : 2;
|
||||
}
|
||||
|
||||
async function cmdPort(args) {
|
||||
const value = args.positionals[1];
|
||||
if (value === undefined) return print(args.json ? { port: getConfigPort() } : `当前端口:${getConfigPort()}`, args);
|
||||
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new CliError('端口必须是 1-65535 的整数');
|
||||
setConfigPort(Number(value));
|
||||
print(args.json ? { port: Number(value), message: '端口已保存,重启后生效' } : `端口已设置为 ${value},重启后生效。`, args);
|
||||
}
|
||||
|
||||
async function cmdPassword(args) {
|
||||
await warnIfServerRunning();
|
||||
let newPass = args.positionals[1];
|
||||
if (!newPass) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
newPass = await ask(rl, '请输入新的管理员密码(至少 6 位):');
|
||||
const confirm = await ask(rl, '请再次输入密码:');
|
||||
rl.close();
|
||||
if (newPass !== confirm) throw new CliError('两次输入的密码不一致');
|
||||
}
|
||||
if (!newPass || newPass.length < 6) throw new CliError('密码至少需要 6 位');
|
||||
const db = getWritableDb();
|
||||
db.getDb();
|
||||
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
|
||||
if (!admin) throw new CliError('未找到管理员账号');
|
||||
db.run('UPDATE users SET password = ? WHERE id = ?', [bcrypt.hashSync(newPass, 10), admin.id]);
|
||||
print(args.json ? { message: '管理员密码已更新' } : '管理员密码已更新。', args);
|
||||
}
|
||||
|
||||
async function cmdCaptcha(args) {
|
||||
await warnIfServerRunning();
|
||||
const db = getWritableDb();
|
||||
db.getDb();
|
||||
const scopes = ['login', 'register', 'forum'];
|
||||
const currentType = db.getSetting('captcha_type') || 'builtin';
|
||||
const allowedTypes = new Set(['none', 'builtin', 'recaptcha', 'turnstile', 'both']);
|
||||
const typeArg = args.type;
|
||||
if (typeArg && !allowedTypes.has(typeArg)) throw new CliError('--type 必须是 none、builtin、recaptcha、turnstile 或 both');
|
||||
const hasOptions = typeArg || scopes.some((scope) => args[scope] !== undefined) || args.failed !== undefined || args.threshold !== undefined;
|
||||
const updates = {};
|
||||
if (hasOptions) {
|
||||
updates.captcha_type = typeArg || currentType;
|
||||
for (const scope of scopes) if (args[scope] !== undefined) updates[`captcha_${scope}`] = normalizeSwitch(args[scope], `--${scope}`);
|
||||
if (args.failed !== undefined) updates.captcha_failed = normalizeSwitch(args.failed, '--failed');
|
||||
if (args.threshold !== undefined) {
|
||||
if (!/^\d+$/.test(String(args.threshold)) || Number(args.threshold) < 1) throw new CliError('--threshold 必须是正整数');
|
||||
updates.captcha_failed_threshold = String(Number(args.threshold));
|
||||
}
|
||||
} else {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
console.log('验证码配置(直接回车保留当前值)');
|
||||
const type = await ask(rl, `类型 none/builtin/recaptcha/turnstile/both [${currentType}]:`);
|
||||
const selectedType = type || currentType;
|
||||
if (!allowedTypes.has(selectedType)) { rl.close(); throw new CliError('验证码类型不合法'); }
|
||||
updates.captcha_type = selectedType;
|
||||
for (const scope of scopes) {
|
||||
const current = db.getSetting(`captcha_${scope}`) || '0';
|
||||
const answer = await ask(rl, `启用 ${scope} 验证码?(y/n) [${current === '1' ? 'y' : 'n'}]:`);
|
||||
updates[`captcha_${scope}`] = answer ? normalizeSwitch(answer, scope) : current;
|
||||
}
|
||||
const failed = await ask(rl, `失败尝试后启用验证码?(y/n) [${db.getSetting('captcha_failed') === '1' ? 'y' : 'n'}]:`);
|
||||
updates.captcha_failed = failed ? normalizeSwitch(failed, 'failed') : (db.getSetting('captcha_failed') || '0');
|
||||
if (updates.captcha_failed === '1') {
|
||||
const threshold = await ask(rl, `失败次数阈值 [${db.getSetting('captcha_failed_threshold') || '5'}]:`);
|
||||
if (threshold) {
|
||||
if (!/^\d+$/.test(threshold) || Number(threshold) < 1) { rl.close(); throw new CliError('失败次数阈值必须是正整数'); }
|
||||
updates.captcha_failed_threshold = threshold;
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
}
|
||||
for (const [key, value] of Object.entries(updates)) db.setSetting(key, value);
|
||||
print(args.json ? { message: '验证码规则已更新', settings: updates } : '验证码规则已更新。', args);
|
||||
}
|
||||
|
||||
async function cmdConfig(args) {
|
||||
const settings = Object.fromEntries(readSettingRows().map((row) => [row.key, maskValue(row.key, row.value)]));
|
||||
const envConfig = readJson(CONFIG_PATH);
|
||||
const safeEnv = Object.fromEntries(Object.entries(envConfig).map(([key, value]) => [key, maskValue(key, value)]));
|
||||
const result = { settings, env: safeEnv, listen_port: getConfigPort() };
|
||||
if (args.json) return print(result, args);
|
||||
console.log('=== RainWeb 配置 ===');
|
||||
for (const [key, value] of Object.entries(settings)) console.log(` ${key}:${value}`);
|
||||
for (const [key, value] of Object.entries(safeEnv)) console.log(` env.${key}:${value}`);
|
||||
console.log(` listen_port:${result.listen_port}`);
|
||||
}
|
||||
|
||||
async function cmdBackup(args) {
|
||||
const action = args.positionals[1] || 'create';
|
||||
if (action === 'create') {
|
||||
const result = await createBackup();
|
||||
print(args.json ? result : `备份成功:${result.path}\n完整性:通过(${formatBytes(result.size)})`, args);
|
||||
return;
|
||||
}
|
||||
if (action === 'list') {
|
||||
const result = listBackupFiles().map(inspectBackup);
|
||||
print(args.json ? result : (result.length ? result.map((item) => `${item.valid ? '正常' : '损坏'}${item.valid && !item.schemaReady ? '(待迁移)' : ''} ${item.file} ${formatBytes(item.size)} ${item.modifiedAt}`).join('\n') : '暂无备份。'), args);
|
||||
return;
|
||||
}
|
||||
if (action === 'prune') {
|
||||
if (args.keep === undefined) throw new CliError('prune 必须明确指定 --keep <数量>;未指定时不会删除任何文件');
|
||||
if (!/^\d+$/.test(String(args.keep)) || Number(args.keep) < 1) throw new CliError('--keep 必须是正整数');
|
||||
const keep = Number(args.keep);
|
||||
const remove = listBackupFiles().slice(keep);
|
||||
const result = { keep, candidates: remove.map((file) => path.basename(file)), deleted: [] };
|
||||
if (args.yes) for (const file of remove) { fs.unlinkSync(file); result.deleted.push(path.basename(file)); }
|
||||
print(args.json ? result : (args.yes ? `已删除 ${result.deleted.length} 个旧备份,保留最新 ${keep} 个。` : `预览:将删除 ${remove.length} 个旧备份;加 --yes 才会执行。\n${result.candidates.join('\n')}`), args);
|
||||
return;
|
||||
}
|
||||
if (action === 'restore') {
|
||||
if (!args.yes) throw new CliError('restore 会覆盖当前数据库,必须显式使用 --yes;且服务必须已停止');
|
||||
if (getOwnedServer()) throw new CliError('restore 必须先停止 RainWeb 服务');
|
||||
const httpState = await probeHttp(getConfigPort(), getTimeout(args));
|
||||
if (httpState.reachable) throw new CliError('检测到 HTTP 服务仍在运行,拒绝 restore');
|
||||
const sourcePath = resolveBackupFile(args.positionals[2]);
|
||||
const check = inspectBackup(sourcePath);
|
||||
if (!check.valid) throw new CliError(`备份完整性检查失败,拒绝恢复:${check.error || check.quickCheck}`);
|
||||
if (!dbExists()) fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
const tempPath = `${DB_PATH}.restore-${process.pid}-${Date.now()}`;
|
||||
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
|
||||
try { await source.backup(tempPath); } finally { source.close(); }
|
||||
let oldPath = null;
|
||||
try {
|
||||
if (fs.existsSync(DB_PATH)) { oldPath = `${DB_PATH}.before-restore-${Date.now()}`; fs.renameSync(DB_PATH, oldPath); }
|
||||
fs.renameSync(tempPath, DB_PATH);
|
||||
} catch (error) {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||||
try { if (oldPath && fs.existsSync(oldPath) && !fs.existsSync(DB_PATH)) fs.renameSync(oldPath, DB_PATH); } catch {}
|
||||
throw new CliError(`恢复失败,已尝试保留原数据库:${error.message}`);
|
||||
}
|
||||
print(args.json ? { message: '数据库已恢复', source: sourcePath, previousDatabase: oldPath } : `数据库已恢复:${sourcePath}\n原数据库保留为:${oldPath || '无'}。`, args);
|
||||
return;
|
||||
}
|
||||
throw new CliError(`未知 backup 子命令:${action}`);
|
||||
}
|
||||
|
||||
function runCommand(command, commandArgs, label) {
|
||||
console.log(`${label}:${command} ${commandArgs.join(' ')}`);
|
||||
try { execFileSync(command, commandArgs, { cwd: PROJECT_ROOT, stdio: 'inherit' }); }
|
||||
catch (error) { throw new CliError(`${label}失败(退出码 ${error.status ?? '未知'})`); }
|
||||
}
|
||||
|
||||
function getNpmCommand() {
|
||||
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
async function cmdUpgrade(args) {
|
||||
if (args.json) throw new CliError('upgrade 不支持 --json,请使用普通输出查看升级过程');
|
||||
if (!fs.existsSync(path.join(PROJECT_ROOT, '.git'))) throw new CliError('当前目录不是 git 仓库,无法升级');
|
||||
let status;
|
||||
try { status = execFileSync('git', ['status', '--porcelain'], { cwd: PROJECT_ROOT, encoding: 'utf8' }); }
|
||||
catch (error) { throw new CliError(`无法读取 git 工作区:${error.message}`); }
|
||||
if (status.trim()) throw new CliError('git 工作区不干净,已停止升级;请先处理本地修改或未跟踪文件');
|
||||
const backup = await createBackup();
|
||||
console.log(`升级前备份完成:${backup.file}`);
|
||||
const wasRunning = !!getOwnedServer();
|
||||
if (wasRunning) await stopServer();
|
||||
try {
|
||||
runCommand('git', ['pull', '--ff-only'], 'git 快进更新');
|
||||
runCommand(getNpmCommand(), ['install'], '依赖安装');
|
||||
runCommand(getNpmCommand(), ['run', 'build'], '前端构建');
|
||||
if (wasRunning) {
|
||||
await startServer();
|
||||
const health = await cmdHealth({ json: false });
|
||||
if (health !== 0) throw new CliError('升级后健康检查未通过');
|
||||
}
|
||||
} catch (error) {
|
||||
if (wasRunning) {
|
||||
let running = false;
|
||||
try { running = !!getOwnedServer(); } catch {}
|
||||
if (!running) {
|
||||
try { await startServer(); } catch (restartError) { console.error(`升级失败后恢复启动也失败:${restartError.message}`); }
|
||||
}
|
||||
}
|
||||
throw error instanceof CliError ? error : new CliError(error.message);
|
||||
}
|
||||
console.log('升级完成。');
|
||||
}
|
||||
|
||||
async function cmdPortlessStart() { await startServer(); }
|
||||
async function cmdPortlessStop() { await stopServer(); }
|
||||
async function cmdPortlessRestart() { await stopServer(); await startServer(); }
|
||||
|
||||
async function warnIfServerRunning() {
|
||||
let owned = null;
|
||||
try { owned = getOwnedServer(); } catch {}
|
||||
if (owned) console.log('警告:server 正在运行,并发写库可能等待;建议先停止服务。');
|
||||
}
|
||||
|
||||
async function dispatch(args) {
|
||||
const command = args.positionals[0] || 'help';
|
||||
switch (command) {
|
||||
case 'status': return cmdStatus(args);
|
||||
case 'health': return cmdHealth(args);
|
||||
case 'db-check': return cmdDbCheck(args);
|
||||
case 'start': return cmdPortlessStart(args);
|
||||
case 'stop': return cmdPortlessStop(args);
|
||||
case 'restart': return cmdPortlessRestart(args);
|
||||
case 'port': return cmdPort(args);
|
||||
case 'password': return cmdPassword(args);
|
||||
case 'captcha': return cmdCaptcha(args);
|
||||
case 'config': return cmdConfig(args);
|
||||
case 'backup': return cmdBackup(args);
|
||||
case 'upgrade': return cmdUpgrade(args);
|
||||
case 'help': return console.log(HELP);
|
||||
default: throw new CliError(`未知命令:${command}\n\n${HELP}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const result = await dispatch(args);
|
||||
if (Number.isInteger(result)) process.exitCode = result;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const exitCode = error.exitCode || 1;
|
||||
if (process.argv.includes('--json')) console.error(JSON.stringify({ error: error.message, exitCode }));
|
||||
else console.error(`错误:${error.message}`);
|
||||
process.exitCode = exitCode;
|
||||
});
|
||||
|
||||
@@ -27,18 +27,25 @@ function initTables() {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
|
||||
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
|
||||
nickname TEXT DEFAULT '', title TEXT DEFAULT '', title_color TEXT DEFAULT '', website TEXT DEFAULT '',
|
||||
bio TEXT DEFAULT '', last_active_at TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS pending_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
// M2:启动时清理超 15 分钟的 pending 注册记录(验证码过期,防表无限增长 / 验证码长期间可试)
|
||||
db.exec("DELETE FROM pending_users WHERE created_at < datetime('now','-15 minutes')");
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS admin_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
|
||||
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
|
||||
proxy_headers TEXT DEFAULT '', proxy_skip_tls_verify INTEGER DEFAULT 0,
|
||||
permissions TEXT DEFAULT '', scale REAL DEFAULT 1.0, trusted INTEGER DEFAULT 1,
|
||||
slug TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
// Migration: add columns if missing
|
||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||
@@ -173,6 +180,230 @@ function migrateSchema() {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''"); } catch {}
|
||||
} },
|
||||
// v4: 论坛贴吧升级——置顶/加精/版主/图标
|
||||
{ version: 4, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_pinned INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_essence INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon_color TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_moderators (
|
||||
category_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (category_id, user_id),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_posts_cat ON forum_posts(category_id, is_pinned DESC, created_at DESC)"); } catch {}
|
||||
} },
|
||||
// v5: 论坛版块级禁言
|
||||
{ version: 5, up: () => {
|
||||
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_mutes (
|
||||
category_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
muted_until DATETIME, -- NULL = 永久
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
created_by INTEGER NOT NULL,
|
||||
PRIMARY KEY (category_id, user_id),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_mutes_cat ON forum_mutes(category_id)"); } catch {}
|
||||
} },
|
||||
// v6: RSS 版块级订阅——版块 feed 开关
|
||||
{ version: 6, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN feed_enabled INTEGER DEFAULT 1"); } catch {}
|
||||
} },
|
||||
// v7: 论坛帖子二次编辑计数
|
||||
{ version: 7, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN edit_count INTEGER DEFAULT 0"); } catch {}
|
||||
} },
|
||||
// v8: markdown [lock:] 锁定——博客/论坛帖子存锁定元数据 JSON([{type, hash}],不含块原文)
|
||||
{ version: 8, up: () => {
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
|
||||
} },
|
||||
// v9: 用户 QQ 号字段(头像优先级:自传 > QQ 头像 > RainID 头像)
|
||||
{ version: 9, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN qq TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v10: 公开个人主页——用户个性签名
|
||||
{ version: 10, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v11: 公开个人主页——最后活跃时间(datetime 字符串,跨天更新)
|
||||
{ version: 11, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN last_active_at TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v12: 用户对外昵称(显示名:nickname||username)
|
||||
{ version: 12, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN nickname TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v13: 自定义头衔 + 头衔颜色(帖子/评论作者位展示)
|
||||
{ version: 13, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN title TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN title_color TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v14: 个人博客链接(公开主页展示)
|
||||
{ version: 14, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN website TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v15: 工作台 SPA 代理——面板链接加代理配置列
|
||||
// (proxy_headers=转发自定义头 JSON、proxy_skip_tls_verify、permissions=iframe 委托权限 JSON 数组、
|
||||
// scale=嵌入缩放、trusted=信任模型(1 保留 allow-same-origin)、slug=唯一标识)
|
||||
{ version: 15, up: () => {
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_headers TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_skip_tls_verify INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN permissions TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN scale REAL DEFAULT 1.0"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v16: 站内工单——用户反馈、公开回复、内部备注及业务事件
|
||||
{ version: 16, up: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_no TEXT NOT NULL UNIQUE,
|
||||
requester_id INTEGER,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
source TEXT NOT NULL DEFAULT 'site',
|
||||
source_url TEXT DEFAULT '',
|
||||
source_type TEXT DEFAULT '',
|
||||
source_id INTEGER DEFAULT 0,
|
||||
browser_info TEXT DEFAULT '',
|
||||
assignee_id INTEGER,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
last_reply_at DATETIME DEFAULT NULL,
|
||||
first_response_at DATETIME,
|
||||
resolved_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
author_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
actor_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
field_name TEXT DEFAULT '',
|
||||
old_value TEXT DEFAULT '',
|
||||
new_value TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } catch {}
|
||||
} },
|
||||
// v17: 工单并发版本与回复时间修正;为已存在的部分工单表补齐字段
|
||||
{ version: 17, up: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_no TEXT NOT NULL UNIQUE,
|
||||
requester_id INTEGER,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
source TEXT NOT NULL DEFAULT 'site',
|
||||
source_url TEXT DEFAULT '',
|
||||
source_type TEXT DEFAULT '',
|
||||
source_id INTEGER DEFAULT 0,
|
||||
browser_info TEXT DEFAULT '',
|
||||
assignee_id INTEGER,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
last_reply_at DATETIME DEFAULT NULL,
|
||||
first_response_at DATETIME,
|
||||
resolved_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
author_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
actor_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
field_name TEXT DEFAULT '',
|
||||
old_value TEXT DEFAULT '',
|
||||
new_value TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
const ensureColumns = (table, columns) => {
|
||||
const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
||||
for (const [name, definition] of columns) {
|
||||
if (!existing.has(name)) {
|
||||
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
ensureColumns('tickets', [
|
||||
['ticket_no', "TEXT NOT NULL DEFAULT ''"], ['requester_id', 'INTEGER'],
|
||||
['subject', "TEXT NOT NULL DEFAULT ''"], ['description', "TEXT NOT NULL DEFAULT ''"],
|
||||
['category', "TEXT NOT NULL DEFAULT 'other'"], ['priority', "TEXT NOT NULL DEFAULT 'normal'"],
|
||||
['status', "TEXT NOT NULL DEFAULT 'open'"], ['source', "TEXT NOT NULL DEFAULT 'site'"],
|
||||
['source_url', "TEXT DEFAULT ''"], ['source_type', "TEXT DEFAULT ''"], ['source_id', 'INTEGER DEFAULT 0'],
|
||||
['browser_info', "TEXT DEFAULT ''"], ['assignee_id', 'INTEGER'],
|
||||
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||
['last_reply_at', 'DATETIME DEFAULT NULL'], ['first_response_at', 'DATETIME DEFAULT NULL'],
|
||||
['resolved_at', 'DATETIME DEFAULT NULL'], ['closed_at', 'DATETIME DEFAULT NULL'],
|
||||
['revision', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
]);
|
||||
ensureColumns('ticket_messages', [
|
||||
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['author_id', 'INTEGER'],
|
||||
['content', "TEXT NOT NULL DEFAULT ''"], ['is_internal', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||
]);
|
||||
ensureColumns('ticket_events', [
|
||||
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['actor_id', 'INTEGER'],
|
||||
['event_type', "TEXT NOT NULL DEFAULT ''"], ['field_name', "TEXT DEFAULT ''"],
|
||||
['old_value', "TEXT DEFAULT ''"], ['new_value', "TEXT DEFAULT ''"],
|
||||
['detail', "TEXT DEFAULT ''"], ['created_at', 'DATETIME DEFAULT NULL'],
|
||||
]);
|
||||
db.exec(`UPDATE tickets SET last_reply_at = NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_messages.ticket_id = tickets.id)`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)');
|
||||
} },
|
||||
];
|
||||
for (const m of migrations) {
|
||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||
@@ -228,8 +459,19 @@ function seedDefaults() {
|
||||
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
|
||||
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
|
||||
footer_desc: '',
|
||||
// 页脚栏目自定义:JSON 字符串 [{title, links:[{label,url}]}];空串时前台回退现有硬编码栏目
|
||||
footer_columns: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
// 评论等次要作者位是否显示 UID 后缀('1'=显示,'0'=隐藏)
|
||||
show_uid_in_comments: '1',
|
||||
// 论坛游客可见开关:'1'=游客可浏览论坛;'0'=游客需登录(读接口 401 + SSR 404,SEO 权衡)
|
||||
forum_guest_visible: '1',
|
||||
// RSS 订阅源:feed_forum_enabled='1' 才对外暴露论坛 feed(默认关,用户没开时不暴露);
|
||||
// feed_show_full='1'=全文 / '0'=摘要;feed_max_items 为每源条数上限
|
||||
feed_forum_enabled: '0',
|
||||
feed_show_full: '0',
|
||||
feed_max_items: '20',
|
||||
};
|
||||
for (const [k, v] of Object.entries(defaults)) {
|
||||
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
|
||||
@@ -278,6 +520,13 @@ function setSetting(key, value) {
|
||||
else run('INSERT INTO site_settings (key, value) VALUES (?, ?)', [key, value]);
|
||||
}
|
||||
|
||||
// 事务辅助:多步写操作原子执行(better-sqlite3 原生事务;失败自动 ROLLBACK)
|
||||
// 供 L8 改 PIN 重加密、以及任何需要"要么全成要么全不"的批量写场景使用。
|
||||
function transaction(fn) {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
return db.transaction(fn)();
|
||||
}
|
||||
|
||||
function seedSampleData() {
|
||||
const existing = get("SELECT COUNT(*) as c FROM forum_categories");
|
||||
if (existing && existing.c > 0) return;
|
||||
@@ -312,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 { Routes, Route } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from './theme.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
@@ -8,15 +8,21 @@ import BlogDetail from './pages/BlogDetail.jsx';
|
||||
import Tag from './pages/Tag.jsx';
|
||||
import Archive from './pages/Archive.jsx';
|
||||
import Forum from './pages/Forum.jsx';
|
||||
import ForumCategory from './pages/ForumCategory.jsx';
|
||||
import ForumDetail from './pages/ForumDetail.jsx';
|
||||
import ForumManagePanel from './pages/ForumManagePanel.jsx';
|
||||
import ForumManageCategory from './pages/ForumManageCategory.jsx';
|
||||
import UserProfile from './pages/UserProfile.jsx';
|
||||
import Passwords from './pages/Passwords.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Register from './pages/Register.jsx';
|
||||
import Profile from './pages/Profile.jsx';
|
||||
import Write from './pages/Write.jsx';
|
||||
import ForumManage from './pages/ForumManage.jsx';
|
||||
import Embed from './pages/Embed.jsx';
|
||||
import Setup from './pages/Setup.jsx';
|
||||
import Tickets from './pages/Tickets.jsx';
|
||||
import TicketCreate from './pages/TicketCreate.jsx';
|
||||
import TicketDetail from './pages/TicketDetail.jsx';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -30,13 +36,21 @@ export default function App() {
|
||||
<Route path="/tag/:name" element={<Tag />} />
|
||||
<Route path="/archive.html" element={<Archive />} />
|
||||
<Route path="/forum.html" element={<Forum />} />
|
||||
<Route path="/forum/c/:id" element={<ForumCategory />} />
|
||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||
<Route path="/tickets.html" element={<Tickets />} />
|
||||
<Route path="/tickets/new" element={<TicketCreate />} />
|
||||
<Route path="/tickets/:id" element={<TicketDetail />} />
|
||||
<Route path="/forum/manage" element={<ForumManagePanel />} />
|
||||
<Route path="/forum/manage/:id" element={<ForumManageCategory />} />
|
||||
<Route path="/u/:id" element={<UserProfile />} />
|
||||
<Route path="/passwords.html" element={<Passwords />} />
|
||||
<Route path="/login.html" element={<Login />} />
|
||||
<Route path="/register.html" element={<Register />} />
|
||||
<Route path="/profile.html" element={<Profile />} />
|
||||
<Route path="/write.html" element={<Write />} />
|
||||
<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="/setup.html" element={<Setup />} />
|
||||
</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 { useTheme as useMuiTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
@@ -13,44 +13,38 @@ import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import GridViewIcon from '@mui/icons-material/GridView';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import 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 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 SearchIcon from '@mui/icons-material/Search';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { logout } from '../api/auth.js';
|
||||
import SnackHost, { showSnack } from './snack.jsx';
|
||||
import { NAV_GROUPS, searchNav } from './navConfig.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/dashboard', label: '仪表盘', icon: <DashboardIcon /> },
|
||||
{ path: '/settings', label: '站点设置', icon: <SettingsIcon /> },
|
||||
{ path: '/captcha', label: '验证码', icon: <VerifiedUserIcon /> },
|
||||
{ path: '/theme', label: '主题', icon: <PaletteIcon /> },
|
||||
{ path: '/homepage', label: '首页设置', icon: <HomeIcon /> },
|
||||
{ path: '/email', label: '邮件配置', icon: <EmailIcon /> },
|
||||
{ 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 /> },
|
||||
];
|
||||
/** 等元素挂载(跨路由跳转后目标页异步渲染,最多轮询 ~2s) */
|
||||
function waitForId(id, tries = 40) {
|
||||
return document.getElementById(id)
|
||||
? Promise.resolve()
|
||||
: tries > 0
|
||||
? new Promise((res) => setTimeout(() => res(waitForId(id, tries - 1)), 50))
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
/** 后台布局:AppBar(返回前台 + 退出)+ Drawer 导航 + 内容区 Outlet */
|
||||
/** 顶栏搜索下拉最多展示条数 */
|
||||
const SEARCH_LIMIT = 5;
|
||||
|
||||
/** 后台布局:AppBar(中间设置搜索 + 工作台/返回前台/退出)+ Drawer(分组折叠导航)+ 内容区 Outlet */
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -58,20 +52,194 @@ export default function AdminLayout() {
|
||||
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const drawerContent = (
|
||||
<List>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
// 分组折叠状态:默认展开全部(单成员组平铺,不参与折叠)
|
||||
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 === item.path}
|
||||
onClick={() => { navigate(item.path); setMobileOpen(false); }}
|
||||
selected={location.pathname.startsWith(item.path)}
|
||||
onClick={() => { goMenu(item.path); }}
|
||||
sx={{ pl: single ? 2 : 4 }}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
<ListItemIcon>{item.icon ? <item.icon fontSize="small" /> : null}</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: 14 }} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
const items = single ? itemList : (
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
{itemList}
|
||||
</Collapse>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box key={g.id}>
|
||||
{single ? null : (
|
||||
<ListSubheader
|
||||
component="div"
|
||||
disableSticky
|
||||
onClick={() => toggleGroup(g.id)}
|
||||
sx={{
|
||||
cursor: 'pointer', userSelect: 'none', display: 'flex', alignItems: 'center',
|
||||
gap: 0.5, fontSize: 12, fontWeight: 700, letterSpacing: 0.06, lineHeight: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>{g.label}</Box>
|
||||
{open ? <ExpandLessIcon sx={{ fontSize: 16, color: 'text.disabled' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'text.disabled' }} />}
|
||||
</ListSubheader>
|
||||
)}
|
||||
{items}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Toolbar />
|
||||
<List dense>{NAV_GROUPS.map(renderGroup)}</List>
|
||||
</>
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -82,24 +250,40 @@ export default function AdminLayout() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
{/* 顶行:汉堡(移动) + 标题(左) + 搜索(桌面中) + 操作按钮(右) */}
|
||||
<Toolbar sx={{ gap: 1, minHeight: isMobile ? 56 : 64 }}>
|
||||
{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 />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography>
|
||||
<Typography variant="h6" noWrap sx={{ flexShrink: 0, fontSize: { xs: 18, sm: 22 } }}>管理后台</Typography>
|
||||
{!isMobile && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', minWidth: 0, px: 2 }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 480 }}>{searchField}</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0, sm: 0.5 }, ml: 'auto', flexShrink: 0 }}>
|
||||
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
|
||||
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
|
||||
</Button>
|
||||
<Button color="inherit" href="/" title="返回前台">
|
||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
|
||||
<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}>
|
||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />退出
|
||||
<Button color="inherit" onClick={handleLogout} sx={{ px: { xs: 1, sm: 1.5 } }}>
|
||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>退出</Box>
|
||||
</Button>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
{/* 移动:第二行全宽搜索(顶栏空间不足,收窄到独立一行不拥挤) */}
|
||||
{isMobile && (
|
||||
<Toolbar variant="dense" sx={{ pt: 0, pb: 1, gap: 1, minHeight: 48 }}>
|
||||
{searchField}
|
||||
</Toolbar>
|
||||
)}
|
||||
</AppBar>
|
||||
|
||||
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
|
||||
@@ -111,19 +295,18 @@ export default function AdminLayout() {
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
|
||||
>
|
||||
<Toolbar />
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Drawer variant="permanent" sx={{ width: 240, '& .MuiDrawer-paper': { width: 240 } }}>
|
||||
<Toolbar />
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, p: { xs: 2, md: 3 }, minWidth: 0 }}>
|
||||
<Toolbar />
|
||||
<Box component="main" sx={{ flex: '1 1 auto', width: { xs: '100%', md: 'calc(100vw - 240px)' }, maxWidth: { xs: '100%', md: 'calc(100vw - 240px)' }, boxSizing: 'border-box', p: { xs: 2, md: 3 }, minWidth: 0, overflowX: 'hidden' }}>
|
||||
{/* AppBar 占位:桌面 64 / 移动 56 + 搜索行 48 */}
|
||||
<Box sx={{ height: isMobile ? 104 : 64 }} />
|
||||
<Outlet />
|
||||
</Box>
|
||||
<SnackHost />
|
||||
|
||||
@@ -20,11 +20,14 @@ import EmailSettings from './pages/EmailSettings.jsx';
|
||||
import BlogManage from './pages/BlogManage.jsx';
|
||||
import CommentManage from './pages/CommentManage.jsx';
|
||||
import ForumManage from './pages/ForumManage.jsx';
|
||||
import PostManage from './pages/PostManage.jsx';
|
||||
import RssManage from './pages/RssManage.jsx';
|
||||
import Users from './pages/Users.jsx';
|
||||
import Announcements from './pages/Announcements.jsx';
|
||||
import Links from './pages/Links.jsx';
|
||||
import Uploads from './pages/Uploads.jsx';
|
||||
import ImportDb from './pages/ImportDb.jsx';
|
||||
import TicketManage from './pages/TicketManage.jsx';
|
||||
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
||||
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
||||
|
||||
@@ -104,11 +107,14 @@ function AdminApp() {
|
||||
<Route path="/blog" element={<BlogManage />} />
|
||||
<Route path="/comments" element={<CommentManage />} />
|
||||
<Route path="/forum" element={<ForumManage />} />
|
||||
<Route path="/posts" element={<PostManage />} />
|
||||
<Route path="/rss" element={<RssManage />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/announcements" element={<Announcements />} />
|
||||
<Route path="/links" element={<Links />} />
|
||||
<Route path="/uploads" element={<Uploads />} />
|
||||
<Route path="/import" element={<ImportDb />} />
|
||||
<Route path="/tickets" element={<TicketManage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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 ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 博客管理:博客侧栏开关 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
|
||||
/** 博客管理:博客侧栏开关 + 评论设置 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
|
||||
export default function BlogManage() {
|
||||
const [posts, setPosts] = useState(null);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [commentModerate, setCommentModerate] = useState('0');
|
||||
const [commentNotify, setCommentNotify] = useState('0');
|
||||
const [showUidComments, setShowUidComments] = useState('1');
|
||||
const [confirm, setConfirm] = useState(null); // { id, title }
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -35,7 +38,12 @@ export default function BlogManage() {
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
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) => {
|
||||
try {
|
||||
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>
|
||||
</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 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
|
||||
@@ -94,6 +115,40 @@ export default function BlogManage() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-comments" sx={{ p: 2, mb: 2, maxWidth: 640 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>评论设置</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={commentModerate === '1'} onChange={(e) => setCommentModerate(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论审核模式</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后新评论需后台审核通过才显示</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={commentNotify === '1'} onChange={(e) => setCommentNotify(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showUidComments === '1'} onChange={(e) => setShowUidComments(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论显示 UID 编号(#id)</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>在评论作者名旁显示 UID 后缀,关闭后仅帖子/楼主等主作者位显示</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button variant="outlined" size="small" onClick={saveCommentSettings}>保存评论设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function CaptchaSettings() {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Paper id="sec-captcha-type" sx={{ p: 3 }}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>验证码类型</InputLabel>
|
||||
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
|
||||
@@ -86,7 +86,7 @@ export default function CaptchaSettings() {
|
||||
</FormControl>
|
||||
|
||||
{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_register} onChange={setSwitch('captcha_register')} />} label="注册验证" />
|
||||
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
|
||||
@@ -94,7 +94,7 @@ export default function CaptchaSettings() {
|
||||
)}
|
||||
|
||||
{showRecaptcha && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box id="sec-captcha-recaptcha" sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Google reCAPTCHA V2 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.recaptcha_site_key} onChange={set('recaptcha_site_key')} margin="normal" placeholder="6L..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.recaptcha_secret_key} onChange={set('recaptcha_secret_key')} margin="normal" placeholder="6L..." />
|
||||
@@ -102,7 +102,7 @@ export default function CaptchaSettings() {
|
||||
)}
|
||||
|
||||
{showTurnstile && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box id="sec-captcha-turnstile" sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Cloudflare Turnstile 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.turnstile_site_key} onChange={set('turnstile_site_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.turnstile_secret_key} onChange={set('turnstile_secret_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
|
||||
@@ -9,24 +9,53 @@ import ListItem from '@mui/material/ListItem';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { 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';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 时间格式化:yyyy-mm-dd hh:mm */
|
||||
function fmtTime(t) {
|
||||
if (!t) return '';
|
||||
return String(t).replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
/** 评论管理:待审核队列 + 全部评论占位(后端暂无全量评论列表接口) */
|
||||
/** 内容截断(单行预览):换行折叠 + 超长省略 */
|
||||
function clip(content, max = 60) {
|
||||
const s = String(content || '').replace(/\s+/g, ' ').trim();
|
||||
return s.length > max ? s.slice(0, max) + '…' : s;
|
||||
}
|
||||
|
||||
/** 状态 chip */
|
||||
function StatusChip({ status }) {
|
||||
if (status === 'approved') return <Chip size="small" label="已通过" color="success" />;
|
||||
if (status === 'rejected') return <Chip size="small" label="已拒绝" color="error" variant="outlined" />;
|
||||
return <Chip size="small" label="待审核" color="warning" />;
|
||||
}
|
||||
|
||||
/** 评论管理:待审核队列 + 全部评论分页列表(status=all) */
|
||||
export default function CommentManage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [pending, setPending] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// 全部评论(分页)
|
||||
const [allList, setAllList] = useState([]);
|
||||
const [allPage, setAllPage] = useState(1);
|
||||
const [allTotal, setAllTotal] = useState(0);
|
||||
const [allTotalPages, setAllTotalPages] = useState(0);
|
||||
const [allLoading, setAllLoading] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setBusy(true);
|
||||
listPendingComments()
|
||||
@@ -37,6 +66,23 @@ export default function CommentManage() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const loadAll = useCallback((page) => {
|
||||
setAllLoading(true);
|
||||
listAllComments({ status: 'all', page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setAllList((res && res.list) || []);
|
||||
setAllTotal(res ? res.total || 0 : 0);
|
||||
setAllTotalPages(res ? res.totalPages || 0 : 0);
|
||||
setAllPage(page);
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setAllLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 1) loadAll(1);
|
||||
}, [tab, loadAll]);
|
||||
|
||||
const act = async (id, fn, okMsg) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -49,17 +95,34 @@ export default function CommentManage() {
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const actAll = async (id, fn, okMsg, reloadPage = allPage) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn(id);
|
||||
showSnack(okMsg);
|
||||
// 同步待审核徽标 + 刷新当前页;若删除后本页空且非第一页则回退一页
|
||||
setPending((list) => list.filter((c) => c.id !== id));
|
||||
const remaining = allList.filter((c) => c.id !== id).length;
|
||||
loadAll(remaining === 0 && reloadPage > 1 ? reloadPage - 1 : reloadPage);
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, allTotalPages);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="h5">评论管理</Typography>
|
||||
<IconButton onClick={load} title="刷新" disabled={busy}><RefreshIcon /></IconButton>
|
||||
<IconButton onClick={() => (tab === 0 ? load() : loadAll(allPage))} title="刷新" disabled={busy || allLoading}><RefreshIcon /></IconButton>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
|
||||
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
|
||||
<Tab label="全部评论" />
|
||||
<Tab label={`全部评论${allTotal ? ` (${allTotal})` : ''}`} />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
@@ -114,12 +177,62 @@ export default function CommentManage() {
|
||||
</Paper>
|
||||
)
|
||||
) : (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
|
||||
<>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>作者</TableCell>
|
||||
<TableCell>文章</TableCell>
|
||||
<TableCell>内容</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{allLoading && allList.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6}>加载中...</TableCell></TableRow>
|
||||
) : allList.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6}>暂无评论</TableCell></TableRow>
|
||||
) : allList.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap', fontWeight: 600 }}>
|
||||
{c.author_name || '匿名'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{c.post_title || `#${c.post_id}`}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 320, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{clip(c.content)}
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={c.status} /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13, whiteSpace: 'nowrap' }}>{fmtTime(c.created_at)}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{c.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" startIcon={<CheckIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, approveComment, '已通过审核')}>通过</Button>
|
||||
<Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, rejectComment, '已拒绝')}>拒绝</Button>
|
||||
</>
|
||||
)}
|
||||
<IconButton size="small" color="error" title="删除评论" disabled={busy} onClick={() => actAll(c.id, deleteComment, '已删除')}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mt: 2 }}>
|
||||
<Button size="small" variant="outlined" disabled={allPage <= 1 || allLoading} onClick={() => loadAll(allPage - 1)}>上一页</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
当前后端仅提供「待审核」评论的管理接口;已通过 / 已拒绝评论可在对应文章页查看。
|
||||
第 {allPage} / {totalPages} 页 · 共 {allTotal} 条
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Button size="small" variant="outlined" disabled={allPage >= totalPages || allLoading} onClick={() => loadAll(allPage + 1)}>下一页</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -66,14 +66,14 @@ export default function EmailSettings() {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<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>
|
||||
<TextField fullWidth label="SMTP 主机" value={form.smtp_host} onChange={set('smtp_host')} margin="normal" placeholder="smtp.example.com" />
|
||||
<TextField fullWidth label="端口" type="number" value={form.smtp_port} onChange={set('smtp_port')} margin="normal" placeholder="587" />
|
||||
<TextField fullWidth label="用户名" value={form.smtp_user} onChange={set('smtp_user')} margin="normal" />
|
||||
<TextField fullWidth label="密码" type="password" value={form.smtp_pass} onChange={set('smtp_pass')} margin="normal" />
|
||||
</Paper>
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Paper id="sec-sender" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>发件人信息</Typography>
|
||||
<TextField fullWidth label="发件人邮箱" type="email" value={form.smtp_from_email} onChange={set('smtp_from_email')} margin="normal" placeholder="noreply@example.com" />
|
||||
<TextField fullWidth label="发件人名称" value={form.smtp_from_name} onChange={set('smtp_from_name')} margin="normal" placeholder="RainWeb" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -10,41 +11,90 @@ import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import CampaignIcon from '@mui/icons-material/Campaign';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { 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 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() {
|
||||
const [cats, setCats] = useState([]);
|
||||
const [counts, setCounts] = useState({});
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 版主指派弹窗
|
||||
const [modDialog, setModDialog] = useState(null); // { id, name, ids: [] }
|
||||
const [users, setUsers] = useState([]);
|
||||
const [modSaving, setModSaving] = useState(false);
|
||||
|
||||
// 公告编辑弹窗
|
||||
const [annDialog, setAnnDialog] = useState(null); // { id, name, text }
|
||||
const [annSaving, setAnnSaving] = useState(false);
|
||||
|
||||
// 论坛设置(forum_guest_visible):'1'=游客可见(默认)
|
||||
const [guestVisible, setGuestVisible] = useState(true);
|
||||
const [settingSaving, setSettingSaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listCategories()
|
||||
.then((cs) => setCats(cs || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
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 openEdit = (c) => {
|
||||
@@ -55,6 +105,8 @@ export default function ForumManage() {
|
||||
announcement: c.announcement || '',
|
||||
sub_categories: c.sub_categories || '',
|
||||
sort_order: String(c.sort_order || 0),
|
||||
icon: c.icon || '',
|
||||
icon_color: c.icon_color || '',
|
||||
});
|
||||
setDialog(true);
|
||||
};
|
||||
@@ -68,6 +120,8 @@ export default function ForumManage() {
|
||||
announcement: form.announcement.trim(),
|
||||
sub_categories: form.sub_categories.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
icon: form.icon.trim(),
|
||||
icon_color: form.icon_color.trim(),
|
||||
};
|
||||
try {
|
||||
if (editingId) await updateCategory(editingId, data);
|
||||
@@ -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 (
|
||||
<Box>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
<Paper id="sec-forum-settings" sx={{ p: 2.5, mb: 2, maxWidth: 640 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>论坛设置</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={guestVisible} onChange={(e) => setGuestVisible(e.target.checked)} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>访客可见</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后未登录访客可浏览论坛;关闭则需登录(私密模式,SEO 同时隐藏)</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button variant="outlined" size="small" onClick={saveForumSettings} disabled={settingSaving}>保存论坛设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">暂无板块,点击"添加板块"创建</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{cats.map((c) => (
|
||||
{cats.map((c) => {
|
||||
const mods = moderatorNames(c);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={c.id}>
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>{c.name}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36, height: 36, borderRadius: 3, flexShrink: 0, fontWeight: 700, fontSize: 17,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
||||
background: c.icon_color || 'rgba(0,0,0,0.08)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{c.icon ? (
|
||||
/^https?:\/\//i.test(c.icon)
|
||||
? <Box component="img" src={c.icon} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: c.icon
|
||||
) : (c.name || '?').charAt(0).toUpperCase()}
|
||||
</Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</Typography>
|
||||
<Chip size="small" label={`排序 ${c.sort_order}`} variant="outlined" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, mb: 1 }}>{c.description || '无描述'}</Typography>
|
||||
{c.announcement && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>📢 {c.announcement}</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>{counts[c.id] || 0} 个帖子</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{typeof c.posts_count === 'number' ? `${c.posts_count} 个帖子` : '—'}
|
||||
{c.today_count ? ` · 今日 ${c.today_count}` : ''}
|
||||
</Typography>
|
||||
{mods.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>版主:{mods.join('、')}</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
|
||||
<IconButton size="small" onClick={() => openMods(c)} title="指派版主"><GroupIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={() => openAnn(c)} title="编辑公告"><CampaignIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={() => openEdit(c)} title="编辑"><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: c.id, name: c.name })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 添加 / 编辑板块 */}
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -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="板块公告" 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 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>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
@@ -143,6 +309,65 @@ export default function ForumManage() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 版主指派 */}
|
||||
<Dialog open={!!modDialog} onClose={() => setModDialog(null)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>指派版主</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
为版块「{modDialog ? modDialog.name : ''}」选择版主(管理员始终可管理全部版块)
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>版主</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={modDialog ? modDialog.ids : []}
|
||||
onChange={(e) => setModDialog((p) => (p ? { ...p, ids: e.target.value } : p))}
|
||||
label="版主"
|
||||
renderValue={(selected) => {
|
||||
const names = users
|
||||
.filter((u) => selected.includes(u.id))
|
||||
.map((u) => u.username)
|
||||
.join('、');
|
||||
return names || '未选择';
|
||||
}}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<MenuItem key={u.id} value={u.id} disabled={u.role === 'admin'}>
|
||||
<Checkbox checked={(modDialog ? modDialog.ids : []).includes(u.id)} />
|
||||
<ListItemText primary={u.username} secondary={u.role === 'admin' ? '管理员' : ''} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setModDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={saveMods} disabled={modSaving}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 公告快捷编辑 */}
|
||||
<Dialog open={!!annDialog} onClose={() => setAnnDialog(null)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>编辑公告</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
版块「{annDialog ? annDialog.name : ''}」顶部公告
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="公告内容(留空清除)"
|
||||
value={annDialog ? annDialog.text : ''}
|
||||
onChange={(e) => setAnnDialog((p) => (p ? { ...p, text: e.target.value } : p))}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAnnDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={saveAnn} disabled={annSaving}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
|
||||
|
||||
@@ -103,14 +103,21 @@ export default function Homepage() {
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<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>
|
||||
<TextField fullWidth label="头像 URL" type="url" value={form.homepage_avatar} onChange={set('homepage_avatar')} margin="normal" placeholder="https://example.com/avatar.jpg" />
|
||||
<TextField fullWidth label="个人简介" multiline rows={2} value={form.homepage_bio} onChange={set('homepage_bio')} margin="normal" placeholder="一段简短的自我介绍" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, mb: 1 }}>联系链接(最多5个)</Typography>
|
||||
{contacts.map((c, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<TextField size="small" label="图标名" value={c.icon} onChange={updateContact(i, 'icon')} sx={{ width: 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="标题(选填)" value={c.title} onChange={updateContact(i, 'title')} sx={{ width: 120 }} />
|
||||
<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>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Paper id="sec-home-content" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主页内容</Typography>
|
||||
<TextField fullWidth label="正文 (Markdown)" multiline rows={10} value={form.homepage_content} onChange={set('homepage_content')} margin="normal" placeholder="支持 Markdown 语法和 [image:filename] 标签" />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
@@ -131,7 +138,7 @@ export default function Homepage() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Paper id="sec-music" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
|
||||
<FormControlLabel control={<Switch checked={form.music_embed_enabled} onChange={setSwitch('music_embed_enabled')} />} label="所有页面显示音乐播放器" />
|
||||
<TextField fullWidth label="嵌入代码" multiline rows={3} value={form.music_embed_code} onChange={set('music_embed_code')} margin="normal" placeholder="粘贴网易云音乐 iframe 代码" />
|
||||
|
||||
@@ -21,22 +21,52 @@ import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = {
|
||||
title: '', url: '', embed_url: '', use_proxy: false, description: '',
|
||||
icon: '', category: '默认', version: '', sort_order: '0',
|
||||
slug: '', trusted: true, permissions: [], scale: '1',
|
||||
};
|
||||
|
||||
/** 面板链接管理: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() {
|
||||
const [links, setLinks] = useState(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [proxyHosts, setProxyHosts] = useState('');
|
||||
const [proxySaving, setProxySaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAdminLinks()
|
||||
@@ -44,7 +74,10 @@ export default function Links() {
|
||||
.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 openEdit = (l) => {
|
||||
@@ -54,6 +87,10 @@ export default function Links() {
|
||||
url: l.url,
|
||||
embed_url: l.embed_url || '',
|
||||
use_proxy: !!l.use_proxy,
|
||||
slug: l.slug || '',
|
||||
trusted: !(l.trusted === 0 || l.trusted === '0'),
|
||||
permissions: parsePermissions(l.permissions),
|
||||
scale: String(parseFloat(l.scale) || 1),
|
||||
description: l.description || '',
|
||||
icon: l.icon || '',
|
||||
category: l.category || '默认',
|
||||
@@ -70,6 +107,10 @@ export default function Links() {
|
||||
url: form.url.trim(),
|
||||
embed_url: form.embed_url.trim(),
|
||||
use_proxy: form.use_proxy ? 1 : 0,
|
||||
slug: form.slug.trim(),
|
||||
trusted: form.trusted ? 1 : 0,
|
||||
permissions: JSON.stringify(form.permissions),
|
||||
scale: parseFloat(form.scale) || 1,
|
||||
description: form.description.trim(),
|
||||
icon: form.icon.trim(),
|
||||
category: form.category.trim() || '默认',
|
||||
@@ -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 (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
@@ -115,24 +167,43 @@ export default function Links() {
|
||||
<TableCell>版本</TableCell>
|
||||
<TableCell>URL</TableCell>
|
||||
<TableCell>嵌入URL</TableCell>
|
||||
<TableCell>代理配置</TableCell>
|
||||
<TableCell>分类</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{links === null ? (
|
||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
|
||||
) : links.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8}>暂无面板</TableCell></TableRow>
|
||||
) : links.map((l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>{l.sort_order}</TableCell>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</Box></TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ fontWeight: 600 }}>{l.title}</Box>
|
||||
{l.slug && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', fontSize: 12, fontFamily: 'monospace' }}>/{l.slug}/</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{l.version || '-'}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 180 }}>
|
||||
<Box component="a" href={l.url} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{l.url}</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 140, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.embed_url || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{l.use_proxy ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={l.trusted === 0 || l.trusted === '0' ? '代理·安全' : '代理·可信'}
|
||||
color={l.trusted === 0 || l.trusted === '0' ? 'default' : 'success'}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : <Chip size="small" label="直嵌" variant="outlined" />}
|
||||
{parseFloat(l.scale) > 0 && parseFloat(l.scale) !== 1 && (
|
||||
<Box component="span" sx={{ ml: 0.75, color: 'text.secondary', fontSize: 12 }}>缩放 {parseFloat(l.scale)}x</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Chip size="small" label={l.category} variant="outlined" /></TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
|
||||
@@ -144,6 +215,27 @@ export default function Links() {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Paper id="sec-proxy" sx={{ p: 2.5, mt: 2, maxWidth: 720 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: 16, mb: 0.5 }}>面板代理</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5, fontSize: 13 }}>
|
||||
通过面板代理嵌入面板(绕过 X-Frame-Options 限制)时,把可信内网地址/网段加入白名单
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="代理允许的内网地址(白名单)"
|
||||
value={proxyHosts}
|
||||
onChange={(e) => setProxyHosts(e.target.value)}
|
||||
margin="normal"
|
||||
multiline
|
||||
minRows={3}
|
||||
placeholder={'每行或逗号分隔一个地址/网段,例如:\n192.168.0.0/16\n10.0.0.0/8\n192.168.3.1:8080'}
|
||||
helperText="https 页面无法嵌入 http 内网面板,把可信内网地址/网段加入白名单后可经面板代理放行。支持单 IP、IPv4 CIDR(如 192.168.0.0/16)与主机名;默认拦截所有内网地址,请谨慎配置。"
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button variant="contained" onClick={saveProxy} disabled={proxySaving}>保存面板代理设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -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(iframe嵌入用)" type="url" value={form.embed_url} onChange={(e) => setForm((p) => ({ ...p, embed_url: e.target.value }))} margin="normal" placeholder="留空则新标签页打开" />
|
||||
<FormControlLabel control={<Switch checked={form.use_proxy} onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))} />} label="通过代理嵌入(绕过 X-Frame-Options 限制)" />
|
||||
{form.use_proxy && (
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="代理路径标识 slug"
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
|
||||
margin="dense"
|
||||
placeholder="留空按标题自动生成"
|
||||
helperText="仅小写字母/数字/连字符,≤32 字符;iframe 走 /proxy/{slug}/"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.trusted} onChange={(e) => setForm((p) => ({ ...p, trusted: e.target.checked }))} />}
|
||||
label="可信模式(保留 allow-same-origin,目标站 cookie 登录态可用)"
|
||||
/>
|
||||
<TextField
|
||||
label="缩放(0.1–3)"
|
||||
type="number"
|
||||
value={form.scale}
|
||||
onChange={(e) => setForm((p) => ({ ...p, scale: e.target.value }))}
|
||||
margin="dense"
|
||||
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
|
||||
sx={{ maxWidth: 160 }}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
fullWidth
|
||||
size="small"
|
||||
options={PERMISSION_OPTIONS}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
|
||||
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="委托权限(可选,iframe allow 属性)" margin="dense" placeholder="摄像头 / 麦克风 / 定位等" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="图标 (Material图标名)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="settings, dashboard, ..." />
|
||||
<TextField fullWidth label="分类" value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,15 @@ import RadioGroup from '@mui/material/RadioGroup';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadVerifyFile, listVerifyFiles, deleteVerifyFile } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import { parseFooterColumns } from '../../lib/outlink.js';
|
||||
|
||||
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
|
||||
const FOOTER_STYLES = [
|
||||
@@ -24,7 +29,7 @@ const FOOTER_STYLES = [
|
||||
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||
];
|
||||
|
||||
/** 基本设置 + 页脚设置 + 面板代理 + RainID 单点登录(v2) */
|
||||
/** 基本设置 + 页脚设置 + 搜索引擎验证 + RainID 单点登录(v2) */
|
||||
export default function Settings() {
|
||||
const [form, setForm] = useState({
|
||||
site_name: '',
|
||||
@@ -35,9 +40,6 @@ export default function Settings() {
|
||||
footer_copyright: '',
|
||||
footer_powered: '',
|
||||
footer_desc: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
proxy_allowed_hosts: '',
|
||||
rainid_enabled: '0',
|
||||
rainid_client_id: '',
|
||||
rainid_client_secret: '',
|
||||
@@ -47,9 +49,55 @@ export default function Settings() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
// 页脚栏目(footer_columns JSON):独立 state 维护,保存时序列化进 form
|
||||
const [columns, setColumns] = useState([]);
|
||||
|
||||
// 搜索引擎验证文件:列表 / 待上传文件 / 上传状态 / 上传成功结果
|
||||
const [verifyFiles, setVerifyFiles] = useState([]);
|
||||
const [verifyFile, setVerifyFile] = useState(null);
|
||||
const [verifyUploading, setVerifyUploading] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState(null);
|
||||
|
||||
const loadVerifyFiles = () => {
|
||||
listVerifyFiles().then(setVerifyFiles).catch((e) => showSnack(e.message, 'error'));
|
||||
};
|
||||
|
||||
const handleVerifyFileChange = (e) => {
|
||||
setVerifyFile((e.target.files && e.target.files[0]) || null);
|
||||
setVerifyResult(null);
|
||||
e.target.value = ''; // 清空 input 允许重复选择同一文件
|
||||
};
|
||||
|
||||
const handleVerifyUpload = async () => {
|
||||
if (!verifyFile) { showSnack('请先选择验证文件', 'error'); return; }
|
||||
setVerifyUploading(true);
|
||||
try {
|
||||
const r = await uploadVerifyFile(verifyFile);
|
||||
setVerifyResult(r);
|
||||
setVerifyFile(null);
|
||||
loadVerifyFiles();
|
||||
showSnack('上传成功');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setVerifyUploading(false);
|
||||
};
|
||||
|
||||
const handleVerifyDelete = async (name) => {
|
||||
if (!window.confirm(`确认删除验证文件 ${name}?`)) return;
|
||||
try {
|
||||
await deleteVerifyFile(name);
|
||||
loadVerifyFiles();
|
||||
showSnack('已删除');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
.then((s) => {
|
||||
setForm({
|
||||
site_name: s.site_name || '',
|
||||
site_description: s.site_description || '',
|
||||
site_url: s.site_url || '',
|
||||
@@ -58,17 +106,17 @@ export default function Settings() {
|
||||
footer_copyright: s.footer_copyright || '',
|
||||
footer_powered: s.footer_powered || '',
|
||||
footer_desc: s.footer_desc || '',
|
||||
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
|
||||
comment_notify: s.comment_notify === '1' ? '1' : '0',
|
||||
proxy_allowed_hosts: s.proxy_allowed_hosts || '',
|
||||
rainid_enabled: s.rainid_enabled === '1' ? '1' : '0',
|
||||
rainid_client_id: s.rainid_client_id || '',
|
||||
// secret 后端不回读(ALLOWED_SET 可写不可读),始终为空,留空提交=不修改
|
||||
rainid_client_secret: '',
|
||||
rainid_discovery_url: s.rainid_discovery_url || 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: s.rainid_register_redirect === '1' ? '1' : '0',
|
||||
}))
|
||||
});
|
||||
setColumns(parseFooterColumns(s.footer_columns)); // 页脚栏目(同一次响应)
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
loadVerifyFiles(); // 加载已上传的验证文件
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
@@ -77,7 +125,7 @@ export default function Settings() {
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { ...form };
|
||||
const body = { ...form, footer_columns: JSON.stringify(columns) };
|
||||
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
|
||||
if (!body.rainid_client_secret) delete body.rainid_client_secret;
|
||||
await saveSettings(body);
|
||||
@@ -88,44 +136,34 @@ export default function Settings() {
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// ── 页脚栏目编辑器(简单版:无拖拽排序;最多 3 栏并行) ──
|
||||
const MAX_COLUMNS = 3;
|
||||
const addColumn = () => {
|
||||
if (columns.length >= MAX_COLUMNS) { showSnack(`最多添加 ${MAX_COLUMNS} 栏`, 'error'); return; }
|
||||
setColumns((cs) => [...cs, { title: '', links: [{ label: '', url: '' }] }]);
|
||||
};
|
||||
const removeColumn = (i) => setColumns((cs) => cs.filter((_, idx) => idx !== i));
|
||||
const setColumnTitle = (i, v) => setColumns((cs) => cs.map((c, idx) => (idx === i ? { ...c, title: v } : c)));
|
||||
const addLink = (ci) => setColumns((cs) => cs.map((c, idx) => (idx === ci ? { ...c, links: [...c.links, { label: '', url: '' }] } : c)));
|
||||
const removeLink = (ci, li) => setColumns((cs) => cs.map((c, idx) => (idx === ci ? { ...c, links: c.links.filter((_, lIdx) => lIdx !== li) } : c)));
|
||||
const setLink = (ci, li, k, v) => setColumns((cs) => cs.map((c, idx) => (idx === ci
|
||||
? { ...c, links: c.links.map((l, lIdx) => (lIdx === li ? { ...l, [k]: v } : l)) }
|
||||
: c)));
|
||||
|
||||
const footStyle = form.footer_style;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
|
||||
|
||||
<Paper 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="网站描述(SEO)" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" />
|
||||
<TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" />
|
||||
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
|
||||
</Paper>
|
||||
|
||||
<Paper 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 }}>
|
||||
<Paper id="sec-footer" sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
|
||||
|
||||
<FormControl component="fieldset" sx={{ mb: 1 }}>
|
||||
@@ -152,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_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
|
||||
<FormHelperText sx={{ mt: 1 }}>
|
||||
导航栏目固定不可编辑(首页 / 博客 / 论坛 / 管理后台 / 个人中心)
|
||||
分栏导航默认显示固定栏目(首页 / 博客 / 论坛 / 管理后台 / 个人中心),可在下方自定义
|
||||
</FormHelperText>
|
||||
|
||||
{/* 页脚栏目编辑器(简单版) */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>页脚栏目</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
链接以 / 开头为站内页,http(s) 开头为外链(经跳转确认页);留空使用默认栏目
|
||||
</Typography>
|
||||
{columns.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>暂无自定义栏目,点击「添加栏目」创建</Typography>
|
||||
)}
|
||||
{columns.map((col, ci) => (
|
||||
<Box key={ci} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<TextField size="small" fullWidth label={`栏目 ${ci + 1} 名称`} value={col.title} onChange={(e) => setColumnTitle(ci, e.target.value)} />
|
||||
<IconButton size="small" color="error" onClick={() => removeColumn(ci)} aria-label={`删除栏目 ${ci + 1}`}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{(col.links || []).map((l, li) => (
|
||||
<Box key={li} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<TextField size="small" label="链接文字" value={l.label} onChange={(e) => setLink(ci, li, 'label', e.target.value)} sx={{ flex: 1, minWidth: 100 }} />
|
||||
<TextField size="small" label="链接地址" value={l.url} onChange={(e) => setLink(ci, li, 'url', e.target.value)} placeholder="/ 开头站内 或 https://" sx={{ flex: 1.4, minWidth: 160 }} />
|
||||
<IconButton size="small" color="error" onClick={() => removeLink(ci, li)} aria-label="删除链接">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => addLink(ci)}>添加链接</Button>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={addColumn}
|
||||
disabled={columns.length >= MAX_COLUMNS}
|
||||
>
|
||||
添加栏目({columns.length}/{MAX_COLUMNS})
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -163,24 +243,58 @@ export default function Settings() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>面板代理</Typography>
|
||||
<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>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="代理允许的内网地址(白名单)"
|
||||
value={form.proxy_allowed_hosts}
|
||||
onChange={set('proxy_allowed_hosts')}
|
||||
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={{ 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 sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>RainID 单点登录</Typography>
|
||||
<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>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function ThemeSettings() {
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<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>
|
||||
<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' }} />
|
||||
@@ -93,7 +93,7 @@ export default function ThemeSettings() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Paper id="sec-wallpaper" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
||||
@@ -119,7 +119,7 @@ export default function ThemeSettings() {
|
||||
</TextField>
|
||||
</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="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 }}>
|
||||
@@ -134,7 +134,7 @@ export default function ThemeSettings() {
|
||||
</ToggleButtonGroup>
|
||||
</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="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 }} />
|
||||
@@ -142,7 +142,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 }} />
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Paper id="sec-dark" sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
|
||||
|
||||
@@ -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 InputLabel from '@mui/material/InputLabel';
|
||||
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 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() {
|
||||
const [users, setUsers] = useState(null);
|
||||
const [selfId, setSelfId] = useState(null); // 当前管理员自己的 id(自我保护:禁止改权/删除自己)
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
|
||||
const [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 [roleDialog, setRoleDialog] = useState(null); // { id, username, role }
|
||||
const [confirm, setConfirm] = useState(null); // { id, username }
|
||||
const [deleteTarget, setDeleteTarget] = useState(null); // { id, username }
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -42,6 +54,11 @@ export default function Users() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 自我 id(禁止对自己改权/删除)
|
||||
useEffect(() => {
|
||||
me().then((u) => setSelfId(u && u.id)).catch(() => setSelfId(null));
|
||||
}, []);
|
||||
|
||||
const addUser = async () => {
|
||||
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
|
||||
setBusy(true);
|
||||
@@ -57,15 +74,52 @@ export default function Users() {
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const openEdit = (u) => {
|
||||
setEditUser(u);
|
||||
setEditForm({
|
||||
nickname: u.nickname || '',
|
||||
title: u.title || '',
|
||||
title_color: u.title_color || '',
|
||||
website: u.website || '',
|
||||
email: u.email || '',
|
||||
role: u.role || 'user',
|
||||
});
|
||||
setPwForm({ p1: '', p2: '' });
|
||||
};
|
||||
|
||||
const closeEdit = () => setEditUser(null);
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!editUser) return;
|
||||
if (editForm.nickname.length > 20) { showSnack('昵称最多 20 字', 'error'); return; }
|
||||
if (editForm.title.length > 20) { showSnack('头衔最多 20 字', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateUser(editUser.id, {
|
||||
nickname: editForm.nickname.trim(),
|
||||
title: editForm.title.trim(),
|
||||
title_color: editForm.title_color.trim(),
|
||||
website: editForm.website.trim(),
|
||||
email: editForm.email.trim(),
|
||||
role: editForm.role,
|
||||
});
|
||||
showSnack('资料已保存');
|
||||
closeEdit();
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const resetPw = async () => {
|
||||
if (!pwDialog) return;
|
||||
if (!editUser) return;
|
||||
if (!pwForm.p1 || pwForm.p1.length < 6) { showSnack('密码至少6位', 'error'); return; }
|
||||
if (pwForm.p1 !== pwForm.p2) { showSnack('两次密码不一致', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await resetUserPassword(pwDialog.id, pwForm.p1);
|
||||
await resetUserPassword(editUser.id, pwForm.p1);
|
||||
showSnack('密码已重置');
|
||||
setPwDialog(null);
|
||||
setPwForm({ p1: '', p2: '' });
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
@@ -73,13 +127,14 @@ export default function Users() {
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const changeRole = async () => {
|
||||
if (!roleDialog) return;
|
||||
const doDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setUserRole(roleDialog.id, roleDialog.role);
|
||||
showSnack('角色已更新');
|
||||
setRoleDialog(null);
|
||||
await deleteUser(deleteTarget.id);
|
||||
showSnack('已删除');
|
||||
setDeleteTarget(null);
|
||||
setEditUser(null); // 删除后关闭编辑弹窗
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
@@ -87,19 +142,7 @@ export default function Users() {
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteUser(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
const isSelf = !!editUser && editUser.id === selfId;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -113,7 +156,7 @@ export default function Users() {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>用户名</TableCell>
|
||||
<TableCell>用户</TableCell>
|
||||
<TableCell>邮箱</TableCell>
|
||||
<TableCell>验证</TableCell>
|
||||
<TableCell>角色</TableCell>
|
||||
@@ -129,7 +172,12 @@ export default function Users() {
|
||||
) : users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<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>
|
||||
<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 sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Button size="small" onClick={() => { setPwDialog({ id: u.id, username: u.username }); setPwForm({ p1: '', p2: '' }); }}>改密</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>
|
||||
<Button size="small" startIcon={<EditIcon fontSize="small" />} onClick={() => openEdit(u)}>编辑</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -169,43 +215,165 @@ export default function Users() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 重置密码 */}
|
||||
<Dialog open={!!pwDialog} onClose={() => setPwDialog(null)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>重置密码</DialogTitle>
|
||||
{/* 编辑用户大弹窗 */}
|
||||
<Dialog open={!!editUser} onClose={closeEdit} fullWidth maxWidth="sm">
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>重置用户 {pwDialog ? pwDialog.username : ''} 的密码</Typography>
|
||||
<TextField fullWidth label="新密码(至少6位)" type="password" value={pwForm.p1} onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="确认新密码" type="password" value={pwForm.p2} onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))} margin="normal" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPwDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={resetPw} disabled={busy}>确认重置</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
{editUser && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
|
||||
{/* 基本信息 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>基本信息</Typography>
|
||||
<TextField fullWidth label="用户名(不可修改)" value={editUser.username} margin="normal" disabled />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="对外昵称"
|
||||
value={editForm.nickname}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, nickname: e.target.value }))}
|
||||
margin="normal"
|
||||
inputProps={{ maxLength: 20 }}
|
||||
helperText="显示在帖子 / 评论 / 个人主页的作者名,留空则显示用户名"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="邮箱"
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, email: e.target.value }))}
|
||||
margin="normal"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<Box sx={{ mt: 3.4, flexShrink: 0 }}>
|
||||
<Chip size="small" label={editUser.email_verified ? '已验证' : '未验证'} color={editUser.email_verified ? 'primary' : 'default'} variant={editUser.email_verified ? 'filled' : 'outlined'} />
|
||||
</Box>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="个人博客"
|
||||
type="url"
|
||||
value={editForm.website}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, website: e.target.value }))}
|
||||
margin="normal"
|
||||
placeholder="https://example.com"
|
||||
helperText="显示在个人主页(外链跳确认页)"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 修改角色 */}
|
||||
<Dialog open={!!roleDialog} onClose={() => setRoleDialog(null)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>修改角色</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>修改用户 {roleDialog ? roleDialog.username : ''} 的角色</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Divider />
|
||||
|
||||
{/* 头衔 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>头衔</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="自定义头衔"
|
||||
value={editForm.title}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, title: e.target.value }))}
|
||||
margin="normal"
|
||||
inputProps={{ maxLength: 20 }}
|
||||
placeholder="如:技术宅 / 站长 / 摸鱼大师"
|
||||
/>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>头衔颜色(点击选择,或输入自定义 hex)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{COLOR_PALETTE.map((col) => (
|
||||
<Box
|
||||
key={col}
|
||||
onClick={() => setEditForm((p) => ({ ...p, title_color: col === p.title_color ? '' : col }))}
|
||||
sx={{
|
||||
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer', background: col,
|
||||
border: col === editForm.title_color ? '2px solid #fff' : '2px solid transparent',
|
||||
outline: col === editForm.title_color ? '2px solid var(--md-sys-color-outline, rgba(0,0,0,0.38))' : 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
title={col}
|
||||
/>
|
||||
))}
|
||||
<TextField
|
||||
size="small"
|
||||
label="自定义"
|
||||
value={editForm.title_color}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, title_color: e.target.value }))}
|
||||
placeholder="#6750a4"
|
||||
sx={{ width: 130 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 权限 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>权限</Typography>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>角色</InputLabel>
|
||||
<Select value={roleDialog ? roleDialog.role : 'user'} onChange={(e) => setRoleDialog((p) => (p ? { ...p, role: e.target.value } : p))} label="角色">
|
||||
<Select value={editForm.role} onChange={(e) => setEditForm((p) => ({ ...p, role: e.target.value }))} label="角色" disabled={isSelf}>
|
||||
<MenuItem value="user">普通用户</MenuItem>
|
||||
<MenuItem value="admin">管理员</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{isSelf && <Typography variant="caption" color="text.secondary">不能修改自己的角色</Typography>}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 账号安全(危险区) */}
|
||||
<Box sx={{ border: '1px solid', borderColor: 'error.main', borderRadius: 2, p: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'error.main' }}>账号安全</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-end' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="新密码(至少6位)"
|
||||
type="password"
|
||||
value={pwForm.p1}
|
||||
onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))}
|
||||
margin="normal"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="确认新密码"
|
||||
type="password"
|
||||
value={pwForm.p2}
|
||||
onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))}
|
||||
margin="normal"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="outlined" size="small" onClick={resetPw} disabled={busy} sx={{ mb: 0.5 }}>重置密码</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: 13 }}>
|
||||
删除后将无法恢复,帖子 / 评论 / 附件一并处理
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<DeleteIcon />}
|
||||
disabled={isSelf || busy}
|
||||
onClick={() => setDeleteTarget({ id: editUser.id, username: editUser.username })}
|
||||
title={isSelf ? '不能删除自己' : ''}
|
||||
>
|
||||
删除用户
|
||||
</Button>
|
||||
</Box>
|
||||
{isSelf && <Typography variant="caption" color="text.secondary">不能删除自己的账号</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRoleDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={changeRole} disabled={busy}>确认修改</Button>
|
||||
<Button onClick={closeEdit}>取消</Button>
|
||||
<Button variant="contained" onClick={saveProfile} disabled={busy}>保存资料</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.username : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
open={!!deleteTarget}
|
||||
message={`确定要删除用户 "${deleteTarget ? deleteTarget.username : ''}" 吗?此操作不可恢复`}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,11 @@ body {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 设置区块锚点(搜索跳转定位):预留固定 AppBar 高度,避免被遮挡 */
|
||||
[id^="sec-"] {
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
|
||||
/* 滚动条(对应前台细滚动条) */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -27,6 +32,254 @@ body {
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; }
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; }
|
||||
|
||||
/* 工单工作区:占满 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: #2e7d32; }
|
||||
.ticket-save-error { color: #d32f2f; }
|
||||
|
||||
[data-theme="dark"] .ticket-save-success { color: #81c784; }
|
||||
[data-theme="dark"] .ticket-save-error { color: #ef9a9a; }
|
||||
|
||||
@media (max-width: 1199px) and (min-width: 700px) {
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: minmax(220px, 1.6fr) repeat(3, minmax(118px, 1fr));
|
||||
}
|
||||
|
||||
.ticket-filter-grid .ticket-filter-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ticket-filter-grid > .MuiButton-root {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) and (min-width: 700px) {
|
||||
.ticket-work-area {
|
||||
grid-template-columns: minmax(250px, 0.62fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 699px) {
|
||||
.ticket-workspace {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ticket-work-area {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ticket-list-pane > .MuiPaper-root,
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ticket-list-card {
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
flex: 0 1 auto;
|
||||
max-height: 350px;
|
||||
}
|
||||
|
||||
.ticket-list-row {
|
||||
grid-template-columns: max-content minmax(140px, 1.2fr) minmax(120px, 1fr) max-content max-content;
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ticket-filter-grid .ticket-filter-search,
|
||||
.ticket-filter-grid > .MuiButton-root {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 459px) {
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 减弱动效偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
|
||||
@@ -53,3 +53,7 @@ export function resetUserPassword(id, newPassword) {
|
||||
export function setUserRole(id, role) {
|
||||
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { role } });
|
||||
}
|
||||
/** 编辑用户资料(管理员):nickname/title/title_color/website/email/role */
|
||||
export function updateUser(id, data) {
|
||||
return request('/auth/users/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ export function createPost(data) {
|
||||
export function updatePost(id, data) {
|
||||
return request('/blog/posts/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
/** 解锁 markdown 锁定块(password 类):{ password? } → { ok, content }(内容仅前端内存态) */
|
||||
export function unlockLock(postId, index, password) {
|
||||
return request('/blog/posts/' + postId + '/locks/' + index + '/unlock', {
|
||||
method: 'POST',
|
||||
body: password !== undefined && password !== null ? { password } : {},
|
||||
});
|
||||
}
|
||||
export function deletePost(id) {
|
||||
return request('/blog/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -66,3 +74,8 @@ export function approveComment(id) {
|
||||
export function rejectComment(id) {
|
||||
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** 全量评论列表(仅管理员):status=all|pending|approved|rejected,分页 → { list, total, page, pageSize, totalPages } */
|
||||
export function listAllComments({ status = 'all', page = 1, pageSize = 20 } = {}) {
|
||||
return request(`/blog/comments?status=${encodeURIComponent(status)}&page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 分类 ────────────────────────────────────────
|
||||
/** 版块列表(含聚合:posts_count / today_count / moderators / icon / icon_color) */
|
||||
export function listCategories() {
|
||||
return request('/forum/categories');
|
||||
}
|
||||
/** 版块详情(含聚合与公告) */
|
||||
export function getCategory(id) {
|
||||
return request('/forum/categories/' + id);
|
||||
}
|
||||
export function createCategory(data) {
|
||||
return request('/forum/categories', { method: 'POST', body: data });
|
||||
}
|
||||
@@ -13,11 +18,30 @@ export function updateCategory(id, data) {
|
||||
export function deleteCategory(id) {
|
||||
return request('/forum/categories/' + id, { method: 'DELETE' });
|
||||
}
|
||||
/** 更新版块公告(版主 / 管理员) */
|
||||
export function updateAnnouncement(id, announcement) {
|
||||
return request('/forum/categories/' + id + '/announcement', { method: 'PUT', body: { announcement } });
|
||||
}
|
||||
/** 版块资料更新(版主 / 管理员):部分更新,只提交有改动的字段。
|
||||
* fields = { name?, description?, icon?, icon_color? };icon_color 空串表示清除 → 按名称哈希配色 */
|
||||
export function updateCategoryProfile(id, fields) {
|
||||
return request('/forum/categories/' + id + '/profile', { method: 'PUT', body: fields });
|
||||
}
|
||||
/** 指派版主(管理员);userIds 为用户 id 数组(后端 body 字段 user_ids) */
|
||||
export function updateModerators(id, userIds) {
|
||||
return request('/forum/categories/' + id + '/moderators', { method: 'PUT', body: { user_ids: userIds } });
|
||||
}
|
||||
|
||||
// ── 帖子 ────────────────────────────────────────
|
||||
/** 帖子列表;categoryId 可选 */
|
||||
export function listPosts(categoryId) {
|
||||
return request('/forum/posts' + (categoryId ? '?category_id=' + encodeURIComponent(categoryId) : ''));
|
||||
/** 帖子列表;opts = { categoryId, page, subCategory, q },返回 { posts, total, page, pageSize }(兼容裸数组) */
|
||||
export function listPosts(opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.categoryId) params.set('category_id', opts.categoryId);
|
||||
if (opts.page && opts.page > 1) params.set('page', String(opts.page));
|
||||
if (opts.subCategory) params.set('sub_category', opts.subCategory);
|
||||
if (opts.q) params.set('q', opts.q);
|
||||
const qs = params.toString();
|
||||
return request('/forum/posts' + (qs ? '?' + qs : ''));
|
||||
}
|
||||
export function getPost(id) {
|
||||
return request('/forum/posts/' + id);
|
||||
@@ -32,6 +56,64 @@ export function reply(postId, content) {
|
||||
export function deletePost(id) {
|
||||
return request('/forum/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
/** 编辑帖子(作者/版主/admin):body 部分更新 { title?, content?, sub_category?, use_markdown? } → edit_count+1 */
|
||||
export function updatePost(id, fields) {
|
||||
return request('/forum/posts/' + id, { method: 'PUT', body: fields });
|
||||
}
|
||||
/** 解锁 markdown 锁定块(password 类):{ password? } → { ok, content }(内容仅前端内存态) */
|
||||
export function unlockLock(postId, index, password) {
|
||||
return request('/forum/posts/' + postId + '/locks/' + index + '/unlock', {
|
||||
method: 'POST',
|
||||
body: password !== undefined && password !== null ? { password } : {},
|
||||
});
|
||||
}
|
||||
export function deleteReply(id) {
|
||||
return request('/forum/replies/' + id, { method: 'DELETE' });
|
||||
}
|
||||
/** 置顶 / 取消置顶(管理员或版主);后端 body 字段 pinned */
|
||||
export function setPinned(id, value) {
|
||||
return request('/forum/posts/' + id + '/pin', { method: 'PUT', body: { pinned: value ? 1 : 0 } });
|
||||
}
|
||||
/** 加精 / 取消加精(管理员或版主);后端 body 字段 essence */
|
||||
export function setEssence(id, value) {
|
||||
return request('/forum/posts/' + id + '/essence', { method: 'PUT', body: { essence: value ? 1 : 0 } });
|
||||
}
|
||||
/** 当前用户管理的版块 id 数组(admin 返回全部版块 id;版主返回其管理的) */
|
||||
export function listModerated() {
|
||||
return request('/forum/moderated').then((d) => {
|
||||
const raw = d && (d.category_ids || d.ids);
|
||||
if (Array.isArray(raw)) return raw.map((x) => Number(x)).filter((n) => !isNaN(n));
|
||||
const cats = d && d.categories;
|
||||
if (Array.isArray(cats)) {
|
||||
return cats.map((c) => (typeof c === 'object' ? c.id : Number(c))).filter((n) => !isNaN(n));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** 我管理的版块详情数组(admin 全量 / 版主其管辖),供管理台首页用。
|
||||
* 兼容三种后端形态:直接返回版块详情数组;{ categories: 详情数组, category_ids };旧 { category_ids } 按 id 过滤全量分类 */
|
||||
export async function listModeratedCategories(user) {
|
||||
const d = await request('/forum/moderated');
|
||||
if (Array.isArray(d)) return d; // 形态1:直接是版块详情数组
|
||||
if (Array.isArray(d && d.categories)) return d.categories; // 形态2:categories 已是详情数组
|
||||
const ids = (d && (d.category_ids || d.ids)) || []; // 形态3:仅 id 数组
|
||||
const cats = (await listCategories().catch(() => [])) || [];
|
||||
if (user && user.role === 'admin') return cats; // admin 的 category_ids 为空 = 全版块可管
|
||||
const idSet = new Set(ids.map(Number));
|
||||
return cats.filter((c) => idSet.has(c.id));
|
||||
}
|
||||
|
||||
// ── 禁言(版主/管理员) ──────────────────────────
|
||||
/** 版块禁言列表;返回 [{ user_id, username, permanent, muted_until, created_at }] */
|
||||
export function listMutes(categoryId) {
|
||||
return request('/forum/mutes?category_id=' + encodeURIComponent(categoryId));
|
||||
}
|
||||
/** 添加禁言:username 为被禁用户名(后端解析为 user_id),duration 为天数(1/7/30)或 'forever'(永久) */
|
||||
export function createMute(categoryId, username, duration) {
|
||||
return request('/forum/mutes', { method: 'PUT', body: { category_id: categoryId, username, duration } });
|
||||
}
|
||||
/** 解除禁言:按 (category_id, user_id) 定位(无独立 id) */
|
||||
export function deleteMute(categoryId, userId) {
|
||||
return request('/forum/mutes', { method: 'DELETE', body: { category_id: categoryId, user_id: userId } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
async function upload(path, file, extra) {
|
||||
async function upload(path, file, extra, fieldName = 'file') {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append(fieldName, file);
|
||||
if (extra) {
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
|
||||
@@ -38,6 +38,11 @@ export function uploadWallpaper(file) {
|
||||
return upload('/wallpaper', file);
|
||||
}
|
||||
|
||||
/** 版块图标上传(multipart 字段名 icon,≤1MB),返回 { url: '/uploads/icons/xxx.png' } */
|
||||
export function uploadIcon(file) {
|
||||
return upload('/icon', file, undefined, 'icon');
|
||||
}
|
||||
|
||||
/** 按 uid 获取头像地址(支持 QQ 自动头像) */
|
||||
export function avatarUrl(uid) {
|
||||
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
|
||||
@@ -56,3 +61,18 @@ export function listAttachments(refType, refId) {
|
||||
export function deleteAttachment(id) {
|
||||
return request('/upload/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** 搜索引擎验证文件上传(仅管理员,multipart 字段名 file,≤64KB),返回 { url, message } */
|
||||
export function uploadVerifyFile(file) {
|
||||
return upload('/verify-file', file);
|
||||
}
|
||||
|
||||
/** 已上传验证文件列表(仅管理员),返回 [{ name, url }] */
|
||||
export function listVerifyFiles() {
|
||||
return request('/upload/verify-files');
|
||||
}
|
||||
|
||||
/** 删除验证文件(仅管理员),返回 { message } */
|
||||
export function deleteVerifyFile(name) {
|
||||
return request('/upload/verify-file/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 公开用户主页(无鉴权) ────────────────────────
|
||||
|
||||
/**
|
||||
* 公开用户资料:{ id, username, role, avatar, bio, created_at, last_active_at, stats }
|
||||
* 不存在时 404 { error: '用户不存在' }
|
||||
*/
|
||||
export function getPublicUser(id) {
|
||||
return request('/users/' + id);
|
||||
}
|
||||
|
||||
/** 用户帖子列表(分页)→ { list, total, page, pageSize, totalPages };论坛私密时 403 */
|
||||
export function getUserPosts(id, page = 1, pageSize = 10) {
|
||||
return request(`/users/${id}/posts?page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
|
||||
/** 用户回复列表(分页)→ { list, total, page, pageSize, totalPages };论坛私密时 403 */
|
||||
export function getUserReplies(id, page = 1, pageSize = 10) {
|
||||
return request(`/users/${id}/replies?page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/** 名称哈希 → 0..11 色调组(复用 style.css 的 --fi-* 12 色 MD3 变量,深浅色自适应;供 Banner 渐变等复用) */
|
||||
export function hashTone(name) {
|
||||
let h = 0;
|
||||
const k = String(name || '');
|
||||
for (let i = 0; i < k.length; i += 1) h = (h * 31 + k.charCodeAt(i)) >>> 0;
|
||||
return h % 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆形用户头像(MD3):
|
||||
* - src 非空 → 圆形 <img>(object-fit: cover)
|
||||
* - src 空 → 首字兜底(name[0] 大写 + 名称哈希底色)
|
||||
* - to 非空 → 外层包 <Link to={to}>(点击跳公开主页;内层 stopPropagation 防嵌套 Link 冒泡)
|
||||
* props: { src, name, size=36, className, to }
|
||||
*/
|
||||
export default function Avatar({ src = '', name = '', size = 36, className = '', to }) {
|
||||
const style = { width: size, height: size, fontSize: Math.round(size * 0.42) };
|
||||
const base = 'avatar' + (className ? ' ' + className : '');
|
||||
const alt = name ? `${name} 的头像` : '头像';
|
||||
const letter = String(name || '?').charAt(0).toUpperCase();
|
||||
|
||||
// 跳转版本:Link 作为圆形容器(cursor 指针),fallback 底色放内层 span
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={base + ' avatar-link'}
|
||||
style={style}
|
||||
aria-label={alt}
|
||||
title={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{src
|
||||
? <img src={src} alt={alt} />
|
||||
: (
|
||||
<span
|
||||
className={'avatar-fallback tone-' + hashTone(name)}
|
||||
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: style.fontSize }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<span className={base} style={style}>
|
||||
<img src={src} alt={alt} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`${base} avatar-fallback tone-${hashTone(name)}`} style={style} role="img" aria-label={alt}>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { listPosts } from '../api/blog.js';
|
||||
import BrandIcon, { BRAND_ICON_NAMES } from './BrandIcon.jsx';
|
||||
|
||||
/**
|
||||
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
|
||||
@@ -42,7 +43,9 @@ export default function BlogSidebar({ settings = {}, showRecent = false, forceSh
|
||||
<div className="homepage-contacts">
|
||||
{contacts.map((l, i) => (
|
||||
<a key={i} href={l.url} target="_blank" rel="noopener" className="hp-contact-link" title={l.title || ''}>
|
||||
<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>
|
||||
))}
|
||||
</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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -68,18 +68,64 @@ function ensureThirdPartyScript(type) {
|
||||
}
|
||||
}
|
||||
|
||||
// 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码
|
||||
const THIRD_PARTY_LOAD_TIMEOUT = 8000;
|
||||
|
||||
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
|
||||
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
|
||||
function queueRender(type, render) {
|
||||
// 脚本注入后启动 8s 加载超时:届时库仍未就绪 → 调用 onTimeout 触发降级;
|
||||
// 库已就绪但回调错过 → 冲刷队列兜底(wrappedRender 执行并自行清理计时器)。
|
||||
// 返回清理函数:移除排队回调并清除 300ms 竞态与 8s 超时两个计时器,
|
||||
// 供弹窗关闭/重开时防泄漏、防残留降级状态。
|
||||
function queueRender(type, render, onTimeout) {
|
||||
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
|
||||
if (typeof window[lib] !== 'undefined') { render(); return; }
|
||||
if (typeof window[lib] !== 'undefined') { render(); return null; }
|
||||
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||
window[key] = window[key] || [];
|
||||
window[key].push(render);
|
||||
|
||||
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);
|
||||
setTimeout(() => {
|
||||
|
||||
// 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 使用)
|
||||
@@ -89,8 +135,8 @@ async function sha256(str) {
|
||||
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// 内置 SVG 验证码弹窗
|
||||
function BuiltinCaptcha({ onFinish }) {
|
||||
// 内置 SVG 验证码弹窗(notice 为可选提示,用于第三方降级时告知用户)
|
||||
function BuiltinCaptcha({ onFinish, notice }) {
|
||||
const [svg, setSvg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [answer, setAnswer] = useState('');
|
||||
@@ -159,6 +205,23 @@ function BuiltinCaptcha({ onFinish }) {
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 12 }}>验证码</h3>
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
color: 'var(--md-ref-on-surface-variant)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: '0 0 12px',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--md-ref-surface-container-high)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
|
||||
{svg
|
||||
? <div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
@@ -202,12 +265,14 @@ function BuiltinCaptcha({ onFinish }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
|
||||
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方。
|
||||
// 脚本 8s 内加载不出来时自动降级到内置 SVG 验证码(proof 路径),不阻塞用户操作。
|
||||
function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
const siteKey = type === 'recaptcha'
|
||||
? (window._recaptchaSiteKey || '')
|
||||
: (window._turnstileSiteKey || '');
|
||||
const [status, setStatus] = useState('正在加载...');
|
||||
const [degraded, setDegraded] = useState(false);
|
||||
const widgetRef = useRef(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
@@ -215,7 +280,7 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
if (!siteKey) { onFinish(null); return; }
|
||||
const container = widgetRef.current;
|
||||
const render = () => {
|
||||
if (doneRef.current) return; // 弹窗已取消/卸载,不再向已移除的容器渲染
|
||||
if (doneRef.current) return; // 弹窗已取消/卸载或已降级,不再向已移除的容器渲染
|
||||
try {
|
||||
if (type === 'recaptcha') {
|
||||
const wid = window.grecaptcha.render(container, {
|
||||
@@ -247,11 +312,30 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
}
|
||||
};
|
||||
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
|
||||
queueRender(type, render);
|
||||
// 卸载时不触发重复回调
|
||||
return () => { doneRef.current = true; };
|
||||
// 8s 加载超时 → 自动降级到内置验证码,避免长时间卡「正在加载」阻塞登录
|
||||
const cleanupQueue = queueRender(type, render, () => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
setDegraded(true);
|
||||
});
|
||||
// 卸载/重开时:防止重复回调,清除超时计时器并移除排队回调(防泄漏/防残留降级状态)
|
||||
return () => {
|
||||
doneRef.current = true;
|
||||
if (cleanupQueue) cleanupQueue();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 降级:第三方脚本长时间加载不出来,复用内置 SVG 验证码完整流程
|
||||
// (校验仍走 loadImage/verify,调用方拿到 { type: 'proof', value })
|
||||
if (degraded) {
|
||||
return (
|
||||
<BuiltinCaptcha
|
||||
onFinish={onFinish}
|
||||
notice="第三方验证码加载失败,已切换为内置验证码"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
||||
|
||||
@@ -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 { Link } from 'react-router-dom';
|
||||
import { safeOutUrl, parseFooterColumns } from '../lib/outlink.js';
|
||||
|
||||
/**
|
||||
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
|
||||
@@ -28,18 +29,35 @@ export default function Footer({ settings = {}, version = '' }) {
|
||||
const desc = settings.footer_desc || settings.site_description || '';
|
||||
|
||||
if (style === 'columns') {
|
||||
// 导航栏目固定不可编辑(后台提示),首页/博客/论坛走 SPA,管理后台整页跳转
|
||||
const navCols = [
|
||||
// 栏目来源:后台 footer_columns 配置(非空优先,最多取前 3 栏);空则回退硬编码三栏导航
|
||||
const hardcodedCols = [
|
||||
{ title: '导航', links: [
|
||||
{ to: '/', label: '首页' },
|
||||
{ to: '/blog.html', label: '博客' },
|
||||
{ to: '/forum.html', label: '论坛' },
|
||||
{ label: '首页', url: '/' },
|
||||
{ label: '博客', url: '/blog.html' },
|
||||
{ label: '论坛', url: '/forum.html' },
|
||||
] },
|
||||
{ title: '其他', links: [
|
||||
{ to: '/admin', label: '管理后台', external: true },
|
||||
{ to: '/profile.html', label: '个人中心' },
|
||||
{ title: '阅读', links: [
|
||||
{ label: '归档', url: '/archive.html' },
|
||||
{ label: 'RSS 订阅', url: '/feed.xml' },
|
||||
] },
|
||||
{ title: '账号', links: [
|
||||
{ label: '个人中心', url: '/profile.html' },
|
||||
{ label: '管理后台', url: '/admin', fullPage: true },
|
||||
{ label: '登录', url: '/login.html' },
|
||||
] },
|
||||
];
|
||||
const customCols = parseFooterColumns(settings.footer_columns);
|
||||
const cols = (customCols.length > 0 ? customCols : hardcodedCols).slice(0, 3);
|
||||
|
||||
/** 栏目链接:站内路径走 SPA Link(整页跳转项除外);http(s) 外链走 /out 确认页 */
|
||||
const colLink = (l, i) => {
|
||||
const isSite = l.url.startsWith('/');
|
||||
const isSpa = isSite && !l.fullPage;
|
||||
return isSpa
|
||||
? <Link key={i} to={l.url}>{l.label || l.url}</Link>
|
||||
: <a key={i} href={safeOutUrl(l.url)}>{l.label || l.url}</a>;
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="footer-columns">
|
||||
<div className="fc-grid">
|
||||
@@ -51,17 +69,11 @@ export default function Footer({ settings = {}, version = '' }) {
|
||||
</div>
|
||||
{desc && <HtmlOrText value={desc} className="brand-desc" />}
|
||||
</div>
|
||||
{navCols.map((col) => (
|
||||
<nav key={col.title} className="fc-col">
|
||||
<div className="col-title">{col.title}</div>
|
||||
{cols.map((col, ci) => (
|
||||
<nav key={ci} className="fc-col">
|
||||
<div className="col-title">{col.title || '未命名栏目'}</div>
|
||||
<div className="col-links">
|
||||
{col.links.map((l) =>
|
||||
l.external ? (
|
||||
<a key={l.to} href={l.to}>{l.label}</a>
|
||||
) : (
|
||||
<Link key={l.to} to={l.to}>{l.label}</Link>
|
||||
)
|
||||
)}
|
||||
{(col.links || []).map((l, li) => colLink(l, ci + '-' + li))}
|
||||
</div>
|
||||
</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,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useTheme } from '../theme.jsx';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
import { isFamilyDomain } from '../lib/outlink.js';
|
||||
import MusicEmbed from './MusicEmbed.jsx';
|
||||
import CaptchaModalHost from './CaptchaModal.jsx';
|
||||
import Footer from './Footer.jsx';
|
||||
@@ -23,6 +24,48 @@ export default function Layout() {
|
||||
const [version, setVersion] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// ── 移动端汉堡抽屉 ──
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuBtnRef = useRef(null);
|
||||
const drawerRef = useRef(null);
|
||||
|
||||
// 抽屉焦点管理:打开锁定背景滚动并聚焦首个可聚焦项,关闭还原滚动并聚焦汉堡按钮
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
const first = drawerRef.current && drawerRef.current.querySelector('a, button, [tabindex]');
|
||||
if (first && typeof first.focus === 'function') first.focus();
|
||||
else if (drawerRef.current) drawerRef.current.focus();
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
if (menuBtnRef.current && typeof menuBtnRef.current.focus === 'function') menuBtnRef.current.focus();
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [menuOpen]);
|
||||
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
|
||||
// ── 全站外链拦截:http(s) 非本站链接 → /out 确认页(事件委托,覆盖 markdown 渲染出的外链)──
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.defaultPrevented) return;
|
||||
// 新标签打开(Ctrl/Cmd/Shift/中键)是用户主动分屏意图,不劫持
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
const href = (a.getAttribute('href') || '').trim();
|
||||
if (!/^https?:\/\//i.test(href)) return; // 站内路径(/ 开头)不拦
|
||||
// 本站同源(www.rainnya.asia)不拦——解析相对链接得到本站 origin 也自然跳过;
|
||||
// rainnya.asia 家族域名(含子域名)也视为站内,直接跳转不走确认页
|
||||
if (new URL(href, window.location.origin).origin === window.location.origin) return;
|
||||
if (isFamilyDomain(href)) return;
|
||||
e.preventDefault();
|
||||
window.location.href = '/out?url=' + encodeURIComponent(href);
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, []);
|
||||
|
||||
// 初始化向导:未完成安装时跳转 /setup.html(迁移自 nav.js)
|
||||
useEffect(() => {
|
||||
fetch('/api/setup/status')
|
||||
@@ -138,6 +181,7 @@ export default function Layout() {
|
||||
{ to: '/', label: '首页', end: true, show: true },
|
||||
{ to: '/blog.html', label: '博客', show: true },
|
||||
{ to: '/forum.html', label: '论坛', show: !!user },
|
||||
{ to: '/tickets.html', label: '工单', show: true },
|
||||
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
||||
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
|
||||
];
|
||||
@@ -149,6 +193,17 @@ export default function Layout() {
|
||||
<>
|
||||
<nav className={navClass} id="mainNav">
|
||||
<div className="nav-left">
|
||||
<button
|
||||
ref={menuBtnRef}
|
||||
type="button"
|
||||
className="btn-icon nav-hamburger"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
aria-label="打开导航菜单"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className="material-icons">menu</span>
|
||||
</button>
|
||||
<Link to="/" className="nav-brand">{siteName}</Link>
|
||||
<span className="nav-version">v{version}</span>
|
||||
<div className="nav-tabs">
|
||||
@@ -179,13 +234,14 @@ export default function Layout() {
|
||||
</button>
|
||||
{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
|
||||
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover', marginRight: 4 }} />
|
||||
: <span className="material-icons" style={{ fontSize: 18, marginRight: 2 }}>person</span>}
|
||||
{user.username}
|
||||
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
: <span className="material-icons" style={{ fontSize: 18 }}>person</span>}
|
||||
<span className="nav-user-name">{user.username}</span>
|
||||
</Link>
|
||||
<button className="btn btn-text btn-sm" onClick={handleLogout}>退出</button>
|
||||
{/* 退出登录统一在个人中心 / 移动端抽屉中操作,顶栏不再放退出按钮 */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -196,6 +252,65 @@ export default function Layout() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 移动端导航抽屉(汉堡菜单) */}
|
||||
<div className={'drawer-overlay' + (menuOpen ? ' open' : '')} onClick={closeMenu} aria-hidden="true" />
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className={'drawer' + (menuOpen ? ' open' : '')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="导航菜单"
|
||||
aria-hidden={!menuOpen}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') closeMenu(); }}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<span className="drawer-brand">{siteName}</span>
|
||||
<button type="button" className="btn-icon" onClick={closeMenu} aria-label="关闭菜单">
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="drawer-menu" aria-label="页面导航">
|
||||
{tabs.filter((t) => t.show).map((t) =>
|
||||
t.fullPage ? (
|
||||
<a key={t.to} href={t.to} className="drawer-item" onClick={closeMenu}>{t.label}</a>
|
||||
) : (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) => 'drawer-item' + (isActive ? ' active' : '')}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">账号</div>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/profile.html" className="drawer-item" onClick={closeMenu}>个人中心</Link>
|
||||
<button type="button" className="drawer-item drawer-item-btn" onClick={() => { closeMenu(); handleLogout(); }}>
|
||||
退出登录
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login.html" className="drawer-item" onClick={closeMenu}>登录</Link>
|
||||
<Link to="/register.html" className="drawer-item" onClick={closeMenu}>注册</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="drawer-footer">
|
||||
<button type="button" className="btn btn-tonal btn-sm" onClick={() => { closeMenu(); handleToggleTheme(); }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
{theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -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 { escapeHtml } from '../lib/utils.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import LockBlock from './LockBlock.jsx';
|
||||
|
||||
/** [image:文件名] → /uploads/ 图片 */
|
||||
function imageTag(filename) {
|
||||
if (!filename) return '';
|
||||
return `<img src="/uploads/${encodeURIComponent(filename)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
|
||||
/**
|
||||
* 锁块附件鉴权地址:后端 /api/upload/locked 按 (ref_type, ref_id, block, file) + token 校验,
|
||||
* 只允许取「该帖该块」已解锁的附件。缺 refType/refId/token 返回 null → 调用方回退原直链。
|
||||
*/
|
||||
function lockedAttachmentUrl(refType, refId, blockIndex, filename, token) {
|
||||
if (!refType || refId == null || !token) return null;
|
||||
return `/api/upload/locked?ref_type=${encodeURIComponent(refType)}&ref_id=${refId}&block=${encodeURIComponent(blockIndex)}&file=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/** [file:文件名] → 带 token 的下载链接 */
|
||||
function fileTag(filename) {
|
||||
/** [image:文件名] → 图片标签;lockedUrl 存在则 src 走鉴权接口,否则 /uploads/ 直链 */
|
||||
function imageTag(filename, lockedUrl) {
|
||||
if (!filename) return '';
|
||||
const src = lockedUrl || `/uploads/${encodeURIComponent(filename)}`;
|
||||
return `<img src="${src}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
|
||||
}
|
||||
|
||||
/** [file:文件名] → 下载链接;lockedUrl 存在则 href 走鉴权接口(保留 target=_blank 与样式),否则原下载逻辑 */
|
||||
function fileTag(filename, lockedUrl) {
|
||||
if (!filename) return '';
|
||||
const token = getToken() || '';
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥离内联 [lock:] 成对标签,保留块内内容(只去标签不去内容)。
|
||||
* 保留导出供外部/预览使用;renderContent 不再调用——组件层已用切段方案接管(保留锁块容器)。
|
||||
*/
|
||||
export function stripLockTags(content) {
|
||||
return String(content).replace(/\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\]/g, '$1');
|
||||
}
|
||||
|
||||
/** 从整块标签提取 lock 类型参数:'login' / 'reply' / 'password'(无参数返回 '') */
|
||||
function extractLockType(block) {
|
||||
const m = String(block).match(/^\[lock(?::([^\]]*))?\]/);
|
||||
const param = (m && m[1]) || '';
|
||||
return param.split(':')[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按内联 [lock:] 成对标签切段(split 带捕获组):
|
||||
* 交替产出 [文本, 整块(含标签), 块内内容, 文本, …](i%3:0=文本 / 1=整块 / 2=块内内容)
|
||||
*/
|
||||
const INLINE_LOCK_RE = /(\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\])/g;
|
||||
|
||||
/** 统计文本段内内联 [lock:] 成对标签数量(全文锁块索引游标推进用) */
|
||||
function countInlineLocks(text) {
|
||||
const m = String(text).match(INLINE_LOCK_RE);
|
||||
return m ? m.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一段文本:文本段走 renderContent;内联 [lock:] 段渲染「已解锁内容」容器。
|
||||
* 已解锁块(admin/作者/登录或评论后可见)后端在 content 里原样保留 [lock:...]...[/lock] 标签,
|
||||
* 这里按标签切成锁块容器(同款背景+边框 + 🔓 已解锁头部),内容递归 MarkdownRenderer 渲染。
|
||||
* rest.inlineStartIdx:本段起始内联块在全文中的索引(buildNodes 用锁块游标给出),
|
||||
* 用于查 locks 数组拿该块的附件 token(块内 [image:]/[file:] 走鉴权接口)。
|
||||
*/
|
||||
function renderSegments(text, useMarkdown, rest) {
|
||||
const { locks, refType, refId, blockCtx: parentCtx } = rest;
|
||||
const parts = String(text).split(INLINE_LOCK_RE);
|
||||
const nodes = [];
|
||||
let textIdx = 0;
|
||||
let lockIdx = 0;
|
||||
let tagIdx = rest.inlineStartIdx || 0; // 内联块在全文中的索引游标
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const p = parts[i];
|
||||
if (i % 3 === 1) {
|
||||
// 整块(含标签),紧随其后 i+1 是对应块内内容
|
||||
const inner = parts[i + 1] || '';
|
||||
const type = extractLockType(p);
|
||||
const idx = tagIdx++;
|
||||
// 内联块附件上下文:已处锁块内(parentCtx)时沿用父块 token(嵌套块文件同属父块);
|
||||
// 顶层则按本段索引查 locks 数组拿该块的 token(无 token 保持直链向后兼容)
|
||||
let childCtx = null;
|
||||
if (parentCtx) {
|
||||
childCtx = parentCtx;
|
||||
} else {
|
||||
const meta = (Array.isArray(locks) && locks.find((l) => l.index === idx)) || {};
|
||||
if (meta.token) childCtx = { index: idx, token: meta.token };
|
||||
}
|
||||
nodes.push(
|
||||
<div key={'reveal' + lockIdx++} className="lock-reveal">
|
||||
<div className="lock-placeholder lock-revealed">
|
||||
<div className="lock-revealed-head">
|
||||
<span className="material-icons" style={{ fontSize: 16 }} aria-hidden="true">lock_open</span>
|
||||
已解锁内容
|
||||
{type ? <span className="lock-revealed-type">{type}</span> : null}
|
||||
</div>
|
||||
<div className="lock-revealed-body">
|
||||
<MarkdownRenderer
|
||||
content={inner}
|
||||
useMarkdown={useMarkdown}
|
||||
locks={locks}
|
||||
unlocked={rest.unlocked}
|
||||
lockContent={rest.lockContent}
|
||||
onUnlock={rest.onUnlock}
|
||||
onGoComment={rest.onGoComment}
|
||||
refType={refType}
|
||||
refId={refId}
|
||||
blockCtx={childCtx}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
i += 1; // 跳过内容段(已作为 inner 渲染)
|
||||
} else if (p) {
|
||||
// 文本段:仅当外层处于已解锁锁块内(parentCtx)时附件才改走鉴权接口
|
||||
const attach = parentCtx ? { refType, refId, blockIndex: parentCtx.index, token: parentCtx.token || '' } : null;
|
||||
nodes.push(<RawBlock key={'text' + textIdx++} html={renderContent(p, useMarkdown, attach)} />);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
|
||||
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
|
||||
* attach(可选)= { refType, refId, blockIndex, token }:处于已解锁锁块内时的附件鉴权上下文;
|
||||
* 缺省则 [image:]/[file:] 照旧 /uploads/ 直链与下载逻辑。本函数只处理单段文本,lock 分段在组件层完成。
|
||||
*/
|
||||
export function renderContent(content, useMarkdown) {
|
||||
export function renderContent(content, useMarkdown, attach) {
|
||||
if (content == null) return '';
|
||||
|
||||
// 锁块附件地址生成器:无 attach 上下文或无 token 时返回 null → 走原直链/下载逻辑
|
||||
const lockedUrl = (filename) => (attach
|
||||
? lockedAttachmentUrl(attach.refType, attach.refId, attach.blockIndex, filename, attach.token)
|
||||
: null);
|
||||
|
||||
if (!useMarkdown) {
|
||||
let html = escapeHtml(content);
|
||||
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f));
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f));
|
||||
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f, lockedUrl(f)));
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f, lockedUrl(f)));
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
@@ -41,8 +152,8 @@ export function renderContent(content, useMarkdown) {
|
||||
|
||||
html = marked.parse(html, { breaks: true, gfm: true });
|
||||
|
||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
|
||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[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)], lockedUrl(files[parseInt(i)])));
|
||||
html = DOMPurify.sanitize(html);
|
||||
|
||||
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
||||
@@ -52,7 +163,110 @@ export function renderContent(content, useMarkdown) {
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
|
||||
const html = useMemo(() => renderContent(content, useMarkdown), [content, useMarkdown]);
|
||||
return <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
/** 单段文本块:dangerouslySetInnerHTML 包装(renderContent 已 DOMPurify 净化) */
|
||||
function RawBlock({ html }) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建渲染节点(两层分段):
|
||||
* 1. @@LOCK<n>@@ 占位分段(详情页 locks 非空)——lock 段:已解锁+有内容 → 递归渲染(.lock-reveal 淡入),
|
||||
* 否则 LockBlock 锁定卡片;文本段 → 再按内联 [lock:] 切段(已解锁容器 / 文本)。
|
||||
* 文本段内联块索引用全文游标(nextIdx)衔接,保证与后端 parseLocks 的 index 一致。
|
||||
* 2. locks 为空(含 previewMode 编辑预览):整段按内联 [lock:] 切段,作者视角同样显示已解锁容器
|
||||
*/
|
||||
function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }) {
|
||||
if (content == null) return null;
|
||||
const str = String(content);
|
||||
const hasLocks = Array.isArray(locks) && locks.length > 0;
|
||||
|
||||
if (hasLocks) {
|
||||
// 服务端已把未解锁块替换为 @@LOCK<n>@@;split 交替产出 [文本, 索引, 文本, 索引…]
|
||||
const parts = str.split(/@@LOCK(\d+)@@/);
|
||||
const nodes = [];
|
||||
let nextIdx = 0; // 全文锁块索引游标:内联块与 @@LOCK 占位按文档顺序递增
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const p = parts[i];
|
||||
if (i % 2 === 1) {
|
||||
const n = parseInt(p, 10);
|
||||
nextIdx = n + 1; // 占位符自带索引,同步游标
|
||||
const meta = locks.find((l) => l.index === n) || {};
|
||||
const type = meta.type || 'password';
|
||||
const lockEntry = unlocked && unlocked.has(n) && lockContent && lockContent.get(n);
|
||||
if (lockEntry) {
|
||||
// lockContent 兼容纯字符串或 { content, token };token 优先取 locks 数组项(后端按块签发)
|
||||
const raw = typeof lockEntry === 'object' ? lockEntry.content : lockEntry;
|
||||
const entryToken = typeof lockEntry === 'object' && lockEntry.token ? lockEntry.token : '';
|
||||
const token = (meta.token || entryToken) || '';
|
||||
nodes.push(
|
||||
<div key={'lock' + n} className="lock-reveal">
|
||||
<MarkdownRenderer
|
||||
content={raw}
|
||||
useMarkdown={useMarkdown}
|
||||
locks={locks}
|
||||
unlocked={unlocked}
|
||||
lockContent={lockContent}
|
||||
onUnlock={onUnlock}
|
||||
onGoComment={onGoComment}
|
||||
refType={refType}
|
||||
refId={refId}
|
||||
blockCtx={{ index: n, token }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
nodes.push(<LockBlock key={'lock' + n} index={n} type={type} onUnlock={onUnlock} onGoComment={onGoComment} />);
|
||||
}
|
||||
} else if (p) {
|
||||
// 文本段:可能仍含内联 [lock:](已解锁块)→ 切段渲染已解锁容器
|
||||
nodes.push(...renderSegments(p, useMarkdown, {
|
||||
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
|
||||
inlineStartIdx: nextIdx,
|
||||
}));
|
||||
nextIdx += countInlineLocks(p);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// locks 为空:整段按内联 [lock:] 切段(previewMode 作者预览同样显示「已解锁内容」容器)
|
||||
return renderSegments(str, useMarkdown, {
|
||||
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
|
||||
inlineStartIdx: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染器(前台统一入口):
|
||||
* props:
|
||||
* content / useMarkdown — 同原有
|
||||
* locks — [{index, type}](新后端带 {index, type, token})详情接口返回的锁定块元信息
|
||||
* unlocked — Set<number> 已解锁索引(login/reply 由服务端判定后内容直接给原文;
|
||||
* password 类解锁后内容存 lockContent)
|
||||
* lockContent — Map<number, string | {content, token}> password 块解锁拿到的 markdown(仅内存态)
|
||||
* onUnlock — async (index, password?) → 解锁;失败 throw
|
||||
* onGoComment — reply 块「去评论」滚动回调
|
||||
* previewMode — 编辑器作者预览:锁定标签直接展开
|
||||
* refType — 'blog' | 'forum'(可选):提供后锁块内 [image:]/[file:] 附件改走鉴权接口
|
||||
* refId — 详情 id(可选,配合 refType)
|
||||
* blockCtx — {index, token}(可选):当前渲染上下文处于已解锁锁块内,附件用该块的 token 鉴权
|
||||
*/
|
||||
export default function MarkdownRenderer({
|
||||
content = '',
|
||||
useMarkdown = true,
|
||||
locks,
|
||||
unlocked,
|
||||
lockContent,
|
||||
onUnlock,
|
||||
onGoComment,
|
||||
previewMode = false,
|
||||
refType,
|
||||
refId,
|
||||
blockCtx,
|
||||
}) {
|
||||
const nodes = useMemo(
|
||||
() => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }),
|
||||
[content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx]
|
||||
);
|
||||
return <div className="md-body">{nodes}</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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 */
|
||||
export function formatDate(input) {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { listPosts, searchPosts, getTags } from '../api/blog.js';
|
||||
import { getPublicSettings } from '../api/settings.js';
|
||||
@@ -13,10 +13,23 @@ function excerptOf(p) {
|
||||
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
|
||||
* 顶部搜索框(/api/blog/search)+ 标签云(/api/blog/tags,点击进 /tag/:name)+ 归档入口 */
|
||||
export default function Blog() {
|
||||
const navigate = useNavigate();
|
||||
const [settings, setSettings] = useState({});
|
||||
const [posts, setPosts] = useState(null); // null=加载中
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 分享链接跳转 SPA 完整版:/blog/:id/share → 302 到此页 ?share=<id>,
|
||||
// 检测到后客户端 navigate 到详情(React Router 内部跳转,不触发整页刷新 → 完整 SPA UI)。
|
||||
// 只在首次挂载处理一次;replaceState 清掉 share 参数避免刷新重复跳。
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const shareId = params.get('share');
|
||||
if (shareId && /^\d+$/.test(shareId)) {
|
||||
history.replaceState({}, '', window.location.pathname);
|
||||
navigate('/blog/' + shareId);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// 搜索状态
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState(null); // null=未搜索
|
||||
@@ -114,6 +127,9 @@ export default function Blog() {
|
||||
<Link to="/archive.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
|
||||
</Link>
|
||||
<a href="/feed.xml" target="_blank" rel="noopener" className="btn btn-text btn-sm" title="RSS 订阅" aria-label="RSS 订阅">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>rss_feed</span> RSS
|
||||
</a>
|
||||
</form>
|
||||
|
||||
{/* 标签云 */}
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as blogApi from '../api/blog.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { 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:] 标签后,中文字符 + 英文单词数 */
|
||||
function countWords(content) {
|
||||
@@ -34,6 +37,47 @@ export default function BlogDetail() {
|
||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||
const [toc, setToc] = useState([]);
|
||||
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
// 评论作者 UID 显示开关(site_settings.show_uid_in_comments,默认显示)
|
||||
const [showCommentUid, setShowCommentUid] = useState(true);
|
||||
// markdown 锁定块:locks 元信息 / 已解锁索引(Set)/ password 块内容(Map,仅内存态)/ viewer 判定结果
|
||||
const [locks, setLocks] = useState([]);
|
||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||
const [lockContent, setLockContent] = useState(() => new Map());
|
||||
const [viewer, setViewer] = useState(null);
|
||||
|
||||
// 分享链接:/blog/:id/share → 服务端 302 到 SPA 完整版(运行时拼接,不硬编码域名)
|
||||
const shareUrl = typeof window !== 'undefined'
|
||||
? window.location.origin + '/blog/' + id + '/share'
|
||||
: '';
|
||||
|
||||
// 分享弹窗键盘/焦点管理(Esc 关闭 + 焦点还给触发按钮)
|
||||
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
|
||||
|
||||
/** 剪贴板写入:优先 Async Clipboard,失败降级 execCommand(非安全上下文/LAN 部署) */
|
||||
const copyShareLink = async () => {
|
||||
const ok = await (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = shareUrl;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const res = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return res;
|
||||
} catch { return false; }
|
||||
}
|
||||
})();
|
||||
if (ok) showSnackbar('链接已复制');
|
||||
else showSnackbar('复制失败,请手动复制');
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -50,6 +94,11 @@ export default function BlogDetail() {
|
||||
setComments(cs || []);
|
||||
setPrevnext(pn);
|
||||
setLike(lk || { liked: false, count: 0 });
|
||||
// 锁定块:详情接口返回 locks/unlocked/viewer(旧数据无则空)
|
||||
setLocks((p && p.locks) || []);
|
||||
setUnlocked(new Set((p && p.unlocked) || []));
|
||||
setLockContent(new Map());
|
||||
setViewer((p && p.viewer) || null);
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
@@ -59,6 +108,10 @@ export default function BlogDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// 评论 UID 开关(公开设置;后端未加字段时默认显示)
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => setShowCommentUid(s.show_uid_in_comments !== '0'))
|
||||
.catch(() => setShowCommentUid(true));
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
} else {
|
||||
@@ -84,6 +137,26 @@ export default function BlogDetail() {
|
||||
|
||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
||||
|
||||
// ── markdown 锁定块 ──
|
||||
// 解锁 password 块:调解锁接口,内容 + 附件 token(lockToken)仅存内存 state(刷新失效)
|
||||
const unlockLock = async (index, password) => {
|
||||
const res = await blogApi.unlockLock(id, index, password);
|
||||
if (!res || !res.ok || !res.content) throw new Error((res && res.error) || '解锁失败');
|
||||
setLockContent((prev) => {
|
||||
const m = new Map(prev);
|
||||
m.set(index, { content: res.content, token: res.lockToken || '' });
|
||||
return m;
|
||||
});
|
||||
setUnlocked((prev) => { const s = new Set(prev); s.add(index); return s; });
|
||||
};
|
||||
|
||||
// reply 块「去评论」:滚动到评论区(锁定块在正文中,评论区在下方)
|
||||
const scrollToComment = () => {
|
||||
const el = document.querySelector('.comment-form') || document.querySelector('.comment-list');
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
else window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// ── 点赞(乐观更新)──
|
||||
const toggleLike = async () => {
|
||||
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
||||
@@ -107,7 +180,15 @@ export default function BlogDetail() {
|
||||
return (
|
||||
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
|
||||
<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>}
|
||||
</div>
|
||||
<div className="reply-body">{c.content}</div>
|
||||
@@ -171,7 +252,15 @@ export default function BlogDetail() {
|
||||
<h1 className="article-title">{post.title}</h1>
|
||||
|
||||
<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="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
|
||||
</span>
|
||||
@@ -191,12 +280,25 @@ export default function BlogDetail() {
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||
<span className="like-count">{like.count}</span>
|
||||
</button>
|
||||
<button className="btn btn-tonal btn-sm" onClick={() => setShareOpen(true)} aria-label="分享文章">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
||||
<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) && (
|
||||
@@ -293,6 +395,43 @@ export default function BlogDetail() {
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 分享弹窗(与全站 dialog 风格一致:Esc/遮罩关闭、焦点管理) */}
|
||||
{shareOpen && (
|
||||
<div
|
||||
ref={shareDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="分享文章"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
|
||||
onKeyDown={shareDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>分享文章</h3>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
|
||||
复制链接分享,好友打开直达完整阅读页面
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="shareUrlInput">分享链接</label>
|
||||
<input
|
||||
id="shareUrlInput"
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
|
||||
<button className="btn btn-filled" onClick={copyShareLink}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,282 +1,176 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import * as announcementsApi from '../api/announcements.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { uploadFile } from '../api/upload.js';
|
||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
import ForumIcon from '../components/ForumIcon.jsx';
|
||||
|
||||
/** 板块的子分类列表 */
|
||||
function subCatsOf(cat) {
|
||||
if (!cat || !cat.sub_categories) return [];
|
||||
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
|
||||
/** 版主名提取:兼容 ["a","b"] / [{username:"a"}] / "a,b" 三种后端形态 */
|
||||
function moderatorsOf(cat) {
|
||||
const m = cat && cat.moderators;
|
||||
if (Array.isArray(m)) {
|
||||
return m
|
||||
.map((x) => (typeof x === 'string' ? x : (x && x.username) || ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
if (typeof m === 'string') {
|
||||
return m.split(',').map((s) => s.trim()).filter(Boolean).slice(0, 3);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 论坛首页:分类导航(侧栏 + 子分类 chips 筛选)+ 帖子列表 + 发帖弹窗(含验证码) */
|
||||
/**
|
||||
* 论坛首页(路由 /forum.html,L1 版块索引):
|
||||
* 全站公告区 + 页头(标题/发帖)+ 版块卡片网格(聚合统计)。
|
||||
* 分享链接处理:?share=<id> → 帖子详情;?share_c=<id> → 版块页(replaceState 清参)。
|
||||
* 游客预览开关:forum_guest_visible==='0' 且未登录 → 登录提示。
|
||||
*/
|
||||
export default function Forum() {
|
||||
const navigate = useNavigate();
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [currentCatId, setCurrentCatId] = useState(null); // null=全部最新
|
||||
const [filterSub, setFilterSub] = useState('');
|
||||
const [posts, setPosts] = useState(null); // null=加载中
|
||||
const [categories, setCategories] = useState(null); // null=加载中
|
||||
const [announcements, setAnnouncements] = useState([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
const [moderatedIds, setModeratedIds] = useState([]);
|
||||
const [guestLocked, setGuestLocked] = useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
|
||||
// 发帖弹窗状态
|
||||
const [showNewPost, setShowNewPost] = useState(false);
|
||||
const [npCategory, setNpCategory] = useState('');
|
||||
const [npSub, setNpSub] = useState('');
|
||||
const [npTitle, setNpTitle] = useState('');
|
||||
const [npContent, setNpContent] = useState('');
|
||||
const [npStatus, setNpStatus] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
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([]);
|
||||
// 分享链接跳转 SPA 完整版:?share=<id> / ?share_c=<id> → 客户端 navigate,
|
||||
// 只在首次挂载处理一次;replaceState 清参避免刷新重复跳。
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const shareC = params.get('share_c');
|
||||
if (shareC && /^\d+$/.test(shareC)) {
|
||||
history.replaceState({}, '', window.location.pathname);
|
||||
navigate('/forum/c/' + shareC);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
const shareId = params.get('share');
|
||||
if (shareId && /^\d+$/.test(shareId)) {
|
||||
history.replaceState({}, '', window.location.pathname);
|
||||
navigate('/forum/' + shareId);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
forumApi.listCategories().then((cs) => setCategories(cs || [])).catch(() => {});
|
||||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
||||
else setUser(null);
|
||||
load(null, '');
|
||||
}, [load]);
|
||||
|
||||
const selectCategory = (catId) => {
|
||||
setCurrentCatId(catId);
|
||||
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);
|
||||
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => { setGuestLocked(s.forum_guest_visible === '0'); setSettingsLoaded(true); })
|
||||
.catch(() => { setSettingsLoaded(true); });
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
// 我管理的版块(admin 恒真由 role 判断;版主取真实归属),用于「管理我的版块」入口
|
||||
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
|
||||
} else {
|
||||
navigate('/login.html');
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
// 全站公告(公开接口,无公告不渲染)
|
||||
announcementsApi.listActive().then(setAnnouncements).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleCategoryChange = (e) => {
|
||||
setNpCategory(e.target.value);
|
||||
setNpSub(''); // 切换板块后重置子分类
|
||||
};
|
||||
|
||||
const doUpload = async (file) => {
|
||||
try {
|
||||
const data = await uploadFile(file);
|
||||
setNpContent((c) => c + '\n' + data.tag + '\n');
|
||||
setNpStatus('已插入: ' + data.tag);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
// 版块加载:已登录或游客可见才拉取(锁定时不请求,避免后端 401 报错噪音)
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
if (getToken() || !guestLocked) {
|
||||
forumApi.listCategories()
|
||||
.then((cs) => setCategories(cs || []))
|
||||
.catch((e) => { setLoadError(e.message || '加载失败'); setCategories([]); });
|
||||
}
|
||||
}, [settingsLoaded, guestLocked]);
|
||||
|
||||
// 发帖按钮:已登录整页跳转 Write 页论坛模式(统一编辑体验);未登录跳登录页
|
||||
const handleNewPostBtn = () => {
|
||||
if (user) window.location.href = '/write.html?type=forum';
|
||||
else navigate('/login.html');
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (f) doUpload(f);
|
||||
e.target.value = '';
|
||||
};
|
||||
// 版主/管理员 → 页头显示「管理我的版块」入口
|
||||
const canManage = !!user && (user.role === 'admin' || moderatedIds.length > 0);
|
||||
|
||||
const submitPost = async () => {
|
||||
if (!npTitle.trim() || !npContent.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const base = {
|
||||
category_id: parseInt(npCategory, 10),
|
||||
title: npTitle.trim(),
|
||||
content: npContent.trim(),
|
||||
sub_category: npSub,
|
||||
use_markdown: 1,
|
||||
};
|
||||
// 验证码: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);
|
||||
// 游客锁定:未登录且论坛关闭游客预览 → 登录提示卡片
|
||||
const locked = settingsLoaded && guestLocked && !getToken();
|
||||
if (locked) {
|
||||
return (
|
||||
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
|
||||
<div className="empty-icon">🔒</div>
|
||||
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见,登录后即可浏览与发帖</p>
|
||||
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="forum-layout">
|
||||
<h1 className="sr-only">论坛</h1>
|
||||
<aside className="forum-sidebar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
||||
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
|
||||
<div className="forum-home">
|
||||
{announcements.length > 0 && (
|
||||
<div className="announcement-bar">
|
||||
<span className="material-icons ann-icon">campaign</span>
|
||||
<span className="ann-content">
|
||||
{announcements.map((a) => (
|
||||
<span key={a.id} className="ann-item">
|
||||
{a.title ? <strong>{a.title}</strong> : null}
|
||||
{a.title ? ':' : ''}{a.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="forum-home-head">
|
||||
<h1 className="page-title" style={{ margin: 0 }}>论坛</h1>
|
||||
<div className="forum-home-actions">
|
||||
<a className="btn-icon" href="/feed/forum.xml" target="_blank" rel="noopener" title="RSS 订阅(论坛全站)" aria-label="RSS 订阅">
|
||||
<span className="material-icons">rss_feed</span>
|
||||
</a>
|
||||
{canManage && (
|
||||
<Link to="/forum/manage" className="btn btn-tonal">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>manage_accounts</span> 管理我的版块
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-filled" onClick={handleNewPostBtn}>
|
||||
{user ? '发帖' : '登录发帖'}
|
||||
</button>
|
||||
</div>
|
||||
<div 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>}
|
||||
{categories === 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>
|
||||
{categories !== null && !loadError && categories.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 || '';
|
||||
|
||||
{categories !== null && categories.length > 0 && (
|
||||
<div className="forum-grid">
|
||||
{categories.map((c) => {
|
||||
const mods = moderatorsOf(c);
|
||||
const postsCount = typeof c.post_count === 'number' ? c.post_count : null;
|
||||
const todayCount = typeof c.today_count === 'number' ? c.today_count : null;
|
||||
return (
|
||||
<Link key={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}
|
||||
<Link key={c.id} to={'/forum/c/' + c.id} className="card board-card">
|
||||
<div className="board-card-top">
|
||||
<ForumIcon icon={c.icon} name={c.name} iconColor={c.icon_color} size={44} />
|
||||
<div className="board-card-title">
|
||||
<span className="board-name">{c.name}</span>
|
||||
{c.announcement ? <span className="board-badge" title="版块公告">📢</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="board-desc">{c.description || '暂无描述'}</p>
|
||||
<div className="board-meta">
|
||||
<span>{postsCount != null ? `${postsCount} 帖` : ''}</span>
|
||||
{todayCount ? <span className="board-today">今日 {todayCount}</span> : null}
|
||||
{!postsCount && !todayCount ? <span>暂无帖子</span> : null}
|
||||
</div>
|
||||
{mods.length > 0 && (
|
||||
<div className="board-mods">版主:{mods.join('、')}</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 发帖弹窗 */}
|
||||
{showNewPost && (
|
||||
<div
|
||||
ref={npDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="发布新帖"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
||||
onKeyDown={npDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>发布新帖</h3>
|
||||
<div className="form-group">
|
||||
<label>板块</label>
|
||||
<select value={npCategory} 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={npSub} onChange={(e) => setNpSub(e.target.value)}>
|
||||
<option value="">无</option>
|
||||
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { 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 内路由):
|
||||
* 标题/meta/正文(MarkdownRenderer)+ 回复列表/回复框(登录检查)+ 删除按钮(作者/admin)。
|
||||
* 论坛帖子详情(路由 /forum/:id,L3):
|
||||
* 面包屑(首页/论坛/版块/标题)+ 楼主标识 + 楼层(楼主1,回复i+2)+ 置顶/加精徽章 +
|
||||
* 管理操作(置顶/加精/删除,admin 或版主)+ 分享弹窗 + 回复列表/回复框。
|
||||
* 回复无需验证码(照 v1:仅发帖走 captcha_forum)。
|
||||
* 游客预览开关:forum_guest_visible==='0' 且未登录 → 登录提示。
|
||||
*/
|
||||
export default function ForumDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -17,10 +30,27 @@ export default function ForumDetail() {
|
||||
const [post, setPost] = useState(null);
|
||||
const [replies, setReplies] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [moderatedIds, setModeratedIds] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [guestLocked, setGuestLocked] = useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
// 回复楼层作者 UID 显示开关(site_settings.show_uid_in_comments,默认显示)
|
||||
const [showReplyUid, setShowReplyUid] = useState(true);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [adminBusy, setAdminBusy] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(''); // 403 禁言等错误 → 弹窗
|
||||
// markdown 锁定块
|
||||
const [locks, setLocks] = useState([]);
|
||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||
const [lockContent, setLockContent] = useState(() => new Map());
|
||||
|
||||
const shareUrl = typeof window !== 'undefined'
|
||||
? window.location.origin + '/forum/' + id + '/share'
|
||||
: '';
|
||||
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -29,6 +59,12 @@ export default function ForumDetail() {
|
||||
const data = await forumApi.getPost(id);
|
||||
setPost(data.post);
|
||||
setReplies(data.replies || []);
|
||||
// 锁定块:兼容放 post 内或响应顶层(旧数据无则空)
|
||||
const lp = (data.post && data.post.locks) || data.locks;
|
||||
const up = (data.post && data.post.unlocked) || data.unlocked;
|
||||
setLocks(Array.isArray(lp) ? lp : []);
|
||||
setUnlocked(new Set(Array.isArray(up) ? up : []));
|
||||
setLockContent(new Map());
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
@@ -37,12 +73,65 @@ export default function ForumDetail() {
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
||||
else setUser(null);
|
||||
}, [load]);
|
||||
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)+ 回复 UID 开关
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
setGuestLocked(s.forum_guest_visible === '0');
|
||||
setShowReplyUid(s.show_uid_in_comments !== '0');
|
||||
setSettingsLoaded(true);
|
||||
})
|
||||
.catch(() => { setSettingsLoaded(true); });
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
// 我管理的版块(api 已归一化为 id 数组;admin 由 role 恒真)——挂载时拉一次
|
||||
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 content = replyText.trim();
|
||||
@@ -54,7 +143,9 @@ export default function ForumDetail() {
|
||||
setReplyText('');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
// 403:被禁言等拒绝操作 → 弹窗展示后端错误文案(含到期时间)
|
||||
if (e.status === 403) setSubmitError(e.message);
|
||||
else showSnackbar(e.message);
|
||||
}
|
||||
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) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
@@ -98,33 +247,95 @@ export default function ForumDetail() {
|
||||
return (
|
||||
<div className="post-detail">
|
||||
<div className="post-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Link to="/forum.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回
|
||||
{/* 面包屑:首页 / 论坛 / 版块 / 标题 */}
|
||||
<nav className="breadcrumbs" aria-label="面包屑">
|
||||
<Link to="/">首页</Link>
|
||||
<span className="sep">/</span>
|
||||
<Link to="/forum.html">论坛</Link>
|
||||
<span className="sep">/</span>
|
||||
{post.category_id != null ? (
|
||||
<>
|
||||
<Link to={'/forum/c/' + post.category_id}>{post.category_name || '版块'}</Link>
|
||||
<span className="sep">/</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="breadcrumb-title">{post.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* 标题 + 置顶/加精徽章 */}
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{post.title}</span>
|
||||
{post.is_pinned ? <span className="post-badge pin">📌 置顶</span> : null}
|
||||
{post.is_essence ? <span className="post-badge essence">⭐ 精华</span> : null}
|
||||
</h1>
|
||||
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Avatar src={post.author_avatar} name={post.author_name} size={28} to={post.author_id ? `/u/${post.author_id}` : undefined} />
|
||||
<UserName
|
||||
name={post.author_name || '匿名'}
|
||||
uid={post.author_id}
|
||||
title={post.author_title}
|
||||
titleColor={post.author_title_color}
|
||||
role={post.author_role}
|
||||
showUid={!!post.author_id}
|
||||
/>
|
||||
<span className="lz-chip">楼主</span>
|
||||
<span>{post.created_at}</span>
|
||||
<span className="floor-num">#1 楼</span>
|
||||
<span className="chip chip-static">{post.category_name || ''}</span>
|
||||
{post.sub_category && (
|
||||
<span className="chip chip-tonal">{post.sub_category}</span>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?type=forum&edit=${post.id}`} className="btn btn-text btn-sm" aria-label="编辑帖子">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>edit</span> 编辑
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-text btn-sm" onClick={() => setShareOpen(true)}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑标记:已编辑过 → 展示更新时间与次数 */}
|
||||
{post.edit_count > 0 && (
|
||||
<div className="post-edit-info">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>edit_note</span>
|
||||
本文章于 {formatEditTime(post.updated_at)} 更新。已更新 {post.edit_count} 次。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 管理操作区:置顶 / 加精 / 删除 */}
|
||||
{canManage && (
|
||||
<div className="post-admin-actions">
|
||||
<button type="button" className="btn btn-tonal btn-sm" onClick={togglePin} disabled={adminBusy}>
|
||||
📌 {post.is_pinned ? '取消置顶' : '置顶'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-tonal btn-sm" onClick={toggleEssence} disabled={adminBusy}>
|
||||
⭐ {post.is_essence ? '取消加精' : '加精'}
|
||||
</button>
|
||||
{canDeletePost && (
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 'auto' }} onClick={deletePost}>
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)' }} onClick={deletePost} disabled={adminBusy}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{post.author_name || '匿名'}</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' }}>
|
||||
{post.category_name || ''}
|
||||
</span>
|
||||
{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' }}>
|
||||
{post.sub_category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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 ? (
|
||||
<div className="text-muted" style={{ padding: 16 }}>暂无回复</div>
|
||||
) : (
|
||||
replies.map((r) => {
|
||||
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin');
|
||||
replies.map((r, i) => {
|
||||
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin' || (catId != null && moderatedIds.includes(catId)));
|
||||
// 楼层:楼主 1,回复 i+2(后端返回 floor 优先)
|
||||
const floor = r.floor != null ? r.floor : i + 2;
|
||||
return (
|
||||
<div className="reply-item" key={r.id}>
|
||||
<div className="reply-meta">
|
||||
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
|
||||
<div className="floor-item" key={r.id}>
|
||||
<div className="floor-head">
|
||||
<Avatar src={r.author_avatar} name={r.author_name} size={24} to={r.author_id ? `/u/${r.author_id}` : undefined} />
|
||||
<UserName
|
||||
name={r.author_name || '匿名'}
|
||||
uid={r.author_id}
|
||||
title={r.author_title}
|
||||
titleColor={r.author_title_color}
|
||||
role={r.author_role}
|
||||
showUid={showReplyUid && !!r.author_id}
|
||||
/>
|
||||
{r.author_id === post.author_id ? <span className="lz-chip">楼主</span> : null}
|
||||
<span className="floor-num">#{floor} 楼</span>
|
||||
<span className="floor-time">{r.created_at}</span>
|
||||
{canDeleteReply && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除回复"
|
||||
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
||||
className="floor-del"
|
||||
onClick={() => deleteReply(r.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="reply-body">{r.content}</div>
|
||||
<div className="floor-body">{r.content}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 分享弹窗(复用 BlogDetail 模式:Esc/遮罩关闭、焦点管理) */}
|
||||
{shareOpen && (
|
||||
<div
|
||||
ref={shareDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="分享帖子"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
|
||||
onKeyDown={shareDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>分享帖子</h3>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
|
||||
复制链接分享,好友打开直达完整阅读页面
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="sharePostInput">分享链接</label>
|
||||
<input
|
||||
id="sharePostInput"
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
|
||||
<button className="btn btn-filled" onClick={copyShareLink}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 403 禁言等拒绝操作提示 */}
|
||||
<ErrorDialog open={!!submitError} message={submitError} onClose={() => setSubmitError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export default function Login() {
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { 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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as profileApi from '../api/profile.js';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
||||
import { getToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||
@@ -29,6 +30,17 @@ export default function Profile() {
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
// RainID 单点登录:开启时个人中心资料/改密交给 RainID(rainid_enabled 公开设置)
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
// QQ 号(rainweb 层字段,后端返回 qq 时显示编辑;用于 QQ 头像渲染优先级)
|
||||
const [qq, setQq] = useState('');
|
||||
// 个性签名(公开主页 bio)
|
||||
const [bio, setBio] = useState('');
|
||||
// 对外昵称 + 个人博客(全站作者名 / 个人主页展示)
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [infoBusy, setInfoBusy] = useState(false);
|
||||
|
||||
// 修改密码弹窗(两步:发送验证码 → 输入验证码/新旧密码)
|
||||
const [pwDialog, setPwDialog] = useState(false);
|
||||
const [pwStep, setPwStep] = useState(1);
|
||||
@@ -51,9 +63,17 @@ export default function Profile() {
|
||||
try {
|
||||
const u = await profileApi.getProfile();
|
||||
setUser(u);
|
||||
setQq(u.qq || ''); // 后端提供 qq 字段时预填
|
||||
setBio(u.bio || ''); // 个性签名
|
||||
setNickname(u.nickname || ''); // 对外昵称
|
||||
setWebsite(u.website || ''); // 个人博客
|
||||
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
|
||||
const av = await avatarUrl(u.id);
|
||||
setAvatar(av.url || '');
|
||||
// RainID 开关(公开设置)
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => setRainidEnabled(s.rainid_enabled === '1'))
|
||||
.catch(() => setRainidEnabled(false));
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
if (e.status === 401) { navigate('/login.html'); return; }
|
||||
@@ -100,6 +120,20 @@ export default function Profile() {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// 对外昵称 + 个人博客 + QQ 号 + 个性签名统一保存
|
||||
const saveAll = async () => {
|
||||
if (nickname.trim().length > 20) { showSnackbar('昵称最多 20 字'); return; }
|
||||
if (website.trim() && !/^https?:\/\//i.test(website.trim())) { showSnackbar('个人博客需以 http(s):// 开头'); return; }
|
||||
setInfoBusy(true);
|
||||
try {
|
||||
await profileApi.updateProfile({ nickname: nickname.trim(), website: website.trim(), qq: qq.trim(), bio: bio.trim() });
|
||||
showSnackbar('资料已保存');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setInfoBusy(false);
|
||||
};
|
||||
|
||||
const sendPwCode = async () => {
|
||||
setPwBusy(true);
|
||||
try {
|
||||
@@ -178,9 +212,91 @@ export default function Profile() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对外昵称 + 个人博客:显示在帖子/评论/个人主页 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileNickname">对外昵称</label>
|
||||
<input
|
||||
id="profileNickname"
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="显示在帖子 / 评论 / 个人主页的作者名"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileWebsite">个人博客</label>
|
||||
<input
|
||||
id="profileWebsite"
|
||||
type="url"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QQ 号:rainweb 层字段(后端返回 qq 时显示;用于 QQ 头像渲染优先级) */}
|
||||
{typeof user.qq !== 'undefined' && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileQq">QQ 号(用于 QQ 头像)</label>
|
||||
<input id="profileQq" type="text" value={qq} onChange={(e) => setQq(e.target.value)} placeholder="QQ 号码" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 个性签名:公开主页展示 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="profileBio">个性签名</label>
|
||||
<textarea
|
||||
id="profileBio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="写一句自我介绍,会展示在公开主页上"
|
||||
style={{ minHeight: 72 }}
|
||||
maxLength={200}
|
||||
/>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>展示在个人主页的签名区,最多 200 字</span>
|
||||
<span>{bio.length}/200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--md-ref-on-surface-variant)', margin: '4px 0 12px' }}>
|
||||
昵称留空则显示用户名;博客会以「个人博客」链接展示在主页(外链跳确认页)
|
||||
</p>
|
||||
|
||||
{/* 统一保存按钮 */}
|
||||
<button className="btn btn-tonal" onClick={saveAll} disabled={infoBusy}>
|
||||
{infoBusy ? '保存中…' : '保存全部'}
|
||||
</button>
|
||||
|
||||
{/* RainID 开启:改密/资料交 RainID,本地只保留头像与 QQ */}
|
||||
{rainidEnabled && (
|
||||
<div className="rainid-notice">
|
||||
<span className="material-icons" style={{ fontSize: 22 }}>badge</span>
|
||||
<div className="rainid-notice-body">
|
||||
<div className="rainid-notice-title">账号由 RainID 统一管理</div>
|
||||
<div className="rainid-notice-desc">资料与密码请在 RainID 个人中心修改;头像与 QQ 仍在本站管理</div>
|
||||
</div>
|
||||
<a
|
||||
className="btn btn-tonal btn-sm"
|
||||
href="https://rainid.rainnya.asia/account"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="前往 RainID 个人中心(新窗口打开)"
|
||||
>
|
||||
前往 RainID 个人中心
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!rainidEnabled && (
|
||||
<>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '24px 0' }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 12 }}>安全设置</h4>
|
||||
<button className="btn btn-outline" onClick={() => { setPwStep(1); setPwDialog(true); }}>修改密码</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 8 }} onClick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,36 +1,53 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import * as blogApi from '../api/blog.js';
|
||||
import { uploadFile } from '../api/upload.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import MarkdownEditor from '../components/MarkdownEditor.jsx';
|
||||
import ErrorDialog from '../components/ErrorDialog.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/** 板块的子分类列表(论坛模式;照搬原 Forum.jsx 发帖弹窗) */
|
||||
function subCatsOf(cat) {
|
||||
if (!cat || !cat.sub_categories) return [];
|
||||
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 写文章页(路由 /write.html;?edit=id 进入编辑模式):
|
||||
* 标题/摘要/内容(Markdown)/发布开关;附件上传用 uploadFile + [image:]/[file:] 标签插入;
|
||||
* 支持拖拽图片上传与预览(迁移自 write.html 内联脚本)。
|
||||
* 写文章 / 发帖页(路由 /write.html):
|
||||
* - 默认博客模式(?edit=id 进入编辑):标题/摘要/标签/发布开关 → /api/blog
|
||||
* - ?type=forum 论坛模式(?type=forum&edit=id 进入编辑):标题 + 板块/子分类选择 → /api/forum/posts
|
||||
* - 发帖走验证码(captcha_forum);编辑不需要验证码,且不改版块(下拉只读)
|
||||
* 内容编辑统一用共用 MarkdownEditor(左编辑右实时预览 + 防抖刷新)。
|
||||
*/
|
||||
export default function Write() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const mode = searchParams.get('type') === 'forum' ? 'forum' : 'blog';
|
||||
const isForum = mode === 'forum';
|
||||
const editId = searchParams.get('edit') ? parseInt(searchParams.get('edit'), 10) : null;
|
||||
const isForumEdit = isForum && !!editId;
|
||||
// 论坛模式预选版块:?category=id 优先(无则第一个);编辑时由帖子原版块决定
|
||||
const categoryParam = searchParams.get('category');
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [excerpt, setExcerpt] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [published, setPublished] = useState(true);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
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(() => {
|
||||
if (!editId) return;
|
||||
if (!isForum && editId) {
|
||||
blogApi.getPost(editId)
|
||||
.then((p) => {
|
||||
setTitle(p.title);
|
||||
@@ -40,40 +57,88 @@ export default function Write() {
|
||||
setPublished(!!p.published);
|
||||
})
|
||||
.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) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (f) doUpload(f);
|
||||
e.target.value = '';
|
||||
};
|
||||
// 论坛模式:拉取板块列表(?category= 参数预选,无则第一个;编辑时由帖子原版块决定)
|
||||
useEffect(() => {
|
||||
if (!isForum) return;
|
||||
let mounted = true;
|
||||
forumApi.listCategories()
|
||||
.then((cs) => {
|
||||
if (!mounted) return;
|
||||
setCategories(cs || []);
|
||||
if (editId || !cs || !cs.length) return; // 编辑模式跳过自动选中
|
||||
setFpCategory((cur) => {
|
||||
if (cur) return cur;
|
||||
if (categoryParam && cs.some((c) => String(c.id) === String(categoryParam))) {
|
||||
return String(categoryParam);
|
||||
}
|
||||
return String(cs[0].id);
|
||||
});
|
||||
})
|
||||
.catch((e) => { if (mounted) setLoadError(e.message || '加载板块失败'); });
|
||||
return () => { mounted = false; };
|
||||
}, [isForum, editId]);
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
if (contentRef.current) contentRef.current.style.borderColor = '';
|
||||
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 handleCategoryChange = (e) => {
|
||||
setFpCategory(e.target.value);
|
||||
setFpSub(''); // 切换板块后重置子分类
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||||
if (isForum && !fpCategory) { showSnackbar('请选择板块'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isForum) {
|
||||
if (editId) {
|
||||
// 编辑帖子:不需要验证码;部分更新只提交 title/content/sub_category(版块不可改)
|
||||
await forumApi.updatePost(editId, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
sub_category: fpSub,
|
||||
use_markdown: 1,
|
||||
});
|
||||
showSnackbar('已更新');
|
||||
navigate('/forum/' + editId);
|
||||
} else {
|
||||
const base = {
|
||||
category_id: parseInt(fpCategory, 10),
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
sub_category: fpSub,
|
||||
use_markdown: 1,
|
||||
};
|
||||
// 验证码:captcha_forum 开启时走 showCaptcha 拿 proof / 第三方 token
|
||||
//(服务端 /api/forum/posts 强制校验,与旧发帖弹窗流程一致)
|
||||
const cap = await captchaRequired('forum');
|
||||
if (cap && cap.required) {
|
||||
const result = await showCaptcha('forum');
|
||||
if (result === null) { setSaving(false); return; } // 取消
|
||||
applyCaptchaResult(base, result);
|
||||
}
|
||||
const post = await forumApi.createPost(base);
|
||||
showSnackbar('发布成功');
|
||||
navigate('/forum/' + post.id);
|
||||
}
|
||||
} else {
|
||||
const data = {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
@@ -82,7 +147,6 @@ export default function Write() {
|
||||
published,
|
||||
use_markdown: 1,
|
||||
};
|
||||
try {
|
||||
let id = editId;
|
||||
if (editId) {
|
||||
await blogApi.updatePost(editId, data);
|
||||
@@ -93,20 +157,25 @@ export default function Write() {
|
||||
showSnackbar('已发布');
|
||||
}
|
||||
navigate('/blog/' + id);
|
||||
}
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
// 403:被禁言等拒绝操作 → 弹窗展示后端错误文案(含到期时间)
|
||||
if (e.status === 403) setSubmitError(e.message);
|
||||
else showSnackbar(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const heading = isForum ? (editId ? '编辑帖子' : '发帖') : (editId ? '编辑文章' : '写文章');
|
||||
const backHref = isForum ? '/forum.html' : '/blog.html';
|
||||
const backText = isForum ? '返回论坛' : '返回博客';
|
||||
|
||||
return (
|
||||
<div className="write-page" style={{ maxWidth: 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 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>
|
||||
{editId ? '编辑文章' : '写文章'}
|
||||
</h1>
|
||||
<a href="/blog.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons">arrow_back</span> 返回博客
|
||||
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>{heading}</h1>
|
||||
<a href={backHref} className="btn btn-text btn-sm">
|
||||
<span className="material-icons">arrow_back</span> {backText}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -118,10 +187,45 @@ export default function Write() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isForum && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>板块 *</label>
|
||||
{isForumEdit ? (
|
||||
<>
|
||||
<select value={fpCategory} disabled aria-label="板块(编辑时不可修改)">
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>编辑帖子不支持修改版块</div>
|
||||
</>
|
||||
) : (
|
||||
<select value={fpCategory} onChange={handleCategoryChange}>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>帖子分类(可选)</label>
|
||||
<select value={fpSub} onChange={(e) => setFpSub(e.target.value)}>
|
||||
<option value="">无</option>
|
||||
{subCatsOf(categories.find((c) => c.id === parseInt(fpCategory, 10))).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="文章标题" />
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={isForum ? '帖子标题' : '文章标题'}
|
||||
/>
|
||||
</div>
|
||||
{!isForum && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>摘要</label>
|
||||
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
|
||||
@@ -131,48 +235,33 @@ export default function Write() {
|
||||
<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}
|
||||
<MarkdownEditor
|
||||
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}
|
||||
onChange={setContent}
|
||||
placeholder={isForum ? '支持 Markdown 语法,可插入附件/图片' : '支持 Markdown 语法,拖入图片自动上传'}
|
||||
label={isForum ? '帖子内容' : '文章内容'}
|
||||
/>
|
||||
</div>
|
||||
{!isForum && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
|
||||
<span className="material-icons">upload</span> 上传附件/图片
|
||||
</button>
|
||||
<button className="btn btn-text btn-sm" onClick={() => setPreview((v) => !v)}>
|
||||
<span className="material-icons">visibility</span> 预览
|
||||
</button>
|
||||
<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>
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||||
{uploadStatus && (
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>{uploadStatus}</div>
|
||||
)}
|
||||
{preview && (
|
||||
<div style={{ padding: 16, background: 'var(--md-ref-surface-container-low)', borderRadius: 12, marginBottom: 16, maxHeight: 400, overflowY: 'auto' }}>
|
||||
<MarkdownRenderer content={content} useMarkdown />
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-filled" onClick={submit} disabled={saving}>
|
||||
{editId ? '保存修改' : '发布文章'}
|
||||
{isForum ? (editId ? '保存修改' : '发布') : (editId ? '保存修改' : '发布文章')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 403 禁言等拒绝操作提示 */}
|
||||
<ErrorDialog open={!!submitError} message={submitError} onClose={() => setSubmitError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePan
|
||||
/* ============================================================
|
||||
* AddPanelDialog:添加面板
|
||||
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
|
||||
* + 嵌入 URL + 代理开关;保存到 admin_links(复用现有 API)
|
||||
* + 嵌入 URL + 代理开关 + 信任模式 + 缩放 + 权限委托;
|
||||
* 保存到 admin_links(复用现有 API,trusted/scale/permissions 为代理新字段)
|
||||
* ============================================================ */
|
||||
|
||||
const MODE_META = [
|
||||
@@ -30,6 +31,19 @@ const MODE_META = [
|
||||
{ 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) {
|
||||
const s = (raw || '').trim();
|
||||
if (!s) return '';
|
||||
@@ -40,9 +54,16 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
||||
const [form, setForm] = useState({
|
||||
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
|
||||
use_proxy: false, embed_url: '',
|
||||
trusted: true, scale: '1', permissions: [],
|
||||
});
|
||||
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(() => {
|
||||
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
|
||||
}, [form.url]);
|
||||
@@ -61,6 +82,9 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
||||
embed_url: form.embed_url.trim(),
|
||||
category: form.category.trim() || '默认',
|
||||
use_proxy: form.use_proxy ? 1 : 0,
|
||||
trusted: form.trusted ? 1 : 0,
|
||||
scale: parseFloat(form.scale) || 1,
|
||||
permissions: JSON.stringify(form.permissions),
|
||||
description: '',
|
||||
icon: '',
|
||||
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);
|
||||
setErr('');
|
||||
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
|
||||
resetForm();
|
||||
onSaved(panel);
|
||||
} catch (e) {
|
||||
setErr(e.message || '保存失败');
|
||||
@@ -176,6 +200,53 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
|
||||
margin="dense"
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -17,20 +17,33 @@ import { getToken } from '../../../api/client.js';
|
||||
* - 懒加载:首次激活才创建 iframe DOM,之后 display:none 保留状态
|
||||
* - 超时 + load 双保险:加载中显示覆盖层;超时(X-Frame-Options
|
||||
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
|
||||
* - proxy 模式:use_proxy=1 或 HTTPS 页内嵌 HTTP 目标时走
|
||||
* /api/proxy/fetch?url=&token=(proxy 剥 X-Frame-Options/CSP)。
|
||||
* token 用 /api/proxy/token 签发的 5 分钟短 TTL JWT,避免把 7 天
|
||||
* 主 token 暴露在 iframe URL 中(模块级缓存,4 分钟刷新一次)。
|
||||
* - 站内 URL(/ 开头)为同源内容:剥离 allow-same-origin 防其访问
|
||||
* 父页面 DOM;跨源面板保留 allow-same-origin(多数站点依赖)。
|
||||
* - 代理模式(use_proxy=1 或 HTTPS 页内嵌 HTTP 目标):走
|
||||
* /proxy/{slug}/ 前缀代理(后端 HTML 改写 + shim 注入 + WebSocket
|
||||
* + 全方法透传)。鉴权由后端处理:首次加载用 Authorization header
|
||||
* 预热一次(触发下发 HttpOnly 的 rwp_{slug} 面板 cookie,Path 限定
|
||||
* /proxy/{slug}/,12h 有效),iframe src 本身不带任何 token,
|
||||
* 被代理页脚本读不到凭据。旧面板无 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 控制挂载/卸载(此组件不自行卸载)
|
||||
* ============================================================ */
|
||||
|
||||
const LOAD_TIMEOUT_MS = 15000;
|
||||
const TOKEN_TTL_MS = 4 * 60 * 1000; // 缓存 4 分钟(短 token 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 };
|
||||
|
||||
function fetchProxyToken() {
|
||||
@@ -53,6 +66,36 @@ function fetchProxyToken() {
|
||||
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 }) {
|
||||
const [mounted, setMounted] = useState(active);
|
||||
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 isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
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(失败进入超时回退态)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
|
||||
// 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));
|
||||
}
|
||||
if (alive()) setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) {
|
||||
statusRef.current = 'timeout';
|
||||
setStatus('timeout');
|
||||
}
|
||||
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
|
||||
});
|
||||
}
|
||||
}, [needsProxy, hasSlug, slug, proxyPrefix, targetUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
bootProxy(() => alive);
|
||||
return () => { alive = false; };
|
||||
}, [needsProxy, targetUrl]);
|
||||
}, [bootProxy]);
|
||||
|
||||
const src = needsProxy ? proxySrc : targetUrl;
|
||||
|
||||
@@ -119,18 +196,30 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [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);
|
||||
useEffect(() => {
|
||||
if (!active || refreshNonce === lastNonce.current) return;
|
||||
lastNonce.current = refreshNonce;
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !src) return;
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
startTimer();
|
||||
try { iframe.contentWindow.location.reload(); }
|
||||
catch { iframe.src = iframe.src; }
|
||||
performReload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshNonce]);
|
||||
|
||||
@@ -148,13 +237,7 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !src) return;
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
startTimer();
|
||||
try { iframe.contentWindow.location.reload(); }
|
||||
catch { iframe.src = iframe.src; }
|
||||
performReload();
|
||||
};
|
||||
|
||||
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
|
||||
@@ -167,15 +250,25 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
|
||||
}}
|
||||
>
|
||||
{mounted && (
|
||||
/* scale 缩放:外盒宽高取 1/scale(相对容器),transform scale(scale) 后
|
||||
* 恰好铺满容器;scale=1 时恒等,无副作用 */
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
width: `${100 / scale}%`, height: `${100 / scale}%`,
|
||||
transform: `scale(${scale})`, transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={panel.title || '面板'}
|
||||
src={src}
|
||||
onLoad={handleLoad}
|
||||
sandbox={sandbox}
|
||||
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allow={allowAttr}
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'loading' && (
|
||||
|
||||
@@ -7,6 +7,11 @@ import { listAdminLinks } from '../../../api/adminLinks.js';
|
||||
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
|
||||
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
|
||||
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 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
|
||||
@@ -33,6 +38,20 @@ function write(key, value) {
|
||||
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() {
|
||||
const [links, setLinks] = useState(null); // null = 加载中
|
||||
const [loadError, setLoadError] = useState('');
|
||||
@@ -82,7 +101,7 @@ export default function usePanels() {
|
||||
setLinks(null);
|
||||
return listAdminLinks()
|
||||
.then((ls) => {
|
||||
const arr = Array.isArray(ls) ? ls : [];
|
||||
const arr = Array.isArray(ls) ? ls.map(normalizePanel) : [];
|
||||
setLinks(arr);
|
||||
const valid = new Set(arr.map((p) => String(p.id)));
|
||||
// String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
|
||||
@@ -118,7 +137,7 @@ export default function usePanels() {
|
||||
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
||||
const openPanel = useCallback((id, panelOverride) => {
|
||||
id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
|
||||
const panel = panelOverride || (links && links.find((p) => String(p.id) === id));
|
||||
const panel = normalizePanel(panelOverride || (links && links.find((p) => String(p.id) === id)));
|
||||
if (!panel) return false;
|
||||
touch(id);
|
||||
const mode = modes[id] || OPEN_MODE_EMBED;
|
||||
|
||||
@@ -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 };
|
||||
@@ -50,7 +50,8 @@ async function getOidcConfig() {
|
||||
err.code = 'RAINID_NOT_CONFIGURED';
|
||||
throw err;
|
||||
}
|
||||
configCache = await openidClient.discovery(s.discoveryUrl, s.clientId, s.clientSecret);
|
||||
// v6 的 discovery() 第一个参数必须是 URL 实例(传字符串会抛 "server must be an instance of URL")
|
||||
configCache = await openidClient.discovery(new URL(s.discoveryUrl), s.clientId, s.clientSecret);
|
||||
return configCache;
|
||||
}
|
||||
|
||||
@@ -58,7 +59,14 @@ async function getOidcConfig() {
|
||||
function siteBase(req) {
|
||||
const u = db.getSetting('site_url');
|
||||
if (u) return String(u).replace(/\/+$/, '');
|
||||
return (req.protocol || 'http') + '://' + (req.get('host') || 'localhost');
|
||||
// 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 稳定绑定键):
|
||||
@@ -118,7 +126,14 @@ async function rainidRopcLogin(username, password) {
|
||||
if (!s.enabled) return { ok: false, status: 400, error: 'RainID 登录未启用' };
|
||||
let config;
|
||||
try { config = await getOidcConfig(); }
|
||||
catch { return { ok: false, status: 500, error: 'RainID 客户端未配置' }; }
|
||||
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,
|
||||
|
||||
@@ -3,6 +3,20 @@ const db = require('../db');
|
||||
|
||||
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) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
@@ -11,6 +25,8 @@ function authMiddleware(req, res, next) {
|
||||
try {
|
||||
const payload = jwt.verify(header.slice(7), SECRET);
|
||||
req.user = payload;
|
||||
// 记录最后活跃(跨天节流)
|
||||
touchLastActive(payload.id);
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: '登录已过期' });
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rainweb-links",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -19,10 +20,12 @@
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dompurify": "^3.4.13",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"http-proxy-middleware": "^4.2.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"marked": "^18.0.5",
|
||||
@@ -568,7 +571,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/utils": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||
"license": "MIT"
|
||||
@@ -1323,6 +1326,24 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -1393,6 +1414,90 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1403,7 +1508,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
@@ -1501,6 +1606,22 @@
|
||||
"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": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -1514,6 +1635,18 @@
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1563,7 +1696,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
@@ -1601,6 +1734,59 @@
|
||||
"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": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
@@ -1610,6 +1796,20 @@
|
||||
"@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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1640,7 +1840,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
@@ -1648,6 +1848,31 @@
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
@@ -1832,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": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -1998,6 +2235,37 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -2018,6 +2286,45 @@
|
||||
"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": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -2091,6 +2398,48 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
@@ -2165,7 +2514,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"license": "MIT"
|
||||
@@ -2615,6 +2964,31 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
@@ -2649,13 +3023,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
@@ -2735,6 +3109,18 @@
|
||||
"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",
|
||||
@@ -2832,6 +3218,79 @@
|
||||
"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": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -2941,7 +3400,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
@@ -3466,6 +3925,18 @@
|
||||
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -3665,6 +4136,31 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
@@ -3719,7 +4215,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"description": "链接聚合管理平台",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -22,10 +22,12 @@
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dompurify": "^3.4.13",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"http-proxy-middleware": "^4.2.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"marked": "^18.0.5",
|
||||
@@ -42,5 +44,6 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"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();
|
||||
|
||||
// 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) => {
|
||||
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) => {
|
||||
const { title, url, embed_url, description, icon, category, sort_order, use_proxy, version } = req.body;
|
||||
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(
|
||||
'INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order, use_proxy, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[title, url, embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '']);
|
||||
`INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order, use_proxy, 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]));
|
||||
});
|
||||
|
||||
router.put('/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
if (!db.get('SELECT id FROM admin_links WHERE id = ?', [req.params.id]))
|
||||
return res.status(404).json({ error: '链接不存在' });
|
||||
const existing = db.get('SELECT * FROM admin_links WHERE id = ?', [req.params.id]);
|
||||
if (!existing) return res.status(404).json({ error: '链接不存在' });
|
||||
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=?',
|
||||
[title || '', url || '', embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '', req.params.id]);
|
||||
const vf = validateProxyFields(req.body, existing);
|
||||
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]));
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
// L5:?all=1(查看含草稿/下架的全部公告)需登录——中间件链:all=1 时先走 authMiddleware
|
||||
// 校验 token,未登录返回 401;默认只返回 active 公告,游客可读。
|
||||
router.get('/', (req, res, next) => {
|
||||
if (req.query.all === '1') return authMiddleware(req, res, next);
|
||||
next();
|
||||
}, (req, res) => {
|
||||
const allFlag = req.query.all === '1';
|
||||
let rows;
|
||||
if (allFlag) {
|
||||
|
||||
@@ -6,10 +6,15 @@ const rateLimit = require('express-rate-limit');
|
||||
const db = require('../db');
|
||||
const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth');
|
||||
const { consumeProof } = require('./captcha');
|
||||
const { rainidRopcLogin } = require('../lib/rainid');
|
||||
const { rainidRopcLogin, getOidcSettings } = require('../lib/rainid');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// L6:时序侧信道消除——用户不存在时也执行一次固定 dummy bcrypt 比较,
|
||||
// 使「用户名不存在」与「密码错误」的响应时间一致,杜绝用户名枚举。
|
||||
// (hash 在模块加载时生成一次,仅用于占位比较,不匹配任何真实用户)
|
||||
const DUMMY_HASH = bcrypt.hashSync('rainweb-timing-side-channel-dummy', 10);
|
||||
|
||||
// 登录限流:15 分钟窗口内最多 10 次尝试
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
@@ -19,6 +24,16 @@ const loginLimiter = rateLimit({
|
||||
message: { error: '尝试次数过多,请 15 分钟后再试' },
|
||||
});
|
||||
|
||||
// M2:注册/验证码限流——15 分钟窗口内每 IP 最多 10 次
|
||||
//(防注册接口邮箱轰炸 + pending 验证码被批量刷取;与 loginLimiter 同级)
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: '操作过于频繁,请 15 分钟后再试' },
|
||||
});
|
||||
|
||||
// 校验并一次性消费验证码证明令牌:不存在、无效或已使用返回 false
|
||||
function validateCaptchaProof(proof) {
|
||||
return consumeProof(proof).ok;
|
||||
@@ -83,8 +98,25 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
return res.status(400).json({ error: '请先完成验证码验证' });
|
||||
}
|
||||
|
||||
// RainID 启用时:用户名/密码转发 RainID(ROPC),按 sub 登录/绑定影子账号
|
||||
if (db.getSetting('rainid_enabled') === '1') {
|
||||
// RainID 启用时:
|
||||
// - 站长(username='admin' 的账号)保留本地 bcrypt 密码登录(逃生通道,防 RainID 配置错误锁死后台)
|
||||
// - 其余用户用户名/密码转发 RainID(ROPC),按 sub 登录/绑定影子账号
|
||||
// L9:用 getOidcSettings().enabled(三项齐全才为 true,fail-closed)判断而非只查
|
||||
// rainid_enabled 设置——rainid_enabled=1 但 client_id/secret 缺失时视为未启用,
|
||||
// 本地登录不受影响(与 server.js 启动警告、lib/rainid.js getOidcSettings 口径一致)。
|
||||
if (getOidcSettings().enabled) {
|
||||
// 逃生通道写死站长账号:只有 username='admin'(有本地密码)走本地 bcrypt;
|
||||
// 其他账号一律走 RainID ROPC——即使 role=admin 且有本地密码也不走逃生通道。
|
||||
// 避免多 admin 账号共享逃生通道(其余 admin 应走 RainID 登录)。
|
||||
const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||||
const isAdminEscape = localUser && localUser.username === '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, email: localUser.email, email_verified: localUser.email_verified });
|
||||
}
|
||||
const r = await rainidRopcLogin(username, password);
|
||||
if (!r.ok) return res.status(r.status).json({ error: r.error });
|
||||
const user = r.user;
|
||||
@@ -95,7 +127,12 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
// 本地 bcrypt 登录(RainID 未启用 / 未配置时回退,本地功能不受 RainID 影响)
|
||||
const user = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||||
// 影子账号(rainid_user_id 非空)无本地密码 → 禁止本地密码登入
|
||||
if (!user || (user.rainid_user_id && !user.password) || !bcrypt.compareSync(password, user.password)) {
|
||||
if (!user || (user.rainid_user_id && !user.password)) {
|
||||
// L6:用户不存在/影子账号也跑一次 dummy bcrypt,对齐响应时间防枚举
|
||||
bcrypt.compareSync(password, DUMMY_HASH);
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
if (!bcrypt.compareSync(password, user.password)) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
@@ -103,7 +140,8 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
res.json({ token, username: user.username, role: user.role, email: user.email, email_verified: user.email_verified });
|
||||
});
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
// M2:注册接口加限流(防邮箱轰炸 / 验证码批量刷取)
|
||||
router.post('/register', registerLimiter, async (req, res) => {
|
||||
// 注册跳转 RainID 开启:前端直接跳 RainID 注册页,本地注册接口拒绝
|
||||
if (db.getSetting('rainid_register_redirect') === '1') {
|
||||
return res.status(400).json({ error: '注册已跳转 RainID' });
|
||||
@@ -137,6 +175,8 @@ router.post('/register', async (req, res) => {
|
||||
host: smtpHost, port: parseInt(db.getSetting('smtp_port')) || 587,
|
||||
secure: parseInt(db.getSetting('smtp_port')) === 465,
|
||||
auth: { user: db.getSetting('smtp_user'), pass: db.getSetting('smtp_pass') },
|
||||
// L4:保留 rejectUnauthorized: false(不改行为)——内网 SMTP 多为自签证书,改 true 会大面积失败。
|
||||
// 风险:SMTP 凭据在 TLS 握手时可能被中间人嗅探。生产环境建议改 true 并将自签证书加入系统 CA。
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
const siteName = db.getSetting('site_name') || 'RainWeb';
|
||||
@@ -191,7 +231,7 @@ router.post('/register-by-admin', authMiddleware, adminOnly, (req, res) => {
|
||||
});
|
||||
|
||||
router.get('/users', authMiddleware, adminOnly, (req, res) => {
|
||||
res.json(db.all('SELECT id, username, email, email_verified, role, created_at FROM users'));
|
||||
res.json(db.all('SELECT id, username, email, email_verified, role, nickname, title, title_color, website, created_at FROM users'));
|
||||
});
|
||||
|
||||
router.delete('/users/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
@@ -221,5 +261,74 @@ router.put('/users/:id/role', authMiddleware, adminOnly, (req, res) => {
|
||||
res.json({ message: '角色已更新' });
|
||||
});
|
||||
|
||||
// 后台「编辑用户」大弹窗保存接口:白名单字段部分更新(动态 SET,只更新出现的字段)。
|
||||
// nickname/title ≤20、title_color 仅 #hex、website 仅 http(s)、email 格式+查重(更新后 email_verified=0,
|
||||
// 重发验证码走个人中心);role 仅 admin|user 且不允许改自己;至少一个有效字段否则 400。
|
||||
// 不动 password(走 /users/:id/password)与 avatar/qq(走个人中心 PUT /api/profile)。
|
||||
router.put('/users/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
const user = db.get('SELECT id FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).json({ error: '用户不存在' });
|
||||
const sets = [];
|
||||
const params = [];
|
||||
|
||||
if (req.body.nickname !== undefined) {
|
||||
if (typeof req.body.nickname === 'string') { // 非字符串忽略
|
||||
const v = req.body.nickname.trim();
|
||||
if (v.length > 20) return res.status(400).json({ error: '昵称不能超过 20 个字符' });
|
||||
sets.push('nickname = ?'); params.push(v);
|
||||
}
|
||||
}
|
||||
if (req.body.title !== undefined) {
|
||||
if (typeof req.body.title === 'string') {
|
||||
const v = req.body.title.trim();
|
||||
if (v.length > 20) return res.status(400).json({ error: '头衔不能超过 20 个字符' });
|
||||
sets.push('title = ?'); params.push(v);
|
||||
}
|
||||
}
|
||||
if (req.body.title_color !== undefined) {
|
||||
if (typeof req.body.title_color === 'string') {
|
||||
const v = req.body.title_color.trim();
|
||||
if (v !== '' && !/^#[0-9a-fA-F]{3,8}$/.test(v))
|
||||
return res.status(400).json({ error: '头衔颜色需为 #hex 色值' });
|
||||
sets.push('title_color = ?'); params.push(v);
|
||||
}
|
||||
}
|
||||
if (req.body.website !== undefined) {
|
||||
if (typeof req.body.website === 'string') {
|
||||
const v = req.body.website.trim();
|
||||
if (v !== '') {
|
||||
if (v.length > 200) return res.status(400).json({ error: '个人博客链接不能超过 200 个字符' });
|
||||
if (!/^https?:\/\//i.test(v)) return res.status(400).json({ error: '个人博客需为 http(s) 链接' });
|
||||
}
|
||||
sets.push('website = ?'); params.push(v);
|
||||
}
|
||||
}
|
||||
if (req.body.email !== undefined) {
|
||||
if (typeof req.body.email === 'string') {
|
||||
const v = req.body.email.trim();
|
||||
if (v !== '') {
|
||||
if (!/^\S+@\S+\.\S+$/.test(v)) return res.status(400).json({ error: '邮箱格式不正确' });
|
||||
const dup = db.get('SELECT id FROM users WHERE email = ? AND id != ?', [v, req.params.id]);
|
||||
if (dup) return res.status(409).json({ error: '该邮箱已被占用' });
|
||||
}
|
||||
sets.push('email = ?'); params.push(v);
|
||||
sets.push('email_verified = 0');
|
||||
}
|
||||
}
|
||||
if (req.body.role !== undefined) {
|
||||
const v = String(req.body.role);
|
||||
if (!['admin', 'user'].includes(v)) return res.status(400).json({ error: '无效的角色' });
|
||||
if (user.id === req.user.id) return res.status(400).json({ error: '不能修改自己的角色' });
|
||||
sets.push('role = ?'); params.push(v);
|
||||
}
|
||||
|
||||
if (!sets.length) return res.status(400).json({ error: '没有可更新的字段' });
|
||||
params.push(req.params.id);
|
||||
db.run(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, params);
|
||||
const updated = db.get(
|
||||
'SELECT id, username, nickname, title, title_color, website, email, role FROM users WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: '用户已更新', user: updated });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.resolveCaptcha = resolveCaptcha;
|
||||
|
||||
@@ -3,6 +3,8 @@ const jwt = require('jsonwebtoken');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const db = require('../db');
|
||||
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
|
||||
const locks = require('../lib/locks');
|
||||
const { attachAuthor } = require('../lib/author');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -36,16 +38,29 @@ function normalizeTags(tags) {
|
||||
return String(tags || '').split(',').map(t => t.trim()).filter(Boolean).join(',');
|
||||
}
|
||||
|
||||
// 评论邮件通知:comment_notify='1' 且评论者不是文章作者时通知作者(失败不影响评论创建)
|
||||
// 评论邮件通知:comment_notify='1' 且评论者不是文章作者时通知作者(失败不影响评论创建)。
|
||||
// 作者未设置邮箱时回退通知任意 admin(标题/正文标明「管理员代收」);
|
||||
// SMTP 未配置(getTransporter 返回 null)时打 console.warn 而非完全静默。
|
||||
async function notifyCommentToAuthor(post, userId, comment) {
|
||||
try {
|
||||
if (db.getSetting('comment_notify') !== '1') return;
|
||||
if (post.author_id === userId) return; // 作者本人评论不通知
|
||||
const author = db.get('SELECT id, email FROM users WHERE id = ?', [post.author_id]);
|
||||
if (!author || !author.email) return;
|
||||
const { getTransporter, emailTemplate } = require('./email');
|
||||
const transporter = getTransporter();
|
||||
if (!transporter) return;
|
||||
if (!transporter) {
|
||||
console.warn('评论通知未发送:SMTP 未配置(getTransporter 返回 null),请先配置 SMTP');
|
||||
return;
|
||||
}
|
||||
const author = db.get('SELECT id, email FROM users WHERE id = ?', [post.author_id]);
|
||||
let recipient = (author && author.email) || '';
|
||||
let adminProxy = false;
|
||||
if (!recipient) {
|
||||
// 回退:作者未设邮箱 → 通知任意 admin(管理员代收)
|
||||
const admin = db.get("SELECT email FROM users WHERE role = 'admin' AND email <> '' AND email IS NOT NULL LIMIT 1");
|
||||
if (!admin || !admin.email) return; // 无收件人可发
|
||||
recipient = admin.email;
|
||||
adminProxy = true;
|
||||
}
|
||||
const siteName = db.getSetting('site_name') || 'RainWeb';
|
||||
const siteUrl = db.getSetting('site_url') || '';
|
||||
const base = siteUrl.replace(/\/$/, '');
|
||||
@@ -54,9 +69,10 @@ async function notifyCommentToAuthor(post, userId, comment) {
|
||||
const commenterName = (user && user.username) || '匿名';
|
||||
await transporter.sendMail({
|
||||
from: `"${db.getSetting('smtp_from_name')}" <${db.getSetting('smtp_from_email')}>`,
|
||||
to: author.email,
|
||||
subject: `您有新评论 - ${post.title}`,
|
||||
to: recipient,
|
||||
subject: `您有新评论 - ${post.title}` + (adminProxy ? '(管理员代收)' : ''),
|
||||
html: emailTemplate('新评论通知',
|
||||
(adminProxy ? '<p style="color:#b3261e"><strong>(管理员代收:文章作者未设置邮箱)</strong></p>' : '') +
|
||||
`<p>您的文章《<strong>${post.title}</strong>》收到一条新评论:</p>
|
||||
<p style="padding:12px;background:#f5f5f5;border-radius:8px;margin:12px 0">${String(comment.content).replace(/</g, '<')}</p>
|
||||
<p style="color:#79747e">评论者:${commenterName}</p>
|
||||
@@ -67,10 +83,42 @@ async function notifyCommentToAuthor(post, userId, comment) {
|
||||
}
|
||||
}
|
||||
|
||||
// [lock:] 真锁:按 viewer 视角剥离单篇文章,返回带 locks/unlocked/viewer 字段的 post。
|
||||
// 列表与详情共用——未解锁块内容(含 [image:]/[file:] 附件标签)一律不进 API 响应。
|
||||
function applyLocks(post, req) {
|
||||
if (!post) return post;
|
||||
const viewer = {
|
||||
userId: req.user ? req.user.id : null,
|
||||
isAdmin: !!(req.user && req.user.role === 'admin'),
|
||||
isAuthor: !!(req.user && req.user.id === post.author_id),
|
||||
hasCommented: false,
|
||||
};
|
||||
if (req.user) {
|
||||
viewer.hasCommented = !!db.get(
|
||||
'SELECT 1 x FROM blog_comments WHERE post_id = ? AND author_id = ?', [post.id, req.user.id]);
|
||||
}
|
||||
const locksMeta = (() => { try { return JSON.parse(post.locks || '[]'); } catch { return []; } })();
|
||||
const blocks = locks.parseLocks(post.content).blocks;
|
||||
const { stripped, unlocked } = locks.stripLocks(post.content, { locksMeta, viewer });
|
||||
post.content = stripped;
|
||||
const lockMetaList = blocks.map(b => ({ index: b.index, type: b.type })); // 不含 hash
|
||||
// 附件真锁:对已解锁索引签发附件 token,前端用它加载块内 [image:]/[file:] 附件
|
||||
//(password 块被 admin/作者解锁时同样签发——他们本来就可见该块)
|
||||
lockMetaList.forEach(m => {
|
||||
if (unlocked.includes(m.index)) m.token = locks.makeLockToken('blog', post.id, m.index);
|
||||
});
|
||||
post.locks = lockMetaList;
|
||||
post.unlocked = unlocked;
|
||||
post.viewer = { loggedIn: !!req.user, isAdmin: viewer.isAdmin, isAuthor: viewer.isAuthor, hasCommented: viewer.hasCommented };
|
||||
return post;
|
||||
}
|
||||
|
||||
// 公开列表:仅返回已发布文章
|
||||
router.get('/posts', (req, res, next) => {
|
||||
router.get('/posts', optionalAuth, (req, res, next) => {
|
||||
if (req.query.all !== '1') {
|
||||
return res.json(db.all('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.published = 1 ORDER BY bp.created_at DESC'));
|
||||
const rows = db.all("SELECT bp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.published = 1 ORDER BY bp.created_at DESC");
|
||||
rows.forEach(p => { applyLocks(p, req); attachAuthor(p); });
|
||||
return res.json(rows);
|
||||
}
|
||||
// ?all=1:仅管理员可见,交给下方鉴权路由处理
|
||||
next();
|
||||
@@ -78,7 +126,9 @@ router.get('/posts', (req, res, next) => {
|
||||
|
||||
// ?all=1:返回全部文章(含草稿),仅管理员可见
|
||||
router.get('/posts', authMiddleware, adminOnly, (req, res) => {
|
||||
res.json(db.all('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id ORDER BY bp.created_at DESC'));
|
||||
const rows = db.all("SELECT bp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id ORDER BY bp.created_at DESC");
|
||||
rows.forEach(p => { applyLocks(p, req); attachAuthor(p); });
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// 搜索:标题/正文/摘要模糊匹配(参数化 + 通配符转义),仅已发布,最多 20 条
|
||||
@@ -87,11 +137,15 @@ router.get('/search', (req, res) => {
|
||||
if (!q) return res.json([]);
|
||||
const pattern = '%' + escapeLike(q) + '%';
|
||||
const posts = db.all(
|
||||
`SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name
|
||||
`SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email
|
||||
FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id
|
||||
WHERE bp.published = 1 AND (bp.title LIKE ? ESCAPE '\\' OR bp.content LIKE ? ESCAPE '\\' OR bp.excerpt LIKE ? ESCAPE '\\')
|
||||
ORDER BY bp.created_at DESC LIMIT 20`,
|
||||
[pattern, pattern, pattern]);
|
||||
posts.forEach(p => { attachAuthor(p); });
|
||||
res.json(posts);
|
||||
});
|
||||
|
||||
@@ -115,11 +169,15 @@ router.get('/tag/:name', (req, res) => {
|
||||
const name = String(req.params.name || '').trim();
|
||||
if (!name) return res.json([]);
|
||||
const posts = db.all(
|
||||
`SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name
|
||||
`SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email
|
||||
FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id
|
||||
WHERE bp.published = 1 AND (bp.tags = ? OR bp.tags LIKE ? OR bp.tags LIKE ? OR bp.tags LIKE ?)
|
||||
ORDER BY bp.created_at DESC`,
|
||||
[name, name + ',%', '%,' + name + ',%', '%,' + name]);
|
||||
posts.forEach(p => { attachAuthor(p); });
|
||||
res.json(posts);
|
||||
});
|
||||
|
||||
@@ -130,7 +188,7 @@ router.get('/archive', (req, res) => {
|
||||
|
||||
router.get('/posts/:id', optionalAuth, (req, res) => {
|
||||
const post = db.get(
|
||||
'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?',
|
||||
"SELECT bp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?",
|
||||
[req.params.id]);
|
||||
if (!post) return res.status(404).json({ error: '文章不存在' });
|
||||
// 未发布文章(草稿):仅管理员或作者本人可见,其余按 404 处理避免泄露存在性
|
||||
@@ -142,6 +200,10 @@ router.get('/posts/:id', optionalAuth, (req, res) => {
|
||||
db.run('UPDATE blog_posts SET views = views + 1 WHERE id = ?', [post.id]);
|
||||
post.views = (post.views || 0) + 1;
|
||||
}
|
||||
// [lock:] 真锁:按 viewer 视角剥离,未解锁块内容不进响应
|
||||
applyLocks(post, req);
|
||||
// 作者头像(自传 > QQ > RainID)
|
||||
attachAuthor(post);
|
||||
res.json(post);
|
||||
});
|
||||
|
||||
@@ -192,12 +254,40 @@ router.delete('/posts/:id/like', authMiddleware, (req, res) => {
|
||||
res.json({ liked: false, count });
|
||||
});
|
||||
|
||||
// [lock:] 解锁:返回块内原文(含 [image:]/[file:] 标签,前端自行渲染)。
|
||||
// login 块需登录;reply 块需已评论(含待审核);password 块 bcrypt 校验,
|
||||
// 失败统一 401(不区分密码错/块不存在);admin/作者恒可解锁。
|
||||
router.post('/posts/:id/locks/:index/unlock', authMiddleware, locks.unlockLimiter, (req, res) => {
|
||||
const post = db.get('SELECT id, author_id, published, content, locks FROM blog_posts WHERE id = ?', [req.params.id]);
|
||||
if (!post) return res.status(404).json({ error: '文章不存在' });
|
||||
if (post.published !== 1 && !(req.user.role === 'admin' || req.user.id === post.author_id))
|
||||
return res.status(404).json({ error: '文章不存在' });
|
||||
const index = parseInt(req.params.index);
|
||||
if (!Number.isInteger(index) || index < 0) return res.status(401).json({ error: '解锁失败' });
|
||||
const locksMeta = (() => { try { return JSON.parse(post.locks || '[]'); } catch { return []; } })();
|
||||
const hasCommented = !!db.get(
|
||||
'SELECT 1 x FROM blog_comments WHERE post_id = ? AND author_id = ?', [post.id, req.user.id]);
|
||||
const result = locks.verifyUnlock({
|
||||
blocks: locks.parseLocks(post.content).blocks,
|
||||
locksMeta,
|
||||
index,
|
||||
password: req.body.password,
|
||||
viewer: { userId: req.user.id, isAdmin: req.user.role === 'admin', isAuthor: req.user.id === post.author_id },
|
||||
replyQualified: hasCommented,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return res.status(result.status).json({ error: result.status === 403 ? '评论后解锁' : '解锁失败' });
|
||||
}
|
||||
// 附件真锁:随解锁内容一并签发附件 token(前端据此加载块内附件)
|
||||
res.json({ ok: true, content: result.inner, lockToken: locks.makeLockToken('blog', post.id, index) });
|
||||
});
|
||||
|
||||
router.post('/posts', authMiddleware, adminOnly, (req, res) => {
|
||||
const { title, content, excerpt, published, use_markdown, tags } = req.body;
|
||||
if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' });
|
||||
const id = db.run(
|
||||
'INSERT INTO blog_posts (title, content, excerpt, author_id, published, use_markdown, tags) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[title, content, excerpt || '', req.user.id, published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags)]);
|
||||
'INSERT INTO blog_posts (title, content, excerpt, author_id, published, use_markdown, tags, locks) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[title, content, excerpt || '', req.user.id, published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags), locks.saveLocks(content)]);
|
||||
res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [id]));
|
||||
});
|
||||
|
||||
@@ -206,8 +296,8 @@ router.put('/posts/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]))
|
||||
return res.status(404).json({ error: '文章不存在' });
|
||||
db.run(
|
||||
"UPDATE blog_posts SET title=?, content=?, excerpt=?, published=?, use_markdown=?, tags=?, updated_at=datetime('now') WHERE id=?",
|
||||
[title || '', content || '', excerpt || '', published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags), req.params.id]);
|
||||
"UPDATE blog_posts SET title=?, content=?, excerpt=?, published=?, use_markdown=?, tags=?, locks=?, updated_at=datetime('now') WHERE id=?",
|
||||
[title || '', content || '', excerpt || '', published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags), locks.saveLocks(content || ''), req.params.id]);
|
||||
res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [req.params.id]));
|
||||
});
|
||||
|
||||
@@ -224,21 +314,56 @@ router.delete('/posts/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
// 待审核评论列表(管理接口,须在 /comments/:postId 之前注册,避免 "pending" 被当作 postId)
|
||||
router.get('/comments/pending', authMiddleware, adminOnly, (req, res) => {
|
||||
const comments = db.all(
|
||||
`SELECT bc.*, u.username as author_name, bp.title as post_title
|
||||
`SELECT bc.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email,
|
||||
bp.title as post_title
|
||||
FROM blog_comments bc
|
||||
LEFT JOIN users u ON bc.author_id = u.id
|
||||
LEFT JOIN blog_posts bp ON bc.post_id = bp.id
|
||||
WHERE bc.status = 'pending' ORDER BY bc.created_at ASC`);
|
||||
comments.forEach(c => { attachAuthor(c); });
|
||||
res.json(comments);
|
||||
});
|
||||
|
||||
// 评论列表:仅返回已通过审核(approved)的评论
|
||||
// 全量评论列表(管理接口,adminOnly):?status=all|pending|approved|rejected&page=&pageSize=
|
||||
router.get('/comments', authMiddleware, adminOnly, (req, res) => {
|
||||
const status = String(req.query.status || 'all');
|
||||
if (!['all', 'pending', 'approved', 'rejected'].includes(status))
|
||||
return res.status(400).json({ error: '无效的 status' });
|
||||
let page = parseInt(req.query.page);
|
||||
let pageSize = parseInt(req.query.pageSize);
|
||||
if (!Number.isInteger(page) || page < 1) page = 1;
|
||||
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50) pageSize = 20;
|
||||
const where = status === 'all' ? '' : 'WHERE bc.status = ?';
|
||||
const params = status === 'all' ? [] : [status];
|
||||
const total = (db.get(`SELECT COUNT(*) c FROM blog_comments bc ${where}`, params) || {}).c || 0;
|
||||
const list = db.all(
|
||||
`SELECT bc.id, bc.post_id, bp.title as post_title, bc.content, bc.author_id,
|
||||
u.username as author_name, u.avatar, u.qq,
|
||||
u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email,
|
||||
bc.status, bc.created_at
|
||||
FROM blog_comments bc
|
||||
LEFT JOIN users u ON bc.author_id = u.id
|
||||
LEFT JOIN blog_posts bp ON bc.post_id = bp.id
|
||||
${where} ORDER BY bc.created_at DESC, bc.id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, (page - 1) * pageSize]);
|
||||
list.forEach(c => { attachAuthor(c); });
|
||||
res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||
});
|
||||
|
||||
// 评论列表:仅返回已通过审核(approved)的评论(带作者头像)
|
||||
router.get('/comments/:postId', (req, res) => {
|
||||
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.postId]))
|
||||
return res.status(404).json({ error: '文章不存在' });
|
||||
const comments = db.all(
|
||||
"SELECT bc.*, u.username as author_name FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.post_id = ? AND bc.status = 'approved' ORDER BY bc.created_at ASC",
|
||||
"SELECT bc.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.post_id = ? AND bc.status = 'approved' ORDER BY bc.created_at ASC",
|
||||
[req.params.postId]);
|
||||
comments.forEach(c => { attachAuthor(c); });
|
||||
res.json(comments);
|
||||
});
|
||||
|
||||
@@ -258,7 +383,12 @@ router.post('/comments/:postId', authMiddleware, commentLimiter, async (req, res
|
||||
const id = db.run('INSERT INTO blog_comments (post_id, content, author_id, parent_id, status) VALUES (?, ?, ?, ?, ?)',
|
||||
[post.id, content, req.user.id, parent_id, status]);
|
||||
const comment = db.get(
|
||||
'SELECT bc.*, u.username as author_name FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.id = ?', [id]);
|
||||
`SELECT bc.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email
|
||||
FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.id = ?`, [id]);
|
||||
attachAuthor(comment);
|
||||
// 邮件通知(失败不影响评论创建)
|
||||
notifyCommentToAuthor(post, req.user.id, comment);
|
||||
res.json(comment);
|
||||
|
||||
@@ -156,8 +156,9 @@ router.post('/required', (req, res) => {
|
||||
if (captchaType === 'turnstile' && !hasTurnstile) return res.json({ required: false, type: 'turnstile' });
|
||||
res.json({ required: isRequired, type: captchaType });
|
||||
} catch (e) {
|
||||
// L3:不向客户端回显内部错误细节(防信息泄露),仅记录日志
|
||||
console.error('Captcha required error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,21 @@ const express = require('express');
|
||||
const nodemailer = require('nodemailer');
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const db = require('../db');
|
||||
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// M2:验证码重发/校验限流——15 分钟窗口内每 IP 最多 10 次(防 8 位验证码爆破 + 邮箱轰炸)
|
||||
const verifyLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: '操作过于频繁,请 15 分钟后再试' },
|
||||
});
|
||||
|
||||
function getTransporter() {
|
||||
const host = db.getSetting('smtp_host');
|
||||
if (!host) return null;
|
||||
@@ -14,6 +24,8 @@ function getTransporter() {
|
||||
host, port: parseInt(db.getSetting('smtp_port')) || 587,
|
||||
secure: parseInt(db.getSetting('smtp_port')) === 465,
|
||||
auth: { user: db.getSetting('smtp_user'), pass: db.getSetting('smtp_pass') },
|
||||
// L4:保留 rejectUnauthorized: false(不改行为)——内网 SMTP 多为自签证书,改 true 会大面积失败。
|
||||
// 风险:SMTP 凭据在 TLS 握手时可能被中间人嗅探。生产环境建议改 true 并将自签证书加入系统 CA。
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
@@ -57,12 +69,15 @@ router.post('/test', authMiddleware, adminOnly, async (req, res) => {
|
||||
});
|
||||
|
||||
// Re-send verification code
|
||||
router.post('/send-verify', async (req, res) => {
|
||||
router.post('/send-verify', verifyLimiter, async (req, res) => {
|
||||
const { email, username } = req.body;
|
||||
if (!email || !username) return res.status(400).json({ error: '参数不完整' });
|
||||
|
||||
// 先取出旧 pending 记录(其中存有注册时加密的密码),避免删除后无法重建
|
||||
const pending = db.get('SELECT * FROM pending_users WHERE email = ?', [email]);
|
||||
// 先取出旧 pending 记录(其中存有注册时加密的密码),避免删除后无法重建;
|
||||
// M2:只接受 15 分钟内的有效记录(过期视为已失效并拒绝,防旧码无限重发)
|
||||
const pending = db.get(
|
||||
`SELECT * FROM pending_users WHERE email = ? AND created_at > datetime('now','-15 minutes')`,
|
||||
[email]);
|
||||
if (!pending) return res.status(400).json({ error: '未找到待验证的注册信息,请重新注册' });
|
||||
|
||||
// 先确认 SMTP 已配置,避免删除旧记录后无法发送新验证码
|
||||
@@ -105,11 +120,18 @@ router.post('/send-verify', async (req, res) => {
|
||||
});
|
||||
|
||||
// Complete registration with verification code
|
||||
router.post('/complete-register', async (req, res) => {
|
||||
router.post('/complete-register', verifyLimiter, async (req, res) => {
|
||||
const { code, username, password } = req.body;
|
||||
if (!code || !username || !password) return res.status(400).json({ error: '参数不完整' });
|
||||
const pending = db.get('SELECT * FROM pending_users WHERE token = ? AND username = ?', [code, username]);
|
||||
if (!pending) return res.status(400).json({ error: '验证码错误或已过期' });
|
||||
// M2:pending TTL——仅接受创建 15 分钟内的验证码,超时删除并拒绝(防 8 位码无限期可试 + 表无限增长)
|
||||
const pending = db.get(
|
||||
`SELECT * FROM pending_users WHERE token = ? AND username = ? AND created_at > datetime('now','-15 minutes')`,
|
||||
[code, username]);
|
||||
if (!pending) {
|
||||
// 顺手清理所有过期残留
|
||||
db.run(`DELETE FROM pending_users WHERE created_at < datetime('now','-15 minutes')`);
|
||||
return res.status(400).json({ error: '验证码错误或已过期' });
|
||||
}
|
||||
if (!bcrypt.compareSync(password, pending.password)) return res.status(400).json({ error: '密码不匹配' });
|
||||
|
||||
const existing = db.get('SELECT id FROM users WHERE username = ?', [username]);
|
||||
|
||||
@@ -13,7 +13,7 @@ function escapeXml(s) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// created_at('YYYY-MM-DD HH:MM:SS',UTC)转 RFC822 格式
|
||||
// created_at('YYYY-MM-DD HH:MM:SS',UTC)转 RFC822 格式('ddd, DD MMM YYYY HH:MM:SS GMT')
|
||||
function toRfc822(createdAt) {
|
||||
try {
|
||||
const d = new Date(String(createdAt).replace(' ', 'T') + 'Z');
|
||||
@@ -23,47 +23,142 @@ function toRfc822(createdAt) {
|
||||
}
|
||||
}
|
||||
|
||||
// RSS 2.0:最新 20 篇已发布博文
|
||||
router.get('/feed.xml', (req, res) => {
|
||||
const siteName = db.getSetting('site_name') || 'RainWeb';
|
||||
const siteDesc = db.getSetting('site_description') || '个人云平台';
|
||||
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
|
||||
const base = siteUrl.replace(/\/$/, '');
|
||||
|
||||
const posts = db.all(
|
||||
'SELECT id, title, excerpt, content, created_at FROM blog_posts WHERE published = 1 ORDER BY created_at DESC LIMIT 20');
|
||||
|
||||
let items = '';
|
||||
posts.forEach(p => {
|
||||
const link = base + '/blog/' + p.id;
|
||||
let description;
|
||||
if (p.excerpt) {
|
||||
description = `<description>${escapeXml(p.excerpt)}</description>`;
|
||||
} else {
|
||||
description = `<description><![CDATA[${String(p.content || '').replace(/\]\]>/g, ']]]]><![CDATA[>')}]]></description>`;
|
||||
// 摘要:剥 markdown/自定义标签符号,截断 ~maxLen 字
|
||||
// 处理 [image:]/[file:] 标签、代码块/行内代码、链接(留文字)、标题#、强调*/_/~、引用>、列表符号
|
||||
function makeSummary(content, maxLen = 200) {
|
||||
let s = String(content || '')
|
||||
.replace(/\[image:[^\]]*\]/g, ' ')
|
||||
.replace(/\[file:[^\]]*\]/g, ' ')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`]*)`/g, '$1')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^#{1,6}\s*/gm, '')
|
||||
.replace(/[*_~]{1,3}/g, '')
|
||||
.replace(/^\s*>\s?/gm, '')
|
||||
.replace(/^\s*[-+*]\s+/gm, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (s.length > maxLen) s = s.slice(0, maxLen) + '…';
|
||||
return s;
|
||||
}
|
||||
items += ` <item>
|
||||
<title>${escapeXml(p.title)}</title>
|
||||
<link>${escapeXml(link)}</link>
|
||||
<guid>${escapeXml(link)}</guid>
|
||||
<pubDate>${escapeXml(toRfc822(p.created_at))}</pubDate>
|
||||
${description}
|
||||
|
||||
// RSS 2.0 生成器(博客/论坛全站/版块三源共用)
|
||||
// items = [{ title, link, description, author?, pubDate }]
|
||||
function renderRss({ title, description, link, items }) {
|
||||
let itemsXml = '';
|
||||
items.forEach(it => {
|
||||
const descCdata = String(it.description || '').replace(/\]\]>/g, ']]]]><![CDATA[>');
|
||||
const authorXml = it.author
|
||||
? ` <dc:creator><![CDATA[${String(it.author).replace(/\]\]>/g, ']]]]><![CDATA[>')}]]></dc:creator>\n`
|
||||
: '';
|
||||
itemsXml += ` <item>
|
||||
<title>${escapeXml(it.title)}</title>
|
||||
<link>${escapeXml(it.link)}</link>
|
||||
<guid>${escapeXml(it.link)}</guid>
|
||||
<pubDate>${escapeXml(toRfc822(it.pubDate))}</pubDate>
|
||||
${authorXml} <description><![CDATA[${descCdata}]]></description>
|
||||
</item>
|
||||
`;
|
||||
});
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>${escapeXml(siteName)}</title>
|
||||
<link>${escapeXml(base)}</link>
|
||||
<description>${escapeXml(siteDesc)}</description>
|
||||
${items} </channel>
|
||||
<title>${escapeXml(title)}</title>
|
||||
<link>${escapeXml(link)}</link>
|
||||
<description>${escapeXml(description)}</description>
|
||||
${itemsXml} </channel>
|
||||
</rss>
|
||||
`;
|
||||
}
|
||||
|
||||
function sendFeed(res, xml) {
|
||||
// feed 要新鲜:禁缓存(RSS 阅读器拉取频繁,旧缓存会推迟新帖出现)
|
||||
res.header('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
res.header('Cache-Control', 'no-cache');
|
||||
res.send(xml);
|
||||
}
|
||||
|
||||
// 论坛源对外条件:全站开关 feed_forum_enabled='1' 且论坛游客可见(私密模式不对外订阅)
|
||||
function forumFeedEnabled() {
|
||||
return db.getSetting('feed_forum_enabled') === '1' && db.getSetting('forum_guest_visible') === '1';
|
||||
}
|
||||
|
||||
// 论坛帖子 → feed item(摘要剥离 markdown)
|
||||
function forumItem(p, base) {
|
||||
return {
|
||||
title: p.title,
|
||||
link: base + '/forum/' + p.id,
|
||||
description: makeSummary(p.content),
|
||||
author: p.username || '匿名',
|
||||
pubDate: p.created_at
|
||||
};
|
||||
}
|
||||
|
||||
function siteBase(req) {
|
||||
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
|
||||
return siteUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function feedMaxItems() {
|
||||
return Math.min(Math.max(parseInt(db.getSetting('feed_max_items')) || 20, 1), 100);
|
||||
}
|
||||
|
||||
// ── 博客全站源(原有 /feed.xml 保留,内容受 feed_show_full / feed_max_items 控制)────────
|
||||
router.get('/feed.xml', (req, res) => {
|
||||
const siteName = db.getSetting('site_name') || 'RainWeb';
|
||||
const siteDesc = db.getSetting('site_description') || '个人云平台';
|
||||
const base = siteBase(req);
|
||||
const maxItems = feedMaxItems();
|
||||
const showFull = db.getSetting('feed_show_full') === '1';
|
||||
const posts = db.all(
|
||||
'SELECT id, title, excerpt, content, created_at FROM blog_posts WHERE published = 1 ORDER BY created_at DESC LIMIT ?', [maxItems]);
|
||||
const items = posts.map(p => ({
|
||||
title: p.title,
|
||||
link: base + '/blog/' + p.id,
|
||||
// 全文模式用 content;摘要模式优先 excerpt,无 excerpt 则剥 markdown 取前 200 字
|
||||
description: showFull ? (p.content || '') : (p.excerpt || makeSummary(p.content)),
|
||||
pubDate: p.created_at
|
||||
}));
|
||||
sendFeed(res, renderRss({ title: siteName, description: siteDesc, link: base, items }));
|
||||
});
|
||||
|
||||
// ── 论坛全站源 ──────────────────────────────────
|
||||
router.get('/feed/forum.xml', (req, res) => {
|
||||
if (!forumFeedEnabled()) return res.status(404).send('Not found');
|
||||
const siteName = db.getSetting('site_name') || 'RainWeb';
|
||||
const siteDesc = db.getSetting('site_description') || '个人云平台';
|
||||
const base = siteBase(req);
|
||||
const maxItems = feedMaxItems();
|
||||
const posts = db.all(
|
||||
`SELECT fp.*, u.username FROM forum_posts fp JOIN users u ON fp.author_id = u.id
|
||||
ORDER BY fp.created_at DESC LIMIT ?`, [maxItems]);
|
||||
sendFeed(res, renderRss({
|
||||
title: siteName + ' 论坛',
|
||||
description: siteDesc + ' —— 论坛最新帖子',
|
||||
link: base + '/forum.html',
|
||||
items: posts.map(p => forumItem(p, base))
|
||||
}));
|
||||
});
|
||||
|
||||
// ── 版块源 ─────────────────────────────────────
|
||||
router.get('/feed/forum/c/:id.xml', (req, res) => {
|
||||
if (!forumFeedEnabled()) return res.status(404).send('Not found');
|
||||
const cat = db.get('SELECT id, name, feed_enabled FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
// 版块不存在或该版块 feed 已关闭 → 404
|
||||
if (!cat || !cat.feed_enabled) return res.status(404).send('Not found');
|
||||
const siteName = db.getSetting('site_name') || 'RainWeb';
|
||||
const base = siteBase(req);
|
||||
const maxItems = feedMaxItems();
|
||||
const posts = db.all(
|
||||
`SELECT fp.*, u.username FROM forum_posts fp JOIN users u ON fp.author_id = u.id
|
||||
WHERE fp.category_id = ? ORDER BY fp.created_at DESC LIMIT ?`, [cat.id, maxItems]);
|
||||
sendFeed(res, renderRss({
|
||||
title: cat.name + ' - ' + siteName,
|
||||
description: cat.name + ' 版块最新帖子',
|
||||
link: base + '/forum/c/' + cat.id,
|
||||
items: posts.map(p => forumItem(p, base))
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,126 +1,644 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const getDb = require('../db').getDb;
|
||||
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
|
||||
const { resolveCaptcha } = require('./auth');
|
||||
const locks = require('../lib/locks');
|
||||
const { authorAvatar } = require('../lib/avatar');
|
||||
const { attachAuthor } = require('../lib/author');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/categories', (req, res) => {
|
||||
res.json(db.all('SELECT * FROM forum_categories ORDER BY sort_order ASC'));
|
||||
// 论坛游客可见开关:forum_guest_visible='1'(默认)游客可浏览论坛;
|
||||
// '0'(私密模式)时所有读接口要求登录(401),发帖/回复始终需登录。
|
||||
// 与 ssr.js forumSSR 的 404 门禁联动(SEO 权衡:私密模式下论坛详情页不再对外索引)。
|
||||
function requireGuestVisible(req, res, next) {
|
||||
if (db.getSetting('forum_guest_visible') === '1') return next();
|
||||
return authMiddleware(req, res, next);
|
||||
}
|
||||
|
||||
// 可选鉴权:有效 token 解析出 req.user(用于 [lock:] viewer 判定与作者/管理员识别),
|
||||
// 匿名/无效 token 直接放行。公共模式下详情页读接口需要它(requireGuestVisible 不解析 token)。
|
||||
function optionalAuth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (header && header.startsWith('Bearer ')) {
|
||||
try { req.user = jwt.verify(header.slice(7), SECRET); } catch { /* 无效 token 按匿名 */ }
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ── 版主权限 ────────────────────────────────────
|
||||
// admin 恒为版主;非 admin 查 forum_moderators 归属表
|
||||
function isModerator(userId, categoryId) {
|
||||
if (!userId) return false;
|
||||
const u = db.get('SELECT role FROM users WHERE id = ?', [userId]);
|
||||
if (u && u.role === 'admin') return true;
|
||||
return !!db.get('SELECT 1 FROM forum_moderators WHERE category_id = ? AND user_id = ?', [categoryId, userId]);
|
||||
}
|
||||
|
||||
// 站长 = username 'admin' 的账号(超级管理员)。
|
||||
// 站长发的帖子是"站长帖":任何人(本版版主/其他 admin)都无权删改/置顶/加精,仅站长本人可操作。
|
||||
function isOwnerPost(post) {
|
||||
const author = db.get('SELECT username FROM users WHERE id = ?', [post.author_id]);
|
||||
return author && author.username === 'admin';
|
||||
}
|
||||
|
||||
// pin/essence 用:admin/版主(作者不含)——站长帖仅站长本人可操作
|
||||
function moderatorPostGuard(req, res, next) {
|
||||
const post = db.get('SELECT * FROM forum_posts WHERE id = ?', [req.params.id]);
|
||||
if (!post) return res.status(404).json({ error: '帖子不存在' });
|
||||
// 站长帖保护:仅站长本人可置顶/加精,版主/其他 admin 一律无权操作站长帖子
|
||||
if (isOwnerPost(post) && req.user.id !== post.author_id)
|
||||
return res.status(403).json({ error: '无权限' });
|
||||
if (!isModerator(req.user.id, post.category_id)) return res.status(403).json({ error: '无权限' });
|
||||
req.post = post;
|
||||
next();
|
||||
}
|
||||
|
||||
// DELETE/编辑帖子用:作者/admin/版主——站长帖仅站长本人可删改
|
||||
function authorOrModeratorGuard(req, res, next) {
|
||||
const post = db.get('SELECT * FROM forum_posts WHERE id = ?', [req.params.id]);
|
||||
if (!post) return res.status(404).json({ error: '帖子不存在' });
|
||||
// 站长帖保护:仅站长本人可删改,版主/其他 admin 一律无权
|
||||
if (isOwnerPost(post) && req.user.id !== post.author_id)
|
||||
return res.status(403).json({ error: '无权限' });
|
||||
if (post.author_id !== req.user.id && !isModerator(req.user.id, post.category_id))
|
||||
return res.status(403).json({ error: '无权限' });
|
||||
req.post = post;
|
||||
next();
|
||||
}
|
||||
|
||||
// DELETE 回复用:回复作者/admin/所属帖版主。
|
||||
// 站长保护只针对帖子本体:站长帖里的回复按回复作者判定(独立于帖子作者),
|
||||
// 回复作者是站长时才仅站长本人可删,否则按原逻辑(回复作者/版主/admin)——不查帖子作者,勿误伤。
|
||||
function replyAuthorOrModeratorGuard(req, res, next) {
|
||||
const reply = db.get('SELECT * FROM forum_replies WHERE id = ?', [req.params.id]);
|
||||
if (!reply) return res.status(404).json({ error: '回复不存在' });
|
||||
// 回复作者是站长:仅站长本人可删除该回复(站长保护延伸到站长发的回复本体)
|
||||
if (isOwnerPost(reply) && req.user.id !== reply.author_id)
|
||||
return res.status(403).json({ error: '无权限' });
|
||||
const post = db.get('SELECT category_id FROM forum_posts WHERE id = ?', [reply.post_id]);
|
||||
const categoryId = post ? post.category_id : 0;
|
||||
if (reply.author_id !== req.user.id && !isModerator(req.user.id, categoryId))
|
||||
return res.status(403).json({ error: '无权限' });
|
||||
req.reply = reply;
|
||||
next();
|
||||
}
|
||||
|
||||
// 公告编辑用:admin/版主
|
||||
function categoryModeratorGuard(req, res, next) {
|
||||
const cat = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
if (!cat) return res.status(404).json({ error: '分类不存在' });
|
||||
if (!isModerator(req.user.id, cat.id)) return res.status(403).json({ error: '无权限' });
|
||||
req.category = cat;
|
||||
next();
|
||||
}
|
||||
|
||||
// ── 版块级禁言 ──────────────────────────────────
|
||||
// 检查用户是否在禁言期:命中返回禁言行(muted_until 为 NULL 表示永久),否则 null。
|
||||
// admin 豁免(管理员不受禁言);版主不豁免——版主互禁设计下版主不会被禁言,但保险起见同样检查
|
||||
function isMuted(userId, categoryId) {
|
||||
if (!userId || !categoryId) return null;
|
||||
const u = db.get('SELECT role FROM users WHERE id = ?', [userId]);
|
||||
if (u && u.role === 'admin') return null;
|
||||
return db.get(
|
||||
`SELECT muted_until FROM forum_mutes
|
||||
WHERE category_id = ? AND user_id = ? AND (muted_until IS NULL OR muted_until > datetime('now'))`,
|
||||
[categoryId, userId]) || null;
|
||||
}
|
||||
|
||||
// 禁言管理权限:category_id 来自 query(GET)或 body(PUT/DELETE),仅本版版主/admin
|
||||
function muteCategoryGuard(req, res, next) {
|
||||
const catId = Number(req.query.category_id || (req.body && req.body.category_id));
|
||||
if (!catId) return res.status(400).json({ error: '缺少 category_id' });
|
||||
const cat = db.get('SELECT id FROM forum_categories WHERE id = ?', [catId]);
|
||||
if (!cat) return res.status(404).json({ error: '分类不存在' });
|
||||
if (!isModerator(req.user.id, catId)) return res.status(403).json({ error: '无权限' });
|
||||
req.muteCategoryId = catId;
|
||||
next();
|
||||
}
|
||||
|
||||
// DB 的 'YYYY-MM-DD HH:MM:SS'(UTC)转 ISO 8601 输出
|
||||
function toIso(s) {
|
||||
return s ? String(s).replace(' ', 'T') : null;
|
||||
}
|
||||
|
||||
// 禁言拦截(发帖/回复共用):命中返回 403 响应,未命中继续
|
||||
function assertNotMuted(req, res, categoryId) {
|
||||
const m = isMuted(req.user.id, categoryId);
|
||||
if (!m) return true;
|
||||
res.status(403).json({
|
||||
error: '你已被本版块禁言' + (m.muted_until ? '至 ' + toIso(m.muted_until) : '(永久)')
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 版块聚合字段:帖子数 / 今日新帖 / 最后回复时间 / 版主(逗号分隔用户名)
|
||||
const CAT_AGG_SELECT = `fc.*,
|
||||
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.category_id = fc.id) AS post_count,
|
||||
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.category_id = fc.id AND date(fp.created_at) = date('now')) AS today_count,
|
||||
(SELECT MAX(fp.updated_at) FROM forum_posts fp WHERE fp.category_id = fc.id) AS last_post_at,
|
||||
COALESCE((SELECT group_concat(u.username, ',') FROM forum_moderators fm LEFT JOIN users u ON fm.user_id = u.id WHERE fm.category_id = fc.id), '') AS moderators`;
|
||||
|
||||
router.get('/categories', requireGuestVisible, (req, res) => {
|
||||
res.json(db.all(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc ORDER BY fc.sort_order ASC`));
|
||||
});
|
||||
|
||||
router.post('/categories', authMiddleware, (req, res) => {
|
||||
// 单版块详情(含聚合,供版块页 L2 头部)
|
||||
router.get('/categories/:id', requireGuestVisible, (req, res) => {
|
||||
const cat = db.get(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc WHERE fc.id = ?`, [req.params.id]);
|
||||
if (!cat) return res.status(404).json({ error: '分类不存在' });
|
||||
res.json(cat);
|
||||
});
|
||||
|
||||
// H4:分类增删改仅限管理员(原仅 authMiddleware,任意登录用户可越权)
|
||||
router.post('/categories', authMiddleware, adminOnly, (req, res) => {
|
||||
try {
|
||||
const { name, description, sort_order, announcement, sub_categories } = req.body;
|
||||
const { name, description, sort_order, announcement, sub_categories, icon, icon_color, feed_enabled } = req.body;
|
||||
if (!name) return res.status(400).json({ error: '名称不能为空' });
|
||||
const existing = db.get('SELECT id FROM forum_categories WHERE name = ?', [name]);
|
||||
if (existing) return res.status(400).json({ error: '分类已存在' });
|
||||
const sc = Array.isArray(sub_categories) ? sub_categories.join(',') : (sub_categories || '');
|
||||
const id = db.run('INSERT INTO forum_categories (name, description, sort_order, announcement, sub_categories) VALUES (?, ?, ?, ?, ?)',
|
||||
[name, description || '', sort_order || 0, announcement || '', sc]);
|
||||
const id = db.run('INSERT INTO forum_categories (name, description, sort_order, announcement, sub_categories, icon, icon_color, feed_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, description || '', sort_order || 0, announcement || '', sc, icon || '', icon_color || '', feed_enabled !== undefined ? (feed_enabled ? 1 : 0) : 1]);
|
||||
res.json(db.get('SELECT * FROM forum_categories WHERE id = ?', [id]));
|
||||
} catch (e) { console.error('Create category error:', e.message); res.status(500).json({ error: '操作失败' }); }
|
||||
});
|
||||
|
||||
router.put('/categories/:id', authMiddleware, (req, res) => {
|
||||
router.put('/categories/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
try {
|
||||
const { name, description, sort_order, announcement, sub_categories } = req.body;
|
||||
const existing = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
const { name, description, sort_order, announcement, sub_categories, icon, icon_color, feed_enabled } = req.body;
|
||||
const existing = db.get('SELECT id, feed_enabled FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
if (!existing) return res.status(404).json({ error: '分类不存在' });
|
||||
const sc = Array.isArray(sub_categories) ? sub_categories.join(',') : (sub_categories || '');
|
||||
db.run('UPDATE forum_categories SET name=?, description=?, sort_order=?, announcement=?, sub_categories=? WHERE id=?',
|
||||
[name || '', description || '', sort_order || 0, announcement || '', sc, req.params.id]);
|
||||
db.run('UPDATE forum_categories SET name=?, description=?, sort_order=?, announcement=?, sub_categories=?, icon=?, icon_color=?, feed_enabled=? WHERE id=?',
|
||||
[name || '', description || '', sort_order || 0, announcement || '', sc, icon || '', icon_color || '',
|
||||
feed_enabled !== undefined ? (feed_enabled ? 1 : 0) : (existing.feed_enabled || 1), req.params.id]);
|
||||
const updated = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
if (!updated) return res.status(500).json({ error: '更新后读取失败' });
|
||||
res.json(updated);
|
||||
} catch (e) { console.error('Update category error:', e.message); res.status(500).json({ error: '操作失败' }); }
|
||||
});
|
||||
|
||||
router.delete('/categories/:id', authMiddleware, (req, res) => {
|
||||
// 版主列表设置(仅管理员):事务内 DELETE 全部 + 校验用户存在后循环 INSERT
|
||||
router.put('/categories/:id/moderators', authMiddleware, adminOnly, (req, res) => {
|
||||
try {
|
||||
const existing = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
if (!existing) return res.status(404).json({ error: '分类不存在' });
|
||||
db.run('DELETE FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: '删除成功' });
|
||||
});
|
||||
|
||||
router.get('/posts', (req, res) => {
|
||||
const { category_id } = req.query;
|
||||
let sql, params;
|
||||
if (category_id) {
|
||||
sql = `SELECT fp.*, u.username as author_name,
|
||||
(SELECT COUNT(*) FROM forum_replies WHERE post_id = fp.id) as reply_count
|
||||
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
|
||||
WHERE fp.category_id = ? ORDER BY fp.created_at DESC`;
|
||||
params = [category_id];
|
||||
} else {
|
||||
sql = `SELECT fp.*, u.username as author_name,
|
||||
(SELECT COUNT(*) FROM forum_replies WHERE post_id = fp.id) as reply_count
|
||||
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
|
||||
ORDER BY fp.created_at DESC`;
|
||||
params = [];
|
||||
const raw = Array.isArray(req.body.user_ids) ? req.body.user_ids : [];
|
||||
const ids = raw.map(Number).filter(n => Number.isInteger(n) && n > 0);
|
||||
// 用原生 prepared 语句执行:db.run 会吞异常,事务内必须让 FK/校验错误真正抛出并回滚
|
||||
getDb().transaction(() => {
|
||||
getDb().prepare('DELETE FROM forum_moderators WHERE category_id = ?').run(req.params.id);
|
||||
for (const uid of ids) {
|
||||
const u = db.get('SELECT id FROM users WHERE id = ?', [uid]);
|
||||
if (!u) throw new Error('用户不存在: ' + uid);
|
||||
getDb().prepare('INSERT INTO forum_moderators (category_id, user_id) VALUES (?, ?)').run(req.params.id, uid);
|
||||
}
|
||||
})();
|
||||
res.json({ message: '保存成功', user_ids: ids });
|
||||
} catch (e) {
|
||||
console.error('Update moderators error:', e.message);
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
res.json(db.all(sql, params));
|
||||
});
|
||||
|
||||
router.get('/posts/:id', (req, res) => {
|
||||
// 公告编辑(admin/版主)
|
||||
router.put('/categories/:id/announcement', authMiddleware, categoryModeratorGuard, (req, res) => {
|
||||
const announcement = String(req.body.announcement || '');
|
||||
db.run('UPDATE forum_categories SET announcement = ? WHERE id = ?', [announcement, req.params.id]);
|
||||
res.json({ message: '公告已更新', announcement });
|
||||
});
|
||||
|
||||
// 版块 profile 编辑(admin/版主):名称/描述/图标/图标底色——部分更新,只改提供的字段。
|
||||
// 独立接口不放开通用 PUT /categories(admin 全量):防版主越权改 sort_order/公告/子版块等。
|
||||
router.put('/categories/:id/profile', authMiddleware, categoryModeratorGuard, (req, res) => {
|
||||
try {
|
||||
const updates = {};
|
||||
// name:非空字符串,≤50
|
||||
if (req.body.name !== undefined) {
|
||||
const name = String(req.body.name).trim();
|
||||
if (!name) return res.status(400).json({ error: '名称不能为空' });
|
||||
if (name.length > 50) return res.status(400).json({ error: '名称不能超过 50 个字符' });
|
||||
updates.name = name;
|
||||
}
|
||||
// description:≤500
|
||||
if (req.body.description !== undefined) {
|
||||
const desc = String(req.body.description || '');
|
||||
if (desc.length > 500) return res.status(400).json({ error: '描述不能超过 500 个字符' });
|
||||
updates.description = desc;
|
||||
}
|
||||
// icon:≤100,emoji 或 http(s) 图片 URL 或 /uploads/ 相对路径(本站上传的图标)
|
||||
if (req.body.icon !== undefined) {
|
||||
const icon = String(req.body.icon || '').trim();
|
||||
if (icon.length > 100) return res.status(400).json({ error: '图标不能超过 100 个字符' });
|
||||
if (icon && !/^(https?:\/\/|\/uploads\/|[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}])/u.test(icon)) {
|
||||
return res.status(400).json({ error: '图标需为 emoji 或 http(s) 图片 URL' });
|
||||
}
|
||||
updates.icon = icon;
|
||||
}
|
||||
// icon_color:空字符串清空,或 #hex(3/6 位)
|
||||
if (req.body.icon_color !== undefined) {
|
||||
const color = String(req.body.icon_color || '').trim();
|
||||
if (color && !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) {
|
||||
return res.status(400).json({ error: '图标底色需为 #hex 色值' });
|
||||
}
|
||||
updates.icon_color = color;
|
||||
}
|
||||
// feed_enabled:版块级 RSS 开关(后台 RSS 管理页用),'1'/'0'
|
||||
if (req.body.feed_enabled !== undefined) {
|
||||
const fe = String(req.body.feed_enabled);
|
||||
if (fe !== '0' && fe !== '1') return res.status(400).json({ error: 'feed_enabled 需为 0 或 1' });
|
||||
updates.feed_enabled = fe;
|
||||
}
|
||||
if (!Object.keys(updates).length) return res.status(400).json({ error: '没有可更新的字段' });
|
||||
|
||||
const cols = Object.keys(updates).map(k => k + ' = ?').join(', ');
|
||||
db.run(`UPDATE forum_categories SET ${cols} WHERE id = ?`, [...Object.values(updates), req.params.id]);
|
||||
const cat = db.get(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc WHERE fc.id = ?`, [req.params.id]);
|
||||
if (!cat) return res.status(500).json({ error: '更新后读取失败' });
|
||||
res.json({
|
||||
message: '版块信息已更新',
|
||||
category: { id: cat.id, name: cat.name, description: cat.description, icon: cat.icon || '', icon_color: cat.icon_color || '', feed_enabled: cat.feed_enabled }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Update category profile error:', e.message);
|
||||
res.status(500).json({ error: '操作失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除分类(仅管理员):FK 约束下必须先删依赖行——事务内删 replies→posts→moderators→category。
|
||||
// 之前直接 DELETE category 在 foreign_keys=ON 时 FK 报错被 db.run 吞掉,造成"删除成功"假象。
|
||||
router.delete('/categories/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
const existing = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
|
||||
if (!existing) return res.status(404).json({ error: '分类不存在' });
|
||||
try {
|
||||
getDb().transaction(() => {
|
||||
getDb().prepare('DELETE FROM forum_replies WHERE post_id IN (SELECT id FROM forum_posts WHERE category_id = ?)').run(req.params.id);
|
||||
getDb().prepare('DELETE FROM forum_posts WHERE category_id = ?').run(req.params.id);
|
||||
getDb().prepare('DELETE FROM forum_moderators WHERE category_id = ?').run(req.params.id);
|
||||
getDb().prepare('DELETE FROM forum_categories WHERE id = ?').run(req.params.id);
|
||||
})();
|
||||
res.json({ message: '删除成功' });
|
||||
} catch (e) {
|
||||
console.error('Delete category error:', e.message);
|
||||
res.status(500).json({ error: '操作失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 我管理的版块列表(供版主管理页):返回版块详情数组,admin 返回全部版块
|
||||
router.get('/moderated', authMiddleware, (req, res) => {
|
||||
const u = db.get('SELECT role FROM users WHERE id = ?', [req.user.id]);
|
||||
const isAdmin = u && u.role === 'admin';
|
||||
const rows = isAdmin
|
||||
? db.all(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc ORDER BY fc.sort_order ASC`)
|
||||
: db.all(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc
|
||||
JOIN forum_moderators fm ON fm.category_id = fc.id
|
||||
WHERE fm.user_id = ? ORDER BY fc.sort_order ASC`, [req.user.id]);
|
||||
const categories = rows.map(r => ({
|
||||
id: r.id, name: r.name, icon: r.icon || '', icon_color: r.icon_color || '',
|
||||
post_count: r.post_count || 0, announcement: r.announcement || '',
|
||||
moderators: r.moderators || ''
|
||||
}));
|
||||
// category_ids 保留兼容旧调用
|
||||
res.json({ category_ids: categories.map(c => c.id), categories });
|
||||
});
|
||||
|
||||
// ── 禁言 API(均需登录;管理操作仅本版版主/admin)──────────────────
|
||||
|
||||
// 检查当前用户是否被禁言(前台发帖提示用)
|
||||
router.get('/mutes/check', requireGuestVisible, authMiddleware, (req, res) => {
|
||||
const catId = Number(req.query.category_id);
|
||||
if (!catId) return res.status(400).json({ error: '缺少 category_id' });
|
||||
const cat = db.get('SELECT id FROM forum_categories WHERE id = ?', [catId]);
|
||||
if (!cat) return res.status(404).json({ error: '分类不存在' });
|
||||
const row = db.get(
|
||||
`SELECT muted_until FROM forum_mutes
|
||||
WHERE category_id = ? AND user_id = ? AND (muted_until IS NULL OR muted_until > datetime('now'))`,
|
||||
[catId, req.user.id]);
|
||||
if (!row) return res.json({ muted: false });
|
||||
res.json({ muted: true, permanent: !row.muted_until, muted_until: toIso(row.muted_until) });
|
||||
});
|
||||
|
||||
// 本版禁言列表(只回未过期,顺手清理过期行)
|
||||
router.get('/mutes', requireGuestVisible, authMiddleware, muteCategoryGuard, (req, res) => {
|
||||
const catId = req.muteCategoryId;
|
||||
getDb().prepare("DELETE FROM forum_mutes WHERE category_id = ? AND muted_until IS NOT NULL AND muted_until <= datetime('now')").run(catId);
|
||||
const rows = db.all(
|
||||
`SELECT fm.user_id, u.username, u.avatar, u.qq, fm.muted_until, fm.created_at
|
||||
FROM forum_mutes fm LEFT JOIN users u ON fm.user_id = u.id
|
||||
WHERE fm.category_id = ? AND (fm.muted_until IS NULL OR fm.muted_until > datetime('now'))
|
||||
ORDER BY fm.created_at DESC`, [catId]);
|
||||
res.json(rows.map(r => ({
|
||||
user_id: r.user_id, username: r.username || '已注销用户',
|
||||
author_avatar: authorAvatar(r),
|
||||
permanent: !r.muted_until, muted_until: toIso(r.muted_until), created_at: toIso(r.created_at)
|
||||
})));
|
||||
});
|
||||
|
||||
// 添加/更新禁言(duration: 1|7|30|'forever';存在则 UPDATE 否则 INSERT)
|
||||
router.put('/mutes', requireGuestVisible, authMiddleware, muteCategoryGuard, (req, res) => {
|
||||
try {
|
||||
const catId = req.muteCategoryId;
|
||||
let uid = Number(req.body.user_id);
|
||||
// 前端按用户名禁言:user_id 缺失时按 username 解析(版主无 /api/auth/users 权限)
|
||||
if (!uid && req.body.username) {
|
||||
const byName = db.get('SELECT id, role FROM users WHERE username = ?', [String(req.body.username).trim()]);
|
||||
if (!byName) return res.status(404).json({ error: '用户不存在' });
|
||||
uid = byName.id;
|
||||
}
|
||||
if (!uid) return res.status(400).json({ error: '缺少 user_id' });
|
||||
// 目标用户必须存在
|
||||
const target = db.get('SELECT id, role FROM users WHERE id = ?', [uid]);
|
||||
if (!target) return res.status(404).json({ error: '用户不存在' });
|
||||
// 不能禁言 admin
|
||||
if (target.role === 'admin') return res.status(400).json({ error: '不能禁言管理员' });
|
||||
// 不能禁言本版版主(版主互不禁)
|
||||
if (db.get('SELECT 1 FROM forum_moderators WHERE category_id = ? AND user_id = ?', [catId, uid]))
|
||||
return res.status(400).json({ error: '不能禁言本版版主' });
|
||||
// 计算到期时间(muted_until 为 NULL = 永久)
|
||||
let mutedUntil = null;
|
||||
const durStr = String(req.body.duration);
|
||||
if (durStr === 'forever') {
|
||||
mutedUntil = null;
|
||||
} else if (durStr === '1' || durStr === '7' || durStr === '30') {
|
||||
mutedUntil = db.get(`SELECT datetime('now', '+${durStr} day') AS t`).t;
|
||||
} else {
|
||||
return res.status(400).json({ error: '无效的禁言时长' });
|
||||
}
|
||||
const existing = db.get('SELECT 1 FROM forum_mutes WHERE category_id = ? AND user_id = ?', [catId, uid]);
|
||||
if (existing) {
|
||||
db.run('UPDATE forum_mutes SET muted_until = ?, created_by = ? WHERE category_id = ? AND user_id = ?',
|
||||
[mutedUntil, req.user.id, catId, uid]);
|
||||
} else {
|
||||
db.run('INSERT INTO forum_mutes (category_id, user_id, muted_until, created_by) VALUES (?, ?, ?, ?)',
|
||||
[catId, uid, mutedUntil, req.user.id]);
|
||||
}
|
||||
res.json({ message: '已禁言', user_id: uid, permanent: !mutedUntil, muted_until: toIso(mutedUntil) });
|
||||
} catch (e) {
|
||||
console.error('Mute user error:', e.message);
|
||||
res.status(500).json({ error: '操作失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 解除禁言
|
||||
router.delete('/mutes', requireGuestVisible, authMiddleware, muteCategoryGuard, (req, res) => {
|
||||
try {
|
||||
const catId = req.muteCategoryId;
|
||||
const uid = Number(req.body.user_id);
|
||||
if (!uid) return res.status(400).json({ error: '缺少 user_id' });
|
||||
const existing = db.get('SELECT 1 FROM forum_mutes WHERE category_id = ? AND user_id = ?', [catId, uid]);
|
||||
if (!existing) return res.status(404).json({ error: '该用户不在禁言名单中' });
|
||||
db.run('DELETE FROM forum_mutes WHERE category_id = ? AND user_id = ?', [catId, uid]);
|
||||
res.json({ message: '已解除禁言', user_id: uid });
|
||||
} catch (e) {
|
||||
console.error('Unmute error:', e.message);
|
||||
res.status(500).json({ error: '操作失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 帖子列表:置顶优先;支持分页 / 子版块过滤 / 标题搜索。
|
||||
// 兼容策略:无 page 参数返回数组(老调用不破坏),带 page 返回 {list,total,page,pageSize,totalPages}
|
||||
// [lock:] 真锁:按 viewer 视角剥离单篇帖子,返回带 locks/unlocked/viewer 字段的 post。
|
||||
// 列表与详情共用——未解锁块内容(含 [image:]/[file:] 附件标签)一律不进 API 响应。
|
||||
function applyLocks(post, req) {
|
||||
if (!post) return post;
|
||||
const viewer = {
|
||||
userId: req.user ? req.user.id : null,
|
||||
isAdmin: !!(req.user && req.user.role === 'admin'),
|
||||
isAuthor: !!(req.user && req.user.id === post.author_id),
|
||||
hasCommented: false,
|
||||
};
|
||||
if (req.user) {
|
||||
viewer.hasCommented = !!db.get(
|
||||
'SELECT 1 x FROM forum_replies WHERE post_id = ? AND author_id = ?', [post.id, req.user.id]);
|
||||
}
|
||||
const locksMeta = (() => { try { return JSON.parse(post.locks || '[]'); } catch { return []; } })();
|
||||
const blocks = locks.parseLocks(post.content).blocks;
|
||||
const { stripped, unlocked } = locks.stripLocks(post.content, { locksMeta, viewer });
|
||||
post.content = stripped;
|
||||
const lockMetaList = blocks.map(b => ({ index: b.index, type: b.type })); // 不含 hash
|
||||
// 附件真锁:对已解锁索引签发附件 token,前端用它加载块内 [image:]/[file:] 附件
|
||||
lockMetaList.forEach(m => {
|
||||
if (unlocked.includes(m.index)) m.token = locks.makeLockToken('forum', post.id, m.index);
|
||||
});
|
||||
post.locks = lockMetaList;
|
||||
post.unlocked = unlocked;
|
||||
post.viewer = { loggedIn: !!req.user, isAdmin: viewer.isAdmin, isAuthor: viewer.isAuthor, hasCommented: viewer.hasCommented };
|
||||
return post;
|
||||
}
|
||||
|
||||
// 帖子列表:置顶优先;支持分页 / 子版块过滤 / 标题搜索。
|
||||
// 兼容策略:无 page 参数返回数组(老调用不破坏),带 page 返回 {list,total,page,pageSize,totalPages}
|
||||
router.get('/posts', requireGuestVisible, optionalAuth, (req, res) => {
|
||||
const { category_id, sub_category, q } = req.query;
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (category_id) { where.push('fp.category_id = ?'); params.push(category_id); }
|
||||
if (sub_category) { where.push('fp.sub_category = ?'); params.push(sub_category); }
|
||||
if (q) { where.push('fp.title LIKE ?'); params.push('%' + q + '%'); }
|
||||
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const baseSql = `SELECT fp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email,
|
||||
(SELECT COUNT(*) FROM forum_replies WHERE post_id = fp.id) as reply_count
|
||||
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
|
||||
${whereSql}`;
|
||||
|
||||
if (req.query.page === undefined) {
|
||||
const rows = db.all(baseSql + ' ORDER BY fp.is_pinned DESC, fp.created_at DESC', params);
|
||||
rows.forEach(p => { applyLocks(p, req); attachAuthor(p); });
|
||||
return res.json(rows);
|
||||
}
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const pageSize = Math.min(Math.max(parseInt(req.query.pageSize) || 20, 1), 100);
|
||||
const total = (db.get(`SELECT COUNT(*) as c FROM forum_posts fp ${whereSql}`, params) || {}).c || 0;
|
||||
const list = db.all(baseSql + ' ORDER BY fp.is_pinned DESC, fp.created_at DESC LIMIT ? OFFSET ?',
|
||||
[...params, pageSize, (page - 1) * pageSize]);
|
||||
list.forEach(p => applyLocks(p, req));
|
||||
// 作者头像(自传 > QQ > RainID)
|
||||
list.forEach(p => { attachAuthor(p); });
|
||||
res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||
});
|
||||
|
||||
// 详情(含 replies):同样受游客可见开关门禁
|
||||
router.get('/posts/:id', requireGuestVisible, optionalAuth, (req, res) => {
|
||||
const post = db.get(
|
||||
`SELECT fp.*, u.username as author_name, fc.name as category_name
|
||||
`SELECT fp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email,
|
||||
fc.name as category_name
|
||||
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
|
||||
LEFT JOIN forum_categories fc ON fp.category_id = fc.id
|
||||
WHERE fp.id = ?`, [req.params.id]);
|
||||
if (!post) return res.status(404).json({ error: '帖子不存在' });
|
||||
const replies = db.all(
|
||||
`SELECT fr.*, u.username as author_name
|
||||
`SELECT fr.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email
|
||||
FROM forum_replies fr LEFT JOIN users u ON fr.author_id = u.id
|
||||
WHERE fr.post_id = ? ORDER BY fr.created_at ASC`, [req.params.id]);
|
||||
// 楼层后端算:楼主=1,replies[i].floor=i+2——删除回复后不错位(前端自增序号会错位)
|
||||
replies.forEach((r, i) => { r.floor = i + 2; });
|
||||
// [lock:] 真锁:按 viewer 视角剥离,未解锁块内容不进响应
|
||||
applyLocks(post, req);
|
||||
// 作者/回复头像(自传 > QQ > RainID)
|
||||
attachAuthor(post);
|
||||
replies.forEach(r => { attachAuthor(r); });
|
||||
res.json({ post, replies });
|
||||
});
|
||||
|
||||
// 帖子编辑:作者本人/本版版主/admin(authorOrModeratorGuard 已覆盖"作者"场景),
|
||||
// 任何时间不设时限。部分更新(title?/content?/sub_category?/use_markdown?),标题/正文必填其一。
|
||||
// 不校验验证码(编辑非发帖);回复不可编辑(无对应接口)。
|
||||
router.put('/posts/:id', authMiddleware, authorOrModeratorGuard, (req, res) => {
|
||||
try {
|
||||
const updates = {};
|
||||
// title:非空且 ≤100
|
||||
if (req.body.title !== undefined) {
|
||||
const title = String(req.body.title).trim();
|
||||
if (!title) return res.status(400).json({ error: '标题不能为空' });
|
||||
if (title.length > 100) return res.status(400).json({ error: '标题不能超过 100 个字符' });
|
||||
updates.title = title;
|
||||
}
|
||||
// content:非空
|
||||
if (req.body.content !== undefined) {
|
||||
const content = String(req.body.content || '');
|
||||
if (!content.trim()) return res.status(400).json({ error: '内容不能为空' });
|
||||
updates.content = content;
|
||||
// 内容变更时同步锁定元数据([lock:] 块索引/密码 hash 与正文保持一致)
|
||||
updates.locks = locks.saveLocks(content);
|
||||
}
|
||||
// sub_category:允许空字符串(清空子版块)
|
||||
if (req.body.sub_category !== undefined) {
|
||||
updates.sub_category = String(req.body.sub_category || '');
|
||||
}
|
||||
// use_markdown:0/1
|
||||
if (req.body.use_markdown !== undefined) {
|
||||
updates.use_markdown = req.body.use_markdown ? 1 : 0;
|
||||
}
|
||||
if (!Object.keys(updates).length) return res.status(400).json({ error: '没有可更新的字段' });
|
||||
|
||||
const cols = Object.keys(updates).map(k => k + ' = ?').join(', ');
|
||||
// 成功编辑:刷新 updated_at + edit_count 自增(前端展示「已更新 x 次」)
|
||||
db.run(`UPDATE forum_posts SET ${cols}, updated_at = datetime('now'), edit_count = edit_count + 1 WHERE id = ?`,
|
||||
[...Object.values(updates), req.params.id]);
|
||||
const post = db.get(
|
||||
`SELECT fp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email,
|
||||
fc.name as category_name
|
||||
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
|
||||
LEFT JOIN forum_categories fc ON fp.category_id = fc.id
|
||||
WHERE fp.id = ?`, [req.params.id]);
|
||||
if (!post) return res.status(500).json({ error: '更新后读取失败' });
|
||||
attachAuthor(post);
|
||||
res.json(post);
|
||||
} catch (e) {
|
||||
console.error('Edit post error:', e.message);
|
||||
res.status(500).json({ error: '操作失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/posts', authMiddleware, async (req, res) => {
|
||||
const { category_id, title, content, use_markdown, sub_category } = req.body;
|
||||
if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' });
|
||||
// 禁言拦截:本版块被禁言期间禁止发帖(admin 豁免)
|
||||
if (category_id && !assertNotMuted(req, res, category_id)) return;
|
||||
// 服务端强制验证码校验:内置 proof 或第三方 token 任一通过即可
|
||||
if (!(await resolveCaptcha(req, 'forum'))) {
|
||||
return res.status(400).json({ error: '请先完成验证码验证' });
|
||||
}
|
||||
const id = db.run(
|
||||
'INSERT INTO forum_posts (category_id, title, content, author_id, use_markdown, sub_category) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[category_id, title, content, req.user.id, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, sub_category || '']);
|
||||
'INSERT INTO forum_posts (category_id, title, content, author_id, use_markdown, sub_category, locks) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[category_id, title, content, req.user.id, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, sub_category || '', locks.saveLocks(content)]);
|
||||
const post = db.get(
|
||||
`SELECT fp.*, u.username as author_name
|
||||
`SELECT fp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email
|
||||
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
|
||||
WHERE fp.id = ?`, [id]);
|
||||
attachAuthor(post);
|
||||
res.json(post);
|
||||
});
|
||||
|
||||
router.post('/posts/:id/replies', authMiddleware, (req, res) => {
|
||||
const { content } = req.body;
|
||||
if (!content) return res.status(400).json({ error: '回复内容不能为空' });
|
||||
const post = db.get('SELECT id FROM forum_posts WHERE id = ?', [req.params.id]);
|
||||
const post = db.get('SELECT id, category_id FROM forum_posts WHERE id = ?', [req.params.id]);
|
||||
if (!post) return res.status(404).json({ error: '帖子不存在' });
|
||||
// 禁言拦截:本版块被禁言期间禁止回复(admin 豁免)
|
||||
if (!assertNotMuted(req, res, post.category_id)) return;
|
||||
const id = db.run(
|
||||
'INSERT INTO forum_replies (post_id, content, author_id) VALUES (?, ?, ?)',
|
||||
[req.params.id, content, req.user.id]);
|
||||
// 修 bug:回复后应刷新帖子 updated_at(否则版块"最后回复时间"不随回复更新)
|
||||
db.run("UPDATE forum_posts SET updated_at = datetime('now') WHERE id = ?", [req.params.id]);
|
||||
const reply = db.get(
|
||||
'SELECT fr.*, u.username as author_name FROM forum_replies fr LEFT JOIN users u ON fr.author_id = u.id WHERE fr.id = ?', [id]);
|
||||
`SELECT fr.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role,
|
||||
CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN ''
|
||||
WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email
|
||||
FROM forum_replies fr LEFT JOIN users u ON fr.author_id = u.id WHERE fr.id = ?`, [id]);
|
||||
attachAuthor(reply);
|
||||
res.json(reply);
|
||||
});
|
||||
|
||||
router.delete('/posts/:id', authMiddleware, (req, res) => {
|
||||
const post = db.get('SELECT * FROM forum_posts WHERE id = ?', [req.params.id]);
|
||||
// [lock:] 解锁:返回块内原文(含 [image:]/[file:] 标签,前端自行渲染)。
|
||||
// login 块需登录;reply 块需已回复(含本版任何回复);password 块 bcrypt 校验,
|
||||
// 失败统一 401(不区分密码错/块不存在);admin/作者恒可解锁。
|
||||
router.post('/posts/:id/locks/:index/unlock', authMiddleware, locks.unlockLimiter, (req, res) => {
|
||||
const post = db.get('SELECT id, author_id, content, locks FROM forum_posts WHERE id = ?', [req.params.id]);
|
||||
if (!post) return res.status(404).json({ error: '帖子不存在' });
|
||||
if (post.author_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: '无权限' });
|
||||
db.run('DELETE FROM forum_replies WHERE post_id = ?', [req.params.id]);
|
||||
db.run('DELETE FROM forum_posts WHERE id = ?', [req.params.id]);
|
||||
const index = parseInt(req.params.index);
|
||||
if (!Number.isInteger(index) || index < 0) return res.status(401).json({ error: '解锁失败' });
|
||||
const locksMeta = (() => { try { return JSON.parse(post.locks || '[]'); } catch { return []; } })();
|
||||
const hasReplied = !!db.get(
|
||||
'SELECT 1 x FROM forum_replies WHERE post_id = ? AND author_id = ?', [post.id, req.user.id]);
|
||||
const result = locks.verifyUnlock({
|
||||
blocks: locks.parseLocks(post.content).blocks,
|
||||
locksMeta,
|
||||
index,
|
||||
password: req.body.password,
|
||||
viewer: { userId: req.user.id, isAdmin: req.user.role === 'admin', isAuthor: req.user.id === post.author_id },
|
||||
replyQualified: hasReplied,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return res.status(result.status).json({ error: result.status === 403 ? '回复后解锁' : '解锁失败' });
|
||||
}
|
||||
// 附件真锁:随解锁内容一并签发附件 token(前端据此加载块内附件)
|
||||
res.json({ ok: true, content: result.inner, lockToken: locks.makeLockToken('forum', post.id, index) });
|
||||
});
|
||||
|
||||
// 置顶 / 取消置顶(admin/版主)
|
||||
router.put('/posts/:id/pin', authMiddleware, moderatorPostGuard, (req, res) => {
|
||||
const pinned = req.body.pinned ? 1 : 0;
|
||||
db.run('UPDATE forum_posts SET is_pinned = ? WHERE id = ?', [pinned, req.params.id]);
|
||||
res.json({ message: pinned ? '已置顶' : '已取消置顶', is_pinned: pinned });
|
||||
});
|
||||
|
||||
// 加精 / 取消加精(admin/版主)
|
||||
router.put('/posts/:id/essence', authMiddleware, moderatorPostGuard, (req, res) => {
|
||||
const essence = req.body.essence ? 1 : 0;
|
||||
db.run('UPDATE forum_posts SET is_essence = ? WHERE id = ?', [essence, req.params.id]);
|
||||
res.json({ message: essence ? '已加精' : '已取消加精', is_essence: essence });
|
||||
});
|
||||
|
||||
router.delete('/posts/:id', authMiddleware, authorOrModeratorGuard, (req, res) => {
|
||||
getDb().transaction(() => {
|
||||
getDb().prepare('DELETE FROM forum_replies WHERE post_id = ?').run(req.params.id);
|
||||
getDb().prepare('DELETE FROM forum_posts WHERE id = ?').run(req.params.id);
|
||||
})();
|
||||
res.json({ message: '删除成功' });
|
||||
});
|
||||
|
||||
router.delete('/replies/:id', authMiddleware, (req, res) => {
|
||||
const reply = db.get('SELECT * FROM forum_replies WHERE id = ?', [req.params.id]);
|
||||
if (!reply) return res.status(404).json({ error: '回复不存在' });
|
||||
if (reply.author_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: '无权限' });
|
||||
router.delete('/replies/:id', authMiddleware, replyAuthorOrModeratorGuard, (req, res) => {
|
||||
db.run('DELETE FROM forum_replies WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: '删除成功' });
|
||||
});
|
||||
|
||||
@@ -15,6 +15,24 @@ const IMPORT_TABLES = [
|
||||
'attachments', 'site_settings', 'user_settings'
|
||||
];
|
||||
|
||||
// M1:目标表真实列白名单(与 db.js initTables 建表语句保持一致,新增列需同步更新)。
|
||||
// 导入列名必须命中白名单且为合法标识符(长度 ≤64,不含 ( ; " , 及任何非法字符),
|
||||
// 否则拒绝——防止恶意表定义把列名拼进 INSERT 实现 SQL 注入(读密钥 / 造 admin)。
|
||||
const COLUMN_WHITELIST = {
|
||||
users: ['id', 'username', 'password', 'email', 'email_verified', 'role', 'avatar', 'created_at', 'rainid_user_id'],
|
||||
announcements: ['id', 'title', 'content', 'active', 'created_at', 'updated_at'],
|
||||
forum_categories: ['id', 'name', 'description', 'sort_order', 'announcement', 'sub_categories'],
|
||||
forum_posts: ['id', 'category_id', 'title', 'content', 'author_id', 'use_markdown', 'sub_category', 'tags', 'created_at', 'updated_at'],
|
||||
forum_replies: ['id', 'post_id', 'content', 'author_id', 'created_at'],
|
||||
blog_posts: ['id', 'title', 'content', 'excerpt', 'author_id', 'published', 'use_markdown', 'tags', 'views', 'created_at', 'updated_at'],
|
||||
blog_comments: ['id', 'post_id', 'content', 'author_id', 'author_name', 'parent_id', 'status', 'created_at'],
|
||||
password_entries: ['id', 'user_id', 'title', 'username', 'encrypted_password', 'url', 'notes', 'created_at', 'updated_at'],
|
||||
admin_links: ['id', 'title', 'url', 'embed_url', 'description', 'icon', 'category', 'sort_order', 'use_proxy', 'version', 'created_at'],
|
||||
attachments: ['id', 'filename', 'original_name', 'size', 'mime_type', 'user_id', 'ref_type', 'ref_id', 'created_at'],
|
||||
site_settings: ['key', 'value'],
|
||||
user_settings: ['id', 'user_id', 'pin_hash', 'kdf_salt', 'pin_iter'],
|
||||
};
|
||||
|
||||
router.post('/database', authMiddleware, adminOnly, (req, res) => {
|
||||
const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 50 * 1024 * 1024 } }).single('file');
|
||||
upload(req, res, (err) => {
|
||||
@@ -38,19 +56,31 @@ router.post('/database', authMiddleware, adminOnly, (req, res) => {
|
||||
}
|
||||
// Get columns from old table
|
||||
const colInfo = oldDb.pragma('table_info(' + table + ')');
|
||||
const columns = colInfo.map(c => c.name); // column names
|
||||
const rawCols = colInfo.map(c => c.name); // column names
|
||||
// M1:列名白名单校验——仅保留命中白名单且为合法标识符的列
|
||||
//(长度 ≤64、仅 [A-Za-z_][A-Za-z0-9_]*,天然排除 ( ; " , 等注入字符)
|
||||
const safeCols = rawCols.filter(c =>
|
||||
typeof c === 'string' && c.length > 0 && c.length <= 64
|
||||
&& /^[A-Za-z_][A-Za-z0-9_]*$/.test(c)
|
||||
&& Array.isArray(COLUMN_WHITELIST[table]) && COLUMN_WHITELIST[table].includes(c)
|
||||
);
|
||||
// 源表有列但无一通过白名单 → 拒绝该表,不执行任何 INSERT
|
||||
if (rawCols.length > 0 && safeCols.length === 0) {
|
||||
report.errors.push(table + ': 列名未通过白名单校验,已跳过该表');
|
||||
continue;
|
||||
}
|
||||
const rows = oldDb.prepare('SELECT * FROM ' + table).all();
|
||||
if (rows.length === 0) {
|
||||
report.imported[table] = 0;
|
||||
continue;
|
||||
}
|
||||
const colNames = columns.join(',');
|
||||
const placeholders = columns.map(() => '?').join(',');
|
||||
const colNames = safeCols.join(',');
|
||||
const placeholders = safeCols.map(() => '?').join(',');
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
// all() 返回对象数组,按列顺序还原为值数组后插入目标库
|
||||
const values = columns.map(c => row[c]);
|
||||
// all() 返回对象数组,按白名单列顺序还原为值数组后插入目标库
|
||||
const values = safeCols.map(c => row[c]);
|
||||
db.run('INSERT OR IGNORE INTO ' + table + ' (' + colNames + ') VALUES (' + placeholders + ')', values);
|
||||
count++;
|
||||
} catch (e) {
|
||||
|
||||
@@ -100,17 +100,51 @@ router.post('/set-pin', authMiddleware, (req, res) => {
|
||||
if (!pin || typeof pin !== 'string' || pin.length < 6) {
|
||||
return res.status(400).json({ error: 'PIN 至少 6 位,建议包含字母' });
|
||||
}
|
||||
const existing = db.get('SELECT id FROM user_settings WHERE user_id = ?', [req.user.id]);
|
||||
const existing = db.get('SELECT id, kdf_salt FROM user_settings WHERE user_id = ?', [req.user.id]);
|
||||
const entries = db.all('SELECT id, encrypted_password FROM password_entries WHERE user_id = ?', [req.user.id]);
|
||||
|
||||
// L8:改 PIN 旧密文重新加密——旧条目用「解锁会话中的旧密钥」解密,
|
||||
// 换新 salt 新密钥后重新加密写回。若已有条目但无解锁会话,则无法解密旧数据,
|
||||
// 拒绝修改并要求先解锁(否则改完 PIN 旧条目将永久不可解)。
|
||||
let oldKey = null;
|
||||
if (existing && existing.kdf_salt && entries.length > 0) {
|
||||
const session = getSession(req.user.id);
|
||||
if (!session) {
|
||||
return res.status(400).json({ error: '请先解锁密码管理器后再修改 PIN(需用旧 PIN 重新加密已有条目)' });
|
||||
}
|
||||
oldKey = session.key;
|
||||
}
|
||||
|
||||
const pinHash = bcrypt.hashSync(pin, 10);
|
||||
const kdfSalt = crypto.randomBytes(16).toString('hex');
|
||||
// 新密钥直接用新 salt 派生(避免依赖已更新到 DB 的 kdf_salt)
|
||||
const newKey = crypto.pbkdf2Sync(pin, Buffer.from(kdfSalt, 'hex'), PBKDF2_ITERATIONS, 32, 'sha256');
|
||||
|
||||
try {
|
||||
// 事务:换 salt 与重加密原子完成(失败自动 ROLLBACK,绝不出现"盐已换但密文没重加密"的中间态)
|
||||
db.transaction(() => {
|
||||
if (existing) {
|
||||
db.run('UPDATE user_settings SET pin_hash=?, kdf_salt=?, pin_iter=? WHERE user_id=?', [pinHash, kdfSalt, PBKDF2_ITERATIONS, req.user.id]);
|
||||
db.run('UPDATE user_settings SET pin_hash=?, kdf_salt=?, pin_iter=? WHERE user_id=?',
|
||||
[pinHash, kdfSalt, PBKDF2_ITERATIONS, req.user.id]);
|
||||
} else {
|
||||
db.run('INSERT INTO user_settings (user_id, pin_hash, kdf_salt, pin_iter) VALUES (?, ?, ?, ?)', [req.user.id, pinHash, kdfSalt, PBKDF2_ITERATIONS]);
|
||||
db.run('INSERT INTO user_settings (user_id, pin_hash, kdf_salt, pin_iter) VALUES (?, ?, ?, ?)',
|
||||
[req.user.id, pinHash, kdfSalt, PBKDF2_ITERATIONS]);
|
||||
}
|
||||
// Auto-unlock after setting PIN(滑动过期)
|
||||
const key = getEncryptionKey(req.user.id, pin);
|
||||
if (key) unlockedSessions.set(req.user.id, { key, expires: Date.now() + SESSION_TTL });
|
||||
if (oldKey) {
|
||||
for (const e of entries) {
|
||||
const plain = decrypt(e.encrypted_password, oldKey);
|
||||
if (plain === null) continue; // 单条损坏则跳过保留原样,避免整批失败
|
||||
db.run('UPDATE password_entries SET encrypted_password = ? WHERE id = ?',
|
||||
[encrypt(plain, newKey), e.id]);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Set-PIN error:', e.message);
|
||||
return res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
// Auto-unlock after setting PIN(滑动过期)——直接用新密钥,避免再查库
|
||||
unlockedSessions.set(req.user.id, { key: newKey, expires: Date.now() + SESSION_TTL });
|
||||
res.json({ message: 'PIN 设置成功' });
|
||||
});
|
||||
|
||||
|
||||
@@ -49,20 +49,54 @@ function sendCodeEmail(email, code, username) {
|
||||
}
|
||||
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
const user = db.get('SELECT id, username, email, email_verified, role, avatar, created_at FROM users WHERE id = ?', [req.user.id]);
|
||||
const user = db.get('SELECT id, username, email, email_verified, role, avatar, qq, nickname, website, bio, title, title_color, created_at FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!user) return res.status(404).json({ error: '用户不存在' });
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
// Email is read-only after registration
|
||||
router.put('/', authMiddleware, (req, res) => {
|
||||
const { avatar } = req.body;
|
||||
const { avatar, qq, bio, nickname, website } = req.body;
|
||||
if (avatar !== undefined) {
|
||||
// 头像仅接受站内上传路径(sha256 hash + 图片扩展名),防止外链、路径穿越与引号绕过
|
||||
if (typeof avatar !== 'string' || !/^\/uploads\/avatars\/[0-9a-f]{64}\.(png|jpe?g|gif|webp)$/i.test(avatar))
|
||||
return res.status(400).json({ error: '头像必须是站内路径' });
|
||||
db.run('UPDATE users SET avatar = ? WHERE id = ?', [avatar, req.user.id]);
|
||||
}
|
||||
if (qq !== undefined) {
|
||||
// QQ 号:纯数字 5-12 位,或空字符串清空
|
||||
const q = String(qq).trim();
|
||||
if (q !== '' && !/^\d{5,12}$/.test(q))
|
||||
return res.status(400).json({ error: 'QQ 号需为 5-12 位数字' });
|
||||
db.run('UPDATE users SET qq = ? WHERE id = ?', [q, req.user.id]);
|
||||
}
|
||||
if (bio !== undefined) {
|
||||
// 个性签名:非字符串忽略,字符串 trim 后限长 200 字符
|
||||
if (typeof bio === 'string') {
|
||||
const b = bio.trim();
|
||||
if (b.length > 200) return res.status(400).json({ error: '个性签名不能超过 200 字符' });
|
||||
db.run('UPDATE users SET bio = ? WHERE id = ?', [b, req.user.id]);
|
||||
}
|
||||
}
|
||||
if (nickname !== undefined) {
|
||||
// 昵称:非字符串忽略,字符串 trim 后限长 20 字符
|
||||
if (typeof nickname === 'string') {
|
||||
const n = nickname.trim();
|
||||
if (n.length > 20) return res.status(400).json({ error: '昵称最多 20 字' });
|
||||
db.run('UPDATE users SET nickname = ? WHERE id = ?', [n, req.user.id]);
|
||||
}
|
||||
}
|
||||
if (website !== undefined) {
|
||||
// 个人博客:非字符串忽略,字符串 trim ≤200,必须 http(s) 开头;空串=清空
|
||||
if (typeof website === 'string') {
|
||||
const w = website.trim();
|
||||
if (w !== '') {
|
||||
if (w.length > 200) return res.status(400).json({ error: '个人博客不能超过 200 字' });
|
||||
if (!/^https?:\/\//i.test(w)) return res.status(400).json({ error: '个人博客需以 http(s):// 开头' });
|
||||
}
|
||||
db.run('UPDATE users SET website = ? WHERE id = ?', [w, req.user.id]);
|
||||
}
|
||||
}
|
||||
res.json({ message: '已更新' });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
const dns = require('dns');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const cheerio = require('cheerio');
|
||||
const db = require('../db');
|
||||
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// SSRF 防护:拒绝内网/回环/链路本地地址
|
||||
// ── SSRF 防护(保留既有逻辑)────────────────────────────────────────
|
||||
function isBlockedHost(hostname) {
|
||||
if (!hostname) return true;
|
||||
let host = String(hostname).toLowerCase();
|
||||
// new URL().hostname 对 IPv6 会带方括号(如 [::1]),先去掉
|
||||
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
||||
// IPv4 映射的 IPv6(如 ::ffff:127.0.0.1)按 IPv4 规则判断
|
||||
const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped) host = mapped[1];
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1'
|
||||
|| host === '0' || host === '0.0.0.0' || host === '::') return true;
|
||||
if (host.startsWith('10.')) return true;
|
||||
if (host.startsWith('192.168.')) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
|
||||
@@ -25,11 +27,15 @@ function isBlockedHost(hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- 内网代理白名单(settings: proxy_allowed_hosts,逗号/空格/换行分隔)----
|
||||
// 用途:https 页面无法嵌入 http 内网面板,管理员可在后台把可信内网地址/网段加入白名单放行。
|
||||
// 仅放行被 isBlockedHost 拦截的地址;公网地址不受影响。SSRF 信任模型不变(仍 adminOnly)。
|
||||
function lookupIpv4(hostname) {
|
||||
return new Promise((resolve) => {
|
||||
dns.lookup(hostname, { family: 4, all: true }, (err, addrs) => {
|
||||
if (err) return resolve([]);
|
||||
resolve((addrs || []).map(a => a.address));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// IPv4 字符串 → 32 位整数(解析失败返回 null)
|
||||
function ipv4ToInt(ip) {
|
||||
const parts = String(ip).split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
@@ -43,8 +49,6 @@ function ipv4ToInt(ip) {
|
||||
return n >>> 0;
|
||||
}
|
||||
|
||||
// IPv4 CIDR 匹配(手写实现,项目无 ipaddr 依赖)。
|
||||
// 返回 true/false 表示确定命中/不命中;返回 null 表示条目无法解析(调用方跳过该行)。
|
||||
function ipv4CidrContains(cidr, host) {
|
||||
const m = String(cidr).match(/^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/);
|
||||
if (!m) return null;
|
||||
@@ -56,7 +60,6 @@ function ipv4CidrContains(cidr, host) {
|
||||
return (net & mask) === (hostInt & mask);
|
||||
}
|
||||
|
||||
// 判断 hostname 是否命中白名单:支持单 IP / IPv4 CIDR 网段 / 主机名精确匹配,IPv4 映射 IPv6 自动归一。
|
||||
function proxyAllowed(hostname) {
|
||||
if (!hostname) return false;
|
||||
let host = String(hostname).toLowerCase();
|
||||
@@ -68,7 +71,6 @@ function proxyAllowed(hostname) {
|
||||
for (const entry of raw.split(/[,;\s]+/)) {
|
||||
let e = entry.trim().toLowerCase();
|
||||
if (!e) continue;
|
||||
// 兼容 ip:port 写法(仅 IPv4 形态,避免误伤 IPv6 冒号)
|
||||
const lastColon = e.lastIndexOf(':');
|
||||
if (lastColon > 0 && e.indexOf('.') !== -1 && !e.includes('/')) e = e.slice(0, lastColon);
|
||||
if (e === host) return true;
|
||||
@@ -77,83 +79,462 @@ function proxyAllowed(hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function proxyRequest(target, res, maxRedirects = 5) {
|
||||
if (maxRedirects <= 0) return res.status(502).json({ error: '重定向次数过多' });
|
||||
try {
|
||||
const parsed = new URL(target);
|
||||
// 发起请求前校验目标 hostname:内网地址一律拦截,除非命中白名单(proxy_allowed_hosts)。
|
||||
// 注:403 JSON 经 Cloudflare 显示为 502 属正常(上游非 2xx),白名单放行后不再触发。
|
||||
if (isBlockedHost(parsed.hostname) && !proxyAllowed(parsed.hostname)) {
|
||||
return res.status(403).json({ error: '禁止访问内网地址' });
|
||||
// SSRF 校验:返回 null=放行,否则为错误信息
|
||||
async function ssrfCheck(hostname) {
|
||||
if (isBlockedHost(hostname) && !proxyAllowed(hostname)) return '禁止访问内网地址';
|
||||
const resolvedIps = await lookupIpv4(hostname);
|
||||
if (resolvedIps.length > 0) {
|
||||
const hitInternal = resolvedIps.some(ip => isBlockedHost(ip) && !proxyAllowed(ip));
|
||||
if (hitInternal) return '禁止访问内网地址';
|
||||
}
|
||||
const client = parsed.protocol === 'https:' ? https : http;
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
family: 4,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
const req = client.get(opts, (proxyRes) => {
|
||||
// Follow redirects
|
||||
if ([301, 302, 303, 307, 308].includes(proxyRes.statusCode) && proxyRes.headers.location) {
|
||||
try {
|
||||
return proxyRequest(new URL(proxyRes.headers.location, target).href, res, maxRedirects - 1);
|
||||
} catch { return res.status(502).json({ error: '重定向地址无效' }); }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip headers that would block embedding
|
||||
const headers = { ...proxyRes.headers };
|
||||
delete headers['x-frame-options'];
|
||||
delete headers['X-Frame-Options'];
|
||||
delete headers['content-security-policy'];
|
||||
delete headers['Content-Security-Policy'];
|
||||
// 防止被代理页面内部资源请求把本站来源(/admin 等)泄露给第三方域名
|
||||
headers['Referrer-Policy'] = 'no-referrer';
|
||||
// ── 面板解析 ─────────────────────────────────────────────────────────
|
||||
const RESERVED_SLUGS = new Set(['token', 'fetch']);
|
||||
|
||||
// Inject <base> tag so relative URLs resolve to the original domain
|
||||
const contentType = (headers['content-type'] || '').toLowerCase();
|
||||
if (contentType.includes('text/html')) {
|
||||
const baseUrl = `${parsed.protocol}//${parsed.host}`;
|
||||
let html = '';
|
||||
proxyRes.on('data', chunk => { html += chunk.toString('utf8'); });
|
||||
proxyRes.on('end', () => {
|
||||
// Insert <base> tag after <head> or at the beginning
|
||||
html = html.replace('<head>', `<head><base href="${baseUrl}">`);
|
||||
// Also remove meta CSP/X-Frame-Options
|
||||
html = html.replace(/<meta[^>]+http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '');
|
||||
html = html.replace(/<meta[^>]+http-equiv=["']X-Frame-Options["'][^>]*>/gi, '');
|
||||
res.writeHead(proxyRes.statusCode || 200, headers);
|
||||
res.end(html);
|
||||
});
|
||||
proxyRes.on('error', e => {
|
||||
console.error('Proxy stream error:', target, e.message);
|
||||
sendError(res, '代理响应错误: ' + e.message);
|
||||
});
|
||||
function getPanelBySlug(slug) {
|
||||
return db.get('SELECT * FROM admin_links WHERE slug = ?', [String(slug || '').toLowerCase()]);
|
||||
}
|
||||
|
||||
// 短 TTL 代理 token 认证 cookie 名(同源子资源鉴权用,Path=/proxy/{slug}/)
|
||||
function authCookieName(slug) {
|
||||
return 'rwp_' + String(slug).replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
|
||||
// 手动解析 Cookie 头(项目未用 cookie-parser,req.cookies 不存在)
|
||||
function getCookie(req, name) {
|
||||
const raw = req.headers.cookie || '';
|
||||
for (const part of raw.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 校验请求者是否为管理员:Authorization header → ?token= → rwp cookie 任一有效即可
|
||||
function isAuthorized(req, slug) {
|
||||
const token = req.headers.authorization && req.headers.authorization.startsWith('Bearer ')
|
||||
? req.headers.authorization.slice(7)
|
||||
: (req.query.token || getCookie(req, authCookieName(slug)));
|
||||
if (!token) return false;
|
||||
try {
|
||||
const p = jwt.verify(token, SECRET);
|
||||
if (!p || p.role !== 'admin') return false;
|
||||
// 短代理 token(query/cookie):带 slug 时须匹配当前面板;旧版无 slug 的 5 分钟 token 任意面板放行
|
||||
if (p.proxy && p.slug && p.slug !== slug) return false;
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// 签发面板访问 cookie(同源子资源自动携带)
|
||||
function setPanelCookie(res, slug) {
|
||||
const token = jwt.sign({ id: 0, username: 'panel', role: 'admin', proxy: true, slug }, SECRET, { expiresIn: '12h' });
|
||||
const cookie = `${authCookieName(slug)}=${token}; Path=/proxy/${slug}/; HttpOnly; SameSite=Lax; Max-Age=43200`;
|
||||
const setCookieHeader = res.getHeader('Set-Cookie');
|
||||
if (setCookieHeader) {
|
||||
const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [String(setCookieHeader)];
|
||||
arr.push(cookie);
|
||||
res.setHeader('Set-Cookie', arr);
|
||||
} else {
|
||||
// Non-HTML: pipe directly (images, CSS, JS, etc.)
|
||||
res.writeHead(proxyRes.statusCode || 200, headers);
|
||||
proxyRes.pipe(res);
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL 改写 ─────────────────────────────────────────────────────────
|
||||
// 根相对路径 /xxx → basePath + xxx;同 host 绝对 URL → basePath + path;其余原样
|
||||
function rewriteUrl(value, basePath, originHost) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return v;
|
||||
if (/^(data:|javascript:|blob:|mailto:|tel:|about:)/i.test(v)) return v;
|
||||
if (v.startsWith('//')) return v; // 协议相对外部资源:保持原样(base 下仍解析到外部 host)
|
||||
if (v.startsWith('/proxy/')) return v; // 已是代理路径
|
||||
if (v.startsWith('/')) return basePath + v.slice(1);
|
||||
if (/^https?:\/\//i.test(v)) {
|
||||
try {
|
||||
const u = new URL(v);
|
||||
if (u.hostname === originHost) return basePath + u.pathname + u.search + u.hash;
|
||||
return v;
|
||||
} catch { return v; }
|
||||
}
|
||||
return v; // 相对路径:<base> 已处理
|
||||
}
|
||||
|
||||
// srcset 逗号分隔项改写
|
||||
function rewriteSrcset(value, basePath, originHost) {
|
||||
return String(value || '').split(',').map((part) => {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) return part;
|
||||
const m = trimmed.match(/^(\S+)(\s+.*)?$/);
|
||||
if (!m) return part;
|
||||
return rewriteUrl(m[1], basePath, originHost) + (m[2] || '');
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
// ── 运行时 shim(Layer 2 客户端注入)────────────────────────────────
|
||||
// 在目标 JS 前执行:patch fetch/XHR/WS/EventSource/元素属性 setter/history/存储隔离
|
||||
function shimScript(slug, basePath) {
|
||||
return `<script>/* RainWeb 面板代理 shim */
|
||||
(function(){
|
||||
var P=${JSON.stringify(basePath)};
|
||||
var SLUG=${JSON.stringify(slug)};
|
||||
// 恢复前缀:根相对/同 host 绝对 → 加 /proxy/{slug}/ 前缀
|
||||
function rP(u){
|
||||
if(typeof u!=='string'||!u) return u;
|
||||
if(u.charAt(0)==='/'&&u.charAt(1)!=='/') return P+u.slice(1);
|
||||
if(/^https?:\/\//i.test(u)){ try{var a=new URL(u);if(a.host===location.host)return P+a.pathname+a.search+a.hash;}catch(e){} }
|
||||
return u;
|
||||
}
|
||||
function rWs(u){
|
||||
if(typeof u!=='string'||!u) return u;
|
||||
var proto=(location.protocol==='https:')?'wss://':'ws://';
|
||||
if(u.charAt(0)==='/') return proto+location.host+P+u.slice(1);
|
||||
if(/^wss?:/i.test(u)){ try{var a=new URL(u.replace(/^ws/i,'http'));if(a.host===location.host)return proto+location.host+P+a.pathname+a.search;}catch(e){} }
|
||||
if(/^https?:\/\//i.test(u)){ try{var b=new URL(u);if(b.host===location.host)return proto+location.host+P+b.pathname+b.search;}catch(e){} }
|
||||
return u;
|
||||
}
|
||||
// document.baseURI 指向代理前缀
|
||||
try{Object.defineProperty(document,'baseURI',{configurable:true,get:function(){return location.origin+P;}});}catch(e){}
|
||||
// fetch
|
||||
if(window.fetch){
|
||||
var of=window.fetch;
|
||||
window.fetch=function(input,init){
|
||||
if(typeof input==='string'){input=rP(input);}
|
||||
else if(input&&input.url){input=new Request(rP(input.url),input);}
|
||||
return of(input,init);
|
||||
};
|
||||
}
|
||||
// XHR
|
||||
var _ox=XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open=function(method,url){return _ox.apply(this,arguments.length>1?[method,rP(url)]:arguments);};
|
||||
// sendBeacon
|
||||
if(navigator.sendBeacon){var _sb=navigator.sendBeacon.bind(navigator);navigator.sendBeacon=function(url,data){return _sb(rP(url),data);};}
|
||||
// WebSocket
|
||||
var _WS=window.WebSocket;
|
||||
window.WebSocket=function(url,protocols){return protocols?new _WS(rWs(url),protocols):new _WS(rWs(url));};
|
||||
window.WebSocket.prototype=_WS.prototype;
|
||||
window.WebSocket.CONNECTING=0;window.WebSocket.OPEN=1;window.WebSocket.CLOSING=2;window.WebSocket.CLOSED=3;
|
||||
// EventSource
|
||||
if(window.EventSource){var _ES=window.EventSource;window.EventSource=function(url,c){return c?new _ES(rWs(url),c):new _ES(rWs(url));};window.EventSource.prototype=_ES.prototype;}
|
||||
// 元素属性 setter 同步改写
|
||||
function patch(proto,prop){
|
||||
var d=Object.getOwnPropertyDescriptor(proto,prop);
|
||||
if(!d||!d.set)return;
|
||||
Object.defineProperty(proto,prop,{configurable:true,get:function(){return d.get.call(this);},set:function(v){if(typeof v==='string')v=rP(v);d.set.call(this,v);}});
|
||||
}
|
||||
if(HTMLImageElement)patch(HTMLImageElement.prototype,'src');
|
||||
if(HTMLScriptElement)patch(HTMLScriptElement.prototype,'src');
|
||||
if(HTMLLinkElement)patch(HTMLLinkElement.prototype,'href');
|
||||
if(HTMLAnchorElement)patch(HTMLAnchorElement.prototype,'href');
|
||||
if(HTMLFormElement)patch(HTMLFormElement.prototype,'action');
|
||||
if(HTMLIFrameElement)patch(HTMLIFrameElement.prototype,'src');
|
||||
if(HTMLVideoElement)patch(HTMLVideoElement.prototype,'src');
|
||||
if(HTMLAudioElement)patch(HTMLAudioElement.prototype,'src');
|
||||
if(HTMLSourceElement)patch(HTMLSourceElement.prototype,'src');
|
||||
if(HTMLEmbedElement)patch(HTMLEmbedElement.prototype,'src');
|
||||
if(HTMLObjectElement)patch(HTMLObjectElement.prototype,'data');
|
||||
// history:写入干净路径时自动加回前缀;location.pathname 读取时去前缀
|
||||
var _hs=history.pushState,_rs=history.replaceState;
|
||||
function cleanPath(p){p=p||'';if(p.indexOf(P)===0)return p.slice(P.length-1);return p;}
|
||||
history.pushState=function(s,t,u){return _hs.call(this,s,t,u?P+String(u).replace(/^\\/,'').replace(/^\//,''):u);};
|
||||
history.replaceState=function(s,t,u){return _rs.call(this,s,t,u?P+String(u).replace(/^\\/,'').replace(/^\//,''):u);};
|
||||
try{var _pl=Object.getOwnPropertyDescriptor(Location.prototype,'pathname');
|
||||
Object.defineProperty(Location.prototype,'pathname',{configurable:true,get:function(){var v=_pl.get.call(this);return cleanPath(v);},set:function(v){_pl.set.call(this,v.indexOf(P)===0?v:P+String(v).replace(/^\\/,'').replace(/^\//,''));}});
|
||||
}catch(e){}
|
||||
// 存储命名空间隔离(按 slug)
|
||||
function nsStore(st){
|
||||
var prefix=SLUG+':';
|
||||
var g=st.getItem.bind(st),s=st.setItem.bind(st),r=st.removeItem.bind(st),c=st.clear.bind(st),k=st.key.bind(st),gl=st.length;
|
||||
st.getItem=function(n){return g(prefix+n);};
|
||||
st.setItem=function(n,v){return s(prefix+n,v);};
|
||||
st.removeItem=function(n){return r(prefix+n);};
|
||||
st.clear=function(){var keys=[];for(var i=0;i<gl;i++){var kk=k(i);if(kk&&kk.indexOf(prefix)===0)keys.push(kk);}keys.forEach(function(x){r(x);});};
|
||||
st.key=function(i){return k(i);};
|
||||
}
|
||||
try{if(window.localStorage)nsStore(window.localStorage);}catch(e){}
|
||||
try{if(window.sessionStorage)nsStore(window.sessionStorage);}catch(e){}
|
||||
// serviceWorker 禁用
|
||||
if(navigator.serviceWorker&&navigator.serviceWorker.register){navigator.serviceWorker.register=function(){return Promise.resolve({});};}
|
||||
// parent/top 隔离到自身
|
||||
try{Object.defineProperty(window,'parent',{configurable:true,get:function(){return window;}});}catch(e){}
|
||||
try{Object.defineProperty(window,'top',{configurable:true,get:function(){return window;}});}catch(e){}
|
||||
// MutationObserver 兜底:动态插入元素的属性改写
|
||||
try{
|
||||
function fixNode(n){
|
||||
if(!n||n.nodeType!==1)return;
|
||||
var attrs=['src','href','action','poster','data-src','data-href'];
|
||||
for(var i=0;i<attrs.length;i++){var a=attrs[i];if(n.hasAttribute&&n.hasAttribute(a)){n.setAttribute(a,rP(n.getAttribute(a)));}}
|
||||
if(n.tagName==='IMG'&&n.src)try{n.src=rP(n.src);}catch(e){}
|
||||
if(n.tagName==='SCRIPT'&&n.src)try{n.src=rP(n.src);}catch(e){}
|
||||
}
|
||||
var _mo=window.MutationObserver;
|
||||
if(_mo){var mo=new _mo(function(muts){muts.forEach(function(mu){if(mu.type==='childList'){mu.addedNodes.forEach(fixNode);}});});mo.observe(document.documentElement,{childList:true,subtree:true});}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
// ── HTML 改写(Layer 1,cheerio 静态)──────────────────────────────
|
||||
function rewriteHtml(html, basePath, originHost, slug) {
|
||||
try {
|
||||
const $ = cheerio.load(html);
|
||||
// <base> 指向代理前缀(带尾斜杠)
|
||||
if ($('base').length) { $('base').attr('href', basePath); }
|
||||
else { $('head').prepend('<base href="' + basePath + '">'); }
|
||||
|
||||
// 改写根相对/同 host 属性
|
||||
$('a,link,script,img,iframe,form,video,audio,source,embed,object').each((i, el) => {
|
||||
const $el = $(el);
|
||||
const tag = el.tagName;
|
||||
if (tag === 'a' || tag === 'link') { if ($el.attr('href')) $el.attr('href', rewriteUrl($el.attr('href'), basePath, originHost)); }
|
||||
if (['script', 'img', 'iframe', 'video', 'audio', 'source', 'embed'].includes(tag)) { if ($el.attr('src')) $el.attr('src', rewriteUrl($el.attr('src'), basePath, originHost)); }
|
||||
if (tag === 'form') { if ($el.attr('action')) $el.attr('action', rewriteUrl($el.attr('action'), basePath, originHost)); }
|
||||
if (tag === 'object') { if ($el.attr('data')) $el.attr('data', rewriteUrl($el.attr('data'), basePath, originHost)); }
|
||||
if ($el.attr('poster')) $el.attr('poster', rewriteUrl($el.attr('poster'), basePath, originHost));
|
||||
if ($el.attr('srcset')) $el.attr('srcset', rewriteSrcset($el.attr('srcset'), basePath, originHost));
|
||||
if ($el.attr('data-src')) $el.attr('data-src', rewriteUrl($el.attr('data-src'), basePath, originHost));
|
||||
if ($el.attr('data-href')) $el.attr('data-href', rewriteUrl($el.attr('data-href'), basePath, originHost));
|
||||
});
|
||||
// meta[content](og 等 URL 型)
|
||||
$('meta[content]').each((i, el) => {
|
||||
const $el = $(el);
|
||||
const prop = String($el.attr('property') || $el.attr('name') || '').toLowerCase();
|
||||
if (prop.includes('image') || prop.includes('url') || prop.includes('og:')) {
|
||||
$el.attr('content', rewriteUrl($el.attr('content'), basePath, originHost));
|
||||
}
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
sendError(res, '代理请求超时(15秒),目标服务器无响应');
|
||||
});
|
||||
req.on('error', e => {
|
||||
sendError(res, '代理请求失败: ' + e.message);
|
||||
// 剥 SRI integrity / crossorigin(同源代理下无意义且可能失败)
|
||||
$('[integrity]').removeAttr('integrity');
|
||||
$('[crossorigin]').removeAttr('crossorigin');
|
||||
// 剥 CSP/XFO meta
|
||||
$('meta[http-equiv]').each((i, el) => {
|
||||
const he = String($(el).attr('http-equiv') || '').toLowerCase();
|
||||
if (he === 'content-security-policy' || he === 'x-frame-options' || he === 'content-script-type') $(el).remove();
|
||||
});
|
||||
// 注入 shim(head 最前,早于目标脚本执行)
|
||||
$('head').prepend(shimScript(slug, basePath));
|
||||
return $.html();
|
||||
} catch (e) {
|
||||
sendError(res, '无效的 URL: ' + e.message);
|
||||
console.error('Proxy HTML rewrite error:', e.message);
|
||||
// 改写失败:至少注入 base + shim(降级)
|
||||
return html.replace('<head>', `<head><base href="${basePath}">` + shimScript(slug, basePath)) || html;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 响应头处理(Layer 3)────────────────────────────────────────────
|
||||
const HOP_BY_HOP = ['connection', 'keep-alive', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization'];
|
||||
|
||||
function rewriteCookie(cookie, basePath, isHttps) {
|
||||
return String(cookie).split(';').map((part, i) => {
|
||||
const p = part.trim();
|
||||
if (i === 0) return p; // name=value
|
||||
const l = p.toLowerCase();
|
||||
if (l.startsWith('path=')) return 'Path=' + basePath;
|
||||
if (l.startsWith('domain=')) return ''; // 去 Domain
|
||||
if (l === 'secure' && !isHttps) return ''; // http 下剥 Secure
|
||||
if (l.startsWith('samesite')) return 'SameSite=Lax';
|
||||
return p;
|
||||
}).filter(Boolean).join('; ');
|
||||
}
|
||||
|
||||
function rewriteLocationHeader(value, basePath, originHost) {
|
||||
const v = String(value || '');
|
||||
if (v.startsWith('/proxy/')) return v;
|
||||
if (v.startsWith('/')) return basePath + v.slice(1);
|
||||
if (/^https?:\/\//i.test(v)) {
|
||||
try {
|
||||
const u = new URL(v);
|
||||
if (u.hostname === originHost) return basePath + u.pathname + u.search;
|
||||
} catch {}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── 解压响应流(支持 gzip/deflate/br)──────────────────────────────
|
||||
function decodeStream(stream, encoding) {
|
||||
const enc = String(encoding || '').toLowerCase();
|
||||
if (enc === 'gzip' || enc === 'x-gzip') return stream.pipe(zlib.createGunzip());
|
||||
if (enc === 'deflate') return stream.pipe(zlib.createInflate());
|
||||
if (enc === 'br') return stream.pipe(zlib.createBrotliDecompress());
|
||||
return stream;
|
||||
}
|
||||
|
||||
// ── HTTP 代理(全方法 + body 透传)──────────────────────────────────
|
||||
function proxyHttp(slug, reqPath, query, req, res, panel) {
|
||||
let base;
|
||||
try { base = new URL(panel.url); } catch { return sendError(res, '面板 URL 无效'); }
|
||||
const basePath = '/proxy/' + slug + '/';
|
||||
const client = base.protocol === 'https:' ? https : http;
|
||||
const targetPath = base.pathname.replace(/\/+$/, '') + reqPath + (query ? '?' + query : '');
|
||||
|
||||
// 转发头:剥 hop-by-hop,host 重写为目标,合并 proxy_headers
|
||||
const headers = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
const lk = k.toLowerCase();
|
||||
if (HOP_BY_HOP.includes(lk) || lk === 'host' || lk.startsWith('rwp_') || lk === 'content-length') continue;
|
||||
if (lk === 'cookie') {
|
||||
// 只透传非面板鉴权 cookie(剥 rwp_*)
|
||||
const kept = String(v).split(';').map(c => c.trim()).filter(c => !/^rwp_/i.test(c)).join('; ');
|
||||
if (kept) headers[k] = kept;
|
||||
continue;
|
||||
}
|
||||
headers[k] = v;
|
||||
}
|
||||
headers.host = base.host;
|
||||
// 自定义转发头(proxy_headers JSON)
|
||||
try {
|
||||
const ph = JSON.parse(panel.proxy_headers || '{}');
|
||||
if (ph && typeof ph === 'object') {
|
||||
for (const [k, v] of Object.entries(ph)) {
|
||||
if (!/^content-length$/i.test(k) && !HOP_BY_HOP.includes(k.toLowerCase())) headers[k] = String(v);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const upReq = client.request({
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: targetPath,
|
||||
method: req.method,
|
||||
headers,
|
||||
timeout: 20000,
|
||||
family: 4,
|
||||
// proxy_skip_tls_verify=1 时跳过 TLS 校验(内网自签面板);默认校验(公网面板)
|
||||
rejectUnauthorized: Number(panel.proxy_skip_tls_verify) ? false : true,
|
||||
}, (upRes) => {
|
||||
// 响应头:剥 XFO/CSP/Permissions-Policy,改写 Cookie/Location
|
||||
const out = {};
|
||||
const isHttps = req.secure || req.protocol === 'https';
|
||||
for (const [k, v] of Object.entries(upRes.headers)) {
|
||||
const lk = k.toLowerCase();
|
||||
if (['x-frame-options', 'content-security-policy', 'permissions-policy'].includes(lk)) continue;
|
||||
if (lk === 'set-cookie') { out[k] = (Array.isArray(v) ? v : [v]).map(c => rewriteCookie(c, basePath, isHttps)); continue; }
|
||||
if (lk === 'location' || lk === 'refresh') { out[k] = rewriteLocationHeader(v, basePath, base.hostname); continue; }
|
||||
if (HOP_BY_HOP.includes(lk)) continue;
|
||||
if (lk === 'content-length' || lk === 'content-encoding') continue; // 统一转码后重算
|
||||
out[k] = v;
|
||||
}
|
||||
out['Referrer-Policy'] = 'no-referrer';
|
||||
out['X-Content-Type-Options'] = 'nosniff';
|
||||
|
||||
const ctype = (upRes.headers['content-type'] || '').toLowerCase();
|
||||
const status = upRes.statusCode || 200;
|
||||
|
||||
// HTML:解压 → 改写 → 按 identity 发送
|
||||
if (ctype.includes('text/html') && (status >= 200 && status < 300)) {
|
||||
const chunks = [];
|
||||
const dec = decodeStream(upRes, upRes.headers['content-encoding']);
|
||||
dec.on('data', c => chunks.push(c));
|
||||
dec.on('end', () => {
|
||||
try {
|
||||
let html = Buffer.concat(chunks).toString('utf8');
|
||||
html = rewriteHtml(html, basePath, base.hostname, slug);
|
||||
setPanelCookie(res, slug); // 首次加载即下发面板 cookie,子资源同源自动携带
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
out['Content-Type'] = 'text/html; charset=utf-8';
|
||||
out['Content-Length'] = buf.length;
|
||||
res.writeHead(status, out);
|
||||
res.end(buf);
|
||||
} catch (e) {
|
||||
console.error('Proxy HTML process error:', e.message);
|
||||
sendError(res, '代理响应处理失败: ' + e.message);
|
||||
}
|
||||
});
|
||||
dec.on('error', (e) => { console.error('Proxy decode error:', e.message); sendError(res, '代理响应解码失败'); });
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 HTML:原样透传(保留 content-encoding/content-length)
|
||||
if (upRes.headers['content-length']) out['Content-Length'] = upRes.headers['content-length'];
|
||||
if (upRes.headers['content-encoding']) out['Content-Encoding'] = upRes.headers['content-encoding'];
|
||||
res.writeHead(status, out);
|
||||
upRes.pipe(res);
|
||||
});
|
||||
|
||||
upReq.on('timeout', () => { upReq.destroy(); sendError(res, '代理请求超时(20秒)'); });
|
||||
upReq.on('error', (e) => { sendError(res, '代理请求失败: ' + e.message); });
|
||||
// body 透传(POST/PUT/PATCH):application/json 已被 express.json 消费 → 重新序列化发送;
|
||||
// 其余 content-type(form/multipart/raw)请求流完整 → 直接 pipe
|
||||
const isJsonBody = /^application\/json/i.test(String(req.headers['content-type'] || ''));
|
||||
if (isJsonBody && req.body !== undefined) {
|
||||
const body = JSON.stringify(req.body);
|
||||
upReq.setHeader('Content-Length', Buffer.byteLength(body));
|
||||
upReq.end(body);
|
||||
} else {
|
||||
req.pipe(upReq);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket 代理(upgrade 事件用)────────────────────────────────
|
||||
// 返回 true=已接管;false=不处理(调用方销毁 socket)
|
||||
function proxyWsUpgrade(slug, reqPath, query, request, socket, head) {
|
||||
const panel = getPanelBySlug(slug);
|
||||
if (!panel || !panel.url) return false;
|
||||
let base;
|
||||
try { base = new URL(panel.url); } catch { return false; }
|
||||
// SSRF 校验
|
||||
if (isBlockedHost(base.hostname) && !proxyAllowed(base.hostname)) return false;
|
||||
const client = base.protocol === 'https:' ? https : http;
|
||||
const wsPath = base.pathname.replace(/\/+$/, '') + reqPath + (query ? '?' + query : '');
|
||||
const headers = {};
|
||||
for (const h of ['upgrade', 'connection', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions']) {
|
||||
if (request.headers[h]) headers[h] = request.headers[h];
|
||||
}
|
||||
headers.host = base.host;
|
||||
headers.origin = base.protocol + '//' + base.host;
|
||||
|
||||
const upReq = client.request({
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: wsPath,
|
||||
method: 'GET',
|
||||
headers,
|
||||
family: 4,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
upReq.on('upgrade', (upRes, upSocket, upHead) => {
|
||||
// 转发 101 头:Connection/Upgrade 必须保留(Node 客户端据此识别 upgrade),
|
||||
// 仅剥 transfer-encoding/keep-alive 等无关 hop-by-hop
|
||||
let respHead = 'HTTP/1.1 101 Switching Protocols\r\n';
|
||||
const skip101 = ['transfer-encoding', 'keep-alive', 'te', 'trailer', 'proxy-authenticate', 'proxy-authorization'];
|
||||
for (const [k, v] of Object.entries(upRes.headers)) {
|
||||
if (skip101.includes(k.toLowerCase())) continue;
|
||||
const val = Array.isArray(v) ? v.join(', ') : v;
|
||||
respHead += k + ': ' + val + '\r\n';
|
||||
}
|
||||
respHead += '\r\n';
|
||||
try {
|
||||
socket.write(respHead);
|
||||
if (upHead && upHead.length) socket.write(upHead);
|
||||
upSocket.pipe(socket);
|
||||
socket.pipe(upSocket);
|
||||
socket.on('error', () => { try { upSocket.destroy(); } catch {} });
|
||||
upSocket.on('error', () => { try { socket.destroy(); } catch {} });
|
||||
} catch (e) {
|
||||
console.error('WS proxy pipe error:', e.message);
|
||||
try { upSocket.destroy(); } catch {}
|
||||
try { socket.destroy(); } catch {}
|
||||
}
|
||||
});
|
||||
upReq.on('error', (e) => { console.error('WS proxy error:', e.message); socket.destroy(); });
|
||||
upReq.on('timeout', () => { upReq.destroy(); socket.destroy(); });
|
||||
upReq.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 供 server.js upgrade 事件调用:解析 /proxy/:slug/ 前缀的 WS 请求
|
||||
function handleProxyUpgrade(request, socket, head) {
|
||||
try {
|
||||
const u = new URL(request.url, 'http://x');
|
||||
const m = u.pathname.match(/^\/proxy\/([^/]+)\/?(.*)$/);
|
||||
if (!m) return false;
|
||||
const slug = m[1];
|
||||
const reqPath = m[2] ? '/' + m[2] : '/';
|
||||
return proxyWsUpgrade(slug, reqPath, u.search ? u.search.slice(1) : '', request, socket, head);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function sendError(res, msg) {
|
||||
console.error('Proxy error:', msg);
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
@@ -163,21 +544,18 @@ function sendError(res, msg) {
|
||||
p{color:#666;font-size:14px;line-height:1.6;margin:0}
|
||||
code{display:block;font-size:13px;background:#f5f5f5;padding:8px 12px;border-radius:8px;margin-top:12px;word-break:break-all}
|
||||
</style></head><body><div class="box"><h2>⚠️ 代理加载失败</h2><p>${msg}</p></div></body></html>`;
|
||||
res.status(502).send(html);
|
||||
if (res && !res.headersSent) res.status(502).send(html);
|
||||
}
|
||||
|
||||
// 兼容 iframe/直链的 query token 认证:iframe 无法携带 Authorization header,
|
||||
// 从 ?token= 取 JWT 转写为 header,交由 authMiddleware/adminOnly 校验(adminOnly 保持不变)
|
||||
// ── 路由 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// 兼容 iframe/直链的 query token 认证(旧 /fetch 用)
|
||||
function queryTokenAuth(req, res, next) {
|
||||
if (req.query.token) {
|
||||
req.headers.authorization = 'Bearer ' + req.query.token;
|
||||
}
|
||||
if (req.query.token) req.headers.authorization = 'Bearer ' + req.query.token;
|
||||
next();
|
||||
}
|
||||
|
||||
// 短 TTL 代理 token:仅供 iframe 的 ?token= 认证使用(5 分钟有效),
|
||||
// 避免把 7 天长 TTL 主 token 暴露在 iframe URL 中(URL 可见于网络面板/历史记录/日志)。
|
||||
// 前端 PanelFrame 每次组装 proxy URL 前先从此接口取短 token。
|
||||
// 短 TTL 代理 token(保留)
|
||||
router.get('/token', authMiddleware, adminOnly, (req, res) => {
|
||||
const token = jwt.sign(
|
||||
{ id: req.user.id, username: req.user.username, role: req.user.role, proxy: true },
|
||||
@@ -187,9 +565,93 @@ router.get('/token', authMiddleware, adminOnly, (req, res) => {
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
router.get('/fetch', queryTokenAuth, authMiddleware, adminOnly, (req, res) => {
|
||||
// 旧版单 URL 透传(保留兼容)
|
||||
router.get('/fetch', queryTokenAuth, authMiddleware, adminOnly, async (req, res) => {
|
||||
if (!req.query.url) return res.status(400).json({ error: '缺少 url 参数' });
|
||||
proxyRequest(req.query.url, res);
|
||||
let url;
|
||||
try { url = new URL(req.query.url); } catch { return res.status(400).json({ error: '无效的 URL' }); }
|
||||
const err = await ssrfCheck(url.hostname);
|
||||
if (err) return res.status(403).json({ error: err });
|
||||
proxyLegacyFetch(url, res, '/proxy/legacy/');
|
||||
});
|
||||
|
||||
// 旧 /fetch 的底层实现(保留原行为:HTML 注入 <base> + shim + 剥头)
|
||||
function proxyLegacyFetch(url, res, basePath) {
|
||||
const client = url.protocol === 'https:' ? https : http;
|
||||
const req = client.get({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
timeout: 15000,
|
||||
family: 4,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*' },
|
||||
rejectUnauthorized: false,
|
||||
}, (upRes) => {
|
||||
const headers = { ...upRes.headers };
|
||||
delete headers['x-frame-options'];
|
||||
delete headers['content-security-policy'];
|
||||
headers['Referrer-Policy'] = 'no-referrer';
|
||||
headers['X-Content-Type-Options'] = 'nosniff';
|
||||
const ctype = (headers['content-type'] || '').toLowerCase();
|
||||
if (ctype.includes('text/html')) {
|
||||
const chunks = [];
|
||||
upRes.on('data', c => chunks.push(c));
|
||||
upRes.on('end', () => {
|
||||
const origin = url.hostname;
|
||||
let html = Buffer.concat(chunks).toString('utf8');
|
||||
html = rewriteHtml(html, basePath, origin, 'legacy');
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
headers['Content-Length'] = buf.length;
|
||||
headers['Content-Type'] = 'text/html; charset=utf-8';
|
||||
res.writeHead(upRes.statusCode || 200, headers);
|
||||
res.end(buf);
|
||||
});
|
||||
} else {
|
||||
res.writeHead(upRes.statusCode || 200, headers);
|
||||
upRes.pipe(res);
|
||||
}
|
||||
});
|
||||
req.on('error', e => sendError(res, '代理请求失败: ' + e.message));
|
||||
req.on('timeout', () => { req.destroy(); sendError(res, '代理请求超时'); });
|
||||
}
|
||||
|
||||
// ── /proxy/:slug 前缀代理(核心)──────────────────────────────────
|
||||
// 挂载于 server.js 的 app.use('/proxy', proxyRoutes)(在 express.json 之前,保证 body 流完整)
|
||||
|
||||
// 解析 slug 与鉴权的公共处理器
|
||||
function prefixAuth(req, res, next) {
|
||||
const slug = req.params.slug;
|
||||
if (!slug || RESERVED_SLUGS.has(slug)) return sendError(res, '面板不存在');
|
||||
const panel = getPanelBySlug(slug);
|
||||
if (!panel) return sendError(res, '面板不存在');
|
||||
if (!isAuthorized(req, slug)) return res.status(403).json({ error: '未授权' });
|
||||
req.panel = panel;
|
||||
req.proxySlug = slug;
|
||||
next();
|
||||
}
|
||||
|
||||
// 面板内网/SSRF 校验(异步)
|
||||
async function prefixCheck(req, res, next) {
|
||||
try {
|
||||
const base = new URL(req.panel.url);
|
||||
const err = await ssrfCheck(base.hostname);
|
||||
if (err) return res.status(403).json({ error: err });
|
||||
next();
|
||||
} catch { return sendError(res, '面板 URL 无效'); }
|
||||
}
|
||||
|
||||
function prefixProxy(req, res) {
|
||||
const slug = req.proxySlug;
|
||||
const basePath = '/proxy/' + slug + '/';
|
||||
// 去掉 /proxy/{slug} 前缀后的目标路径
|
||||
const m = String(req.path).match(/^\/[^/]+(?:\/(.*))?$/);
|
||||
const reqPath = m && m[1] ? '/' + m[1] : '/';
|
||||
const query = req.url.indexOf('?') !== -1 ? req.url.slice(req.url.indexOf('?') + 1) : '';
|
||||
proxyHttp(slug, reqPath, query, req, res, req.panel);
|
||||
}
|
||||
|
||||
router.all('/:slug', prefixAuth, prefixCheck, prefixProxy);
|
||||
router.all('/:slug/*', prefixAuth, prefixCheck, prefixProxy);
|
||||
|
||||
module.exports = router;
|
||||
module.exports.handleProxyUpgrade = handleProxyUpgrade;
|
||||
|
||||
@@ -9,10 +9,13 @@ const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
|
||||
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style',
|
||||
'glass_blur','glass_opacity','theme_force_dark',
|
||||
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
||||
'rainid_enabled',
|
||||
'rainid_enabled','rainid_register_redirect',
|
||||
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
||||
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
||||
'footer_style','footer_copyright','footer_powered','footer_desc'];
|
||||
'footer_style','footer_copyright','footer_powered','footer_desc',
|
||||
'footer_columns',
|
||||
'show_uid_in_comments',
|
||||
'forum_guest_visible'];
|
||||
|
||||
const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key',
|
||||
'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name',
|
||||
@@ -22,11 +25,39 @@ const ALL_KEYS = ['site_name','site_description','site_url','primary_color','rec
|
||||
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
||||
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
||||
'footer_style','footer_copyright','footer_powered','footer_desc',
|
||||
'comment_moderate','comment_notify',
|
||||
'proxy_allowed_hosts'];
|
||||
'footer_columns',
|
||||
'comment_moderate','comment_notify','show_uid_in_comments',
|
||||
'proxy_allowed_hosts',
|
||||
'forum_guest_visible',
|
||||
'feed_forum_enabled','feed_show_full','feed_max_items'];
|
||||
|
||||
const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key', 'rainid_client_secret'];
|
||||
|
||||
// footer_columns JSON 校验:数组(最多 3 栏,并行展示上限);每项 {title: string ≤20, links: [{label ≤50, url ≤200}]}。
|
||||
// url 白名单:站内相对路径以 / 开头(排除 // 协议相对),或 http(s):// 外链;拒绝 javascript:/data:/vbscript: 等危险协议。
|
||||
// 返回解析后的数组,非法返回 null。
|
||||
function validateFooterColumns(raw) {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(raw); } catch { return null; }
|
||||
if (!Array.isArray(parsed) || parsed.length > 3) return null;
|
||||
for (const col of parsed) {
|
||||
if (!col || typeof col !== 'object') return null;
|
||||
if (typeof col.title !== 'string' || !col.title.trim() || col.title.trim().length > 20) return null;
|
||||
if (!Array.isArray(col.links) || col.links.length > 20) return null;
|
||||
for (const link of col.links) {
|
||||
if (!link || typeof link !== 'object') return null;
|
||||
if (typeof link.label !== 'string' || !link.label.trim() || link.label.trim().length > 50) return null;
|
||||
const url = String(link.url || '').trim();
|
||||
if (!url || url.length > 200) return null;
|
||||
// 站内相对路径:/ 开头且非 // 开头;外链:http(s):// 开头
|
||||
if (!(/^\/(?!\/)/.test(url) || /^https?:\/\//i.test(url))) return null;
|
||||
// 危险协议兜底(上方协议白名单已排除,双保险)
|
||||
if (/^(javascript|data|vbscript):/i.test(url)) return null;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
router.get('/public', (req, res) => {
|
||||
const settings = {};
|
||||
PUBLIC_KEYS.forEach(k => settings[k] = db.getSetting(k));
|
||||
@@ -40,6 +71,14 @@ router.get('/', authMiddleware, adminOnly, (req, res) => {
|
||||
});
|
||||
|
||||
router.put('/', authMiddleware, adminOnly, (req, res) => {
|
||||
// footer_columns 特判校验:非法 JSON/结构/危险协议 → 400 不落库(空串表示回退硬编码,放行)
|
||||
if (req.body.footer_columns !== undefined && String(req.body.footer_columns) !== '') {
|
||||
if (!validateFooterColumns(String(req.body.footer_columns))) {
|
||||
return res.status(400).json({
|
||||
error: '页脚栏目格式无效:需为 [{title, links:[{label,url}]}],标题≤20字、链接标签≤50字、url 仅限站内路径(/开头)或 http(s) 链接'
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(req.body)) {
|
||||
if (ALLOWED_SET.includes(key)) db.setSetting(key, String(value));
|
||||
}
|
||||
|
||||
@@ -192,11 +192,17 @@ function verifyToken(token) {
|
||||
|
||||
// 认证成功 → 种 HttpOnly 短 TTL cookie:WS 握手(同源 GET /ws/terminal)自动携带,
|
||||
// root shell 凭证不再进 URL / access log。Path 精确限定 /ws/terminal,HttpOnly 防 XSS 读取,
|
||||
// SameSite=Strict 防跨站发送。Secure 按 req.secure(HTTPS 连接)条件附加。
|
||||
// SameSite=Strict 防跨站发送。Secure 按连接是否 HTTPS 条件附加。
|
||||
function setTerminalCookie(res, token) {
|
||||
// L7:HTTPS 判定补 x-forwarded-proto(适配 nginx/Cloudflare 等反代场景——
|
||||
// 反代 TLS 终结时 req.secure 可能为 false,但 X-Forwarded-Proto: https 表明真实协议为 HTTPS)
|
||||
const isSecure = res.req && (
|
||||
res.req.secure ||
|
||||
String(res.req.headers['x-forwarded-proto'] || '').toLowerCase().startsWith('https')
|
||||
);
|
||||
res.setHeader('Set-Cookie',
|
||||
`terminal_token=${token}; Path=/ws/terminal; HttpOnly; SameSite=Strict; Max-Age=${TOKEN_COOKIE_MAX_AGE}` +
|
||||
(res.req && res.req.secure ? '; Secure' : ''));
|
||||
(isSecure ? '; Secure' : ''));
|
||||
}
|
||||
|
||||
// 极简 Cookie 解析(upgrade 事件不经 Express,无 cookie-parser)
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
const express = require('express');
|
||||
const { rateLimit } = require('express-rate-limit');
|
||||
const db = require('../db');
|
||||
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function userKey(req) {
|
||||
return String(req.user && req.user.id ? req.user.id : 'anonymous');
|
||||
}
|
||||
|
||||
const createTicketUserLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '提交过于频繁,请稍后再试' } });
|
||||
const createTicketIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络提交过于频繁,请稍后再试' } });
|
||||
const messageUserLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 30, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '回复过于频繁,请稍后再试' } });
|
||||
const messageIpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络回复过于频繁,请稍后再试' } });
|
||||
const detailUserLimiter = rateLimit({ windowMs: 60 * 1000, limit: 60, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '请求过于频繁,请稍后再试' } });
|
||||
const detailIpLimiter = rateLimit({ windowMs: 60 * 1000, limit: 180, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络请求过于频繁,请稍后再试' } });
|
||||
|
||||
const CATEGORIES = new Set(['forum_bug', 'site_bug', 'feature', 'account', 'other']);
|
||||
const PRIORITIES = new Set(['low', 'normal', 'high', 'urgent']);
|
||||
const STATUSES = new Set(['open', 'processing', 'waiting', 'resolved', 'closed']);
|
||||
const TRANSITIONS = {
|
||||
open: new Set(['processing', 'closed']),
|
||||
processing: new Set(['waiting', 'resolved', 'closed']),
|
||||
waiting: new Set(['processing', 'closed']),
|
||||
resolved: new Set(['closed', 'processing']),
|
||||
closed: new Set(['processing']),
|
||||
};
|
||||
|
||||
function text(value, max = 0) {
|
||||
if (typeof value !== 'string') return '';
|
||||
const valueText = value.trim();
|
||||
return max && valueText.length > max ? valueText.slice(0, max) : valueText;
|
||||
}
|
||||
|
||||
function currentUserRole(userId) {
|
||||
const user = db.get('SELECT role FROM users WHERE id = ?', [userId]);
|
||||
return user ? user.role : null;
|
||||
}
|
||||
|
||||
function validateSourceUrl(value) {
|
||||
if (value === undefined || value === null || value === '') return { value: '' };
|
||||
if (typeof value !== 'string') return { error: '来源地址不合法' };
|
||||
if (value.length > 1000 || /[\u0000-\u001f\u007f-\u009f]/.test(value) || value.includes('\\')) return { error: '来源地址不合法' };
|
||||
const sourceUrl = value.trim();
|
||||
if (!sourceUrl) return { value: '' };
|
||||
if (sourceUrl.startsWith('/') && !sourceUrl.startsWith('//')) return { value: sourceUrl };
|
||||
if (!/^https?:\/\//i.test(sourceUrl)) return { error: '来源地址只允许站内路径或 http/https 地址' };
|
||||
try {
|
||||
const parsed = new URL(sourceUrl);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) return { error: '来源地址不合法' };
|
||||
} catch {
|
||||
return { error: '来源地址不合法' };
|
||||
}
|
||||
return { value: sourceUrl };
|
||||
}
|
||||
|
||||
function pageParams(query) {
|
||||
const page = Number.parseInt(query.page, 10);
|
||||
const pageSize = Number.parseInt(query.pageSize, 10);
|
||||
return {
|
||||
page: Number.isInteger(page) && page >= 1 ? page : 1,
|
||||
pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20,
|
||||
};
|
||||
}
|
||||
|
||||
function detailParams(query) {
|
||||
const page = Number.parseInt(query.messagePage || query.page, 10);
|
||||
const pageSize = Number.parseInt(query.messagePageSize || query.pageSize, 10);
|
||||
return {
|
||||
page: Number.isInteger(page) && page >= 1 ? page : 1,
|
||||
pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20,
|
||||
};
|
||||
}
|
||||
|
||||
function eventParams(query) {
|
||||
const page = Number.parseInt(query.eventPage || query.page, 10);
|
||||
const pageSize = Number.parseInt(query.eventPageSize || query.pageSize, 10);
|
||||
return {
|
||||
page: Number.isInteger(page) && page >= 1 ? page : 1,
|
||||
pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20,
|
||||
};
|
||||
}
|
||||
|
||||
function idParam(value) {
|
||||
const id = Number.parseInt(value, 10);
|
||||
return Number.isInteger(id) && id > 0 && String(id) === String(value) ? id : 0;
|
||||
}
|
||||
|
||||
function getTicket(id) {
|
||||
return db.get(`SELECT t.*, u.username AS requester_name, u.nickname AS requester_nickname,
|
||||
a.username AS assignee_name, a.nickname AS assignee_nickname
|
||||
FROM tickets t
|
||||
LEFT JOIN users u ON u.id = t.requester_id
|
||||
LEFT JOIN users a ON a.id = t.assignee_id
|
||||
WHERE t.id = ?`, [id]);
|
||||
}
|
||||
|
||||
function canAccess(ticket, user, role) {
|
||||
return !!ticket && !!role && (ticket.requester_id === user.id || role === 'admin');
|
||||
}
|
||||
|
||||
function addEvent(insertEvent, ticketId, actorId, type, field = '', oldValue = '', newValue = '', detail = '') {
|
||||
insertEvent.run(ticketId, actorId || null, type, field, String(oldValue ?? ''), String(newValue ?? ''), detail);
|
||||
}
|
||||
|
||||
function updateStatus(ticket, status, actorId, insertEvent, database) {
|
||||
if (!STATUSES.has(status)) return { error: '状态不合法' };
|
||||
if (ticket.status === status) return { changed: false };
|
||||
if (!TRANSITIONS[ticket.status] || !TRANSITIONS[ticket.status].has(status)) {
|
||||
return { error: '不允许的状态流转' };
|
||||
}
|
||||
const now = "datetime('now')";
|
||||
const values = [status];
|
||||
let sql = `UPDATE tickets SET status = ?, updated_at = ${now}, revision = revision + 1`;
|
||||
if (status === 'resolved') sql += `, resolved_at = ${now}`;
|
||||
if (status === 'closed') sql += `, closed_at = ${now}`;
|
||||
if (status !== 'resolved') sql += ', resolved_at = NULL';
|
||||
if (status !== 'closed') sql += ', closed_at = NULL';
|
||||
sql += ' WHERE id = ? AND revision = ?';
|
||||
values.push(ticket.id, ticket.revision);
|
||||
const result = database.prepare(sql).run(...values);
|
||||
if (result.changes !== 1) {
|
||||
const error = new Error('工单已被其他人更新,请刷新后重试');
|
||||
error.code = 'TICKET_CONFLICT';
|
||||
throw error;
|
||||
}
|
||||
const eventType = status === 'closed' ? 'ticket_closed' : (ticket.status === 'closed' && status === 'processing' ? 'ticket_reopened' : 'status_changed');
|
||||
addEvent(insertEvent, ticket.id, actorId, eventType, 'status', ticket.status, status);
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
function ticketResponse(ticket, isAdmin, query = {}) {
|
||||
const messagePaging = detailParams(query);
|
||||
const eventPaging = eventParams(query);
|
||||
const { page: messagePage, pageSize: messagePageSize } = messagePaging;
|
||||
const visibility = isAdmin ? '' : 'AND tm.is_internal = 0';
|
||||
const messageTotal = db.get(`SELECT COUNT(*) AS count FROM ticket_messages tm WHERE tm.ticket_id = ? ${visibility}`, [ticket.id]).count;
|
||||
const messages = db.all(`SELECT tm.id, tm.ticket_id, tm.author_id, tm.content, tm.is_internal,
|
||||
tm.created_at, u.username AS author_name, u.nickname AS author_nickname, u.role AS author_role
|
||||
FROM ticket_messages tm LEFT JOIN users u ON u.id = tm.author_id
|
||||
WHERE tm.ticket_id = ? ${visibility}
|
||||
ORDER BY tm.created_at ASC, tm.id ASC LIMIT ? OFFSET ?`, [ticket.id, messagePageSize, (messagePage - 1) * messagePageSize]);
|
||||
const response = { ticket, messages, messagePage, messagePageSize, messageTotal, messageTotalPages: Math.ceil(messageTotal / messagePageSize) };
|
||||
if (isAdmin) {
|
||||
const eventTotal = db.get('SELECT COUNT(*) AS count FROM ticket_events WHERE ticket_id = ?', [ticket.id]).count;
|
||||
response.events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
||||
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
||||
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC LIMIT ? OFFSET ?`, [ticket.id, eventPaging.pageSize, (eventPaging.page - 1) * eventPaging.pageSize]);
|
||||
response.eventPage = eventPaging.page;
|
||||
response.eventPageSize = eventPaging.pageSize;
|
||||
response.eventTotal = eventTotal;
|
||||
response.eventTotalPages = Math.ceil(eventTotal / eventPaging.pageSize);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function getTicketRow(database, id) {
|
||||
return database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function ticketEventStatement(database) {
|
||||
return database.prepare(`INSERT INTO ticket_events
|
||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
}
|
||||
|
||||
function conflictError(message = '工单已被其他人更新,请刷新后重试') {
|
||||
const error = new Error(message);
|
||||
error.code = 'TICKET_CONFLICT';
|
||||
return error;
|
||||
}
|
||||
|
||||
function conflictResponse(error) {
|
||||
return error && error.code === 'TICKET_CONFLICT' ? { status: 409, body: { error: error.message } } : null;
|
||||
}
|
||||
|
||||
// 管理后台列表(作为管理后台的工单子 tab 使用)
|
||||
router.get('/admin', authMiddleware, adminOnly, (req, res) => {
|
||||
const { page, pageSize } = pageParams(req.query);
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
const filters = [
|
||||
['status', STATUSES], ['priority', PRIORITIES], ['category', CATEGORIES],
|
||||
];
|
||||
for (const [key, allowed] of filters) {
|
||||
if (req.query[key]) {
|
||||
if (!allowed.has(String(req.query[key]))) return res.status(400).json({ error: `${key} 不合法` });
|
||||
conditions.push(`t.${key} = ?`); params.push(String(req.query[key]));
|
||||
}
|
||||
}
|
||||
const assigneeId = req.query.assignee_id === 'null' ? null : idParam(req.query.assignee_id || '');
|
||||
if (req.query.assignee_id !== undefined) {
|
||||
if (req.query.assignee_id === 'null') conditions.push('t.assignee_id IS NULL');
|
||||
else if (!assigneeId) return res.status(400).json({ error: '负责人不合法' });
|
||||
else { conditions.push('t.assignee_id = ?'); params.push(assigneeId); }
|
||||
}
|
||||
const requesterId = req.query.requester_id ? idParam(req.query.requester_id) : 0;
|
||||
if (req.query.requester_id && !requesterId) return res.status(400).json({ error: '提交人不合法' });
|
||||
if (requesterId) { conditions.push('t.requester_id = ?'); params.push(requesterId); }
|
||||
if (req.query.q) {
|
||||
const q = text(req.query.q, 100);
|
||||
conditions.push('(t.ticket_no LIKE ? OR t.subject LIKE ? OR t.description LIKE ?)');
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const total = db.get(`SELECT COUNT(*) AS count FROM tickets t ${where}`, params).count;
|
||||
const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status,
|
||||
t.requester_id, t.assignee_id, t.source, t.created_at, t.updated_at, t.last_reply_at,
|
||||
u.username AS requester_name, u.nickname AS requester_nickname,
|
||||
a.username AS assignee_name, a.nickname AS assignee_nickname
|
||||
FROM tickets t LEFT JOIN users u ON u.id = t.requester_id LEFT JOIN users a ON a.id = t.assignee_id
|
||||
${where} ORDER BY CASE t.status WHEN 'open' THEN 0 WHEN 'processing' THEN 1 WHEN 'waiting' THEN 2 WHEN 'resolved' THEN 3 ELSE 4 END,
|
||||
t.updated_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
|
||||
res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||
});
|
||||
|
||||
router.get('/admin/stats', authMiddleware, adminOnly, (req, res) => {
|
||||
const row = db.get(`SELECT COUNT(*) AS total,
|
||||
SUM(status = 'open') AS open, SUM(status = 'processing') AS processing,
|
||||
SUM(status = 'waiting') AS waiting, SUM(status = 'resolved') AS resolved,
|
||||
SUM(status = 'closed') AS closed FROM tickets`);
|
||||
res.json(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Number(value) || 0])));
|
||||
});
|
||||
|
||||
router.get('/admin/assignees', authMiddleware, adminOnly, (req, res) => {
|
||||
res.json(db.all("SELECT id, username, nickname FROM users WHERE role = 'admin' ORDER BY id ASC"));
|
||||
});
|
||||
|
||||
// 用户自己的工单列表
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
const { page, pageSize } = pageParams(req.query);
|
||||
if (req.query.status && !STATUSES.has(String(req.query.status))) return res.status(400).json({ error: '状态不合法' });
|
||||
const params = [req.user.id];
|
||||
const statusSql = req.query.status ? 'AND t.status = ?' : '';
|
||||
if (req.query.status) params.push(String(req.query.status));
|
||||
const total = db.get(`SELECT COUNT(*) AS count FROM tickets t WHERE t.requester_id = ? ${statusSql}`, params).count;
|
||||
const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status, t.source,
|
||||
t.created_at, t.updated_at, t.last_reply_at
|
||||
FROM tickets t WHERE t.requester_id = ? ${statusSql}
|
||||
ORDER BY t.created_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
|
||||
res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, createTicketUserLimiter, createTicketIpLimiter, (req, res) => {
|
||||
const body = req.body || {};
|
||||
const subject = text(body.subject, 120);
|
||||
const description = text(body.description, 20000);
|
||||
const category = text(body.category, 30) || 'other';
|
||||
const priority = text(body.priority, 20) || 'normal';
|
||||
const source = text(body.source, 20) || 'site';
|
||||
if (subject.length < 1 || description.length < 1) return res.status(400).json({ error: '标题和问题描述不能为空' });
|
||||
if (!CATEGORIES.has(category)) return res.status(400).json({ error: '问题分类不合法' });
|
||||
if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' });
|
||||
if (!['forum', 'site'].includes(source)) return res.status(400).json({ error: '来源不合法' });
|
||||
const sourceUrlResult = validateSourceUrl(body.source_url);
|
||||
if (sourceUrlResult.error) return res.status(400).json({ error: sourceUrlResult.error });
|
||||
const sourceUrl = sourceUrlResult.value;
|
||||
const sourceType = text(body.source_type, 40);
|
||||
const sourceId = body.source_id === undefined || body.source_id === '' ? 0 : idParam(String(body.source_id));
|
||||
if (body.source_id !== undefined && body.source_id !== '' && !sourceId) return res.status(400).json({ error: '来源编号不合法' });
|
||||
if (source === 'forum' && sourceId && !db.get('SELECT id FROM forum_posts WHERE id = ?', [sourceId])) {
|
||||
return res.status(400).json({ error: '关联的论坛帖子不存在' });
|
||||
}
|
||||
const browserInfo = text(body.browser_info, 1000);
|
||||
try {
|
||||
const result = db.transaction(() => {
|
||||
const database = db.getDb();
|
||||
const insertTicket = database.prepare(`INSERT INTO tickets
|
||||
(ticket_no, requester_id, subject, description, category, priority, source, source_url, source_type, source_id, browser_info, last_reply_at)
|
||||
VALUES ('PENDING-' || hex(randomblob(8)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`);
|
||||
const info = insertTicket.run(req.user.id, subject, description, category, priority, source, sourceUrl, sourceType, sourceId, browserInfo);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
const ticketNo = `RW-${String(id).padStart(6, '0')}`;
|
||||
database.prepare('UPDATE tickets SET ticket_no = ? WHERE id = ?').run(ticketNo, id);
|
||||
const event = database.prepare(`INSERT INTO ticket_events
|
||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
addEvent(event, id, req.user.id, 'ticket_created', '', '', 'open', '用户创建工单');
|
||||
return { id, ticket_no: ticketNo };
|
||||
});
|
||||
res.status(201).json({ message: '工单已创建', ticket: getTicket(result.id) });
|
||||
} catch (e) {
|
||||
console.error('Ticket create error:', e.message);
|
||||
res.status(500).json({ error: '创建工单失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id/events', authMiddleware, adminOnly, detailUserLimiter, detailIpLimiter, (req, res) => {
|
||||
const id = idParam(req.params.id);
|
||||
if (!id) return res.status(404).json({ error: '工单不存在' });
|
||||
if (!getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
||||
const { page, pageSize } = eventParams(req.query);
|
||||
const eventTotal = db.get('SELECT COUNT(*) AS count FROM ticket_events WHERE ticket_id = ?', [id]).count;
|
||||
const events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
||||
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
||||
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC LIMIT ? OFFSET ?`, [id, pageSize, (page - 1) * pageSize]);
|
||||
// 保留旧接口的数组响应契约,分页信息通过响应头提供。
|
||||
res.set({
|
||||
'X-Page': String(page),
|
||||
'X-Page-Size': String(pageSize),
|
||||
'X-Total-Count': String(eventTotal),
|
||||
'X-Total-Pages': String(Math.ceil(eventTotal / pageSize)),
|
||||
});
|
||||
res.json(events);
|
||||
});
|
||||
|
||||
router.get('/:id', authMiddleware, detailUserLimiter, detailIpLimiter, (req, res) => {
|
||||
const id = idParam(req.params.id);
|
||||
const ticket = id ? getTicket(id) : null;
|
||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||
const role = currentUserRole(req.user.id);
|
||||
if (!role) return res.status(401).json({ error: '登录已失效' });
|
||||
if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
|
||||
res.json(ticketResponse(ticket, role === 'admin', req.query));
|
||||
});
|
||||
|
||||
router.post('/:id/messages', authMiddleware, messageUserLimiter, messageIpLimiter, (req, res) => {
|
||||
const id = idParam(req.params.id);
|
||||
const ticket = id ? getTicket(id) : null;
|
||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||
const role = currentUserRole(req.user.id);
|
||||
if (!role) return res.status(401).json({ error: '登录已失效' });
|
||||
if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
|
||||
const content = text(req.body && req.body.content, 20000);
|
||||
if (!content) return res.status(400).json({ error: '回复内容不能为空' });
|
||||
if (ticket.status === 'closed') return res.status(409).json({ error: '工单已关闭,不能回复' });
|
||||
try {
|
||||
db.transaction(() => {
|
||||
const database = db.getDb();
|
||||
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||
if (freshTicket.status === 'closed') {
|
||||
const error = new Error('工单已关闭,不能回复');
|
||||
error.code = 'TICKET_CLOSED';
|
||||
throw error;
|
||||
}
|
||||
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 0)').run(id, req.user.id, content);
|
||||
let nextStatus = freshTicket.status;
|
||||
if (role !== 'admin' && freshTicket.status === 'waiting') nextStatus = 'processing';
|
||||
const updateResult = database.prepare(`UPDATE tickets SET updated_at = datetime('now'), last_reply_at = datetime('now'),
|
||||
first_response_at = CASE WHEN first_response_at IS NULL AND ? = 'admin' THEN datetime('now') ELSE first_response_at END,
|
||||
status = ?, revision = revision + 1 WHERE id = ? AND revision = ?`).run(role, nextStatus, id, freshTicket.revision);
|
||||
if (updateResult.changes !== 1) throw conflictError();
|
||||
const event = database.prepare(`INSERT INTO ticket_events
|
||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
addEvent(event, id, req.user.id, 'message_added', '', '', '公开回复', '');
|
||||
if (nextStatus !== freshTicket.status) addEvent(event, id, req.user.id, 'status_changed', 'status', freshTicket.status, nextStatus, '用户回复后自动进入处理中');
|
||||
});
|
||||
res.status(201).json(ticketResponse(getTicket(id), role === 'admin', req.query));
|
||||
} catch (e) {
|
||||
console.error('Ticket message error:', e.message);
|
||||
const response = conflictResponse(e)
|
||||
|| (e.code === 'TICKET_CLOSED' ? { status: 409, body: { error: e.message } } : null);
|
||||
res.status(response ? response.status : 500).json(response ? response.body : { error: '回复工单失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/internal-messages', authMiddleware, adminOnly, messageUserLimiter, messageIpLimiter, (req, res) => {
|
||||
const id = idParam(req.params.id);
|
||||
if (!id || !getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
||||
const content = text(req.body && req.body.content, 20000);
|
||||
if (!content) return res.status(400).json({ error: '内部备注不能为空' });
|
||||
try {
|
||||
db.transaction(() => {
|
||||
const database = db.getDb();
|
||||
const freshTicket = getTicketRow(database, id);
|
||||
if (!freshTicket) throw new Error('工单不存在');
|
||||
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 1)').run(id, req.user.id, content);
|
||||
const updateResult = database.prepare("UPDATE tickets SET updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(id, freshTicket.revision);
|
||||
if (updateResult.changes !== 1) throw conflictError();
|
||||
const event = ticketEventStatement(database);
|
||||
addEvent(event, id, req.user.id, 'internal_note_added', '', '', '', '管理员添加内部备注');
|
||||
});
|
||||
res.status(201).json(ticketResponse(getTicket(id), true, req.query));
|
||||
} catch (e) {
|
||||
console.error('Ticket internal message error:', e.message);
|
||||
const conflict = conflictResponse(e);
|
||||
res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '添加内部备注失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/status', authMiddleware, adminOnly, (req, res) => {
|
||||
const id = idParam(req.params.id);
|
||||
const ticket = id ? getTicket(id) : null;
|
||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||
const status = text(req.body && req.body.status, 20);
|
||||
try {
|
||||
const result = db.transaction(() => {
|
||||
const database = db.getDb();
|
||||
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||
return updateStatus(freshTicket, status, req.user.id, ticketEventStatement(database), database);
|
||||
});
|
||||
if (result.error) return res.status(result.error === '不允许的状态流转' ? 409 : 400).json({ error: result.error });
|
||||
res.json({ message: '状态已更新', ticket: getTicket(id) });
|
||||
} catch (e) {
|
||||
console.error('Ticket status error:', e.message);
|
||||
const conflict = conflictResponse(e);
|
||||
res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '更新状态失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => {
|
||||
const id = idParam(req.params.id);
|
||||
const ticket = id ? getTicket(id) : null;
|
||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||
const priority = text(req.body && req.body.priority, 20);
|
||||
if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' });
|
||||
try {
|
||||
db.transaction(() => {
|
||||
const database = db.getDb();
|
||||
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||
if (freshTicket.priority === priority) return;
|
||||
const updateResult = database.prepare("UPDATE tickets SET priority = ?, updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(priority, id, freshTicket.revision);
|
||||
if (updateResult.changes !== 1) throw conflictError();
|
||||
addEvent(ticketEventStatement(database), id, req.user.id, 'priority_changed', 'priority', freshTicket.priority, priority);
|
||||
});
|
||||
res.json({ message: '优先级已更新', ticket: getTicket(id) });
|
||||
} catch (e) {
|
||||
console.error('Ticket priority error:', e.message);
|
||||
const conflict = conflictResponse(e);
|
||||
res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '更新优先级失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => {
|
||||
const id = idParam(req.params.id);
|
||||
const ticket = id ? getTicket(id) : null;
|
||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||
const assigneeId = req.body && (req.body.assignee_id === null || req.body.assignee_id === '' ? null : idParam(String(req.body.assignee_id)));
|
||||
if (assigneeId !== null && !assigneeId) return res.status(400).json({ error: '负责人不合法' });
|
||||
if (assigneeId !== null && !db.get("SELECT id FROM users WHERE id = ? AND role = 'admin'", [assigneeId])) return res.status(400).json({ error: '负责人必须是管理员' });
|
||||
try {
|
||||
db.transaction(() => {
|
||||
const database = db.getDb();
|
||||
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||
if (freshTicket.assignee_id === assigneeId) return;
|
||||
if (assigneeId !== null && !database.prepare("SELECT id FROM users WHERE id = ? AND role = 'admin'").get(assigneeId)) {
|
||||
throw new Error('负责人必须是管理员');
|
||||
}
|
||||
const updateResult = database.prepare("UPDATE tickets SET assignee_id = ?, updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(assigneeId, id, freshTicket.revision);
|
||||
if (updateResult.changes !== 1) throw conflictError();
|
||||
addEvent(ticketEventStatement(database), id, req.user.id, 'assignee_changed', 'assignee_id', freshTicket.assignee_id, assigneeId);
|
||||
});
|
||||
res.json({ message: '负责人已更新', ticket: getTicket(id) });
|
||||
} catch (e) {
|
||||
console.error('Ticket assignee error:', e.message);
|
||||
const conflict = conflictResponse(e);
|
||||
const validation = e.message === '负责人必须是管理员' ? { status: 400, body: { error: e.message } } : null;
|
||||
const response = conflict || validation;
|
||||
res.status(response ? response.status : 500).json(response ? response.body : { error: '更新负责人失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/close', authMiddleware, (req, res) => changeUserStatus(req, res, 'closed'));
|
||||
router.post('/:id/reopen', authMiddleware, (req, res) => changeUserStatus(req, res, 'processing'));
|
||||
|
||||
function changeUserStatus(req, res, status) {
|
||||
const id = idParam(req.params.id);
|
||||
const ticket = id ? getTicket(id) : null;
|
||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||
if (!currentUserRole(req.user.id)) return res.status(401).json({ error: '登录已失效' });
|
||||
if (ticket.requester_id !== req.user.id) return res.status(403).json({ error: '无权限操作该工单' });
|
||||
try {
|
||||
db.transaction(() => {
|
||||
const database = db.getDb();
|
||||
if (!database.prepare('SELECT id FROM users WHERE id = ?').get(req.user.id)) throw new Error('登录已失效');
|
||||
const freshTicket = getTicketRow(database, id);
|
||||
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||
if ((status === 'closed' && freshTicket.status === 'closed') || (status === 'processing' && !['resolved', 'closed'].includes(freshTicket.status))) {
|
||||
throw new Error('当前状态不支持此操作');
|
||||
}
|
||||
if (freshTicket.requester_id !== req.user.id) throw new Error('无权限操作该工单');
|
||||
const result = updateStatus(freshTicket, status, req.user.id, ticketEventStatement(database), database);
|
||||
if (result.error) throw new Error(result.error);
|
||||
});
|
||||
res.json({ message: status === 'closed' ? '工单已关闭' : '工单已重新打开', ticket: getTicket(id) });
|
||||
} catch (e) {
|
||||
const conflict = conflictResponse(e);
|
||||
const response = conflict
|
||||
|| (['当前状态不支持此操作', '不允许的状态流转'].includes(e.message) ? { status: 409, body: { error: e.message } } : null)
|
||||
|| (['登录已失效', '无权限操作该工单', '工单不存在'].includes(e.message)
|
||||
? { status: e.message === '登录已失效' ? 401 : e.message === '无权限操作该工单' ? 403 : 404, body: { error: e.message } }
|
||||
: null);
|
||||
res.status(response ? response.status : 500).json(response ? response.body : { error: '更新工单失败' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,19 +4,30 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||
const locks = require('../lib/locks');
|
||||
|
||||
// 上传扩展名白名单
|
||||
const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.zip', '.txt', '.md']);
|
||||
// 版块图标专用白名单:仅图片格式,排除 svg(svg 可内嵌脚本有 XSS 风险,头像/图标一律不收)
|
||||
const ICON_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
|
||||
// 搜索引擎验证文件白名单:仅 .xml/.html/.txt(Bing/Google/Yandex 站长验证文件就这三类)
|
||||
const VERIFY_EXT = new Set(['.xml', '.html', '.txt']);
|
||||
// 验证文件名安全:不含路径分隔符/控制字符/.. 穿越,≤64 字符,保留原名(站长工具要求完全同名)
|
||||
const VERIFY_NAME_RE = /^[^\\\/\x00-\x1f]{1,64}\.(xml|html|txt)$/i;
|
||||
// 站点核心文件黑名单:删除验证文件时额外拒绝(即使扩展名在白名单内,防误删 index.html 等关键文件)
|
||||
const VERIFY_CORE_FILES = new Set(['index.html', 'admin.html', 'favicon.svg', 'og-default.png', 'og-default.svg', 'robots.txt']);
|
||||
|
||||
const router = express.Router();
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||
const AVATAR_DIR = path.join(UPLOAD_DIR, 'avatars');
|
||||
[UPLOAD_DIR, AVATAR_DIR].forEach(d => { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); });
|
||||
const ICONS_DIR = path.join(UPLOAD_DIR, 'icons');
|
||||
[UPLOAD_DIR, AVATAR_DIR, ICONS_DIR].forEach(d => { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); });
|
||||
|
||||
// Temporary storage, then rename to SHA-256 hash
|
||||
function shaFileUpload(subdir, maxSize) {
|
||||
const targetDir = subdir === 'avatar' ? AVATAR_DIR : UPLOAD_DIR;
|
||||
function shaFileUpload(subdir, maxSize, allowedExts, fieldName = 'file') {
|
||||
const targetDir = subdir === 'avatar' ? AVATAR_DIR : (subdir === 'icon' ? ICONS_DIR : UPLOAD_DIR);
|
||||
const extSet = allowedExts || ALLOWED_EXT;
|
||||
return multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, targetDir),
|
||||
@@ -28,7 +39,7 @@ function shaFileUpload(subdir, maxSize) {
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!ALLOWED_EXT.has(ext)) return cb(new Error('不支持的文件类型'));
|
||||
if (!extSet.has(ext)) return cb(new Error('不支持的文件类型'));
|
||||
// 图片类文件需 MIME 也以 image/ 开头,防止扩展名伪装
|
||||
const isImageExt = ['.png', '.jpg', '.jpeg', '.gif', '.webp'].includes(ext);
|
||||
if (isImageExt && (!file.mimetype || !file.mimetype.startsWith('image/'))) {
|
||||
@@ -37,7 +48,7 @@ function shaFileUpload(subdir, maxSize) {
|
||||
cb(null, true);
|
||||
},
|
||||
limits: { fileSize: maxSize },
|
||||
}).single('file');
|
||||
}).single(fieldName);
|
||||
}
|
||||
|
||||
// After multer saves the file, rename it to its SHA-256 hash (dedup)
|
||||
@@ -68,6 +79,23 @@ router.post('/avatar', authMiddleware, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 版块图标上传:仅图片(png/jpg/jpeg/gif/webp,排除 svg 防 XSS),最大 1MB。
|
||||
// 文件名用内容 SHA-256(shaFileUpload→hashify),无用户可控路径。
|
||||
// 清理策略:上传新图标不自动删旧文件(sha 去重,同名不覆盖;替换由管理端更新
|
||||
// forum_categories.icon 引用;如需清理可后续按旧 URL 删除,本期保持简单)。
|
||||
router.post('/icon', authMiddleware, (req, res) => {
|
||||
const upload = shaFileUpload('icon', 1 * 1024 * 1024, ICON_EXT, 'icon');
|
||||
upload(req, res, (err) => {
|
||||
if (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件超过 1MB 限制' });
|
||||
return res.status(400).json({ error: '上传失败: ' + err.message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: '请选择文件' });
|
||||
const filename = hashify(req.file.path);
|
||||
res.json({ url: '/uploads/icons/' + filename, message: '上传成功' });
|
||||
});
|
||||
});
|
||||
|
||||
// Wallpaper upload
|
||||
router.post('/wallpaper', authMiddleware, (req, res) => {
|
||||
const upload = shaFileUpload('wallpaper', 10 * 1024 * 1024);
|
||||
@@ -127,6 +155,76 @@ router.get('/list', authMiddleware, (req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// ── 搜索引擎验证文件(Bing/Google/Yandex 站长工具)────────────────
|
||||
// 验证文件必须与站长工具给的完全同名且位于站点根目录,经 https://域名/文件名 可访问。
|
||||
// 存 public/(server.js 已把 public/ 静态挂载在根路径)。仅 admin 可操作。
|
||||
const VERIFY_DIR = path.join(__dirname, '..', 'public');
|
||||
// 内存缓冲后先校验内容再写盘:≤64KB,避免后台成为任意文件落地/钓鱼页入口
|
||||
const verifyUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 64 * 1024 },
|
||||
}).single('file');
|
||||
|
||||
// 验证文件上传(adminOnly):保留原名 + 扩展名白名单 + 内容特征校验 + 拒绝覆盖已有文件
|
||||
router.post('/verify-file', authMiddleware, adminOnly, (req, res) => {
|
||||
verifyUpload(req, res, (err) => {
|
||||
if (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件超过 64KB 限制' });
|
||||
return res.status(400).json({ error: '上传失败: ' + err.message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: '请选择文件' });
|
||||
const original = String(req.file.originalname || '');
|
||||
// 扩展名白名单(只验扩展名——xml/html 的 MIME 常被浏览器标为 text/xml 等,不强求)
|
||||
const ext = path.extname(original).toLowerCase();
|
||||
if (!VERIFY_EXT.has(ext)) return res.status(400).json({ error: '仅支持 .xml / .html / .txt 验证文件' });
|
||||
// 文件名安全:保留原名、防路径穿越(.. / \ 控制字符)、≤64
|
||||
if (!VERIFY_NAME_RE.test(original) || original.includes('..')) {
|
||||
return res.status(400).json({ error: '文件名不合法' });
|
||||
}
|
||||
// 内容安全:.xml 以 <?xml 开头;.html/.txt 必须含 verification(Google/Bing/Yandex 验证内容均含该词)
|
||||
const content = req.file.buffer.toString('utf8');
|
||||
if (ext === '.xml' && !content.trimStart().startsWith('<?xml')) {
|
||||
return res.status(400).json({ error: '文件内容不是有效的搜索引擎验证文件' });
|
||||
}
|
||||
if (ext !== '.xml' && !/verification/i.test(content)) {
|
||||
return res.status(400).json({ error: '文件内容不是有效的搜索引擎验证文件' });
|
||||
}
|
||||
// 拒绝覆盖 public/ 下已有文件(防覆盖 index.html/dist/css 等核心文件)
|
||||
const target = path.join(VERIFY_DIR, original);
|
||||
if (!target.startsWith(VERIFY_DIR + path.sep)) return res.status(400).json({ error: '文件名不合法' });
|
||||
if (fs.existsSync(target)) return res.status(409).json({ error: '同名文件已存在,请先删除' });
|
||||
fs.writeFileSync(target, req.file.buffer);
|
||||
res.json({ url: '/' + original, message: '上传成功' });
|
||||
});
|
||||
});
|
||||
|
||||
// 已上传验证文件列表(adminOnly):扫描 public/ 下白名单扩展名文件
|
||||
router.get('/verify-files', authMiddleware, adminOnly, (req, res) => {
|
||||
const files = [];
|
||||
try {
|
||||
fs.readdirSync(VERIFY_DIR).forEach((name) => {
|
||||
if (!VERIFY_NAME_RE.test(name) || name.includes('..')) return;
|
||||
const fp = path.join(VERIFY_DIR, name);
|
||||
try { if (!fs.statSync(fp).isFile()) return; } catch { return; }
|
||||
files.push({ name, url: '/' + name });
|
||||
});
|
||||
} catch { /* 目录读取失败返回空列表 */ }
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
res.json(files);
|
||||
});
|
||||
|
||||
// 删除验证文件(adminOnly):仅允许删白名单扩展名文件,且拒绝站点核心文件
|
||||
router.delete('/verify-file/:filename', authMiddleware, adminOnly, (req, res) => {
|
||||
const name = String(req.params.filename || '');
|
||||
if (!VERIFY_NAME_RE.test(name) || name.includes('..')) return res.status(400).json({ error: '非法文件名' });
|
||||
if (VERIFY_CORE_FILES.has(name)) return res.status(400).json({ error: '不允许删除系统核心文件' });
|
||||
const target = path.join(VERIFY_DIR, name);
|
||||
if (!target.startsWith(VERIFY_DIR + path.sep)) return res.status(400).json({ error: '非法文件名' });
|
||||
if (!fs.existsSync(target)) return res.status(404).json({ error: '文件不存在' });
|
||||
try { fs.unlinkSync(target); } catch (e) { return res.status(500).json({ error: '删除失败' }); }
|
||||
res.json({ message: '已删除' });
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, (req, res) => {
|
||||
const att = db.get('SELECT * FROM attachments WHERE id = ?', [req.params.id]);
|
||||
if (!att) return res.status(404).json({ error: '文件不存在' });
|
||||
@@ -137,6 +235,78 @@ router.delete('/:id', authMiddleware, (req, res) => {
|
||||
res.json({ message: '删除成功' });
|
||||
});
|
||||
|
||||
// 锁定块内附件鉴权接口:/api/upload/locked?ref_type=blog|forum&ref_id=&block=&file=&token=
|
||||
// 前端对已解锁块内的 [image:]/[file:] 附件改用此接口加载(token 由详情/unlock 接口签发)。
|
||||
// 校验:文件名安全 → 文件存在 → 解锁 token 优先放行 → 无 token 按请求身份 + 块类型判定。
|
||||
// 失败统一 403(不返回 404,防探测块存在性)。
|
||||
router.get('/locked', (req, res) => {
|
||||
const { ref_type, ref_id, block, file, token } = req.query;
|
||||
// 1. 文件名安全
|
||||
const fn = String(file || '');
|
||||
if (!fn || fn.includes('..') || fn.includes('/')) return res.status(400).json({ error: '非法文件名' });
|
||||
// 2. 文件存在
|
||||
const filePath = path.join(UPLOAD_DIR, fn);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: '文件不存在' });
|
||||
|
||||
// 3. token 优先:解锁 token 有效且 payload 与 query 完全一致 → 直接放行
|
||||
const payload = locks.verifyLockToken(token);
|
||||
if (payload &&
|
||||
String(payload.refType) === String(ref_type) &&
|
||||
Number(payload.refId) === Number(ref_id) &&
|
||||
Number(payload.blockIndex) === parseInt(block)) {
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
|
||||
// 4. 无(匹配的)token 时按请求身份判定:从 Authorization Bearer 或 ?token=(会话 JWT)解析 userId
|
||||
let userId = null;
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) {
|
||||
try { const jwt = require('jsonwebtoken'); const { SECRET } = require('../middleware/auth'); const p = jwt.verify(auth.slice(7), SECRET); userId = p.id; } catch {}
|
||||
}
|
||||
if (!userId && req.query.token) {
|
||||
try { const jwt = require('jsonwebtoken'); const { SECRET } = require('../middleware/auth'); const p = jwt.verify(req.query.token, SECRET); userId = p.id; } catch {}
|
||||
}
|
||||
if (!userId) return res.status(403).json({ error: '无权限访问该文件' });
|
||||
|
||||
// 5. 加载内容并判定块类型
|
||||
const type = String(ref_type || '');
|
||||
let row = null;
|
||||
if (type === 'blog') {
|
||||
row = db.get('SELECT content, author_id, published FROM blog_posts WHERE id = ?', [ref_id]);
|
||||
} else if (type === 'forum') {
|
||||
row = db.get('SELECT content, author_id FROM forum_posts WHERE id = ?', [ref_id]);
|
||||
}
|
||||
if (!row) return res.status(403).json({ error: '无权限访问该文件' });
|
||||
const b = locks.parseLocks(row.content).blocks.find(x => x.index === parseInt(block));
|
||||
if (!b) return res.status(403).json({ error: '无权限访问该文件' });
|
||||
|
||||
const owner = db.get('SELECT role FROM users WHERE id = ?', [userId]);
|
||||
const isAdmin = !!(owner && owner.role === 'admin');
|
||||
const isAuthor = userId === row.author_id;
|
||||
// 博客草稿:仅 admin/作者可访问其锁定附件(与详情接口的草稿门禁一致)
|
||||
if (type === 'blog' && row.published !== 1 && !(isAdmin || isAuthor)) {
|
||||
return res.status(403).json({ error: '无权限访问该文件' });
|
||||
}
|
||||
if (isAdmin || isAuthor) return res.sendFile(filePath);
|
||||
|
||||
if (b.type === 'login') {
|
||||
if (userId) return res.sendFile(filePath);
|
||||
return res.status(403).json({ error: '无权限访问该文件' });
|
||||
}
|
||||
if (b.type === 'reply') {
|
||||
const replied = type === 'blog'
|
||||
? !!db.get('SELECT 1 x FROM blog_comments WHERE post_id = ? AND author_id = ?', [ref_id, userId])
|
||||
: !!db.get('SELECT 1 x FROM forum_replies WHERE post_id = ? AND author_id = ?', [ref_id, userId]);
|
||||
if (replied) return res.sendFile(filePath);
|
||||
return res.status(403).json({ error: '无权限访问该文件' });
|
||||
}
|
||||
if (b.type === 'password') {
|
||||
// password 块必须走 unlock 接口拿到 token,此处不接受无 token 请求
|
||||
return res.status(403).json({ error: '无权限访问该文件' });
|
||||
}
|
||||
return res.status(403).json({ error: '无权限访问该文件' });
|
||||
});
|
||||
|
||||
// Download with auth
|
||||
router.get('/download/:filename', (req, res) => {
|
||||
const fn = req.params.filename;
|
||||
@@ -152,7 +322,14 @@ router.get('/download/:filename', (req, res) => {
|
||||
if (!userId) return res.status(401).json({ error: '请先登录' });
|
||||
const fp = path.join(UPLOAD_DIR, fn);
|
||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: '文件不存在' });
|
||||
const att = db.get('SELECT original_name FROM attachments WHERE filename = ?', [fn]);
|
||||
const att = db.get('SELECT original_name, user_id FROM attachments WHERE filename = ?', [fn]);
|
||||
// M3:归属校验——仅文件所有者或管理员可下载(图片经 /uploads/ 静态伺服不受影响)
|
||||
if (att) {
|
||||
const owner = db.get('SELECT role FROM users WHERE id = ?', [userId]);
|
||||
if (att.user_id !== userId && (!owner || owner.role !== 'admin')) {
|
||||
return res.status(403).json({ error: '无权限访问该文件' });
|
||||
}
|
||||
}
|
||||
res.download(fp, att ? att.original_name : fn);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
const { SECRET } = require('../middleware/auth');
|
||||
const { resolveAvatar } = require('../lib/avatar');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 可选鉴权:有效 token 解析出 req.user(用于论坛私密模式门禁判定是否登录),匿名放行
|
||||
function optionalAuth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (header && header.startsWith('Bearer ')) {
|
||||
try { req.user = jwt.verify(header.slice(7), SECRET); } catch { /* 无效 token 按匿名 */ }
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 论坛私密模式门禁:forum_guest_visible='0' 且请求未登录 → 403
|
||||
//(与 routes/forum.js requireGuestVisible 同策略,公开主页内容流同样受私密开关约束)
|
||||
function requireForumVisible(req, res, next) {
|
||||
if (db.getSetting('forum_guest_visible') === '1' || req.user) return next();
|
||||
return res.status(403).json({ error: '论坛已设为私密' });
|
||||
}
|
||||
|
||||
// 分页参数解析:page≥1 整数、pageSize 1-50,非法用默认 1/10
|
||||
function parsePage(query) {
|
||||
const page = parseInt(query.page);
|
||||
const pageSize = parseInt(query.pageSize);
|
||||
return {
|
||||
page: Number.isInteger(page) && page >= 1 ? page : 1,
|
||||
pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 10,
|
||||
};
|
||||
}
|
||||
|
||||
// 列表内容净化:剥 [image:]/[file:] 标签与 [lock:] 块(防止锁块残留/锁定内容泄露),再截断
|
||||
function sanitizeContent(c, maxLen) {
|
||||
let s = String(c || '')
|
||||
.replace(/\[(image|file):[^\]]*\]/g, '')
|
||||
.replace(/\[lock(?::[^\]]*)?\][\s\S]*?\[\/lock\]/g, '')
|
||||
.trim();
|
||||
if (maxLen && s.length > maxLen) s = s.slice(0, maxLen) + '…';
|
||||
return s;
|
||||
}
|
||||
|
||||
// 公开个人主页:身份信息 + 统计(绝不返回 email/rainid_user_id/password)
|
||||
// GET /api/users/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const uid = parseInt(req.params.id);
|
||||
if (!Number.isInteger(uid) || uid < 1) return res.status(404).json({ error: '用户不存在' });
|
||||
const user = db.get(
|
||||
`SELECT id, username, role, avatar, bio, nickname, title, title_color, website, qq,
|
||||
CASE WHEN qq IS NOT NULL AND trim(qq) <> '' THEN ''
|
||||
WHEN email GLOB '[0-9]*@qq.com' THEN substr(email, 1, instr(email, '@') - 1)
|
||||
ELSE '' END AS qq_from_email,
|
||||
created_at, last_active_at
|
||||
FROM users WHERE id = ?`, [uid]);
|
||||
if (!user) return res.status(404).json({ error: '用户不存在' });
|
||||
// 头像按优先级解析(自传 > QQ > qq_from_email > email 前缀 > RainID),兼容无 avatar 用户
|
||||
user.avatar = resolveAvatar(user);
|
||||
// 展示名:昵称优先,空则 username;QQ 号脱敏(前3后4,如 273****3776),空则省略
|
||||
user.display_name = String(user.nickname || '').trim() || user.username || '';
|
||||
if (user.qq && /^\d{5,12}$/.test(user.qq)) {
|
||||
user.qq = user.qq.slice(0, 3) + '****' + user.qq.slice(-4);
|
||||
} else {
|
||||
delete user.qq; // 空则省略字段
|
||||
}
|
||||
delete user.qq_from_email; // 内部辅助列不外露
|
||||
// 统计:论坛帖子 / 论坛回复 / 已发布博文 / 帖子获得的赞
|
||||
// 注:post_likes 目前只记录博文点赞(blog.js /posts/:id/like),故按 blog_posts 聚合;
|
||||
// post_id 与 forum_posts.id 存在冲突,不能 JOIN forum_posts(会错配作者)。
|
||||
user.stats = {
|
||||
posts: (db.get('SELECT COUNT(*) c FROM forum_posts WHERE author_id = ?', [uid]) || {}).c || 0,
|
||||
replies: (db.get('SELECT COUNT(*) c FROM forum_replies WHERE author_id = ?', [uid]) || {}).c || 0,
|
||||
articles: (db.get('SELECT COUNT(*) c FROM blog_posts WHERE author_id = ? AND published = 1', [uid]) || {}).c || 0,
|
||||
likes: (db.get('SELECT COUNT(*) c FROM post_likes l JOIN blog_posts bp ON bp.id = l.post_id WHERE bp.author_id = ?', [uid]) || {}).c || 0,
|
||||
};
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
// 公开个人主页:论坛帖子流(标题/分类/回复数,不含正文,无锁泄露风险)
|
||||
// GET /api/users/:id/posts?page=1&pageSize=10
|
||||
router.get('/:id/posts', optionalAuth, requireForumVisible, (req, res) => {
|
||||
const uid = parseInt(req.params.id);
|
||||
if (!Number.isInteger(uid) || uid < 1) return res.status(404).json({ error: '用户不存在' });
|
||||
if (!db.get('SELECT id FROM users WHERE id = ?', [uid])) return res.status(404).json({ error: '用户不存在' });
|
||||
const { page, pageSize } = parsePage(req.query);
|
||||
const total = (db.get('SELECT COUNT(*) c FROM forum_posts WHERE author_id = ?', [uid]) || {}).c || 0;
|
||||
const list = db.all(
|
||||
`SELECT fp.id, fp.title, fp.category_id, fc.name as category_name, fp.created_at, fp.is_pinned,
|
||||
(SELECT COUNT(*) FROM forum_replies WHERE post_id = fp.id) as reply_count
|
||||
FROM forum_posts fp LEFT JOIN forum_categories fc ON fp.category_id = fc.id
|
||||
WHERE fp.author_id = ? ORDER BY fp.created_at DESC, fp.id DESC LIMIT ? OFFSET ?`,
|
||||
[uid, pageSize, (page - 1) * pageSize]);
|
||||
res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||
});
|
||||
|
||||
// 公开个人主页:论坛回复流(content 净化 + 截断 120 字,附帖子标题)
|
||||
// GET /api/users/:id/replies?page=1&pageSize=10
|
||||
router.get('/:id/replies', optionalAuth, requireForumVisible, (req, res) => {
|
||||
const uid = parseInt(req.params.id);
|
||||
if (!Number.isInteger(uid) || uid < 1) return res.status(404).json({ error: '用户不存在' });
|
||||
if (!db.get('SELECT id FROM users WHERE id = ?', [uid])) return res.status(404).json({ error: '用户不存在' });
|
||||
const { page, pageSize } = parsePage(req.query);
|
||||
const total = (db.get('SELECT COUNT(*) c FROM forum_replies WHERE author_id = ?', [uid]) || {}).c || 0;
|
||||
const list = db.all(
|
||||
`SELECT fr.id, fr.content, fr.post_id, fp.title as post_title, fp.category_id, fr.created_at
|
||||
FROM forum_replies fr LEFT JOIN forum_posts fp ON fr.post_id = fp.id
|
||||
WHERE fr.author_id = ? ORDER BY fr.created_at DESC, fr.id DESC LIMIT ? OFFSET ?`,
|
||||
[uid, pageSize, (page - 1) * pageSize]);
|
||||
// 先净化(剥附件/锁块)再截断,避免锁块内容残留泄露
|
||||
list.forEach(r => { r.content = sanitizeContent(r.content, 120); });
|
||||
res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||