Compare commits
91
Commits
5e4cdf26b9
...
v2.3.2
| 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 | ||
|
|
4c67bf269a | ||
|
|
ed40a040e3 | ||
|
|
4200826120 | ||
|
|
6ce6438854 | ||
|
|
0def382511 | ||
|
|
636d13407f | ||
|
|
f9abb877e7 | ||
|
|
c947e62364 | ||
|
|
ded6fdb753 | ||
|
|
9c34374b95 | ||
|
|
8a3e213e40 | ||
|
|
2de07710da | ||
|
|
e16130bf8f | ||
|
|
2b748fc48e | ||
|
|
717ebc44f4 | ||
|
|
f2740afe50 | ||
|
|
d9d2ad3f7d | ||
|
|
0ae8815f33 | ||
|
|
9400f76c7e | ||
|
|
03d5c97315 | ||
|
|
2cb9592d56 | ||
|
|
8a08932d78 | ||
|
|
b152a2a9c9 | ||
|
|
4af71185d5 | ||
|
|
6d60b10c14 | ||
|
|
cf4c7d4ca4 | ||
|
|
5acc461e88 | ||
|
|
391cd1bdae | ||
|
|
5b166ef0e2 | ||
|
|
4f89c63b28 | ||
|
|
7bfeffaaa0 | ||
|
|
aff5764b9b | ||
|
|
bb830f779c | ||
|
|
63d329db5f | ||
|
|
e83a4e6ee1 | ||
|
|
0153f770f6 | ||
|
|
789a2d9b33 | ||
|
|
5a878c1aab |
@@ -8,3 +8,4 @@ server.pid
|
||||
releases/
|
||||
public/dist/
|
||||
backups/
|
||||
workspace/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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'})`);
|
||||
if (!token.startsWith('--')) {
|
||||
positionals.push(token);
|
||||
continue;
|
||||
}
|
||||
} catch {}
|
||||
const match = token.match(/^--([^=]+)(?:=(.*))?$/);
|
||||
if (!match) throw new CliError(`无法解析选项:${token}`);
|
||||
const name = match[1];
|
||||
let value = match[2];
|
||||
if (booleanOptions.has(name)) {
|
||||
if (value !== undefined) throw new CliError(`选项 --${name} 不接受参数`);
|
||||
options[name] = true;
|
||||
continue;
|
||||
}
|
||||
if (!valueOptions.has(name)) throw new CliError(`未知选项:--${name}`);
|
||||
if (value === undefined) {
|
||||
value = argv[i + 1];
|
||||
if (!value || value.startsWith('--')) throw new CliError(`选项 --${name} 缺少参数`);
|
||||
i += 1;
|
||||
}
|
||||
options[name] = value;
|
||||
}
|
||||
return { positionals, ...options };
|
||||
}
|
||||
|
||||
function readJson(file, fallback = {}) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
||||
}
|
||||
|
||||
// === Port ===
|
||||
function getConfigPort() {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
return config.port || 3001;
|
||||
} catch { return 3001; }
|
||||
const config = readJson(CONFIG_PATH);
|
||||
const value = Number(process.env.PORT || config.port || 3001);
|
||||
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : 3001;
|
||||
}
|
||||
|
||||
function setConfigPort(port) {
|
||||
let config = {};
|
||||
try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {}
|
||||
const config = readJson(CONFIG_PATH);
|
||||
config.port = port;
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function cmdPort() {
|
||||
const arg = process.argv[3];
|
||||
if (arg) {
|
||||
const port = parseInt(arg);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
console.error('Invalid port number. Use 1-65535.');
|
||||
process.exit(1);
|
||||
}
|
||||
setConfigPort(port);
|
||||
console.log(`Port set to ${port}. Restart to apply.`);
|
||||
} else {
|
||||
console.log(`Current port: ${getConfigPort()}`);
|
||||
}
|
||||
function dbExists() { return fs.existsSync(DB_PATH); }
|
||||
|
||||
function openReadonlyDatabase(file = DB_PATH) {
|
||||
if (!fs.existsSync(file)) throw new CliError(`数据库不存在:${file}`);
|
||||
try { return new Database(file, { readonly: true, fileMustExist: true }); }
|
||||
catch (error) { throw new CliError(`无法打开数据库:${error.message}`); }
|
||||
}
|
||||
|
||||
// === Password ===
|
||||
async function cmdPassword() {
|
||||
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);
|
||||
}
|
||||
|
||||
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 inspectDatabase(file = DB_PATH) {
|
||||
const connection = openReadonlyDatabase(file);
|
||||
try {
|
||||
const quickCheck = connection.pragma('quick_check', { simple: true });
|
||||
const foreignKeyErrors = connection.prepare('PRAGMA foreign_key_check').all();
|
||||
const tables = connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all().map((row) => row.name);
|
||||
const requiredTables = ['users', 'site_settings', 'tickets', 'ticket_messages', 'ticket_events'];
|
||||
const missingTables = requiredTables.filter((name) => !tables.includes(name));
|
||||
return {
|
||||
file,
|
||||
size: fs.statSync(file).size,
|
||||
userVersion: connection.pragma('user_version', { simple: true }),
|
||||
quickCheck,
|
||||
foreignKeyErrors,
|
||||
missingTables,
|
||||
valid: quickCheck === 'ok' && foreignKeyErrors.length === 0 && missingTables.length === 0,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new CliError(`数据库完整性检查失败:${error.message}`);
|
||||
} finally { connection.close(); }
|
||||
}
|
||||
|
||||
// === Captcha ===
|
||||
async function cmdCaptcha() {
|
||||
await 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']);
|
||||
}
|
||||
|
||||
const failAns = await q('Enable captcha after failed attempts? (y/n): ');
|
||||
db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
|
||||
if (failAns.toLowerCase() === 'y') {
|
||||
const threshold = await q('Failed attempts threshold (default 5): ');
|
||||
if (threshold) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
console.log('\nCaptcha rules updated.');
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes)) return '未知';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
async function cmdConfig() {
|
||||
await 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 formatMtime(date) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
// === Start / Stop / Restart ===
|
||||
function findPidFile() { return path.join(__dirname, 'server.pid'); }
|
||||
function print(value, args) {
|
||||
if (args.json) console.log(JSON.stringify(value, null, 2));
|
||||
else console.log(value);
|
||||
}
|
||||
|
||||
function maskValue(key, value) {
|
||||
if (value === undefined || value === null || value === '') return '(空)';
|
||||
const sensitive = /(pass(word)?|secret|token|private|jwt|credential|api[_-]?key|client[_-]?secret|pin[_-]?(hash|salt)|terminal_pin)/i.test(key);
|
||||
if (!sensitive) return String(value);
|
||||
const text = String(value);
|
||||
return text.length <= 4 ? '****' : `****${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
function getPidFromFile() {
|
||||
if (!fs.existsSync(PID_PATH)) return null;
|
||||
const raw = fs.readFileSync(PID_PATH, 'utf8').trim();
|
||||
if (!/^\d+$/.test(raw)) return { invalid: true, raw };
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
function isRunning(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function cmdStop() {
|
||||
const pidFile = findPidFile();
|
||||
if (fs.existsSync(pidFile)) {
|
||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'));
|
||||
if (isRunning(pid)) {
|
||||
try { process.kill(pid); console.log('Server stopped (PID: ' + pid + ')'); } catch { console.log('Could not stop process.'); }
|
||||
} else { console.log('Server not running.'); }
|
||||
fs.unlinkSync(pidFile);
|
||||
} else {
|
||||
// Try to find node process
|
||||
console.log('No PID file found. Try: taskkill /F /IM node.exe (Windows) or pkill node (Linux/Mac)');
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdStart() {
|
||||
const port = getConfigPort();
|
||||
const proc = spawn('node', ['server.js'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
detached: true,
|
||||
});
|
||||
proc.unref();
|
||||
fs.writeFileSync(findPidFile(), String(proc.pid));
|
||||
console.log(`Server starting on port ${port} (PID: ${proc.pid})`);
|
||||
}
|
||||
|
||||
async function cmdRestart() {
|
||||
await cmdStop();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await cmdStart();
|
||||
}
|
||||
|
||||
// === 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 {}
|
||||
@@ -122,6 +129,20 @@ function initTables() {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
|
||||
|
||||
// 审计日志表(终端会话/密码失败等安全事件落库)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
user_id INTEGER DEFAULT 0,
|
||||
username TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)');
|
||||
// 审计保留策略:启动时惰性清理 90 天前的记录,防止无限增长
|
||||
//(写入路径上 terminal.js 每 50 条也会触发一次同规则清理)
|
||||
db.exec("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
|
||||
|
||||
// Indexes for performance
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
|
||||
@@ -154,6 +175,235 @@ function migrateSchema() {
|
||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
|
||||
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
|
||||
} },
|
||||
// v3: RainID 单点登录——影子账号绑定键(sub)。部分唯一索引:空串(本地账号)不参与唯一约束
|
||||
{ version: 3, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''"); } catch {}
|
||||
} },
|
||||
// v4: 论坛贴吧升级——置顶/加精/版主/图标
|
||||
{ version: 4, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_pinned INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_essence INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon_color TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_moderators (
|
||||
category_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (category_id, user_id),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_posts_cat ON forum_posts(category_id, is_pinned DESC, created_at DESC)"); } catch {}
|
||||
} },
|
||||
// v5: 论坛版块级禁言
|
||||
{ version: 5, up: () => {
|
||||
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_mutes (
|
||||
category_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
muted_until DATETIME, -- NULL = 永久
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
created_by INTEGER NOT NULL,
|
||||
PRIMARY KEY (category_id, user_id),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_mutes_cat ON forum_mutes(category_id)"); } catch {}
|
||||
} },
|
||||
// v6: RSS 版块级订阅——版块 feed 开关
|
||||
{ version: 6, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_categories ADD COLUMN feed_enabled INTEGER DEFAULT 1"); } catch {}
|
||||
} },
|
||||
// v7: 论坛帖子二次编辑计数
|
||||
{ version: 7, up: () => {
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN edit_count INTEGER DEFAULT 0"); } catch {}
|
||||
} },
|
||||
// v8: markdown [lock:] 锁定——博客/论坛帖子存锁定元数据 JSON([{type, hash}],不含块原文)
|
||||
{ version: 8, up: () => {
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
|
||||
try { db.exec("ALTER TABLE forum_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
|
||||
} },
|
||||
// v9: 用户 QQ 号字段(头像优先级:自传 > QQ 头像 > RainID 头像)
|
||||
{ version: 9, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN qq TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v10: 公开个人主页——用户个性签名
|
||||
{ version: 10, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v11: 公开个人主页——最后活跃时间(datetime 字符串,跨天更新)
|
||||
{ version: 11, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN last_active_at TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v12: 用户对外昵称(显示名:nickname||username)
|
||||
{ version: 12, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN nickname TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v13: 自定义头衔 + 头衔颜色(帖子/评论作者位展示)
|
||||
{ version: 13, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN title TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN title_color TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v14: 个人博客链接(公开主页展示)
|
||||
{ version: 14, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN website TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v15: 工作台 SPA 代理——面板链接加代理配置列
|
||||
// (proxy_headers=转发自定义头 JSON、proxy_skip_tls_verify、permissions=iframe 委托权限 JSON 数组、
|
||||
// scale=嵌入缩放、trusted=信任模型(1 保留 allow-same-origin)、slug=唯一标识)
|
||||
{ version: 15, up: () => {
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_headers TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_skip_tls_verify INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN permissions TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN scale REAL DEFAULT 1.0"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {}
|
||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {}
|
||||
} },
|
||||
// v16: 站内工单——用户反馈、公开回复、内部备注及业务事件
|
||||
{ version: 16, up: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_no TEXT NOT NULL UNIQUE,
|
||||
requester_id INTEGER,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
source TEXT NOT NULL DEFAULT 'site',
|
||||
source_url TEXT DEFAULT '',
|
||||
source_type TEXT DEFAULT '',
|
||||
source_id INTEGER DEFAULT 0,
|
||||
browser_info TEXT DEFAULT '',
|
||||
assignee_id INTEGER,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
last_reply_at DATETIME DEFAULT NULL,
|
||||
first_response_at DATETIME,
|
||||
resolved_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
author_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
actor_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
field_name TEXT DEFAULT '',
|
||||
old_value TEXT DEFAULT '',
|
||||
new_value TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); } catch {}
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } catch {}
|
||||
} },
|
||||
// v17: 工单并发版本与回复时间修正;为已存在的部分工单表补齐字段
|
||||
{ version: 17, up: () => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_no TEXT NOT NULL UNIQUE,
|
||||
requester_id INTEGER,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
source TEXT NOT NULL DEFAULT 'site',
|
||||
source_url TEXT DEFAULT '',
|
||||
source_type TEXT DEFAULT '',
|
||||
source_id INTEGER DEFAULT 0,
|
||||
browser_info TEXT DEFAULT '',
|
||||
assignee_id INTEGER,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
last_reply_at DATETIME DEFAULT NULL,
|
||||
first_response_at DATETIME,
|
||||
resolved_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
author_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
actor_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
field_name TEXT DEFAULT '',
|
||||
old_value TEXT DEFAULT '',
|
||||
new_value TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
const ensureColumns = (table, columns) => {
|
||||
const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
||||
for (const [name, definition] of columns) {
|
||||
if (!existing.has(name)) {
|
||||
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
ensureColumns('tickets', [
|
||||
['ticket_no', "TEXT NOT NULL DEFAULT ''"], ['requester_id', 'INTEGER'],
|
||||
['subject', "TEXT NOT NULL DEFAULT ''"], ['description', "TEXT NOT NULL DEFAULT ''"],
|
||||
['category', "TEXT NOT NULL DEFAULT 'other'"], ['priority', "TEXT NOT NULL DEFAULT 'normal'"],
|
||||
['status', "TEXT NOT NULL DEFAULT 'open'"], ['source', "TEXT NOT NULL DEFAULT 'site'"],
|
||||
['source_url', "TEXT DEFAULT ''"], ['source_type', "TEXT DEFAULT ''"], ['source_id', 'INTEGER DEFAULT 0'],
|
||||
['browser_info', "TEXT DEFAULT ''"], ['assignee_id', 'INTEGER'],
|
||||
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||
['last_reply_at', 'DATETIME DEFAULT NULL'], ['first_response_at', 'DATETIME DEFAULT NULL'],
|
||||
['resolved_at', 'DATETIME DEFAULT NULL'], ['closed_at', 'DATETIME DEFAULT NULL'],
|
||||
['revision', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
]);
|
||||
ensureColumns('ticket_messages', [
|
||||
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['author_id', 'INTEGER'],
|
||||
['content', "TEXT NOT NULL DEFAULT ''"], ['is_internal', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||
]);
|
||||
ensureColumns('ticket_events', [
|
||||
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['actor_id', 'INTEGER'],
|
||||
['event_type', "TEXT NOT NULL DEFAULT ''"], ['field_name', "TEXT DEFAULT ''"],
|
||||
['old_value', "TEXT DEFAULT ''"], ['new_value', "TEXT DEFAULT ''"],
|
||||
['detail', "TEXT DEFAULT ''"], ['created_at', 'DATETIME DEFAULT NULL'],
|
||||
]);
|
||||
db.exec(`UPDATE tickets SET last_reply_at = NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_messages.ticket_id = tickets.id)`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)');
|
||||
} },
|
||||
];
|
||||
for (const m of migrations) {
|
||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||
@@ -186,6 +436,7 @@ function seedDefaults() {
|
||||
smtp_from_name: 'RainWeb',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: '1',
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
@@ -194,6 +445,10 @@ function seedDefaults() {
|
||||
captcha_register: '0',
|
||||
captcha_forum: '0',
|
||||
captcha_type: 'builtin',
|
||||
rainid_enabled: '0',
|
||||
rainid_client_id: '',
|
||||
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: '0',
|
||||
site_favicon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>',
|
||||
music_embed_enabled: '0',
|
||||
music_embed_code: '',
|
||||
@@ -204,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])) {
|
||||
@@ -254,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;
|
||||
@@ -288,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/
|
||||
@@ -0,0 +1,83 @@
|
||||
# 工作台模块项目计划(tools/workbench)
|
||||
|
||||
> 独立完整项目流程:调研 ✓ → 设计规范 ✓ → 本计划 → 实现 → 审查 → 验证。
|
||||
> 布局调研:lib-1(VSCode 四层 / Dashy Workspace / Vivaldi Web Panels / Arc pinned / Homepage groups + iframe 技术建议)。
|
||||
> 设计规范:frontend-pack/design skill(8px 网格、语义色 token、层次/留白/焦点环/深浅色/加载空错误态)。
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
「面板工作台」:浏览器式多面板聚合界面(左侧侧边栏 + 顶部工具栏 + 右侧 iframe 内容区),叠加密码库快捷取用。仅管理员(adminOnly)可进入。
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
| 项 | 决定 |
|
||||
|---|---|
|
||||
| 风格 | **MUI**(与后台一致,MD3 主题复用 frontend/src/admin/theme.js) |
|
||||
| 路由 | 独立页面 `/workbench`(React Router,挂管理后台路由树外或独立 BrowserRouter 区——用后台同一入口 admin 应用内加路由最简,basename /admin 下 `/admin/workbench`?——**决定:独立入口**(admin.html 已有),路由挂 `/admin/workbench`,与 Dashboard 等并列,顶栏入口按钮跳转;整页全屏布局不带 AdminLayout 侧栏(工作台自带侧边栏) |
|
||||
| 代码位置 | `frontend/src/tools/workbench/`(独立工具区,页面+组件+密码库组件+工具函数全在这,方便后续升级) |
|
||||
| 鉴权 | 入口守卫:无 token → 跳 /login.html;非 admin → 403 提示返回(复用后台守卫模式) |
|
||||
| 数据 | admin_links API(复用 frontend/src/api/adminLinks.js)+ passwords API(复用) |
|
||||
| proxy | `/api/proxy/fetch?url=&token=`(iframe query token,复用 Embed.jsx 的 needsProxy 逻辑) |
|
||||
|
||||
## 3. 文件结构
|
||||
|
||||
```
|
||||
frontend/src/tools/workbench/
|
||||
├── Workbench.jsx # 页面入口(路由挂载点 + 鉴权守卫 + 布局组装)
|
||||
├── components/
|
||||
│ ├── Toolbar.jsx # 顶部工具栏(◀▶↻ 地址栏 搜索 添加 密码库 侧栏开关 返回)
|
||||
│ ├── Sidebar.jsx # 左侧边栏(固定区 + 分组列表 + 折叠 + 拖拽调宽可选)
|
||||
│ ├── PanelFrame.jsx # iframe 内容区(加载态/超时/失败回退/LRU 保留)
|
||||
│ ├── AddPanelDialog.jsx # 添加面板弹窗(URL/命名/分组/图标 + 打开方式)
|
||||
│ └── VaultDrawer.jsx # 密码库抽屉(PIN 解锁/搜索/复制,MUI)
|
||||
├── hooks/
|
||||
│ ├── usePanels.js # 面板数据/打开集合/历史栈/持久化(localStorage)
|
||||
│ └── useVault.js # 密码库解锁状态/条目加载(复用 passwords API)
|
||||
└── index.js # 模块导出(便于未来升级替换)
|
||||
```
|
||||
|
||||
## 4. 功能清单
|
||||
|
||||
### 核心(必须)
|
||||
- [ ] 鉴权守卫(adminOnly)
|
||||
- [ ] 工具栏:◀ 后退 / ▶ 前进(当前面板历史栈)、↻ 刷新、只读地址栏、🔍 搜索(面板名/URL/分组)、+添加、🔑 密码库、侧边栏折叠、返回前台
|
||||
- [ ] 侧边栏:admin_links 按 category 分组 + 固定区(pin),点击切换,当前项左侧 2px primary 指示条 + surface-container 底(MUI 化:ListItemButton selected 态)
|
||||
- [ ] iframe 区:懒加载(首次激活创建)、display:none 切换保留状态、**超时+load 双保险加载态**、失败(超时/空白启发式)显示「在新标签打开」回退按钮
|
||||
- [ ] 打开方式:内嵌(默认)/ 新标签(target=_blank)/ 弹窗(modal)——面板项右键或添加时选择,存面板设置
|
||||
- [ ] 添加面板弹窗:URL + 标题 + 分组 + 图标(favicon 预览)+ 打开方式;保存到 admin_links(复用现有 API)
|
||||
- [ ] localStorage 持久化:打开面板集合、当前面板、历史栈、侧边栏折叠态
|
||||
- [ ] 密码库抽屉:PIN 解锁(复用 /api/passwords/unlock,服务端会话互通)→ 条目列表(标题/用户名)+ 搜索 + 详情(密码明文)+ 一键复制(clipboard);未设 PIN 引导去 /passwords.html;只读取用不编辑
|
||||
- [ ] 空态/加载态/错误态:无面板引导添加、加载骨架、加载失败重试
|
||||
|
||||
### 进阶(尽力)
|
||||
- [ ] LRU iframe 保留(最多 3 个活跃,超出销毁最久未用,记录 URL 重建)
|
||||
- [ ] 每面板定时刷新(可选 30s/5min/30min)
|
||||
- [ ] 侧边栏拖拽调宽 + 记忆
|
||||
- [ ] 分组折叠状态持久化
|
||||
|
||||
## 5. 设计规范(design skill + 调研要点)
|
||||
|
||||
- 8px 网格间距;一屏内不超过 2 种圆角(MUI 默认 8px/20px pill)
|
||||
- 语义色 token(MD3):工具栏/侧边栏 surface-container、选中 primary-container、hover surface-variant
|
||||
- 选中态:左侧 2px primary 指示条(VSCode 模式)或 MUI selected 态,二选一统一
|
||||
- 深色模式默认适配(后台已有 data-theme 机制)
|
||||
- 焦点环(focus-visible)、语义 HTML(nav/main/aside/button)、触控目标 ≥40px
|
||||
- 加载态用骨架/居中 spinner;空态带图标+CTA;错误态带重试
|
||||
- iframe title 属性必须;allow 权限策略按面板
|
||||
- 工具栏高度 48-56px、侧边栏默认 240px(可 180-360 拖拽)
|
||||
|
||||
## 6. 实施与质量流程
|
||||
|
||||
1. 派 designer 按本计划实现(一个会话,MUI + 复用后台主题/api)
|
||||
2. ora 审查(鉴权/安全/iframe 处理/状态管理/无障碍)→ 修复
|
||||
3. 验证:npm run build + 后台入口跳转 + admin 权限拦截 + 面板切换/proxy/密码库全链路冒烟
|
||||
4. commit + 用户验收
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] 非 admin 无法进入(无 token 跳登录、非 admin 403)
|
||||
- [ ] 面板分组/固定/切换/关闭/添加/持久化全部可用
|
||||
- [ ] proxy 面板正常嵌入;X-Frame-Options 拒绝的面板有回退提示
|
||||
- [ ] 密码库解锁→搜索→复制链路通(与服务端会话互通)
|
||||
- [ ] 深浅色、320/768/1440 三档响应式正常
|
||||
- [ ] build 通过、无 console 错误
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar"></div>
|
||||
<div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-3
@@ -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,43 +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();
|
||||
@@ -57,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();
|
||||
@@ -81,20 +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>
|
||||
<Button color="inherit" href="/" title="返回前台">
|
||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
|
||||
<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" onClick={handleLogout}>
|
||||
<LogoutIcon 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} 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 } }}>
|
||||
@@ -106,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 />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
@@ -20,11 +20,16 @@ import EmailSettings from './pages/EmailSettings.jsx';
|
||||
import BlogManage from './pages/BlogManage.jsx';
|
||||
import CommentManage from './pages/CommentManage.jsx';
|
||||
import ForumManage from './pages/ForumManage.jsx';
|
||||
import PostManage from './pages/PostManage.jsx';
|
||||
import RssManage from './pages/RssManage.jsx';
|
||||
import Users from './pages/Users.jsx';
|
||||
import Announcements from './pages/Announcements.jsx';
|
||||
import Links from './pages/Links.jsx';
|
||||
import Uploads from './pages/Uploads.jsx';
|
||||
import ImportDb from './pages/ImportDb.jsx';
|
||||
import TicketManage from './pages/TicketManage.jsx';
|
||||
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
||||
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
||||
|
||||
// 生产:/admin/* 由后端 fallback 到 dist/admin.html(Phase 6 需求),basename=/admin;
|
||||
// 开发:vite 多入口直接访问 /admin.html,basename 自适应为空。
|
||||
@@ -76,6 +81,21 @@ function AdminApp() {
|
||||
<CssBaseline />
|
||||
<BrowserRouter basename={basename}>
|
||||
<Routes>
|
||||
{/* 工作台:独立全屏布局,不套 AdminLayout(自带 Toolbar+Sidebar+内容区);懒加载分包 */}
|
||||
<Route
|
||||
path="/workbench"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
@@ -87,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>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,17 @@ import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadVerifyFile, listVerifyFiles, deleteVerifyFile } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import { parseFooterColumns } from '../../lib/outlink.js';
|
||||
|
||||
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
|
||||
const FOOTER_STYLES = [
|
||||
@@ -20,7 +29,7 @@ const FOOTER_STYLES = [
|
||||
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||
];
|
||||
|
||||
/** 基本设置 + 页脚设置(v2:页脚样式 / 版权 / Powered by 均支持自定义) */
|
||||
/** 基本设置 + 页脚设置 + 搜索引擎验证 + RainID 单点登录(v2) */
|
||||
export default function Settings() {
|
||||
const [form, setForm] = useState({
|
||||
site_name: '',
|
||||
@@ -31,14 +40,64 @@ export default function Settings() {
|
||||
footer_copyright: '',
|
||||
footer_powered: '',
|
||||
footer_desc: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
rainid_enabled: '0',
|
||||
rainid_client_id: '',
|
||||
rainid_client_secret: '',
|
||||
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: '0',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
// 页脚栏目(footer_columns JSON):独立 state 维护,保存时序列化进 form
|
||||
const [columns, setColumns] = useState([]);
|
||||
|
||||
// 搜索引擎验证文件:列表 / 待上传文件 / 上传状态 / 上传成功结果
|
||||
const [verifyFiles, setVerifyFiles] = useState([]);
|
||||
const [verifyFile, setVerifyFile] = useState(null);
|
||||
const [verifyUploading, setVerifyUploading] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState(null);
|
||||
|
||||
const loadVerifyFiles = () => {
|
||||
listVerifyFiles().then(setVerifyFiles).catch((e) => showSnack(e.message, 'error'));
|
||||
};
|
||||
|
||||
const handleVerifyFileChange = (e) => {
|
||||
setVerifyFile((e.target.files && e.target.files[0]) || null);
|
||||
setVerifyResult(null);
|
||||
e.target.value = ''; // 清空 input 允许重复选择同一文件
|
||||
};
|
||||
|
||||
const handleVerifyUpload = async () => {
|
||||
if (!verifyFile) { showSnack('请先选择验证文件', 'error'); return; }
|
||||
setVerifyUploading(true);
|
||||
try {
|
||||
const r = await uploadVerifyFile(verifyFile);
|
||||
setVerifyResult(r);
|
||||
setVerifyFile(null);
|
||||
loadVerifyFiles();
|
||||
showSnack('上传成功');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setVerifyUploading(false);
|
||||
};
|
||||
|
||||
const handleVerifyDelete = async (name) => {
|
||||
if (!window.confirm(`确认删除验证文件 ${name}?`)) return;
|
||||
try {
|
||||
await deleteVerifyFile(name);
|
||||
loadVerifyFiles();
|
||||
showSnack('已删除');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
.then((s) => {
|
||||
setForm({
|
||||
site_name: s.site_name || '',
|
||||
site_description: s.site_description || '',
|
||||
site_url: s.site_url || '',
|
||||
@@ -47,10 +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',
|
||||
}))
|
||||
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 }));
|
||||
@@ -59,7 +125,10 @@ export default function Settings() {
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings(form);
|
||||
const body = { ...form, footer_columns: JSON.stringify(columns) };
|
||||
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
|
||||
if (!body.rainid_client_secret) delete body.rainid_client_secret;
|
||||
await saveSettings(body);
|
||||
showSnack('设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
@@ -67,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 }}>
|
||||
@@ -131,8 +190,50 @@ export default function Settings() {
|
||||
<TextField fullWidth label="网站简介(页脚品牌区)" value={form.footer_desc} onChange={set('footer_desc')} margin="normal" placeholder="留空则自动使用网站描述" multiline minRows={2} />
|
||||
<TextField fullWidth label="版权文本" value={form.footer_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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -142,6 +243,113 @@ export default function Settings() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-search-verify" sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
|
||||
Bing / Google / Yandex 站长工具要求把验证文件上传到网站根目录,上传后即可经根路径访问
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<Button component="label" variant="outlined" startIcon={<FileUploadIcon />} disabled={verifyUploading}>
|
||||
选择文件
|
||||
<input type="file" hidden accept=".xml,.html,.txt" onChange={handleVerifyFileChange} />
|
||||
</Button>
|
||||
{verifyFile && (
|
||||
<Typography variant="body2" sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{verifyFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
<Button variant="contained" size="small" onClick={handleVerifyUpload} disabled={!verifyFile || verifyUploading}>
|
||||
{verifyUploading ? '上传中…' : '上传'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{verifyResult && (
|
||||
<Box sx={{ mb: 1, fontSize: 13, color: 'success.main' }}>
|
||||
上传成功,可访问:
|
||||
<a href={verifyResult.url} target="_blank" rel="noopener noreferrer" style={{ marginLeft: 4, color: 'inherit', textDecoration: 'underline' }}>
|
||||
{window.location.origin}{verifyResult.url}
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{verifyFiles.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>已上传的验证文件</Typography>
|
||||
{verifyFiles.map((f) => (
|
||||
<Box key={f.name} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
<a
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{f.name}
|
||||
</a>
|
||||
<IconButton size="small" color="error" onClick={() => handleVerifyDelete(f.name)} aria-label={`删除 ${f.name}`}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper id="sec-rainid" sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
|
||||
通过 RainID 统一身份认证,支持 ROPC 密码登录与授权码 SSO
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.rainid_enabled === '1'} onChange={toggle('rainid_enabled')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>启用 RainID 登录</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>登录页显示「使用 RainID 登录」,密码登录自动转发 ROPC 校验</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.rainid_register_redirect === '1'} onChange={toggle('rainid_register_redirect')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>注册跳转 RainID</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后注册页直接跳转 RainID 注册,不再显示本地注册表单</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField fullWidth label="Client ID" value={form.rainid_client_id} onChange={set('rainid_client_id')} margin="normal" placeholder="RainID 应用 Client ID(非机密)" />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Client Secret(机密)"
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={form.rainid_client_secret}
|
||||
onChange={set('rainid_client_secret')}
|
||||
margin="normal"
|
||||
placeholder="••••••••••••"
|
||||
autoComplete="new-password"
|
||||
helperText="保存在服务端且不回读;留空提交则保持原值不变"
|
||||
slotProps={{
|
||||
htmlInput: { 'aria-label': 'RainID Client Secret' },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label={showSecret ? '隐藏 Secret' : '显示 Secret'}
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
>
|
||||
{showSecret ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField fullWidth label="Discovery URL" type="url" value={form.rainid_discovery_url} onChange={set('rainid_discovery_url')} margin="normal" placeholder="https://rainid.rainnya.asia/oauth" helperText="RainID OIDC 配置地址,默认即可" />
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
||||
</Box>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function ThemeSettings() {
|
||||
primary_color: '#6750a4',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: true,
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
@@ -34,6 +35,7 @@ export default function ThemeSettings() {
|
||||
primary_color: s.primary_color || '#6750a4',
|
||||
theme_wallpaper: s.theme_wallpaper || '',
|
||||
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
|
||||
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
|
||||
nav_style: s.nav_style || 'default',
|
||||
card_style: s.card_style || 'default',
|
||||
glass_blur: s.glass_blur || '20',
|
||||
@@ -65,6 +67,7 @@ export default function ThemeSettings() {
|
||||
primary_color: form.primary_color,
|
||||
theme_wallpaper: form.theme_wallpaper.trim(),
|
||||
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
||||
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
|
||||
theme_force_dark: form.theme_force_dark ? '1' : '0',
|
||||
nav_style: form.nav_style,
|
||||
card_style: form.card_style,
|
||||
@@ -82,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' }} />
|
||||
@@ -90,8 +93,13 @@ 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 }))} />}
|
||||
label="启用壁纸背景"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
|
||||
@@ -111,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 }}>
|
||||
@@ -126,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 }} />
|
||||
@@ -134,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 {
|
||||
|
||||
@@ -20,6 +20,17 @@ export function me() {
|
||||
return request('/auth/me');
|
||||
}
|
||||
|
||||
// ── RainID 单点登录(OIDC) ──────────────────────────────
|
||||
/** 发起授权码登录:整页跳转到后端 /api/auth/oidc/login(后端 302 至 RainID) */
|
||||
export function oidcLoginRedirect() {
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
}
|
||||
|
||||
/** RainID 回跳:用一次性过渡码(30s)换取会话 token → {token, username, role} */
|
||||
export function oidcExchange(ticket) {
|
||||
return request('/auth/oidc/exchange', { method: 'POST', body: { ticket } });
|
||||
}
|
||||
|
||||
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
|
||||
export function logout() {
|
||||
setToken(null);
|
||||
@@ -42,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,23 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 工作台「记事本」(routes/notes.js,仅管理员) ────────────
|
||||
/** 文件列表:[{ name, size, mtime }],按修改时间倒序 */
|
||||
export function listNotes() {
|
||||
return request('/notes');
|
||||
}
|
||||
/** 读取内容:{ name, content, size, mtime } */
|
||||
export function getNote(filename) {
|
||||
return request('/notes/' + encodeURIComponent(filename));
|
||||
}
|
||||
/** 创建:{ filename, content },同名返回 409 */
|
||||
export function createNote(filename, content) {
|
||||
return request('/notes', { method: 'POST', body: { filename, content } });
|
||||
}
|
||||
/** 更新内容:{ content } */
|
||||
export function updateNote(filename, content) {
|
||||
return request('/notes/' + encodeURIComponent(filename), { method: 'PUT', body: { content } });
|
||||
}
|
||||
/** 删除 */
|
||||
export function deleteNote(filename) {
|
||||
return request('/notes/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── Web 终端(安全敏感:root shell,PIN + 短 TTL token 双认证) ──
|
||||
// 后端契约见 routes/terminal.js:
|
||||
// GET /api/terminal/status → { hasPin, sessions, maxSessions, idleTimeoutMs }
|
||||
// POST /api/terminal/auth → { token, expiresIn, setup?, needs_setup?, message? }
|
||||
|
||||
/** 检查终端专用密码是否已设置 + 当前会话数 */
|
||||
export function terminalStatus() {
|
||||
return request('/terminal/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置(首次)/校验终端密码,成功发放 5 分钟短 TTL 终端 token。
|
||||
* 失败抛错:400 密码不合法 / 401 密码错误 / 429 尝试次数过多
|
||||
*/
|
||||
export function terminalAuth(pin) {
|
||||
return request('/terminal/auth', { method: 'POST', body: { pin } });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
export function normalizeTicketListResponse(data, fallbackPageSize = 20) {
|
||||
const payload = Array.isArray(data) ? { tickets: data } : (data && typeof data === 'object' ? data : {});
|
||||
const tickets = Array.isArray(payload.tickets)
|
||||
? payload.tickets
|
||||
: (Array.isArray(payload.list) ? payload.list : (Array.isArray(payload.items) ? payload.items : []));
|
||||
const pageSizeValue = Number(payload.pageSize ?? payload.page_size);
|
||||
const pageSize = Number.isInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : fallbackPageSize;
|
||||
const totalValue = Number(payload.total ?? payload.count);
|
||||
const total = Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : tickets.length;
|
||||
const totalPagesValue = Number(payload.totalPages ?? payload.total_pages ?? payload.pages);
|
||||
const totalPages = Number.isInteger(totalPagesValue) && totalPagesValue > 0
|
||||
? totalPagesValue
|
||||
: Math.max(1, Math.ceil(total / pageSize));
|
||||
return { tickets, total, totalPages };
|
||||
}
|
||||
|
||||
export function listTickets(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.pageSize) query.set('pageSize', String(params.pageSize));
|
||||
if (params.status) query.set('status', params.status);
|
||||
const qs = query.toString();
|
||||
return request('/tickets' + (qs ? '?' + qs : ''));
|
||||
}
|
||||
|
||||
export function createTicket(data) {
|
||||
return request('/tickets', { method: 'POST', body: data });
|
||||
}
|
||||
|
||||
export function getTicket(id) {
|
||||
return request('/tickets/' + encodeURIComponent(id));
|
||||
}
|
||||
|
||||
export function addMessage(id, content) {
|
||||
return request('/tickets/' + encodeURIComponent(id) + '/messages', {
|
||||
method: 'POST',
|
||||
body: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTicket(id) {
|
||||
return request('/tickets/' + encodeURIComponent(id) + '/close', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function reopenTicket(id) {
|
||||
return request('/tickets/' + encodeURIComponent(id) + '/reopen', { method: 'POST' });
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as captchaApi from '../api/captcha.js';
|
||||
import { useDialog } from '../lib/utils.js';
|
||||
|
||||
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
|
||||
let pending = null; // { action, type, resolve }
|
||||
@@ -30,10 +31,23 @@ export function showCaptcha(action) {
|
||||
});
|
||||
}
|
||||
|
||||
// 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用)
|
||||
function flushCallbacks(type) {
|
||||
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||
const q = window[key] || [];
|
||||
window[key] = [];
|
||||
q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } });
|
||||
}
|
||||
|
||||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||||
// 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪,
|
||||
// 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。
|
||||
function ensureThirdPartyScript(type) {
|
||||
if (type === 'recaptcha') {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
if (!window.onRecaptchaLoad) {
|
||||
window.onRecaptchaLoad = () => flushCallbacks('recaptcha');
|
||||
}
|
||||
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
||||
@@ -42,6 +56,9 @@ function ensureThirdPartyScript(type) {
|
||||
}
|
||||
} else {
|
||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||
if (!window.onTurnstileLoad) {
|
||||
window.onTurnstileLoad = () => flushCallbacks('turnstile');
|
||||
}
|
||||
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
||||
@@ -51,6 +68,66 @@ function ensureThirdPartyScript(type) {
|
||||
}
|
||||
}
|
||||
|
||||
// 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码
|
||||
const THIRD_PARTY_LOAD_TIMEOUT = 8000;
|
||||
|
||||
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
|
||||
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
|
||||
// 脚本注入后启动 8s 加载超时:届时库仍未就绪 → 调用 onTimeout 触发降级;
|
||||
// 库已就绪但回调错过 → 冲刷队列兜底(wrappedRender 执行并自行清理计时器)。
|
||||
// 返回清理函数:移除排队回调并清除 300ms 竞态与 8s 超时两个计时器,
|
||||
// 供弹窗关闭/重开时防泄漏、防残留降级状态。
|
||||
function queueRender(type, render, onTimeout) {
|
||||
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
|
||||
if (typeof window[lib] !== 'undefined') { render(); return null; }
|
||||
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||
window[key] = window[key] || [];
|
||||
|
||||
let done = false; // 已渲染 / 已超时降级 / 已清理:任一后不再执行 render
|
||||
let raceTimer = null;
|
||||
let timeoutTimer = null;
|
||||
|
||||
const wrappedRender = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(raceTimer);
|
||||
clearTimeout(timeoutTimer);
|
||||
render();
|
||||
};
|
||||
|
||||
window[key].push(wrappedRender);
|
||||
ensureThirdPartyScript(type);
|
||||
|
||||
// 300ms 竞态兜底:脚本恰好在「检查就绪」与「入队」之间完成加载、onload 已错过时重放队列
|
||||
raceTimer = setTimeout(() => {
|
||||
if (typeof window[lib] !== 'undefined') flushCallbacks(type);
|
||||
}, 300);
|
||||
|
||||
// 8s 加载超时:库仍未就绪 → 降级;就绪但回调错过 → 冲刷队列兜底
|
||||
timeoutTimer = setTimeout(() => {
|
||||
if (done) return;
|
||||
if (typeof window[lib] !== 'undefined') {
|
||||
flushCallbacks(type);
|
||||
} else {
|
||||
done = true;
|
||||
clearTimeout(raceTimer);
|
||||
if (onTimeout) onTimeout();
|
||||
}
|
||||
}, THIRD_PARTY_LOAD_TIMEOUT);
|
||||
|
||||
return () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(raceTimer);
|
||||
clearTimeout(timeoutTimer);
|
||||
const q = window[key];
|
||||
if (q) {
|
||||
const i = q.indexOf(wrappedRender);
|
||||
if (i >= 0) q.splice(i, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// SHA-256(Proof of Work 使用)
|
||||
async function sha256(str) {
|
||||
const buf = new TextEncoder().encode(str);
|
||||
@@ -58,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('');
|
||||
@@ -128,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 }} />
|
||||
@@ -140,15 +234,16 @@ function BuiltinCaptcha({ onFinish }) {
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
placeholder="输入验证码"
|
||||
aria-label="输入验证码"
|
||||
maxLength={6}
|
||||
autoComplete="off"
|
||||
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-icon" title="刷新" onClick={loadImage} style={{ flexShrink: 0 }}>
|
||||
<button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
|
||||
<span className="material-icons">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
||||
{error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
|
||||
{powDone ? (
|
||||
<>
|
||||
@@ -170,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);
|
||||
|
||||
@@ -183,6 +280,7 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
if (!siteKey) { onFinish(null); return; }
|
||||
const container = widgetRef.current;
|
||||
const render = () => {
|
||||
if (doneRef.current) return; // 弹窗已取消/卸载或已降级,不再向已移除的容器渲染
|
||||
try {
|
||||
if (type === 'recaptcha') {
|
||||
const wid = window.grecaptcha.render(container, {
|
||||
@@ -213,25 +311,31 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
setTimeout(() => onFinish(null), 2000);
|
||||
}
|
||||
};
|
||||
if (type === 'recaptcha') {
|
||||
if (typeof window.grecaptcha !== 'undefined') render();
|
||||
else {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
window.recaptchaCallbacks.push(render);
|
||||
ensureThirdPartyScript(type);
|
||||
}
|
||||
} else {
|
||||
if (typeof window.turnstile !== 'undefined') render();
|
||||
else {
|
||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||
window.turnstileCallbacks.push(render);
|
||||
ensureThirdPartyScript(type);
|
||||
}
|
||||
}
|
||||
// 卸载时不触发重复回调
|
||||
return () => { doneRef.current = true; };
|
||||
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
|
||||
// 8s 加载超时 → 自动降级到内置验证码,避免长时间卡「正在加载」阻塞登录
|
||||
const cleanupQueue = queueRender(type, render, () => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
setDegraded(true);
|
||||
});
|
||||
// 卸载/重开时:防止重复回调,清除超时计时器并移除排队回调(防泄漏/防残留降级状态)
|
||||
return () => {
|
||||
doneRef.current = true;
|
||||
if (cleanupQueue) cleanupQueue();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 降级:第三方脚本长时间加载不出来,复用内置 SVG 验证码完整流程
|
||||
// (校验仍走 loadImage/verify,调用方拿到 { type: 'proof', value })
|
||||
if (degraded) {
|
||||
return (
|
||||
<BuiltinCaptcha
|
||||
onFinish={onFinish}
|
||||
notice="第三方验证码加载失败,已切换为内置验证码"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
||||
@@ -264,13 +368,21 @@ export default function CaptchaModalHost() {
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
|
||||
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="验证码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{request.type === 'recaptcha' ? (
|
||||
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
||||
|
||||
@@ -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')
|
||||
@@ -76,7 +119,7 @@ export default function Layout() {
|
||||
const wallpaper = settings.theme_wallpaper || '';
|
||||
const scale = settings.theme_wallpaper_scale || 'cover';
|
||||
|
||||
if (wallpaper) {
|
||||
if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
|
||||
body.classList.add('has-wallpaper');
|
||||
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
|
||||
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
|
||||
@@ -118,6 +161,11 @@ export default function Layout() {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
notifyAuthChange();
|
||||
if (settings.rainid_enabled === '1') {
|
||||
// RainID 单点登录:清本地后联动登出 RainID 端会话(整页跳转,不显示本地提示)
|
||||
window.location.href = '/api/auth/oidc/logout';
|
||||
return;
|
||||
}
|
||||
showSnackbar('已退出登录');
|
||||
};
|
||||
|
||||
@@ -133,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 },
|
||||
];
|
||||
@@ -144,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">
|
||||
@@ -167,19 +227,21 @@ export default function Layout() {
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleToggleTheme}
|
||||
aria-label="切换主题"
|
||||
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
|
||||
>
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
</button>
|
||||
{user ? (
|
||||
<>
|
||||
<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>
|
||||
{/* 退出登录统一在个人中心 / 移动端抽屉中操作,顶栏不再放退出按钮 */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -190,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,12 +152,121 @@ 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)]));
|
||||
return DOMPurify.sanitize(html);
|
||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)], lockedUrl(images[parseInt(i)])));
|
||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)], lockedUrl(files[parseInt(i)])));
|
||||
html = DOMPurify.sanitize(html);
|
||||
|
||||
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
||||
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
|
||||
let h2Index = 0;
|
||||
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
|
||||
return html;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ export default function MusicEmbed({ settings }) {
|
||||
>
|
||||
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
|
||||
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
|
||||
<div className="music-embed-icon" onClick={expand} title="展开播放器">
|
||||
<button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
|
||||
<span className="material-icons">music_note</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 全站作者名渲染(前台):
|
||||
* - name 显示名(后端 author_name 已归一 = nickname||username,调用方负责兜底匿名/游客)
|
||||
* - uid 用户 id;showUid 时显示 #id 后缀
|
||||
* - title 自定义头衔文案(非空才渲染 chip)
|
||||
* - titleColor 头衔 chip 背景色(hex,可空 → 默认中性色)
|
||||
* - role 'admin' → 系统身份 chip「管理员」(固定系统配色)
|
||||
* - moderatorLabel 版主 chip 文案(如「版主·闲聊」);接口未提供时留空则不渲染
|
||||
* - showUid UID 后缀开关:帖子发布者/楼主/博客作者/个人主页恒显;
|
||||
* 评论作者/回复楼层由 show_uid_in_comments 设置控制
|
||||
*
|
||||
* 渲染 = 名字 + 系统身份 chip(管理员/版主)+ 自定义头衔 chip + UID 后缀,
|
||||
* 样式命名空间 .username-* / .title-chip(见 public/css/style.css)。
|
||||
*/
|
||||
export default function UserName({
|
||||
name = '',
|
||||
uid,
|
||||
title = '',
|
||||
titleColor = '',
|
||||
role = '',
|
||||
showUid = false,
|
||||
moderatorLabel = '',
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<span className={'username' + (className ? ' ' + className : '')}>
|
||||
<span className="username-name">{name || '匿名'}</span>
|
||||
{role === 'admin' ? <span className="username-chip username-chip-role">管理员</span> : null}
|
||||
{moderatorLabel ? <span className="username-chip username-chip-moderator">{moderatorLabel}</span> : null}
|
||||
{title ? (
|
||||
<span
|
||||
className={'username-chip title-chip' + (titleColor ? ' title-chip-colored' : '')}
|
||||
style={titleColor ? { background: titleColor } : undefined}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
{showUid && uid != null ? <span className="username-uid">#{uid}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 外链跳转确认页工具(配合后端 GET /out?url=<encoded>):
|
||||
* - 站内路径(/ 开头)原样返回
|
||||
* - http(s) 外链 → /out?url=<encodeURIComponent(url)>(确认页 5s 倒计时后跳转)
|
||||
* - 其他协议(javascript: 等)→ 返回 '#' 并 console 警告(防 XSS 链接)
|
||||
*/
|
||||
export function safeOutUrl(url) {
|
||||
const u = String(url || '').trim();
|
||||
if (!u) return '#';
|
||||
if (u.startsWith('/')) return u; // 站内绝对/相对路径(含 /uploads、/out 本身)
|
||||
if (/^https?:\/\//i.test(u)) {
|
||||
// 本站家族域名(rainnya.asia 及子域名)直接跳转,不走确认页
|
||||
if (isFamilyDomain(u)) return u;
|
||||
return '/out?url=' + encodeURIComponent(u);
|
||||
}
|
||||
console.warn('[outlink] 不支持的链接协议,已拦截:', u);
|
||||
return '#';
|
||||
}
|
||||
|
||||
/** 判断链接是否为站内链接(/ 开头) */
|
||||
export function isInternalUrl(url) {
|
||||
return String(url || '').startsWith('/');
|
||||
}
|
||||
|
||||
/** 本站家族域名:rainnya.asia 及所有子域名(www.rainnya.asia / rainid.rainnya.asia 等)视为站内,直接跳转不走确认页 */
|
||||
export function isFamilyDomain(url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return hostname === 'rainnya.asia' || hostname.endsWith('.rainnya.asia');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* footer_columns(JSON 字符串)解析为 [{ title, links: [{ label, url }] }];
|
||||
* 解析失败或非数组返回 [](前端回退硬编码栏目)。
|
||||
*/
|
||||
export function parseFooterColumns(raw) {
|
||||
try {
|
||||
const arr = JSON.parse(raw || '[]');
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr
|
||||
.filter((c) => c && typeof c === 'object')
|
||||
.map((c) => ({
|
||||
title: String(c.title || ''),
|
||||
links: Array.isArray(c.links)
|
||||
? c.links
|
||||
.filter((l) => l && typeof l === 'object')
|
||||
.map((l) => ({ label: String(l.label || ''), url: String(l.url || '') }))
|
||||
: [],
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/** HTML 转义:& < > " ' 全转义 */
|
||||
export function escapeHtml(str) {
|
||||
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
||||
@@ -12,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);
|
||||
@@ -45,3 +62,42 @@ export function showSnackbar(msg) {
|
||||
setTimeout(() => el.classList.remove('show', 'hide'), 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button) */
|
||||
export function focusDialog(container) {
|
||||
if (!container) return;
|
||||
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
|
||||
if (el && typeof el.focus === 'function') el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗键盘/焦点管理(B3):
|
||||
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
|
||||
* - Esc 关闭弹窗
|
||||
* - 关闭后焦点还给触发元素
|
||||
* 返回 { dialogRef, onKeyDown }:dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
|
||||
*/
|
||||
export function useDialog(open, onClose) {
|
||||
const dialogRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement;
|
||||
focusDialog(dialogRef.current);
|
||||
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
if (onClose) onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return { dialogRef, onKeyDown };
|
||||
}
|
||||
|
||||
@@ -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=未搜索
|
||||
@@ -88,6 +101,7 @@ export default function Blog() {
|
||||
|
||||
return (
|
||||
<div className="homepage-layout blog-layout">
|
||||
<h1 className="sr-only">博客</h1>
|
||||
<BlogSidebar settings={settings} />
|
||||
<div className="homepage-content">
|
||||
{/* 工具条:搜索框 + 归档 */}
|
||||
@@ -113,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) {
|
||||
@@ -33,8 +36,48 @@ export default function BlogDetail() {
|
||||
const [prevnext, setPrevnext] = useState(null);
|
||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||
const [toc, setToc] = useState([]);
|
||||
const [tocOpen, setTocOpen] = useState(true);
|
||||
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);
|
||||
@@ -51,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 {
|
||||
@@ -60,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 {
|
||||
@@ -67,22 +119,44 @@ export default function BlogDetail() {
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
// 目录:从已渲染的 .md-body 提取 h2/h3 并打锚点 id
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
const body = document.querySelector('.blog-article .md-body');
|
||||
if (!body) { setToc([]); return; }
|
||||
const items = [...body.querySelectorAll('h2, h3')].map((h, i) => {
|
||||
if (!h.id) h.id = 'toc-' + i;
|
||||
return { id: h.id, text: (h.textContent || '').trim(), level: h.tagName === 'H2' ? 2 : 3 };
|
||||
});
|
||||
setToc(items);
|
||||
}, [post]);
|
||||
|
||||
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
||||
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
||||
|
||||
// 目录:从已渲染的 .md-body 提取 h2(仅一级章节)打锚点;短文章(<500 字)不提取
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
if (wordCount < 500) { setToc([]); return; }
|
||||
const body = document.querySelector('.blog-article .md-body');
|
||||
if (!body) { setToc([]); return; }
|
||||
const items = [...body.querySelectorAll('h2')].map((h, i) => {
|
||||
if (!h.id) h.id = 'toc-' + i;
|
||||
return { id: h.id, text: (h.textContent || '').trim(), level: 2 };
|
||||
});
|
||||
setToc(items);
|
||||
}, [post, wordCount]);
|
||||
|
||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
||||
|
||||
// ── markdown 锁定块 ──
|
||||
// 解锁 password 块:调解锁接口,内容 + 附件 token(lockToken)仅存内存 state(刷新失效)
|
||||
const unlockLock = async (index, password) => {
|
||||
const res = await blogApi.unlockLock(id, index, password);
|
||||
if (!res || !res.ok || !res.content) throw new Error((res && res.error) || '解锁失败');
|
||||
setLockContent((prev) => {
|
||||
const m = new Map(prev);
|
||||
m.set(index, { content: res.content, token: res.lockToken || '' });
|
||||
return m;
|
||||
});
|
||||
setUnlocked((prev) => { const s = new Set(prev); s.add(index); return s; });
|
||||
};
|
||||
|
||||
// reply 块「去评论」:滚动到评论区(锁定块在正文中,评论区在下方)
|
||||
const scrollToComment = () => {
|
||||
const el = document.querySelector('.comment-form') || document.querySelector('.comment-list');
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
else window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// ── 点赞(乐观更新)──
|
||||
const toggleLike = async () => {
|
||||
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
||||
@@ -106,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>
|
||||
@@ -161,7 +243,8 @@ export default function BlogDetail() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="blog-article">
|
||||
<div className="article-layout">
|
||||
<div className="blog-article article-main">
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
|
||||
</Link>
|
||||
@@ -169,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>
|
||||
@@ -185,46 +276,29 @@ export default function BlogDetail() {
|
||||
)}
|
||||
|
||||
<div className="article-actions">
|
||||
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞">
|
||||
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||
<span className="like-count">{like.count}</span>
|
||||
</button>
|
||||
<button className="btn btn-tonal btn-sm" onClick={() => setShareOpen(true)} aria-label="分享文章">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 目录(正文顶部,可折叠) */}
|
||||
{toc.length > 0 && (
|
||||
<div className="toc-block">
|
||||
<button className="toc-toggle" onClick={() => setTocOpen((v) => !v)}>
|
||||
<span className="material-icons" style={{ fontSize: 17 }}>format_list_bulleted</span>
|
||||
目录
|
||||
<span className="toc-count">{toc.length}</span>
|
||||
<span className={'material-icons toc-arrow' + (tocOpen ? ' open' : '')} style={{ fontSize: 18 }}>expand_more</span>
|
||||
</button>
|
||||
{tocOpen && (
|
||||
<ul className="toc-list">
|
||||
{toc.map((t) => (
|
||||
<li key={t.id} className={'toc-item toc-lv' + t.level}>
|
||||
<a
|
||||
href={'#' + t.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(t.id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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) && (
|
||||
@@ -253,7 +327,7 @@ export default function BlogDetail() {
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4>
|
||||
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
|
||||
@@ -295,5 +369,69 @@ export default function BlogDetail() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="article-toc-side">
|
||||
<div className="toc-title">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
|
||||
目录 <span className="toc-count">{toc.length}</span>
|
||||
</div>
|
||||
<ul className="toc-list">
|
||||
{toc.map((t) => (
|
||||
<li key={t.id} className="toc-item">
|
||||
<a
|
||||
href={'#' + t.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(t.id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 分享弹窗(与全站 dialog 风格一致:Esc/遮罩关闭、焦点管理) */}
|
||||
{shareOpen && (
|
||||
<div
|
||||
ref={shareDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="分享文章"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
|
||||
onKeyDown={shareDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>分享文章</h3>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
|
||||
复制链接分享,好友打开直达完整阅读页面
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="shareUrlInput">分享链接</label>
|
||||
<input
|
||||
id="shareUrlInput"
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
|
||||
<button className="btn btn-filled" onClick={copyShareLink}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function Embed() {
|
||||
|
||||
return (
|
||||
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<h1 className="sr-only">嵌入页面</h1>
|
||||
<div
|
||||
className="embed-toolbar"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
|
||||
@@ -67,7 +68,14 @@ export default function Embed() {
|
||||
Ignore X-Frame-Headers
|
||||
</a>{' '}
|
||||
扩展,或点击右上角「原站」在新标签页打开
|
||||
<span onClick={() => setShowHint(false)} style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600 }}>✕</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHint(false)}
|
||||
aria-label="关闭提示"
|
||||
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+134
-229
@@ -1,271 +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 } 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);
|
||||
|
||||
return (
|
||||
<div className="forum-layout">
|
||||
<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) => (
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
|
||||
<div
|
||||
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
|
||||
onClick={showAllPosts}
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
||||
</div>
|
||||
</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" style={{ cursor: 'default', fontSize: 12, padding: '1px 8px', color: 'var(--md-ref-on-surface-variant)', border: '1px solid var(--md-ref-outline-variant)' }}>板块</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 }}>
|
||||
<span className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</span>
|
||||
{subCats.map((s) => (
|
||||
<span key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</span>
|
||||
))}
|
||||
</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" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'transparent', border: '1px solid var(--md-ref-outline-variant)', color: 'var(--md-ref-on-surface-variant)' }}>{catName}</span>
|
||||
{p.sub_category ? (
|
||||
<span className="chip" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>{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
|
||||
className="dialog-overlay active"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
||||
>
|
||||
<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>
|
||||
<h3 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h3>
|
||||
<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,26 +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 && (
|
||||
<span
|
||||
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除回复"
|
||||
className="floor-del"
|
||||
onClick={() => deleteReply(r.id)}
|
||||
>
|
||||
删除
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="homepage-layout">
|
||||
<h1 className="sr-only">首页</h1>
|
||||
<BlogSidebar settings={settings} showRecent forceShow />
|
||||
<div className="homepage-content">
|
||||
<div className="card">
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
|
||||
/** RainID OIDC 回跳错误码 → 用户可读文案 */
|
||||
const OIDC_ERROR_TEXT = {
|
||||
access_denied: '已在 RainID 拒绝授权,可重新登录',
|
||||
consent_required: '请重新完整走 RainID 登录',
|
||||
login_required: '请重新完整走 RainID 登录',
|
||||
invalid_grant: '登录状态已失效,请重新登录',
|
||||
rate_limited: '请求过于频繁,请稍后再试',
|
||||
invalid_scope: '配置错误,请联系管理员',
|
||||
invalid_request: '配置错误,请联系管理员',
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录页(迁移自 login.html):
|
||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error)
|
||||
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
|
||||
*/
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,19 +35,69 @@ export default function Login() {
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
const [oidcBusy, setOidcBusy] = useState(false);
|
||||
|
||||
/** 清理地址栏的 oidc 过渡参数(替换为纯路径,避免残留/刷新重复兑换) */
|
||||
const clearOidcParams = () => {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
};
|
||||
|
||||
/** RainID 回跳收尾:一次性过渡码换 token → 复用本地登录成功逻辑 */
|
||||
const handleOidcTicket = async (ticket) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.oidcExchange(ticket);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
clearOidcParams();
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
clearOidcParams();
|
||||
setError(e.message || 'RainID 登录失败,请重试');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 1) RainID 回跳优先处理(一次性码 30s 有效,须先兑换)
|
||||
// 注意:OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次;
|
||||
// 兑换后 navigate 触发 location 变化时不再重跑(避免覆盖 from 目标)。
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('oidc_ticket');
|
||||
const oidcError = params.get('oidc_error');
|
||||
if (ticket) { handleOidcTicket(ticket); return; }
|
||||
if (oidcError) {
|
||||
setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败,请重试');
|
||||
clearOidcParams();
|
||||
return;
|
||||
}
|
||||
// 2) 已登录直接回首页
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
// 3) 公开设置:rainid_enabled → 显示 RainID 按钮
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => { if (s.rainid_enabled === '1') setRainidEnabled(true); })
|
||||
.catch(() => {});
|
||||
// 4) 人机验证需求
|
||||
captchaRequired('login')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
}, [navigate]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('login');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
/** RainID 整页跳转(loading 防重复点击) */
|
||||
const handleRainId = () => {
|
||||
if (oidcBusy) return;
|
||||
setOidcBusy(true);
|
||||
authApi.oidcLoginRedirect();
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
@@ -46,8 +110,13 @@ export default function Login() {
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录")
|
||||
setError(e.message || '登录失败');
|
||||
setBusy(false);
|
||||
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
|
||||
// 重置「已验证」标记允许用户重新完成验证,否则按钮保持 disabled 死锁。
|
||||
setCapDone(false);
|
||||
setCapResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,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>
|
||||
@@ -115,6 +184,18 @@ export default function Login() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{rainidEnabled && (
|
||||
<button
|
||||
className="btn btn-outline w-full"
|
||||
onClick={handleRainId}
|
||||
disabled={busy || oidcBusy}
|
||||
style={{ display: 'inline-flex', marginBottom: 8, justifyContent: 'center', height: 44, gap: 8, alignItems: 'center' }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>fingerprint</span>
|
||||
<span>{oidcBusy ? '正在跳转 RainID…' : '使用 RainID 登录'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||||
{busy ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import * as pwApi from '../api/passwords.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
|
||||
@@ -250,14 +250,23 @@ export default function Passwords() {
|
||||
|
||||
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
|
||||
|
||||
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
|
||||
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
|
||||
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
|
||||
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
|
||||
|
||||
// 加载中
|
||||
if (phase === 'loading') {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
// PIN 屏(设置 或 解锁)
|
||||
if (phase === 'setup' || phase === 'locked') {
|
||||
return (
|
||||
<>
|
||||
{/* PIN 屏(设置 或 解锁)——全屏覆盖页,非弹窗 */}
|
||||
{phase === 'setup' || phase === 'locked' ? (
|
||||
<div className="pin-overlay">
|
||||
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
@@ -282,11 +291,8 @@ export default function Passwords() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 已解锁:密码箱
|
||||
return (
|
||||
) : (
|
||||
/* 已解锁:密码箱 */
|
||||
<div>
|
||||
<div className="admin-header">
|
||||
<h2>已保存的密码</h2>
|
||||
@@ -313,14 +319,18 @@ export default function Passwords() {
|
||||
{entries && entries.length > 0 && (
|
||||
<div className="password-grid">
|
||||
{entries.map((p) => (
|
||||
<div key={p.id} className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||
<div key={p.id} className="password-card-wrap">
|
||||
<button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||
<div className="pw-title">{p.title}</div>
|
||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||
</button>
|
||||
<div className="pw-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ width: 32, height: 32, fontSize: 16 }}
|
||||
style={{ width: 40, height: 40, fontSize: 16 }}
|
||||
title="复制密码"
|
||||
aria-label={`复制 ${p.title} 的密码`}
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
||||
@@ -330,13 +340,23 @@ export default function Passwords() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 弹窗统一挂载在顶层(setup/locked 阶段也能弹出——功能修复关键,勿移入 phase 分支) ===== */}
|
||||
{/* 结构与论坛发帖弹窗一致:.dialog-overlay + .dialog,MD3 CSS 变量天然适配浅/深色模式 */}
|
||||
|
||||
{/* 设置/修改 PIN 弹窗 */}
|
||||
{pinDialog && (
|
||||
<div
|
||||
ref={pinDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置 PIN 码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
|
||||
onKeyDown={pinDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 360 }}>
|
||||
<h3>设置 PIN 码</h3>
|
||||
@@ -360,9 +380,14 @@ export default function Passwords() {
|
||||
{/* 添加/编辑条目弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editingId ? '编辑密码' : '添加密码'}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
|
||||
@@ -400,9 +425,14 @@ export default function Passwords() {
|
||||
{/* 详情弹窗 */}
|
||||
{detailEntry && (
|
||||
<div
|
||||
ref={detailDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={detailEntry.title}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
|
||||
onKeyDown={detailDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 500 }}>
|
||||
<h3>{detailEntry.title}</h3>
|
||||
@@ -410,16 +440,16 @@ export default function Passwords() {
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">用户名</span>
|
||||
<span className="pw-value">{detailEntry.username || ''}</span>
|
||||
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
|
||||
<button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">密码</span>
|
||||
<span className="pw-value">{detailEntry.password}</span>
|
||||
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
|
||||
<button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{detailEntry.url ? (
|
||||
<div className="pw-field">
|
||||
@@ -447,7 +477,7 @@ export default function Passwords() {
|
||||
|
||||
{/* 密码生成器弹窗 */}
|
||||
{genDialog && (
|
||||
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
|
||||
<div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
|
||||
<div className="dialog" style={{ maxWidth: 380 }}>
|
||||
<h3>生成随机密码</h3>
|
||||
<div className="form-group">
|
||||
@@ -476,7 +506,7 @@ export default function Passwords() {
|
||||
|
||||
{/* 修改 PIN 警告确认弹窗 */}
|
||||
{pinChangeWarn && (
|
||||
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
|
||||
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认修改 PIN</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>⚠️ 修改 PIN 后,现有密码条目将无法解密,需要重新添加。确定继续?</p>
|
||||
@@ -490,7 +520,7 @@ export default function Passwords() {
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDialog && (
|
||||
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
|
||||
<div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认操作</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录?</p>
|
||||
@@ -501,6 +531,6 @@ export default function Passwords() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ 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 } from '../lib/utils.js';
|
||||
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||
|
||||
/** FileReader + Image 加载图片(头像裁剪前读取) */
|
||||
function loadImage(file) {
|
||||
@@ -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);
|
||||
@@ -38,15 +50,30 @@ export default function Profile() {
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwBusy, setPwBusy] = useState(false);
|
||||
|
||||
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
|
||||
useEffect(() => {
|
||||
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
|
||||
}, [pwDialog, pwStep]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const u = await profileApi.getProfile();
|
||||
setUser(u);
|
||||
setQq(u.qq || ''); // 后端提供 qq 字段时预填
|
||||
setBio(u.bio || ''); // 个性签名
|
||||
setNickname(u.nickname || ''); // 对外昵称
|
||||
setWebsite(u.website || ''); // 个人博客
|
||||
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
|
||||
const av = await avatarUrl(u.id);
|
||||
setAvatar(av.url || '');
|
||||
// RainID 开关(公开设置)
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => setRainidEnabled(s.rainid_enabled === '1'))
|
||||
.catch(() => setRainidEnabled(false));
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
if (e.status === 401) { navigate('/login.html'); return; }
|
||||
@@ -93,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 {
|
||||
@@ -171,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>
|
||||
|
||||
@@ -186,9 +309,14 @@ export default function Profile() {
|
||||
{/* 修改密码弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="修改密码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>修改密码</h3>
|
||||
|
||||
@@ -2,15 +2,21 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as emailApi from '../api/email.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/** RainID 注册页地址(后端 rainid_register_redirect=1 时整页跳转) */
|
||||
const RAINID_REGISTER_URL = 'https://rainid.rainnya.asia/register';
|
||||
|
||||
/**
|
||||
* 注册页(迁移自 register.html):
|
||||
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
||||
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
||||
* RainID:rainid_register_redirect=1 → 整页跳转 RainID 注册(不显示本地表单);
|
||||
* 否则保留本地注册,表单下方提供「使用 RainID 登录」入口。
|
||||
*/
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
@@ -25,12 +31,27 @@ export default function Register() {
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
const [redirecting, setRedirecting] = useState(false); // 正在跳转 RainID 注册
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
if (s.rainid_register_redirect === '1') {
|
||||
// 注册托管给 RainID:短暂提示后整页替换(避免白屏感)
|
||||
setRedirecting(true);
|
||||
setTimeout(() => { window.location.replace(RAINID_REGISTER_URL); }, 400);
|
||||
return;
|
||||
}
|
||||
if (s.rainid_enabled === '1') setRainidEnabled(true);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
captchaRequired('register')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
@@ -38,6 +59,11 @@ export default function Register() {
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
/** RainID 登录入口(与登录页同逻辑) */
|
||||
const handleRainId = () => {
|
||||
authApi.oidcLoginRedirect();
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
||||
if (password.length < 6) { setError('密码至少6位'); return; }
|
||||
@@ -60,6 +86,9 @@ export default function Register() {
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || '注册失败');
|
||||
// 与登录一致:验证码 proof 一次性消费,注册失败后重置验证状态允许重新验证
|
||||
setCapDone(false);
|
||||
setCapResult(null);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
@@ -85,6 +114,21 @@ export default function Register() {
|
||||
}
|
||||
};
|
||||
|
||||
// RainID 注册托管:整页跳转前短暂显示提示
|
||||
if (redirecting) {
|
||||
return (
|
||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
|
||||
<span className="material-icons" style={{ fontSize: 36, color: 'var(--md-ref-primary)' }}>fingerprint</span>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 500, marginTop: 12 }}>正在跳转 RainID 注册…</h1>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 8 }}>
|
||||
本平台注册已由 RainID 单点登录托管,即将跳转
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
||||
@@ -167,6 +211,19 @@ export default function Register() {
|
||||
<span className="text-muted">已有账户?</span>
|
||||
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
||||
</div>
|
||||
{rainidEnabled && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text btn-sm"
|
||||
onClick={handleRainId}
|
||||
style={{ fontSize: 14, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>fingerprint</span>
|
||||
已有 RainID 账号?使用 RainID 登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+162
-73
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
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 IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { Group, Panel, Separator } from 'react-resizable-panels';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
|
||||
} from '@dnd-kit/core';
|
||||
import { getToken } from '../../api/client.js';
|
||||
import { me } from '../../api/auth.js';
|
||||
import usePanels from './hooks/usePanels.js';
|
||||
import useWorkbenchLayout, {
|
||||
VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB,
|
||||
} from './hooks/useWorkbenchLayout.js';
|
||||
import Toolbar from './components/Toolbar.jsx';
|
||||
import PanelsView from './components/Sidebar.jsx';
|
||||
import NotesView from './components/NoteEditor.jsx';
|
||||
import VaultView from './components/VaultDrawer.jsx';
|
||||
import SidebarPane from './components/SidebarPane.jsx';
|
||||
import ActivityBar from './components/ActivityBar.jsx';
|
||||
import CollapseArrow from './components/CollapseArrow.jsx';
|
||||
import ToolWindow from './components/ToolWindow.jsx';
|
||||
import TabStrip from './components/TabStrip.jsx';
|
||||
import ContentView from './components/ContentView.jsx';
|
||||
import StatusBar from './components/StatusBar.jsx';
|
||||
import PanelFrame from './components/PanelFrame.jsx';
|
||||
import AddPanelDialog from './components/AddPanelDialog.jsx';
|
||||
import PanelIcon from './components/PanelIcon.jsx';
|
||||
import SnackHost, { showSnack } from '../../admin/snack.jsx';
|
||||
|
||||
/* ============================================================
|
||||
* Workbench:工作台页面(VSCode 风格改造,Lane A)
|
||||
*
|
||||
* 布局(react-resizable-panels,v4 API:Group/Panel/Separator):
|
||||
* 顶栏 56 | 活动栏48 侧栏(可拖 180-360,可折叠) 主区(标签+内容+底部终端) 侧栏 活动栏48 | 状态栏 24
|
||||
* - 左侧活动栏=主控视图(面板),右侧=功能视图(记事本/密码箱)+ 底部终端按钮
|
||||
* - dnd-kit:视图标签可在左右活动栏之间拖拽换边(workbench.viewOrder 持久化)
|
||||
* - 视图双模式:侧边栏(默认)/ 标签页(进主区 TabStrip);浮动为 P2 占位
|
||||
* - 面板 iframe 仍走 PanelFrame(LRU 3),弹窗方式走 Dialog
|
||||
* ============================================================ */
|
||||
|
||||
const LAYOUT_KEY = 'workbench.layout';
|
||||
const MAIN_ACTIVE_KEY = 'workbench.mainActive'; // 主区激活标签(刷新后恢复上次激活项)
|
||||
|
||||
/** 侧边栏默认宽度(px):箭头/活动栏图标展开时的目标宽度 */
|
||||
const SIDEBAR_DEFAULT_WIDTH = { left: 240, right: 300 };
|
||||
/** 视为「已折叠」的宽度阈值(px)。RRP v4 isCollapsed 用零容差比较(尺寸必须精确等于 0),
|
||||
* 布局归一化把面板挤到亚像素宽度时会误判为「未折叠」,导致 expand() 失效、侧栏卡死。
|
||||
* 右侧栏是布局末尾的柔性面板最易被挤压,因此用阈值 + resize() 兜底。 */
|
||||
const SIDEBAR_COLLAPSED_PX = 24;
|
||||
|
||||
function persistMainActive(key) {
|
||||
try {
|
||||
if (key) localStorage.setItem(MAIN_ACTIVE_KEY, key);
|
||||
else localStorage.removeItem(MAIN_ACTIVE_KEY);
|
||||
} catch { /* 隐私模式忽略 */ }
|
||||
}
|
||||
|
||||
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
|
||||
function readLayout() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAYOUT_KEY);
|
||||
const a = raw ? JSON.parse(raw) : null;
|
||||
if (!a || typeof a !== 'object') return null;
|
||||
return {
|
||||
left: Number(a.left) || 0,
|
||||
main: Number(a.main) || 0,
|
||||
right: Number(a.right) || 0,
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function writeLayout(v) {
|
||||
try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(v)); } catch { /* 隐私模式忽略 */ }
|
||||
}
|
||||
|
||||
function AdminGuard({ children }) {
|
||||
const [state, setState] = useState('checking'); // checking | denied | ok
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) { window.location.href = '/login.html'; return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') setState('denied');
|
||||
else setState('ok');
|
||||
})
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
}, []);
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (state === 'denied') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh', display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 2, px: 3, textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<LockIcon sx={{ fontSize: 48, color: 'error.main' }} />
|
||||
<Typography variant="h6">需要管理员权限</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||
面板工作台仅对管理员开放。如需使用,请使用管理员账号登录,或返回前台。
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="outlined" startIcon={<HomeIcon />} href="/">返回前台</Button>
|
||||
<Button variant="contained" href="/login.html">重新登录</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function EmptyState({ icon, title, hint, action, onAction }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>{title}</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{hint}</Typography>
|
||||
</Box>
|
||||
{action && <Button variant="contained" startIcon={<AddIcon />} onClick={onAction}>{action}</Button>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutDialog({ open, onClose }) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs">
|
||||
<DialogTitle>关于 Rain Work</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Rain Work 工作台 v2.1.0 — 管理面板、记事本、密码箱与终端的一体化工作区。
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
终端为管理员 Root Shell,需单独设置终端密码;记事本文件保存在服务器 workspace 目录;
|
||||
面板可拖拽到左右任一活动栏按你的习惯排布。
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 面板分隔条:可拖拽 = 4px 命中区 + 悬停高亮;不可拖拽 = 1px 细线 */
|
||||
function ResizeSeparator({ disabled = false }) {
|
||||
const muiTheme = useTheme();
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<Separator
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: disabled ? 1 : 4,
|
||||
background: hover ? muiTheme.palette.primary.main : 'transparent',
|
||||
transition: 'background .12s ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkbenchContent() {
|
||||
const panels = usePanels();
|
||||
const layout = useWorkbenchLayout();
|
||||
const {
|
||||
links, loadError, reload,
|
||||
openPanels, renderedIds,
|
||||
activeId, activePanel, modalPanel, closeModal,
|
||||
canBack, canForward, goBack, goForward,
|
||||
openPanel, closePanel, togglePin, toggleGroup, setMode,
|
||||
pinned, groups, modes,
|
||||
search, setSearch, filteredPanels, openFirstMatch,
|
||||
} = panels;
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [mainRefresh, setMainRefresh] = useState(0);
|
||||
const [modalRefresh, setModalRefresh] = useState(0);
|
||||
// 主区激活标签:同步从 localStorage 恢复(workbench.mainActive),刷新后回到上次激活项
|
||||
const mainActiveInit = useRef(null);
|
||||
const [activeMainKey, setActiveMainKey] = useState(() => {
|
||||
let v = null;
|
||||
try { v = localStorage.getItem(MAIN_ACTIVE_KEY); } catch { /* ignore */ }
|
||||
mainActiveInit.current = v;
|
||||
return v;
|
||||
});
|
||||
const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
|
||||
const searchRef = useRef(null);
|
||||
|
||||
// ---- react-resizable-panels 引用 ----
|
||||
const groupRef = useRef(null);
|
||||
const mainRef = useRef(null); // 主面板(始终 ≥minSize 可见),用作 setLayout 像素↔百分比换算探针
|
||||
const leftRef = useRef(null);
|
||||
const rightRef = useRef(null);
|
||||
|
||||
const savedLayout = useRef(readLayout());
|
||||
// 侧边栏折叠状态(供折叠箭头方向/语义;拖拽折叠也会经 onResize 同步)
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(
|
||||
() => !!savedLayout.current && savedLayout.current.left === 0
|
||||
);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(
|
||||
() => !!savedLayout.current && savedLayout.current.right === 0
|
||||
);
|
||||
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
|
||||
const defaultLayout = savedLayout.current
|
||||
? {
|
||||
'activity-l': 3,
|
||||
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
|
||||
// main 上限 75%:防止恢复时主区过大挤压两侧栏(右侧栏在布局末尾最易被挤到亚像素卡死)
|
||||
main: Math.max(20, Math.min(75, savedLayout.current.main)),
|
||||
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
|
||||
'activity-r': 3,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const persistLayout = useCallback((l) => {
|
||||
const v = (id) => Math.round(l?.[id] ?? 0);
|
||||
writeLayout({ left: v('sidebar-l'), main: v('main'), right: v('sidebar-r') });
|
||||
}, []);
|
||||
|
||||
/** 立即保存布局(折叠/展开等程序化变更时兜底,拖拽走 onLayoutChanged) */
|
||||
const saveLayoutNow = useCallback(() => {
|
||||
try { persistLayout(groupRef.current?.getLayout()); } catch { /* 布局尚未就绪 */ }
|
||||
}, [persistLayout]);
|
||||
|
||||
// 拖拽期间 onLayoutChanged 高频触发:200ms 防抖后再写 localStorage,避免阻塞主线程
|
||||
const layoutTimer = useRef(null);
|
||||
const handleLayoutChanged = useCallback((layout, { isUserInteraction }) => {
|
||||
if (!isUserInteraction) return;
|
||||
clearTimeout(layoutTimer.current);
|
||||
layoutTimer.current = setTimeout(() => persistLayout(layout), 200);
|
||||
}, [persistLayout]);
|
||||
|
||||
useEffect(() => () => clearTimeout(layoutTimer.current), []);
|
||||
|
||||
/** 程序化调整侧边栏宽度:走 groupRef.setLayout() 整体替换布局。
|
||||
* 不能再依赖 ref.collapse()/resize()/expand()——RRP 命令式 API 按 pivot 相邻面板分配
|
||||
* delta,右侧栏的右邻 activity-r 是刚性面板(minSize=maxSize=48px),delta 被其 clamp
|
||||
* 全部吸收 → 布局不变 → 静默 no-op;拖拽走 Separator(邻面板 main 柔性)所以只有拖拽有效。
|
||||
* setLayout() 不走 pivot:K() 直接归一化 + 逐面板 clamp,目标尺寸落在目标面板上。 */
|
||||
const applySidebarSize = useCallback((side, targetPx) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
const cur = group.getLayout();
|
||||
// defaultLayoutDeferred 期间 getLayout() 返回 {},setLayout 也不应用——直接跳过
|
||||
if (!cur || Object.keys(cur).length === 0) return;
|
||||
// 用任一可见面板换算组宽度:groupPx = inPixels / (asPercentage/100)
|
||||
let groupPx = 0;
|
||||
for (const probe of [mainRef.current, rightRef.current, leftRef.current]) {
|
||||
try {
|
||||
const g = probe?.getSize();
|
||||
if (g && g.inPixels > 0 && g.asPercentage > 0) {
|
||||
groupPx = g.inPixels / (g.asPercentage / 100);
|
||||
break;
|
||||
}
|
||||
} catch { /* 面板尚未就绪 */ }
|
||||
}
|
||||
const id = side === 'left' ? 'sidebar-l' : 'sidebar-r';
|
||||
if (groupPx > 0) {
|
||||
group.setLayout({ ...cur, [id]: (targetPx / groupPx) * 100 });
|
||||
} else if (targetPx === 0) {
|
||||
group.setLayout({ ...cur, [id]: 0 }); // 折叠:无法换算也直接置 0
|
||||
} else {
|
||||
group.setLayout({ ...cur, [id]: side === 'left' ? 20 : 25 }); // 退化固定百分比,K 的 clamp 兜底
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback((side) => {
|
||||
// 用状态(阈值判定)而非 ref.isCollapsed():RRP 零容差比较在亚像素宽度下会误判
|
||||
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||
applySidebarSize(side, collapsed ? SIDEBAR_DEFAULT_WIDTH[side] : 0);
|
||||
setTimeout(saveLayoutNow, 0); // setLayout 的 onLayoutChanged 不带 isUserInteraction,须手动兜底写盘
|
||||
}, [leftCollapsed, rightCollapsed, applySidebarSize, saveLayoutNow]);
|
||||
|
||||
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
|
||||
const leftActive = layout.activeViewOf('left');
|
||||
const rightActive = layout.activeViewOf('right');
|
||||
const leftBarViews = layout.order.left.map((id) => VIEWS[id]).filter(Boolean);
|
||||
const rightBarViews = layout.order.right.map((id) => VIEWS[id]).filter(Boolean);
|
||||
// 侧栏只渲染 sidebar 模式的视图(tab 模式的进主区标签页)
|
||||
const leftPaneViews = leftBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
|
||||
const rightPaneViews = rightBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
|
||||
|
||||
const isViewActive = (vid) => {
|
||||
if (layout.mode[vid] === VIEW_MODE_TAB) return effectiveActiveKey === 'view:' + vid;
|
||||
const side = layout.sideOf[vid];
|
||||
return side ? layout.activeViewOf(side) === vid : false;
|
||||
};
|
||||
|
||||
/** 主区标签列表(每次渲染重建,保证 render() 闭包拿到最新状态) */
|
||||
const buildMainTabs = () => {
|
||||
const tabs = [];
|
||||
openPanels.forEach((p) => {
|
||||
tabs.push({
|
||||
key: 'panel:' + p.id,
|
||||
type: 'panel',
|
||||
title: p.title,
|
||||
closable: true,
|
||||
icon: <PanelIcon url={p.embed_url || p.url} title={p.title} size={14} />,
|
||||
panel: p,
|
||||
});
|
||||
});
|
||||
VIEW_IDS.forEach((id) => {
|
||||
if (layout.mode[id] !== VIEW_MODE_TAB) return;
|
||||
const v = VIEWS[id];
|
||||
tabs.push({
|
||||
key: 'view:' + id,
|
||||
type: id,
|
||||
title: v.label,
|
||||
closable: true,
|
||||
icon: <v.Icon sx={{ fontSize: 14, color: 'text.secondary' }} />,
|
||||
render: () => renderViewContent(id),
|
||||
});
|
||||
});
|
||||
return tabs;
|
||||
};
|
||||
|
||||
// 视图内容渲染(先定义:buildMainTabs / renderPaneView 均依赖)
|
||||
const renderViewContent = (viewId) => {
|
||||
if (viewId === 'panels') {
|
||||
return (
|
||||
<PanelsView
|
||||
loading={links === null && !loadError}
|
||||
error={loadError}
|
||||
onRetry={reload}
|
||||
links={links || []}
|
||||
filtered={filteredPanels}
|
||||
search={search}
|
||||
activeId={activeId}
|
||||
pinned={pinned}
|
||||
onSelect={openPanel}
|
||||
onPin={togglePin}
|
||||
groups={groups}
|
||||
onToggleGroup={toggleGroup}
|
||||
modes={modes}
|
||||
onSetMode={setMode}
|
||||
onAdd={() => setAddOpen(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (viewId === 'notes') return <NotesView />;
|
||||
if (viewId === 'vault') return <VaultView open={isViewActive('vault')} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
const mainTabs = buildMainTabs();
|
||||
// 活动主标签:优先记住的用户选择(含持久化的上次激活项),失效则回退到第一个
|
||||
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
|
||||
? activeMainKey
|
||||
: (mainTabs[0]?.key ?? null);
|
||||
|
||||
// 打开面板 / 历史导航时自动切到对应 iframe 标签。
|
||||
// 首次挂载若已有持久化激活项(可能指向 tab 模式视图)则尊重它,只消费一次;
|
||||
// 之后每次 activeId 变化都同步并持久化。
|
||||
useEffect(() => {
|
||||
if (activeId == null) return;
|
||||
if (mainActiveInit.current) { mainActiveInit.current = null; return; }
|
||||
const key = 'panel:' + activeId;
|
||||
setActiveMainKey(key);
|
||||
persistMainActive(key);
|
||||
}, [activeId]);
|
||||
|
||||
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
|
||||
|
||||
const handleSelectTab = (key) => {
|
||||
setActiveMainKey(key);
|
||||
persistMainActive(key);
|
||||
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
|
||||
};
|
||||
const handleCloseTab = (key) => {
|
||||
if (key.startsWith('panel:')) closePanel(key.slice('panel:'.length));
|
||||
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
|
||||
};
|
||||
|
||||
/** 侧边栏 Panel onResize:阈值判定折叠状态(拖拽折叠/亚像素挤压都覆盖) */
|
||||
const onSidebarResize = useCallback((side) => (size) => {
|
||||
const px = size && typeof size === 'object' ? size.inPixels : size;
|
||||
const collapsed = px < SIDEBAR_COLLAPSED_PX;
|
||||
if (side === 'left') setLeftCollapsed(collapsed);
|
||||
else setRightCollapsed(collapsed);
|
||||
}, []);
|
||||
|
||||
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
|
||||
|
||||
const handleOpenExternal = () => {
|
||||
if (activePanel) window.open(activePanel.embed_url || activePanel.url, '_blank', 'noopener');
|
||||
};
|
||||
|
||||
const handleSaved = (panel) => {
|
||||
showSnack(`已添加「${panel.title}」`);
|
||||
setAddOpen(false);
|
||||
reload().then(() => openPanel(panel.id, panel));
|
||||
};
|
||||
|
||||
/** 模式切换入口:切换前派发保存事件(记事本立即 flush 未落盘草稿),再切模式 */
|
||||
const setViewModeSafely = useCallback((viewId, mode) => {
|
||||
if (viewId === 'notes') {
|
||||
try { window.dispatchEvent(new CustomEvent('workbench:notes-save-request')); } catch { /* ignore */ }
|
||||
}
|
||||
layout.setViewMode(viewId, mode);
|
||||
}, [layout]);
|
||||
|
||||
/** 侧栏内单个视图:ToolWindow 标题栏 + 内容 */
|
||||
const renderPaneView = (viewId) => {
|
||||
const v = VIEWS[viewId];
|
||||
const VIcon = v.Icon;
|
||||
return (
|
||||
<>
|
||||
<ToolWindow
|
||||
icon={<VIcon sx={{ fontSize: 17, color: 'primary.main' }} />}
|
||||
title={v.label}
|
||||
mode={layout.mode[viewId]}
|
||||
onSetMode={(m) => setViewModeSafely(viewId, m)}
|
||||
onFloat={() => showSnack('浮动模式即将上线', 'info')}
|
||||
closeTitle="关闭侧边栏"
|
||||
onClose={() => {
|
||||
// X = 关闭整个侧边栏(折叠到 0 宽度),活动栏图标仍可重新展开
|
||||
const side = layout.sideOf[viewId];
|
||||
toggleSidebar(side);
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{renderViewContent(viewId)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPlaceholder = (side) => {
|
||||
const vid = layout.activeViewOf(side);
|
||||
const v = VIEWS[vid];
|
||||
if (!v) return null;
|
||||
return (
|
||||
<>
|
||||
<v.Icon sx={{ fontSize: 28, color: 'text.disabled' }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
「{v.label}」已在标签页中打开
|
||||
</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => setViewModeSafely(vid, VIEW_MODE_SIDEBAR)}>
|
||||
移回侧边栏
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起(VSCode 语义) */
|
||||
const handleActivateView = (viewId, side) => {
|
||||
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||
const alreadyActive = layout.activeViewOf(side) === viewId;
|
||||
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
|
||||
layout.activateView(viewId);
|
||||
// setLayout 整体替换:展开到默认宽度 / 折叠到 0,绕开 pivot 刚性邻居吸收问题
|
||||
if (alreadyActive && !collapsed) applySidebarSize(side, 0);
|
||||
else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
|
||||
setTimeout(saveLayoutNow, 0);
|
||||
};
|
||||
|
||||
// ---- 菜单栏配置 ----
|
||||
const menus = [
|
||||
{
|
||||
label: '文件', items: [
|
||||
{ label: '添加面板…', Icon: AddIcon, action: () => setAddOpen(true) },
|
||||
{ label: '刷新面板列表', Icon: RefreshIcon, action: () => reload() },
|
||||
{ divider: true },
|
||||
{ label: '返回前台', Icon: HomeIcon, action: () => { window.location.href = '/'; } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '编辑', items: [
|
||||
{ label: '搜索面板', Icon: SearchIcon, action: () => searchRef.current?.focus() },
|
||||
{ label: '刷新当前面板', Icon: RefreshIcon, action: () => { if (activePanel) setMainRefresh((n) => n + 1); } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '视图', items: [
|
||||
{ label: '切换左侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('left') },
|
||||
{ label: '切换右侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('right') },
|
||||
{ divider: true },
|
||||
...VIEW_IDS.map((id) => ({
|
||||
label: VIEWS[id].label,
|
||||
Icon: VIEWS[id].Icon,
|
||||
action: () => { setViewModeSafely(id, VIEW_MODE_SIDEBAR); layout.activateView(id); },
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '终端', items: [
|
||||
// 终端功能临时下线(TerminalPanel 已归档到 archive/),保留占位提示,后续恢复
|
||||
{ label: '终端功能即将回归', Icon: TerminalIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '帮助', items: [
|
||||
{ label: '关于工作台', Icon: InfoOutlinedIcon, action: () => setAboutOpen(true) },
|
||||
{ label: '管理后台', Icon: SettingsIcon, action: () => { window.location.href = '/admin/dashboard'; } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---- dnd-kit 拖拽(活动栏标签换边 / 同侧排序;键盘操作走 Tab+Enter 激活) ----
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
);
|
||||
const handleDragStart = (event) => {
|
||||
const id = String(event.active.id);
|
||||
if (!id.startsWith('view:')) return;
|
||||
const v = VIEWS[id.slice('view:'.length)];
|
||||
if (v) setActiveDrag({ id: v.id, label: v.label, Icon: v.Icon });
|
||||
};
|
||||
const handleDragEnd = (event) => {
|
||||
setActiveDrag(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const vid = String(active.id).replace(/^view:/, '');
|
||||
if (!VIEWS[vid]) return;
|
||||
const overStr = String(over.id);
|
||||
if (overStr.startsWith('activity:')) {
|
||||
const side = overStr.slice('activity:'.length);
|
||||
layout.moveView(vid, side);
|
||||
} else if (overStr.startsWith('view:')) {
|
||||
const overVid = overStr.slice('view:'.length);
|
||||
const mySide = layout.sideOf[vid];
|
||||
const overSide = layout.sideOf[overVid];
|
||||
if (mySide && overSide && mySide === overSide) layout.reorderView(vid, overVid);
|
||||
else if (overSide) layout.moveView(vid, overSide);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 主区内容(加载/错误/空态;有标签时一律走 ContentView 分发) ----
|
||||
let mainBody;
|
||||
if (links === null && !loadError) {
|
||||
mainBody = (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress size={36} />
|
||||
</Box>
|
||||
);
|
||||
} else if (loadError) {
|
||||
mainBody = (
|
||||
<EmptyState
|
||||
icon={<ErrorIcon sx={{ fontSize: 44, color: 'error.main' }} />}
|
||||
title="面板列表加载失败"
|
||||
hint={loadError}
|
||||
action="重试"
|
||||
onAction={reload}
|
||||
/>
|
||||
);
|
||||
} else if (mainTabs.length === 0 && links.length === 0) {
|
||||
mainBody = (
|
||||
<EmptyState
|
||||
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'primary.main' }} />}
|
||||
title="还没有任何面板"
|
||||
hint="添加常用后台 / 监控 / 工具站到工作台,一个界面全部打开"
|
||||
action="添加第一个面板"
|
||||
onAction={() => setAddOpen(true)}
|
||||
/>
|
||||
);
|
||||
} else if (mainTabs.length === 0) {
|
||||
mainBody = (
|
||||
<EmptyState
|
||||
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'text.disabled' }} />}
|
||||
title="未打开面板"
|
||||
hint="从左侧选择一个面板开始,或搜索后按 Enter 直达"
|
||||
action="打开一个面板"
|
||||
onAction={() => { if (filteredPanels[0]) openPanel(filteredPanels[0].id); }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
mainBody = (
|
||||
<ContentView
|
||||
tabs={mainTabs}
|
||||
activeKey={effectiveActiveKey}
|
||||
refreshNonce={mainRefresh}
|
||||
panelRenderKeys={panelRenderKeys}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar
|
||||
menus={menus}
|
||||
canBack={canBack}
|
||||
canForward={canForward}
|
||||
onBack={goBack}
|
||||
onForward={goForward}
|
||||
onRefresh={() => { if (activePanel) setMainRefresh((n) => n + 1); }}
|
||||
currentPanel={activePanel}
|
||||
onOpenExternal={handleOpenExternal}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
onSearchEnter={() => {
|
||||
if (!openFirstMatch() && search.trim()) showSnack('未找到匹配的面板', 'info');
|
||||
}}
|
||||
searchInputRef={searchRef}
|
||||
onBackHome={() => { window.location.href = '/'; }}
|
||||
/>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveDrag(null)}
|
||||
>
|
||||
<Group
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={handleLayoutChanged}
|
||||
groupRef={groupRef}
|
||||
style={{ flex: 1, minHeight: 0, display: 'flex' }}
|
||||
>
|
||||
{/* 左侧活动栏 */}
|
||||
<Panel id="activity-l" defaultSize={48} minSize={48} maxSize={48}>
|
||||
<ActivityBar
|
||||
side="left"
|
||||
views={leftBarViews}
|
||||
activeId={leftActive}
|
||||
onActivate={(id) => handleActivateView(id, 'left')}
|
||||
/>
|
||||
</Panel>
|
||||
<ResizeSeparator disabled />
|
||||
{/* 左侧侧边栏 */}
|
||||
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef} onResize={onSidebarResize('left')}>
|
||||
<SidebarPane
|
||||
side="left"
|
||||
views={leftPaneViews}
|
||||
activeId={leftActive}
|
||||
renderView={renderPaneView}
|
||||
renderPlaceholder={() => renderPlaceholder('left')}
|
||||
/>
|
||||
</Panel>
|
||||
<ResizeSeparator />
|
||||
{/* 主区:标签条 + 内容 + 底部终端 */}
|
||||
<Panel id="main" minSize={320} panelRef={mainRef}>
|
||||
{/* 注意:RRP Panel 内容容器是 block 布局(maxHeight:100% + flexGrow:1),
|
||||
这里必须用 height:'100%' 而非 flex:1,否则高度塌陷导致内容挤顶部 */}
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden', position: 'relative' }}>
|
||||
{mainTabs.length > 0 && (
|
||||
<TabStrip
|
||||
tabs={mainTabs}
|
||||
activeKey={effectiveActiveKey}
|
||||
onSelect={handleSelectTab}
|
||||
onClose={handleCloseTab}
|
||||
onAdd={() => setAddOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
|
||||
{mainBody}
|
||||
</Box>
|
||||
{/* 侧边栏折叠箭头:贴主区整高的边缘(即侧边栏与主区 Separator 位置)垂直居中,
|
||||
折叠为 0 后主区仍在 → 箭头始终可点,支持再点击展开 */}
|
||||
<CollapseArrow side="left" collapsed={leftCollapsed} onToggle={() => toggleSidebar('left')} />
|
||||
<CollapseArrow side="right" collapsed={rightCollapsed} onToggle={() => toggleSidebar('right')} />
|
||||
</Box>
|
||||
</Panel>
|
||||
<ResizeSeparator />
|
||||
{/* 右侧侧边栏 */}
|
||||
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef} onResize={onSidebarResize('right')}>
|
||||
<SidebarPane
|
||||
side="right"
|
||||
views={rightPaneViews}
|
||||
activeId={rightActive}
|
||||
renderView={renderPaneView}
|
||||
renderPlaceholder={() => renderPlaceholder('right')}
|
||||
/>
|
||||
</Panel>
|
||||
<ResizeSeparator disabled />
|
||||
{/* 右侧活动栏 */}
|
||||
<Panel id="activity-r" defaultSize={48} minSize={48} maxSize={48}>
|
||||
<ActivityBar
|
||||
side="right"
|
||||
views={rightBarViews}
|
||||
activeId={rightActive}
|
||||
onActivate={(id) => handleActivateView(id, 'right')}
|
||||
/>
|
||||
</Panel>
|
||||
</Group>
|
||||
|
||||
{/* 拖拽浮层(纯展示,禁止渲染 useSortable 组件) */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDrag ? (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.25, py: 1, borderRadius: 2,
|
||||
bgcolor: 'background.paper', border: 1, borderColor: 'primary.main',
|
||||
boxShadow: (t) => t.shadows[6],
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
fontSize: 12.5, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<activeDrag.Icon sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
{activeDrag.label}
|
||||
</Box>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<StatusBar left={`已打开 ${openPanels.length} 个面板${activePanel ? ` · ${activePanel.title}` : ''}`} />
|
||||
|
||||
<AddPanelDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
categories={categories}
|
||||
onSaved={handleSaved}
|
||||
onSetMode={setMode}
|
||||
/>
|
||||
|
||||
{/* 弹窗打开方式:Dialog 内嵌 PanelFrame */}
|
||||
{modalPanel && (
|
||||
<Dialog
|
||||
open
|
||||
fullWidth
|
||||
maxWidth="lg"
|
||||
onClose={closeModal}
|
||||
sx={{ '& .MuiDialog-paper': { height: '85vh', maxHeight: '85vh' } }}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, pr: 1 }}>
|
||||
<PanelIcon url={modalPanel.embed_url || modalPanel.url} title={modalPanel.title} size={22} />
|
||||
<Typography noWrap sx={{ flex: 1, fontSize: 16, fontWeight: 500 }}>
|
||||
{modalPanel.title}
|
||||
</Typography>
|
||||
<Tooltip title="在新标签页打开">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => window.open(modalPanel.embed_url || modalPanel.url, '_blank', 'noopener')}
|
||||
>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新">
|
||||
<IconButton size="small" onClick={() => setModalRefresh((n) => n + 1)}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={closeModal} aria-label="关闭弹窗">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ p: 0, display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<PanelFrame panel={modalPanel} active refreshNonce={modalRefresh} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
|
||||
<SnackHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Workbench() {
|
||||
return (
|
||||
<AdminGuard>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden',
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<WorkbenchContent />
|
||||
</Box>
|
||||
</AdminGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
import React, {
|
||||
forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { terminalStatus, terminalAuth } from '../../../api/terminal.js';
|
||||
import { showSnack } from '../../../admin/snack.jsx';
|
||||
|
||||
/* ============================================================
|
||||
* TerminalPanel:底部终端面板(xterm.js v6)
|
||||
*
|
||||
* 安全门禁(后端 routes/terminal.js 契约):
|
||||
* /api/terminal/status → hasPin ? /api/terminal/auth(pin) → 短 TTL token
|
||||
* → ws(s)://host/ws/terminal?token=…(Root shell,admin + 终端密码双认证)
|
||||
*
|
||||
* - 最多 3 个会话(标签页切换,keep-alive:display 显隐不销毁)
|
||||
* - 消息协议:输入/输出二进制;控制 JSON {type:'auth'|'resize'|'ping'|'bye'}
|
||||
* - 断线指数退避重连 [250,500,1000,2000,4000,8000],恢复后 term.reset()
|
||||
* - 关闭码:4001 token 过期→回 PIN 门;1013/503 会话满;1011 node-pty 不可用;
|
||||
* 1000(exit)/4000(空闲) 为服务端主动终止→会话结束,需用户显式「重新连接」
|
||||
* (不自动重生 root shell);仅 1006 等网络异常自动重连
|
||||
* - CJK 必装 unicode11,字体栈 JetBrains Mono + Noto Sans Mono CJK SC
|
||||
* - 明暗主题由 MUI MD3 palette 派生,随 data-theme 切换自动更新
|
||||
* ============================================================ */
|
||||
|
||||
const MAX_SESSIONS = 3;
|
||||
const BACKOFF = [250, 500, 1000, 2000, 4000, 8000];
|
||||
const PING_MS = 30000;
|
||||
const TOKEN_MARGIN_MS = 4 * 60 * 1000; // 5 分钟 TTL,提前 1 分钟视为过期
|
||||
const FONT = '"JetBrains Mono","Noto Sans Mono CJK SC","Microsoft YaHei",monospace';
|
||||
|
||||
/* ---------- 颜色工具(由 MD3 palette 派生,不硬编码) ---------- */
|
||||
function hexToRgb(hex) {
|
||||
let h = String(hex).replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
if (!/^[0-9a-f]{6}$/i.test(h)) return [128, 128, 128];
|
||||
const n = parseInt(h, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
function mixHex(a, b, w) {
|
||||
const ca = hexToRgb(a);
|
||||
const cb = hexToRgb(b);
|
||||
return `#${ca.map((v, i) => Math.round(v + (cb[i] - v) * w).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function xtermTheme(muiTheme) {
|
||||
const p = muiTheme.palette;
|
||||
const dark = p.mode === 'dark';
|
||||
const bg = p.background.paper;
|
||||
const black = mixHex(p.text.primary, bg, 0.6);
|
||||
const brightBlack = mixHex(p.text.primary, bg, 0.2);
|
||||
const base = [
|
||||
black, // 0 黑
|
||||
p.error.main, // 1 红
|
||||
p.success.main, // 2 绿
|
||||
p.warning.main, // 3 黄
|
||||
p.info.main, // 4 蓝
|
||||
p.tertiary.main, // 5 品红
|
||||
mixHex(p.secondary.main, p.info.main, 0.5), // 6 青
|
||||
p.text.primary, // 7 白
|
||||
];
|
||||
const bright = base.map((c) => mixHex(c, dark ? '#ffffff' : '#000000', dark ? 0.55 : 0.45));
|
||||
const name = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
|
||||
const out = {
|
||||
background: bg,
|
||||
foreground: p.text.primary,
|
||||
cursor: p.primary.main,
|
||||
cursorAccent: bg,
|
||||
selectionBackground: mixHex(p.primary.main, bg, 0.72),
|
||||
brightBlack,
|
||||
};
|
||||
name.forEach((n, i) => { out[n] = base[i]; out['bright' + n[0].toUpperCase() + n.slice(1)] = bright[i]; });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- WebSocket URL ---------- */
|
||||
// 认证 token 由 /api/terminal/auth 种 HttpOnly cookie(terminal_token,5 分钟),
|
||||
// WS 握手同源自动携带,不再拼 ?token= 到 URL(避免落入 access log)。
|
||||
function wsUrl() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}/ws/terminal`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端面板
|
||||
* @param {{open: boolean}} props open=底部面板是否展开(展开才查状态;收起 keep-alive)
|
||||
* @ref newSession() / lock()
|
||||
*/
|
||||
const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
||||
const muiTheme = useTheme();
|
||||
const themeObj = useMemo(() => xtermTheme(muiTheme), [muiTheme]);
|
||||
|
||||
// ---- 门禁状态:checking | needs-setup | locked | ready | error ----
|
||||
const [gate, setGate] = useState('checking');
|
||||
const [gateError, setGateError] = useState('');
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [showPin, setShowPin] = useState(false);
|
||||
const [pinBusy, setPinBusy] = useState(false);
|
||||
|
||||
// ---- 会话:{id,title,status} + 底层实例(term/ws 等) ----
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const [banner, setBanner] = useState(null); // {type:'warn'|'error', text}
|
||||
|
||||
const instRef = useRef({}); // id → {term,fit,ro,ws,backoffIdx,timers,autoReconnect,closed}
|
||||
const tokenRef = useRef({ value: null, expiresAt: 0 });
|
||||
const seqRef = useRef(0);
|
||||
const stripRef = useRef(null); // 会话标签条(方向键 roving tabindex 用)
|
||||
|
||||
const patchStatus = useCallback((id, status) => {
|
||||
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, status } : s)));
|
||||
}, []);
|
||||
|
||||
/* ---------- WS 连接层(一次性初始化,全走 ref,无陈旧闭包) ---------- */
|
||||
const manager = useRef(null);
|
||||
if (!manager.current) {
|
||||
const M = {};
|
||||
|
||||
M.sendResize = (id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst || !inst.ws || inst.ws.readyState !== 1 || !inst.term) return;
|
||||
try {
|
||||
inst.ws.send(JSON.stringify({ type: 'resize', cols: inst.term.cols, rows: inst.term.rows }));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
M.handleControl = (id, text) => {
|
||||
try {
|
||||
const msg = JSON.parse(text);
|
||||
if (msg.type === 'bye') { /* server 主动结束,交由 close 处理 */ }
|
||||
// 'pong' / 未知控制消息忽略
|
||||
} catch { /* 非 JSON 文本忽略 */ }
|
||||
};
|
||||
|
||||
M.scheduleReconnect = (id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst || inst.closed) return;
|
||||
const delay = BACKOFF[Math.min(inst.backoffIdx, BACKOFF.length - 1)];
|
||||
inst.backoffIdx = Math.min(inst.backoffIdx + 1, BACKOFF.length - 1);
|
||||
clearTimeout(inst.reconnectTimer);
|
||||
inst.reconnectTimer = setTimeout(() => M.connectSession(id), delay);
|
||||
};
|
||||
|
||||
M.connectSession = async (id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst || inst.closed) return;
|
||||
// 防双 WS 竞态:关闭旧连接并置空其回调(避免旧 onclose 触发多余重连)
|
||||
try {
|
||||
if (inst.ws) { inst.ws.onclose = null; inst.ws.onerror = null; try { inst.ws.close(); } catch {} }
|
||||
} catch {}
|
||||
inst.ws = null;
|
||||
clearTimeout(inst.reconnectTimer);
|
||||
clearInterval(inst.pingTimer);
|
||||
inst.gen = (inst.gen || 0) + 1;
|
||||
const gen = inst.gen;
|
||||
patchStatus(id, 'connecting');
|
||||
|
||||
// 无有效 token → 回 PIN 门(4001 过期或从未认证)
|
||||
const token = tokenRef.current.value;
|
||||
if (!token) {
|
||||
inst.autoReconnect = false;
|
||||
setGate('locked');
|
||||
setGateError('终端密码已过期,请重新输入');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先查状态:后端不可用 / 会话满 给明确提示,避免盲连
|
||||
try {
|
||||
const st = await terminalStatus();
|
||||
if (st.sessions >= st.maxSessions) {
|
||||
inst.autoReconnect = false;
|
||||
patchStatus(id, 'error');
|
||||
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!inst.closed && gen === inst.gen) {
|
||||
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
|
||||
patchStatus(id, 'reconnecting');
|
||||
M.scheduleReconnect(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
try { ws = new WebSocket(wsUrl()); } catch { /* 构造失败走 onerror/onclose */ }
|
||||
if (!ws) { M.scheduleReconnect(id); return; }
|
||||
inst.ws = ws;
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
if (inst.closed || gen !== inst.gen) { try { ws.close(); } catch {} return; }
|
||||
inst.backoffIdx = 0;
|
||||
setBanner(null);
|
||||
try { ws.send(JSON.stringify({ type: 'auth', token })); } catch {}
|
||||
M.sendResize(id);
|
||||
// 断线恢复后清屏,避免旧输出残留
|
||||
try { inst.term?.reset(); } catch {}
|
||||
patchStatus(id, 'connected');
|
||||
inst.pingTimer = setInterval(() => {
|
||||
if (inst.closed || gen !== inst.gen || ws.readyState !== 1) return;
|
||||
try { ws.send(JSON.stringify({ type: 'ping' })); } catch {}
|
||||
}, PING_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (inst.closed || gen !== inst.gen) return;
|
||||
if (typeof ev.data === 'string') { M.handleControl(id, ev.data); return; }
|
||||
try {
|
||||
inst.term?.write(ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
ws.onerror = () => { try { ws.close(); } catch {} };
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
if (inst.closed || gen !== inst.gen) return;
|
||||
clearInterval(inst.pingTimer);
|
||||
inst.ws = null;
|
||||
// 关闭码语义(后端 routes/terminal.js 定义)
|
||||
if (ev.code === 4001) { // token 无效/过期 → 回 PIN 门重新认证
|
||||
tokenRef.current = { value: null, expiresAt: 0 };
|
||||
inst.autoReconnect = false;
|
||||
setGate('locked');
|
||||
setGateError('终端密码已过期,请重新输入');
|
||||
return;
|
||||
}
|
||||
if (ev.code === 1013 || ev.code === 503) { // 会话超限
|
||||
inst.autoReconnect = false;
|
||||
patchStatus(id, 'error');
|
||||
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
|
||||
return;
|
||||
}
|
||||
if (ev.code === 1011) { // pty 启动失败 / node-pty 未安装
|
||||
inst.autoReconnect = false;
|
||||
patchStatus(id, 'error');
|
||||
setBanner({ type: 'error', text: '终端服务不可用:node-pty 未安装或启动失败' });
|
||||
return;
|
||||
}
|
||||
// 服务端主动结束会话:exit(1000) / 空闲超时(4000)
|
||||
// 视为会话终止,不自动重生新 root shell;由用户显式「重新连接」
|
||||
if (ev.code === 1000 || ev.code === 4000) {
|
||||
inst.autoReconnect = false;
|
||||
inst.endedReason = ev.code === 4000 ? 'idle' : 'exit';
|
||||
patchStatus(id, 'ended');
|
||||
return;
|
||||
}
|
||||
// 其余(1006 网络异常等)→ 指数退避自动重连
|
||||
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
|
||||
if (inst.autoReconnect) { patchStatus(id, 'reconnecting'); M.scheduleReconnect(id); }
|
||||
else patchStatus(id, 'error');
|
||||
};
|
||||
};
|
||||
|
||||
manager.current = M;
|
||||
}
|
||||
|
||||
/* ---------- 门禁 ---------- */
|
||||
const checkGate = useCallback(async () => {
|
||||
setGate('checking');
|
||||
setGateError('');
|
||||
try {
|
||||
const st = await terminalStatus();
|
||||
if (tokenRef.current.value && Date.now() < tokenRef.current.expiresAt) { setGate('ready'); return; }
|
||||
setGate(st.hasPin ? 'locked' : 'needs-setup');
|
||||
} catch (e) {
|
||||
setGate('error');
|
||||
setGateError(e.message || '无法连接终端服务');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 首次展开面板才查状态(收起时 keep-alive,不重复请求)
|
||||
useEffect(() => {
|
||||
if (open && gate === 'checking') checkGate();
|
||||
}, [open, gate, checkGate]);
|
||||
|
||||
const submitPin = useCallback(async () => {
|
||||
const pin = pinInput.trim();
|
||||
if (!pin) { setGateError('请输入终端密码'); return; }
|
||||
setPinBusy(true);
|
||||
setGateError('');
|
||||
try {
|
||||
const res = await terminalAuth(pin);
|
||||
tokenRef.current = { value: res.token, expiresAt: Date.now() + TOKEN_MARGIN_MS };
|
||||
setPinInput('');
|
||||
setShowPin(false);
|
||||
setGate('ready');
|
||||
// 有会话在等 token → 全部重新连接
|
||||
Object.keys(instRef.current).forEach((id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
|
||||
});
|
||||
} catch (e) {
|
||||
setGateError(e.message || '认证失败');
|
||||
setPinInput('');
|
||||
} finally {
|
||||
setPinBusy(false);
|
||||
}
|
||||
}, [pinInput]);
|
||||
|
||||
/* ---------- 会话生命周期 ---------- */
|
||||
const newSession = useCallback(() => {
|
||||
if (gate !== 'ready') {
|
||||
showSnack('请先解锁终端', 'info');
|
||||
return;
|
||||
}
|
||||
const live = Object.keys(instRef.current).filter((k) => instRef.current[k] && !instRef.current[k].closed);
|
||||
if (live.length >= MAX_SESSIONS) {
|
||||
setBanner({ type: 'error', text: `终端会话数已达上限(最多 ${MAX_SESSIONS} 个)` });
|
||||
return;
|
||||
}
|
||||
seqRef.current += 1;
|
||||
const id = 't' + seqRef.current;
|
||||
instRef.current[id] = {
|
||||
id, term: null, fit: null, ro: null, ws: null,
|
||||
backoffIdx: 0, reconnectTimer: null, pingTimer: null,
|
||||
autoReconnect: true, closed: false, gen: 0,
|
||||
};
|
||||
setSessions((prev) => [...prev, { id, title: `终端 ${seqRef.current}`, status: 'connecting' }]);
|
||||
setActiveId(id);
|
||||
setBanner(null);
|
||||
}, [gate]);
|
||||
|
||||
const closeSession = useCallback((id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst) return;
|
||||
inst.closed = true;
|
||||
clearTimeout(inst.reconnectTimer);
|
||||
clearInterval(inst.pingTimer);
|
||||
try { if (inst.ws && inst.ws.readyState === 1) inst.ws.send(JSON.stringify({ type: 'bye' })); } catch {}
|
||||
try { inst.ws?.close(1000, 'bye'); } catch {}
|
||||
try { inst.term?.dispose(); } catch {}
|
||||
try { inst.ro?.disconnect(); } catch {}
|
||||
instRef.current[id] = null;
|
||||
// 状态更新不嵌套:先算剩余列表,再分别 set(避免 updater 内副作用)
|
||||
const remaining = sessions.filter((s) => s.id !== id);
|
||||
setSessions(remaining);
|
||||
setActiveId((cur) => (cur === id ? (remaining[remaining.length - 1]?.id ?? null) : cur));
|
||||
setBanner(null);
|
||||
}, [sessions]);
|
||||
|
||||
const lockTerminal = useCallback(() => {
|
||||
tokenRef.current = { value: null, expiresAt: 0 };
|
||||
Object.keys(instRef.current).forEach((id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst) return;
|
||||
inst.closed = true;
|
||||
clearTimeout(inst.reconnectTimer);
|
||||
clearInterval(inst.pingTimer);
|
||||
try { inst.ws?.close(1000, 'bye'); } catch {}
|
||||
try { inst.term?.dispose(); } catch {}
|
||||
try { inst.ro?.disconnect(); } catch {}
|
||||
instRef.current[id] = null;
|
||||
});
|
||||
setSessions([]);
|
||||
setActiveId(null);
|
||||
setBanner(null);
|
||||
setGate('locked');
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ newSession, lock: lockTerminal }), [newSession, lockTerminal]);
|
||||
|
||||
// 解锁后自动建首个会话
|
||||
useEffect(() => {
|
||||
if (gate === 'ready' && sessions.length === 0) newSession();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gate]);
|
||||
|
||||
// 卸载时统一销毁(keep-alive 场景下不触发)
|
||||
useEffect(() => () => {
|
||||
Object.keys(instRef.current).forEach((id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst) return;
|
||||
inst.closed = true;
|
||||
clearTimeout(inst.reconnectTimer);
|
||||
clearInterval(inst.pingTimer);
|
||||
try { inst.ws?.close(); } catch {}
|
||||
try { inst.term?.dispose(); } catch {}
|
||||
try { inst.ro?.disconnect(); } catch {}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 主题随 muiTheme / data-theme 更新(后台主题静态,运行期一般不触发)
|
||||
useEffect(() => {
|
||||
Object.keys(instRef.current).forEach((id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (inst?.term) inst.term.options.theme = themeObj;
|
||||
});
|
||||
}, [themeObj]);
|
||||
|
||||
/** xterm 挂载点:会话创建后容器出现时调用;隐藏恢复时重新 fit */
|
||||
const mountTerminal = useCallback((id, el) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst || !el) return;
|
||||
if (inst.term) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit?.fit(); manager.current.sendResize(id); }
|
||||
} catch {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let term;
|
||||
let fit = null;
|
||||
try {
|
||||
term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontFamily: FONT,
|
||||
lineHeight: 1.35,
|
||||
cursorBlink: true,
|
||||
scrollback: 3000,
|
||||
theme: themeObj,
|
||||
});
|
||||
fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
term.open(el);
|
||||
inst.term = term;
|
||||
inst.fit = fit;
|
||||
} catch (e) {
|
||||
// xterm 初始化异常:显式报错而非白屏/整树崩溃
|
||||
console.error('[TerminalPanel] xterm 初始化失败:', e);
|
||||
patchStatus(id, 'error');
|
||||
setBanner({ type: 'error', text: `终端初始化失败:${e?.message || '未知错误'}` });
|
||||
return;
|
||||
}
|
||||
term.onData((d) => {
|
||||
const ws = inst.ws;
|
||||
if (!ws || ws.readyState !== 1) return;
|
||||
try { ws.send(new TextEncoder().encode(d)); } catch {}
|
||||
});
|
||||
term.onResize(({ cols, rows }) => {
|
||||
const ws = inst.ws;
|
||||
if (ws && ws.readyState === 1) {
|
||||
try { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } catch {}
|
||||
}
|
||||
});
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (el.clientWidth > 0 && el.clientHeight > 0) {
|
||||
try { fit.fit(); manager.current.sendResize(id); } catch {}
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
inst.ro = ro;
|
||||
// rAF fit 同样做尺寸守卫:容器零尺寸时跳过,避免 0 行列渲染成空白
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit.fit(); manager.current.sendResize(id); }
|
||||
} catch {}
|
||||
});
|
||||
manager.current.connectSession(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeObj]);
|
||||
|
||||
/* ---------- 渲染 ---------- */
|
||||
|
||||
// PIN / 设置门禁
|
||||
if (gate === 'checking' || gate === 'needs-setup' || gate === 'locked' || gate === 'error') {
|
||||
return (
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
|
||||
{gate === 'checking' && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5 }}>
|
||||
<CircularProgress size={26} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>正在检查终端服务…</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{gate === 'error' && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center' }}>
|
||||
<ErrorIcon sx={{ fontSize: 38, color: 'error.main' }} />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{gateError || '无法连接终端服务'}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={checkGate}>重试</Button>
|
||||
</Box>
|
||||
)}
|
||||
{(gate === 'needs-setup' || gate === 'locked') && (
|
||||
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitPin(); }} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3 }}>
|
||||
<TerminalIcon sx={{ fontSize: 34, color: 'primary.main', mb: 0.5 }} />
|
||||
<Typography sx={{ fontWeight: 600, fontSize: 15 }}>
|
||||
{gate === 'needs-setup' ? '首次使用:设置终端密码' : '输入终端密码解锁'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', mb: 1 }}>
|
||||
{gate === 'needs-setup'
|
||||
? '终端为管理员 Root Shell,请设置专用密码(至少 8 位,与账号密码独立)'
|
||||
: '终端密码与账号密码相互独立,仅管理员可访问'}
|
||||
</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
size="small"
|
||||
type={showPin ? 'text' : 'password'}
|
||||
label="终端密码"
|
||||
value={pinInput}
|
||||
onChange={(e) => { setPinInput(e.target.value); if (gateError) setGateError(''); }}
|
||||
error={!!gateError}
|
||||
helperText={gateError || ' '}
|
||||
autoComplete={gate === 'needs-setup' ? 'new-password' : 'current-password'}
|
||||
slotProps={{
|
||||
htmlInput: { maxLength: 128, 'aria-label': '终端密码' },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" edge="end" aria-label={showPin ? '隐藏密码' : '显示密码'} onClick={() => setShowPin((v) => !v)}>
|
||||
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ width: 300, maxWidth: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={pinBusy || !pinInput}
|
||||
startIcon={pinBusy ? <CircularProgress size={16} color="inherit" /> : <LockOpenIcon />}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{pinBusy ? '处理中…' : gate === 'needs-setup' ? '设置并连接' : '解锁终端'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ready:会话标签栏 + 终端
|
||||
const live = sessions.length;
|
||||
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
|
||||
const moveSessionTab = (i) => {
|
||||
const n = sessions.length;
|
||||
if (n === 0) return;
|
||||
const j = ((i % n) + n) % n;
|
||||
setActiveId(sessions[j].id);
|
||||
tabEls()[j]?.focus();
|
||||
};
|
||||
const handleTabKeyDown = (e, index) => {
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); moveSessionTab(index + 1); }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveSessionTab(index - 1); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); moveSessionTab(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); moveSessionTab(sessions.length - 1); }
|
||||
};
|
||||
/** 会话结束后显式重连:新建 WS 连接 = 服务端 spawn 新 shell */
|
||||
const reconnectSession = (id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (!inst) return;
|
||||
inst.autoReconnect = true;
|
||||
delete inst.endedReason;
|
||||
manager.current.connectSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
|
||||
{/* 会话标签栏 */}
|
||||
<Box
|
||||
ref={stripRef}
|
||||
role="tablist"
|
||||
aria-label="终端会话"
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, height: 32, flexShrink: 0,
|
||||
borderBottom: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{sessions.map((s, index) => {
|
||||
const active = s.id === activeId;
|
||||
return (
|
||||
<Box
|
||||
key={s.id}
|
||||
id={`wb-term-tab-${s.id}`}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={`wb-term-panel-${s.id}`}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => setActiveId(s.id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
|
||||
height: 24, borderRadius: 0.5, cursor: 'pointer', flexShrink: 0, outline: 'none',
|
||||
bgcolor: active ? 'action.selected' : 'transparent',
|
||||
color: active ? 'text.primary' : 'text.secondary',
|
||||
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
|
||||
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: s.status === 'connected' ? 'success.main'
|
||||
: s.status === 'error' ? 'error.main'
|
||||
: s.status === 'ended' ? 'text.disabled' : 'warning.main',
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: 12, fontWeight: active ? 600 : 500 }}>{s.title}</Typography>
|
||||
{live > 1 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); closeSession(s.id); }}
|
||||
aria-label={`关闭 ${s.title}`}
|
||||
sx={{ width: 24, height: 24, borderRadius: 0.5, '& svg': { fontSize: 15 } }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{live < MAX_SESSIONS && (
|
||||
<Tooltip title="新建终端会话">
|
||||
<IconButton size="small" onClick={newSession} aria-label="新建终端会话" sx={{ ml: 0.25 }}>
|
||||
<AddIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Tooltip title="锁定终端(需重新输入密码)">
|
||||
<IconButton size="small" onClick={lockTerminal} aria-label="锁定终端" sx={{ mr: 0.25 }}>
|
||||
<LockIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* 连接状态横幅 */}
|
||||
{banner && (
|
||||
<Box
|
||||
role="status"
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5, flexShrink: 0,
|
||||
bgcolor: banner.type === 'error' ? 'error.main' : 'warning.main',
|
||||
color: banner.type === 'error' ? 'error.contrastText' : 'warning.contrastText',
|
||||
borderBottom: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden
|
||||
sx={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, bgcolor: 'currentColor', opacity: 0.8 }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ flex: 1, fontWeight: 500 }}>{banner.text}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => Object.keys(instRef.current).forEach((id) => {
|
||||
const inst = instRef.current[id];
|
||||
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
|
||||
})}
|
||||
sx={{ minHeight: 24, px: 1, color: 'inherit' }}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
<IconButton size="small" onClick={() => setBanner(null)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 终端区 */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, position: 'relative', bgcolor: 'background.paper' }}>
|
||||
{sessions.map((s) => (
|
||||
<Box
|
||||
key={s.id}
|
||||
id={`wb-term-panel-${s.id}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`wb-term-tab-${s.id}`}
|
||||
sx={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: activeId === s.id ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
|
||||
{/* 未连接状态条:防止"白屏"——始终可见的连接进度/错误提示(banner 可被关掉) */}
|
||||
{s.status !== 'connected' && s.status !== 'ended' && (
|
||||
<Box
|
||||
role="status"
|
||||
sx={{
|
||||
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3,
|
||||
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5,
|
||||
bgcolor: s.status === 'error' ? 'error.main' : 'warning.main',
|
||||
color: s.status === 'error' ? 'error.contrastText' : 'warning.contrastText',
|
||||
borderTop: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{s.status !== 'error' && <CircularProgress size={12} color="inherit" />}
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||
{s.status === 'connecting' ? '正在连接终端服务…'
|
||||
: s.status === 'reconnecting' ? '连接中断,正在重试…'
|
||||
: '终端服务不可用'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
|
||||
{s.status === 'ended' && (
|
||||
<Box
|
||||
role="status"
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, zIndex: 2,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.25,
|
||||
bgcolor: 'background.paper', px: 3, textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<TerminalIcon sx={{ fontSize: 28, color: 'text.disabled' }} />
|
||||
<Typography sx={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{instRef.current[s.id]?.endedReason === 'idle' ? '会话已空闲断开' : '终端会话已结束'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{instRef.current[s.id]?.endedReason === 'idle'
|
||||
? '长时间无输入,连接被服务端关闭。点击「重新连接」可新建会话。'
|
||||
: '输入 exit 退出或进程已结束。点击「重新连接」可新建会话。'}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="contained" startIcon={<RefreshIcon fontSize="small" />} onClick={() => reconnectSession(s.id)}>
|
||||
重新连接
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => closeSession(s.id)}>
|
||||
关闭会话
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<TerminalIcon sx={{ fontSize: 30, color: 'text.disabled' }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>点击 + 新建终端会话</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default TerminalPanel;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
|
||||
/* ============================================================
|
||||
* ActivityBar:VSCode 风格活动栏(宽 48px,竖排图标)
|
||||
*
|
||||
* - 左侧:主控视图(面板…);右侧:功能视图(记事本/密码箱…)
|
||||
* - 活动项左侧 2.5px primary 指示条 + selected 底
|
||||
* - 点击:未激活 → 激活并展开侧栏;已激活 → 收起侧栏(VSCode 语义)
|
||||
* - 拖拽(@dnd-kit sortable):标签可拖到对侧活动栏 → 视图换边;
|
||||
* 同侧可排序。父级 DndContext 负责 moveView / reorderView 落库。
|
||||
* - 无障碍:role=toolbar,仅活动项 tabIndex=0(roving),
|
||||
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
|
||||
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane)
|
||||
* ============================================================ */
|
||||
|
||||
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
|
||||
const {
|
||||
attributes, listeners, setNodeRef, transform, transition, isDragging,
|
||||
} = useSortable({ id: 'view:' + view.id });
|
||||
|
||||
const VIcon = view.Icon;
|
||||
return (
|
||||
<Tooltip title={view.label} placement="right">
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
data-activity-item
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
role="button"
|
||||
tabIndex={active ? 0 : -1}
|
||||
aria-label={view.label}
|
||||
aria-pressed={active}
|
||||
aria-controls={active ? `wb-sidebar-${side}` : undefined}
|
||||
onClick={() => onActivate(view.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActivate(view.id); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(index + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(index - 1); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); moveFocus(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); moveFocus(count - 1); }
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative', width: 48, height: 48, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'grab', touchAction: 'none', outline: 'none',
|
||||
color: active ? 'primary.onContainer' : 'text.secondary',
|
||||
bgcolor: active ? 'action.selected' : 'transparent',
|
||||
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
|
||||
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3, outlineStyle: 'solid' },
|
||||
opacity: isDragging ? 0.35 : 1,
|
||||
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
||||
transition,
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute', left: 0, top: 10, bottom: 10, width: 2.5,
|
||||
bgcolor: 'primary.main', borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<VIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActivityBar({
|
||||
side, views, activeId, onActivate,
|
||||
}) {
|
||||
const right = side === 'right';
|
||||
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
|
||||
const barRef = useRef(null);
|
||||
|
||||
const itemEls = () => Array.from(barRef.current?.querySelectorAll('[data-activity-item]') || []);
|
||||
const moveFocus = (i) => {
|
||||
const n = views.length;
|
||||
if (n === 0) return;
|
||||
const j = ((i % n) + n) % n;
|
||||
itemEls()[j]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
role="toolbar"
|
||||
aria-label={right ? '右侧活动栏' : '左侧活动栏'}
|
||||
aria-orientation="vertical"
|
||||
aria-controls={`wb-sidebar-${side}`}
|
||||
sx={{
|
||||
width: 48, height: '100%', flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
bgcolor: isOver ? 'action.hover' : 'background.paper',
|
||||
borderRight: right ? 0 : 1,
|
||||
borderLeft: right ? 1 : 0,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<SortableContext items={views.map((v) => 'view:' + v.id)} strategy={verticalListSortingStrategy}>
|
||||
<Box ref={barRef} sx={{ display: 'flex', flexDirection: 'column', pt: 0.5 }}>
|
||||
{views.map((v, index) => (
|
||||
<ActivityTab
|
||||
key={v.id}
|
||||
view={v}
|
||||
side={side}
|
||||
index={index}
|
||||
count={views.length}
|
||||
moveFocus={moveFocus}
|
||||
active={activeId === v.id}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</SortableContext>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import WebAssetIcon from '@mui/icons-material/WebAsset';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||
import { createAdminLink } from '../../../api/adminLinks.js';
|
||||
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
|
||||
|
||||
/* ============================================================
|
||||
* AddPanelDialog:添加面板
|
||||
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
|
||||
* + 嵌入 URL + 代理开关 + 信任模式 + 缩放 + 权限委托;
|
||||
* 保存到 admin_links(复用现有 API,trusted/scale/permissions 为代理新字段)
|
||||
* ============================================================ */
|
||||
|
||||
const MODE_META = [
|
||||
{ mode: OPEN_MODE_EMBED, label: '内嵌', Icon: WebAssetIcon },
|
||||
{ mode: OPEN_MODE_TAB, label: '新标签', Icon: OpenInNewIcon },
|
||||
{ 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 '';
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : 'https://' + s;
|
||||
}
|
||||
|
||||
export default function AddPanelDialog({ open, onClose, categories = [], onSaved, onSetMode }) {
|
||||
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]);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const save = async () => {
|
||||
const title = form.title.trim();
|
||||
const url = normalizeUrl(form.url);
|
||||
if (!title) { setErr('请输入面板标题'); return; }
|
||||
if (!url) { setErr('请输入面板链接'); return; }
|
||||
try {
|
||||
const panel = await createAdminLink({
|
||||
title,
|
||||
url,
|
||||
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,
|
||||
version: '',
|
||||
});
|
||||
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
|
||||
setErr('');
|
||||
resetForm();
|
||||
onSaved(panel);
|
||||
} catch (e) {
|
||||
setErr(e.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>添加面板</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="标题 *"
|
||||
value={form.title}
|
||||
onChange={set('title')}
|
||||
margin="normal"
|
||||
placeholder="例如:服务器监控"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="链接 URL *"
|
||||
value={form.url}
|
||||
onChange={set('url')}
|
||||
margin="normal"
|
||||
placeholder="example.com 或 https://…"
|
||||
error={!!err && !form.url.trim()}
|
||||
/>
|
||||
|
||||
{/* favicon 实时预览 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1, mb: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32, height: 32, borderRadius: 1.5, border: 1, borderColor: 'divider',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: 'background.paper', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
loading="lazy"
|
||||
style={{ display: 'block' }}
|
||||
onError={(e) => { e.target.style.visibility = 'hidden'; }}
|
||||
/>
|
||||
) : (
|
||||
<WebAssetIcon sx={{ fontSize: 18, color: 'text.disabled' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{host ? `站点:${host}` : '输入链接后预览站点图标'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
fullWidth
|
||||
value={form.category}
|
||||
onChange={(e, v) => setForm((p) => ({ ...p, category: v || '' }))}
|
||||
onInputChange={(e, v) => setForm((p) => ({ ...p, category: v }))}
|
||||
options={categories}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="分组" margin="normal" placeholder="默认" />
|
||||
)}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 1, mb: 0.5, color: 'text.secondary' }}>
|
||||
打开方式
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
size="small"
|
||||
value={form.openMode}
|
||||
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, openMode: v })); }}
|
||||
aria-label="打开方式"
|
||||
>
|
||||
{MODE_META.map(({ mode, label, Icon }) => (
|
||||
<ToggleButton key={mode} value={mode} sx={{ py: 0.75 }}>
|
||||
<Icon fontSize="small" sx={{ mr: 0.5 }} />{label}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{form.openMode === OPEN_MODE_EMBED && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.use_proxy}
|
||||
onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="通过代理嵌入(绕过 X-Frame-Options 限制)"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="嵌入 URL(可选,iframe 专用)"
|
||||
value={form.embed_url}
|
||||
onChange={set('embed_url')}
|
||||
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>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<Typography variant="body2" sx={{ mt: 1.5, color: 'error.main' }}>{err}</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button variant="contained" onClick={save}>添加</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
|
||||
/* ============================================================
|
||||
* CollapseArrow:侧边栏折叠箭头(VSCode 风格)
|
||||
*
|
||||
* - 由父级绝对定位到主区边缘(即侧边栏与主区的 Separator 位置)垂直居中;
|
||||
* 侧边栏折叠为 0 后主区仍在,箭头始终可见可点,支持"再点击展开"。
|
||||
* - 箭头始终指向栏的方向:展开时指栏(点击收起),折叠后反向(点击展开)。
|
||||
* - 可见性:**常驻可见**——不透明背景(background.paper)+ 实心边框 + 阴影,
|
||||
* opacity 展开 0.9 / 折叠 1,hover 全亮加深阴影;不再半透明叠加 iframe 混色。
|
||||
* - 28x28 触控目标,zIndex 12 盖在 iframe 内容之上。
|
||||
* ============================================================ */
|
||||
|
||||
export default function CollapseArrow({ side, collapsed = false, onToggle }) {
|
||||
const right = side === 'right';
|
||||
const label = collapsed ? `展开${right ? '右' : '左'}侧边栏` : `折叠${right ? '右' : '左'}侧边栏`;
|
||||
// 展开 → 指向栏(收起动作);折叠 → 反向(展开动作)
|
||||
const Icon = collapsed
|
||||
? (right ? ChevronLeftIcon : ChevronRightIcon)
|
||||
: (right ? ChevronRightIcon : ChevronLeftIcon);
|
||||
|
||||
return (
|
||||
<Tooltip title={label} placement={right ? 'left' : 'right'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
position: 'absolute', top: '50%', [right ? 'right' : 'left']: 2,
|
||||
transform: 'translateY(-50%)', zIndex: 12,
|
||||
width: 28, height: 28, minWidth: 28, p: 0,
|
||||
opacity: collapsed ? 1 : 0.9,
|
||||
bgcolor: 'background.paper', border: 1, borderColor: 'divider',
|
||||
boxShadow: (t) => t.shadows[2],
|
||||
transition: 'opacity .12s ease, background-color .12s ease, box-shadow .12s ease',
|
||||
'&:hover': { opacity: 1, bgcolor: 'action.hover', boxShadow: (t) => t.shadows[4] },
|
||||
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -2 },
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import PanelFrame from './PanelFrame.jsx';
|
||||
import { tabBtnId, tabPanelId } from './TabStrip.jsx';
|
||||
|
||||
/* ============================================================
|
||||
* ContentView:主区内容分发(按标签 type)
|
||||
*
|
||||
* - 'panel' → PanelFrame(iframe,LRU 由 panelRenderKeys 控制挂载)
|
||||
* - 其他 react 类型 → tab.render() 返回对应组件(记事本/密码箱/面板列表…)
|
||||
* 仅激活标签可见,其余保持挂载(keep-alive 保留 iframe 与编辑状态)。
|
||||
* 每个标签包一层 role=tabpanel,与 TabStrip 的 tab 通过 id 配对(ARIA)。
|
||||
* ============================================================ */
|
||||
|
||||
export default function ContentView({ tabs, activeKey, refreshNonce = 0, panelRenderKeys = null }) {
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.key === activeKey;
|
||||
if (tab.type === 'panel') {
|
||||
// LRU 裁剪:只渲染当前 + 最近使用的 iframe,其余卸载省内存
|
||||
if (panelRenderKeys && !panelRenderKeys.has(tab.key)) return null;
|
||||
return (
|
||||
<Box
|
||||
key={tab.key}
|
||||
id={tabPanelId(tab.key)}
|
||||
role="tabpanel"
|
||||
aria-labelledby={tabBtnId(tab.key)}
|
||||
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
|
||||
>
|
||||
<PanelFrame
|
||||
panel={tab.panel}
|
||||
active={active}
|
||||
refreshNonce={refreshNonce}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
key={tab.key}
|
||||
id={tabPanelId(tab.key)}
|
||||
role="tabpanel"
|
||||
aria-labelledby={tabBtnId(tab.key)}
|
||||
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
|
||||
>
|
||||
{tab.render ? tab.render() : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
|
||||
/* ============================================================
|
||||
* MenuBar:VSCode 风格菜单栏(MUI Menu,配置数组驱动)
|
||||
*
|
||||
* menus = [{ label, items: [{ label, Icon?, action?, shortcut?, divider? }] }]
|
||||
* - role=menubar / menuitem,顶层 roving tabindex + 方向键导航:
|
||||
* ← → / Home / End 移动焦点,↓ / Enter / 空格 打开菜单
|
||||
* - 菜单内部导航由 MUI Menu 自带(方向键 + Enter/Esc)
|
||||
* ============================================================ */
|
||||
|
||||
export default function MenuBar({ menus = [] }) {
|
||||
const [openIdx, setOpenIdx] = useState(null);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [focusIdx, setFocusIdx] = useState(0); // roving tabindex 停靠点
|
||||
const barRef = useRef(null);
|
||||
|
||||
const btnEls = () => Array.from(barRef.current?.querySelectorAll('[data-menu-btn]') || []);
|
||||
const focusBtn = (i) => {
|
||||
const n = menus.length;
|
||||
if (n === 0) return;
|
||||
const j = ((i % n) + n) % n;
|
||||
setFocusIdx(j);
|
||||
btnEls()[j]?.focus();
|
||||
};
|
||||
const openMenu = (i) => {
|
||||
setFocusIdx(i);
|
||||
setAnchorEl(btnEls()[i]);
|
||||
setOpenIdx(i);
|
||||
};
|
||||
const close = () => { setOpenIdx(null); setAnchorEl(null); };
|
||||
|
||||
const handleTopKeyDown = (e, i) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': e.preventDefault(); focusBtn(i + 1); break;
|
||||
case 'ArrowLeft': e.preventDefault(); focusBtn(i - 1); break;
|
||||
case 'Home': e.preventDefault(); focusBtn(0); break;
|
||||
case 'End': e.preventDefault(); focusBtn(menus.length - 1); break;
|
||||
case 'ArrowDown':
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
openMenu(i); // MUI Menu 打开后自动聚焦首个菜单项
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={barRef}
|
||||
component="nav"
|
||||
role="menubar"
|
||||
aria-label="主菜单"
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{menus.map((menu, i) => (
|
||||
<React.Fragment key={menu.label}>
|
||||
<Button
|
||||
data-menu-btn
|
||||
role="menuitem"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openIdx === i}
|
||||
tabIndex={focusIdx === i ? 0 : -1}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
setFocusIdx(i);
|
||||
if (openIdx === i) { close(); return; }
|
||||
setAnchorEl(e.currentTarget);
|
||||
setOpenIdx(i);
|
||||
}}
|
||||
onKeyDown={(e) => handleTopKeyDown(e, i)}
|
||||
sx={{
|
||||
minWidth: 0, px: 1, height: 30, borderRadius: 1,
|
||||
fontSize: 13, color: openIdx === i ? 'primary.main' : 'text.primary',
|
||||
'&:hover': { bgcolor: openIdx === i ? 'action.selected' : 'action.hover' },
|
||||
}}
|
||||
>
|
||||
{menu.label}
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: 14, ml: 0.25, color: 'text.disabled' }} />
|
||||
</Button>
|
||||
<Menu
|
||||
open={openIdx === i}
|
||||
anchorEl={anchorEl}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
MenuListProps={{ sx: { py: 0.5, minWidth: 200 } }}
|
||||
>
|
||||
{menu.items.map((item, j) => (
|
||||
item.divider ? (
|
||||
<Divider key={`d-${j}`} sx={{ my: 0.5 }} />
|
||||
) : (
|
||||
<MenuItem
|
||||
key={item.label + j}
|
||||
dense
|
||||
onClick={() => { close(); item.action?.(); }}
|
||||
>
|
||||
{item.Icon && <item.Icon sx={{ fontSize: 16, mr: 1.25, color: 'text.secondary' }} />}
|
||||
<span style={{ flex: 1 }}>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<Typography variant="caption" sx={{ ml: 3, color: 'text.disabled' }}>{item.shortcut}</Typography>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
))}
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import NoteAltIcon from '@mui/icons-material/NoteAlt';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import useNotes from '../hooks/useNotes.js';
|
||||
import ConfirmDialog from '../../../admin/ConfirmDialog.jsx';
|
||||
|
||||
/* ============================================================
|
||||
* NotesView:记事本视图(VSCode 侧边栏 / 标签页内容)
|
||||
*
|
||||
* 由 NoteEditor 裁剪:去掉浮窗拖拽 / fixed 侧栏 / 自带标题栏,
|
||||
* 标题栏由父级 ToolWindow 提供。保留:
|
||||
* 状态条(当前文件 + 保存状态)→ 编辑器(自动保存,Ctrl+S)
|
||||
* → 文件管理器(新建/双击重命名/右键菜单/删除)
|
||||
* 常驻挂载(keep-alive),切换视图不丢草稿。
|
||||
* ============================================================ */
|
||||
|
||||
/** 精简时间格式:今天 HH:mm / 今年 MM-DD / 往年 YYYY-MM-DD */
|
||||
function fmtTime(ms) {
|
||||
if (!ms) return '';
|
||||
const d = new Date(ms);
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
if (d.toDateString() === now.toDateString()) return hm;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`;
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** 新建/重命名弹窗 */
|
||||
function NameDialog({ open, title, initial, confirmText, exists, busy, onClose, onConfirm }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) { setValue(initial); setErr(''); }
|
||||
}, [open, initial]);
|
||||
|
||||
const submit = () => {
|
||||
let name = value.trim();
|
||||
if (!name) { setErr('请输入文件名'); return; }
|
||||
if (!name.toLowerCase().endsWith('.txt')) name += '.txt';
|
||||
if (exists.includes(name.toLowerCase())) { setErr('同名笔记已存在'); return; }
|
||||
onConfirm(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
autoFocus
|
||||
size="small"
|
||||
label="文件名"
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setErr(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
error={!!err}
|
||||
helperText={err || '仅支持 .txt 文本文件'}
|
||||
inputProps={{ style: { fontSize: 14 } }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button variant="contained" disabled={busy} onClick={submit}>{confirmText}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotesView() {
|
||||
const notes = useNotes();
|
||||
const {
|
||||
notes: list, loadError, refresh,
|
||||
activeName, content, dirty, saving, busy,
|
||||
open, updateContent, saveNow, create, rename, remove, nextUntitled,
|
||||
} = notes;
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState(null);
|
||||
const [delTarget, setDelTarget] = useState(null);
|
||||
const [ctx, setCtx] = useState(null); // { name, anchorEl }
|
||||
|
||||
const handleCreate = (name) => { setNewOpen(false); create(name); };
|
||||
const handleRename = (name) => { setRenameTarget(null); rename(ctx?.name || renameTarget, name); };
|
||||
const handleDelete = async () => {
|
||||
const name = delTarget;
|
||||
setDelTarget(null);
|
||||
await remove(name);
|
||||
};
|
||||
|
||||
const statusText = saving ? '保存中…' : dirty ? '未保存' : '已保存';
|
||||
|
||||
return (
|
||||
<Box component="section" aria-label="记事本" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 状态条 */}
|
||||
<Box sx={{ px: 1.5, py: 0.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default', borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Typography variant="caption" noWrap sx={{ flex: 1, color: 'text.secondary' }} title={activeName || ''}>
|
||||
{activeName || '未打开笔记'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ flexShrink: 0, color: saving ? 'text.secondary' : dirty ? 'warning.main' : 'success.main', fontWeight: 500 }}>
|
||||
{statusText}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 编辑器(上半) */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', p: 0.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
variant="outlined"
|
||||
value={content}
|
||||
onChange={(e) => updateContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveNow(); }
|
||||
}}
|
||||
disabled={!activeName || busy}
|
||||
placeholder={activeName ? '开始输入…(自动保存,Ctrl+S 立即保存)' : '从下方选择或新建一篇笔记'}
|
||||
inputProps={{ 'aria-label': '笔记内容', style: { fontSize: 13, lineHeight: 1.6 } }}
|
||||
sx={{
|
||||
flex: 1, minHeight: 0,
|
||||
'& .MuiInputBase-root': { height: '100%', alignItems: 'flex-start', borderRadius: 1 },
|
||||
'& .MuiInputBase-inputMultiline': { height: '100% !important', overflow: 'auto !important', boxSizing: 'border-box' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 文件管理器(下半) */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', borderTop: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 1.25, py: 0.5, flexShrink: 0 }}>
|
||||
<Typography sx={{ flex: 1, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: 'text.secondary' }}>
|
||||
笔记({list ? list.length : 0})
|
||||
</Typography>
|
||||
<Tooltip title="新建笔记">
|
||||
<IconButton size="small" onClick={() => setNewOpen(true)} aria-label="新建笔记"><AddIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{list === null && !loadError ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>
|
||||
) : loadError ? (
|
||||
<Box sx={{ px: 2, py: 2, textAlign: 'center' }}>
|
||||
<ErrorIcon sx={{ fontSize: 24, color: 'error.main', mb: 0.5 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{loadError}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={refresh}>重试</Button>
|
||||
</Box>
|
||||
) : list.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
|
||||
<NoteAltIcon sx={{ fontSize: 28, color: 'text.disabled', mb: 0.5 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>暂无笔记,点击上方 + 新建</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List dense disablePadding>
|
||||
{list.map((n) => {
|
||||
const active = n.name === activeName;
|
||||
return (
|
||||
<ListItemButton
|
||||
key={n.name}
|
||||
onClick={() => open(n.name)}
|
||||
onDoubleClick={() => setRenameTarget(n.name)}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCtx({ name: n.name, anchorEl: e.currentTarget }); }}
|
||||
onKeyDown={(e) => {
|
||||
// F2 重命名(标准文件管理器约定,避免与 Enter 打开冲突)
|
||||
if (e.key === 'F2') { e.preventDefault(); setRenameTarget(n.name); }
|
||||
}}
|
||||
aria-label={`打开笔记 ${n.name}`}
|
||||
sx={{
|
||||
minHeight: 40, borderRadius: 0, m: 0, px: 0,
|
||||
bgcolor: active ? 'primary.container' : 'transparent',
|
||||
color: active ? 'primary.onContainer' : 'text.primary',
|
||||
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
|
||||
'&:hover .nb-del': { opacity: 1 },
|
||||
'&:focus-visible .nb-del': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<NoteAltIcon fontSize="small" sx={{ mx: 1, flexShrink: 0, opacity: 0.7 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0, py: 0.5 }}>
|
||||
<Typography noWrap sx={{ fontSize: 12.5, fontWeight: active ? 600 : 500 }}>{n.name}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||
{fmtTime(n.mtime)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
className="nb-del"
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setDelTarget(n.name); }}
|
||||
aria-label={`删除 ${n.name}`}
|
||||
sx={{ opacity: 0, width: 28, height: 28, mr: 0.5, transition: 'opacity .12s' }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
<Menu
|
||||
open={!!ctx}
|
||||
anchorEl={ctx?.anchorEl || null}
|
||||
onClose={() => setCtx(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<MenuItem dense onClick={() => { if (ctx) setRenameTarget(ctx.name); setCtx(null); }}>
|
||||
<EditOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />重命名
|
||||
</MenuItem>
|
||||
<MenuItem dense onClick={() => { if (ctx) setDelTarget(ctx.name); setCtx(null); }}>
|
||||
<DeleteOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'error.main' }} />删除
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<NameDialog
|
||||
open={newOpen}
|
||||
title="新建笔记"
|
||||
initial={nextUntitled}
|
||||
confirmText="创建"
|
||||
exists={(list || []).map((n) => n.name.toLowerCase())}
|
||||
busy={busy}
|
||||
onClose={() => setNewOpen(false)}
|
||||
onConfirm={handleCreate}
|
||||
/>
|
||||
<NameDialog
|
||||
open={!!renameTarget}
|
||||
title="重命名笔记"
|
||||
initial={renameTarget || ''}
|
||||
confirmText="保存"
|
||||
exists={(list || []).map((n) => n.name.toLowerCase()).filter((n) => n !== renameTarget?.toLowerCase())}
|
||||
busy={busy}
|
||||
onClose={() => setRenameTarget(null)}
|
||||
onConfirm={handleRename}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!delTarget}
|
||||
title="删除笔记"
|
||||
message={`确定要删除 "${delTarget || ''}" 吗?此操作不可恢复。`}
|
||||
onClose={() => setDelTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
confirmText="删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
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';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { getToken } from '../../../api/client.js';
|
||||
|
||||
/* ============================================================
|
||||
* PanelFrame:单个面板的 iframe 容器
|
||||
*
|
||||
* - 懒加载:首次激活才创建 iframe DOM,之后 display:none 保留状态
|
||||
* - 超时 + load 双保险:加载中显示覆盖层;超时(X-Frame-Options
|
||||
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
|
||||
* - 代理模式(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 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 分钟
|
||||
|
||||
/* 面板预热缓存:slug -> { p, at }(并发去重 + 定时过期) */
|
||||
const warmCache = new Map();
|
||||
|
||||
/* 短 TTL proxy token 的模块级缓存(仅旧 /api/proxy/fetch 兼容回退使用) */
|
||||
let proxyTokenCache = { promise: null, expiresAt: 0 };
|
||||
|
||||
function fetchProxyToken() {
|
||||
const now = Date.now();
|
||||
if (proxyTokenCache.promise && now < proxyTokenCache.expiresAt) return proxyTokenCache.promise;
|
||||
const p = fetch('/api/proxy/token', {
|
||||
headers: { Authorization: 'Bearer ' + (getToken() || '') },
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('proxy token 获取失败');
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
})
|
||||
.catch((e) => {
|
||||
proxyTokenCache.promise = null;
|
||||
throw e;
|
||||
});
|
||||
proxyTokenCache.promise = p;
|
||||
proxyTokenCache.expiresAt = now + TOKEN_TTL_MS;
|
||||
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
|
||||
const [proxySrc, setProxySrc] = useState('');
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const iframeRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const hintTimerRef = useRef(null);
|
||||
const statusRef = useRef('loading');
|
||||
|
||||
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:'));
|
||||
|
||||
// slug:后端自动生成(小写 [a-z0-9-],≤32)。旧库行可能为空 → 回退旧 /fetch
|
||||
const slug = String(panel.slug || '').toLowerCase();
|
||||
const hasSlug = /^[a-z0-9-]{1,32}$/.test(slug);
|
||||
const proxyPrefix = hasSlug ? '/proxy/' + slug + '/' : '';
|
||||
|
||||
// 信任模型:admin_links.trusted(1=可信,0=安全)。缺失按可信处理
|
||||
const trusted = !(panel.trusted === 0 || panel.trusted === '0');
|
||||
|
||||
// ── sandbox 按信任模型配置 ────────────────────────────────
|
||||
// 代理模式:trusted=1 保 allow-same-origin(保目标站 cookie 登录态);
|
||||
// trusted=0 剥离(opaque origin,被代理页无法以本站身份发请求/读 DOM)
|
||||
// 站内直嵌(/ 开头,本站同源伺服):一律剥离(同源逃逸风险,与信任无关)
|
||||
// 外部直嵌:跨源天然隔离,trusted=1 保 allow-same-origin(站点 cookie 依赖)
|
||||
const stripSameOrigin = needsProxy
|
||||
? !trusted
|
||||
: (targetUrl.startsWith('/') || !trusted);
|
||||
const sandbox = stripSameOrigin
|
||||
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
|
||||
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads';
|
||||
|
||||
// ── permissions 委托:默认能力 + 面板配置的权限 → allow 属性 ──
|
||||
const baseAllow = 'fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
||||
const delegated = parsePermissions(panel.permissions);
|
||||
const allowAttr = delegated.length ? [baseAllow, ...delegated].join('; ') : baseAllow;
|
||||
|
||||
// ── scale 缩放(0.1-3,默认 1):1/scale 外盒 + transform scale ──
|
||||
const scale = (() => {
|
||||
const s = parseFloat(panel.scale);
|
||||
return Number.isFinite(s) ? Math.min(3, Math.max(0.1, s)) : 1;
|
||||
})();
|
||||
|
||||
// 代理模式启动:预热(slug 模式)或取短 token(旧模式),成功后就绪 src。
|
||||
// alive 守卫避免卸载后 setState;预热失败进入超时回退态。
|
||||
const bootProxy = useCallback((alive = () => true) => {
|
||||
if (!needsProxy) { setProxySrc(targetUrl); return; }
|
||||
setProxySrc('');
|
||||
if (hasSlug) {
|
||||
warmProxy(slug)
|
||||
.then(() => { if (alive()) setProxySrc(proxyPrefix); })
|
||||
.catch(() => {
|
||||
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
|
||||
});
|
||||
} else {
|
||||
fetchProxyToken()
|
||||
.then((tok) => {
|
||||
if (alive()) setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
|
||||
});
|
||||
}
|
||||
}, [needsProxy, hasSlug, slug, proxyPrefix, targetUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
bootProxy(() => alive);
|
||||
return () => { alive = false; };
|
||||
}, [bootProxy]);
|
||||
|
||||
const src = needsProxy ? proxySrc : targetUrl;
|
||||
|
||||
// 懒加载:首次激活才创建 iframe
|
||||
useEffect(() => {
|
||||
if (active) setMounted(true);
|
||||
}, [active]);
|
||||
|
||||
const startTimer = () => {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (statusRef.current === 'loading') {
|
||||
statusRef.current = 'timeout';
|
||||
setStatus('timeout');
|
||||
}
|
||||
}, LOAD_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// 挂载 / 地址变化:进入加载态并启动超时
|
||||
useEffect(() => {
|
||||
if (!mounted || !src) return;
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
setShowHint(false);
|
||||
startTimer();
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [mounted, src]);
|
||||
|
||||
// 统一刷新入口:从未就绪(预热/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;
|
||||
performReload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshNonce]);
|
||||
|
||||
const handleLoad = () => {
|
||||
clearTimeout(timerRef.current);
|
||||
statusRef.current = 'loaded';
|
||||
setStatus('loaded');
|
||||
// 直接嵌入(非 proxy)时,加载成功仍可能被 X-Frame-Options 拒成空白页,
|
||||
// 浮动提示「在新标签打开」;非阻塞,5 秒自动消失或可关闭。
|
||||
if (!needsProxy) {
|
||||
setShowHint(true);
|
||||
clearTimeout(hintTimerRef.current);
|
||||
hintTimerRef.current = setTimeout(() => setShowHint(false), HINT_TTL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
performReload();
|
||||
};
|
||||
|
||||
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative', width: '100%', height: '100%', overflow: 'hidden',
|
||||
display: active ? 'block' : 'none', bgcolor: 'background.paper', flexShrink: 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={allowAttr}
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'loading' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
|
||||
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5,
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
正在加载 {panel.title || '面板'}…
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'timeout' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
|
||||
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
bgcolor: 'background.default', px: 3, textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<ErrorIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>面板加载超时</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
目标站点可能拒绝嵌入(X-Frame-Options),或响应过慢。
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<OpenInNewIcon fontSize="small" />}
|
||||
href={targetUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
在新标签页打开
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
startIcon={<RefreshIcon fontSize="small" />}
|
||||
onClick={handleRetry}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 加载成功后的非阻塞提示条:空白页可能被 X-Frame-Options 拒绝 */}
|
||||
{showHint && status === 'loaded' && !needsProxy && (
|
||||
<Box
|
||||
role="status"
|
||||
sx={{
|
||||
position: 'absolute', left: '50%', transform: 'translateX(-50%)', bottom: 16,
|
||||
zIndex: 3, maxWidth: '92%', display: 'flex', alignItems: 'center', gap: 0.5,
|
||||
px: 1.5, py: 0.5, borderRadius: 2,
|
||||
bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||
border: 1, borderColor: 'divider', boxShadow: (t) => t.shadows[3],
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||
若页面空白,可能被 X-Frame-Options 拒绝
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
component="a"
|
||||
href={targetUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ minHeight: 28, px: 1, color: 'primary.onContainer', fontWeight: 700 }}
|
||||
>
|
||||
在新标签打开
|
||||
</Button>
|
||||
<IconButton size="small" onClick={() => setShowHint(false)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
/* ============================================================
|
||||
* 面板图标:Google favicon 服务 + 失败回退(站点首字母 + 确定性 MD3 容器色)
|
||||
*
|
||||
* - 成功:显示 favicon(不变)
|
||||
* - 失败/无 host:显示首字母色块(类似 Google Avatar)——
|
||||
* 背景色从 MD3 容器色(primary/secondary/tertiary.container)按
|
||||
* 域名/标题哈希确定性选取,比默认地球图标更可辨识。
|
||||
* ============================================================ */
|
||||
|
||||
/** 确定性回退色板:MD3 token(不硬编码 hex),按哈希循环选取 */
|
||||
const FALLBACK_PALETTE = [
|
||||
{ bg: 'primary.container', fg: 'primary.onContainer' },
|
||||
{ bg: 'secondary.container', fg: 'secondary.onContainer' },
|
||||
{ bg: 'tertiary.container', fg: 'tertiary.onContainer' },
|
||||
];
|
||||
|
||||
function hashString(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
function hostOf(url) {
|
||||
try {
|
||||
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
|
||||
return new URL(u).hostname;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
||||
const [error, setError] = useState(false);
|
||||
const host = hostOf(url);
|
||||
|
||||
// 首字母 + 确定性颜色
|
||||
const letter = ((title && title[0]) || (host && host[0]) || '?').toUpperCase();
|
||||
const { bg, fg } = FALLBACK_PALETTE[hashString(host || title || '?') % FALLBACK_PALETTE.length];
|
||||
|
||||
// 无 host(内部地址/空)或 favicon 加载失败(404/API 不可达)→ 首字母色块
|
||||
if (!host || error) {
|
||||
return (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: size, height: size, borderRadius: size / 4, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: bg, color: fg,
|
||||
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: size, height: size, borderRadius: size / 4, overflow: 'hidden',
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: 'background.paper', ...sx,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onError={() => setError(true)}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { Fragment, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarBorderIcon from '@mui/icons-material/StarBorder';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import WebAssetIcon from '@mui/icons-material/WebAsset';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import PanelIcon from './PanelIcon.jsx';
|
||||
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
|
||||
|
||||
/* ============================================================
|
||||
* PanelsView:面板列表视图(VSCode 侧边栏「面板」内容)
|
||||
*
|
||||
* - 固定区(pin)+ admin_links 按 category 分组
|
||||
* - 当前项:左侧 2px primary 指示条 + primary-container 选中底
|
||||
* - 分组折叠(持久化)、右键切换打开方式、底部 + 添加
|
||||
* - 搜索时展示扁平结果列表
|
||||
* - 宽度/折叠由外部 react-resizable-panels 布局管理,本组件不再自持
|
||||
* ============================================================ */
|
||||
|
||||
const MODE_META = [
|
||||
{ mode: OPEN_MODE_EMBED, label: '内嵌打开', Icon: WebAssetIcon },
|
||||
{ mode: OPEN_MODE_TAB, label: '新标签打开', Icon: OpenInNewIcon },
|
||||
{ mode: OPEN_MODE_MODAL, label: '弹窗打开', Icon: OpenInFullIcon },
|
||||
];
|
||||
|
||||
export default function PanelsView({
|
||||
loading = false, error = '', onRetry,
|
||||
links = [], filtered = [], search = '',
|
||||
activeId = null, pinned = [], onSelect, onPin,
|
||||
groups = {}, onToggleGroup,
|
||||
modes = {}, onSetMode,
|
||||
onAdd,
|
||||
}) {
|
||||
const [ctx, setCtx] = useState(null); // { id, anchorEl }
|
||||
|
||||
const groupOrder = useMemo(() => {
|
||||
const order = []; const seen = new Set();
|
||||
links.forEach((p) => {
|
||||
const c = p.category || '默认';
|
||||
if (!seen.has(c)) { seen.add(c); order.push(c); }
|
||||
});
|
||||
return order;
|
||||
}, [links]);
|
||||
|
||||
const byGroup = useMemo(() => {
|
||||
const m = {};
|
||||
links.forEach((p) => {
|
||||
const c = p.category || '默认';
|
||||
(m[c] = m[c] || []).push(p);
|
||||
});
|
||||
return m;
|
||||
}, [links]);
|
||||
|
||||
const pinnedPanels = links.filter((p) => pinned.includes(String(p.id)));
|
||||
|
||||
const renderItem = (p) => {
|
||||
const selected = String(p.id) === String(activeId);
|
||||
const isPinned = pinned.includes(String(p.id));
|
||||
const mode = modes[p.id] || OPEN_MODE_EMBED;
|
||||
return (
|
||||
<Box
|
||||
key={p.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', position: 'relative',
|
||||
'&:hover .wb-pin': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
selected={selected}
|
||||
onClick={() => onSelect(p.id)}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCtx({ id: p.id, anchorEl: e.currentTarget }); }}
|
||||
aria-label={`打开面板 ${p.title}`}
|
||||
sx={{
|
||||
flex: 1, minHeight: 40, borderRadius: 0, m: 0, pl: 2, pr: 4,
|
||||
position: 'relative',
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||
'&:hover': { bgcolor: 'primary.container' },
|
||||
},
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
{selected && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute', left: 0, top: '22%', bottom: '22%',
|
||||
width: 2, bgcolor: 'primary.main', borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PanelIcon url={p.embed_url || p.url} title={p.title} size={20} sx={{ mr: 1 }} />
|
||||
<ListItemText
|
||||
primary={p.title}
|
||||
primaryTypographyProps={{ fontSize: 13, fontWeight: selected ? 600 : 500, noWrap: true }}
|
||||
sx={{ my: 0, minWidth: 0 }}
|
||||
/>
|
||||
{mode !== OPEN_MODE_EMBED && (
|
||||
<Box
|
||||
component="span"
|
||||
title={MODE_META.find((m) => m.mode === mode)?.label}
|
||||
sx={{ mr: 0.5, fontSize: 10, color: 'text.disabled', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{mode === OPEN_MODE_TAB ? '新标签' : '弹窗'}
|
||||
</Box>
|
||||
)}
|
||||
</ListItemButton>
|
||||
<IconButton
|
||||
className="wb-pin"
|
||||
size="small"
|
||||
onClick={() => onPin(p.id)}
|
||||
aria-label={isPinned ? `取消固定 ${p.title}` : `固定 ${p.title}`}
|
||||
sx={{
|
||||
position: 'absolute', right: 4, opacity: isPinned ? 1 : 0,
|
||||
transition: 'opacity .12s ease', color: isPinned ? 'warning.main' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{isPinned ? <StarIcon fontSize="small" /> : <StarBorderIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const sectionLabel = (text) => (
|
||||
<Typography
|
||||
component="div"
|
||||
sx={{
|
||||
px: 2, pt: 1.25, pb: 0.5, fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 0.6, color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const listBody = (() => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ px: 2, pt: 1 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} variant="rounded" height={36} sx={{ my: 1, bgcolor: 'action.hover' }} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
|
||||
<ErrorIcon sx={{ fontSize: 32, color: 'error.main', mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>面板加载失败</Typography>
|
||||
<Button size="small" variant="outlined" onClick={onRetry}>重试</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (links.length === 0) {
|
||||
return (
|
||||
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
|
||||
<WidgetsIcon sx={{ fontSize: 32, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>还没有面板,点击下方按钮添加</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (search.trim()) {
|
||||
return (
|
||||
<>
|
||||
{sectionLabel(`搜索结果(${filtered.length})`)}
|
||||
{filtered.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ px: 2, py: 2, color: 'text.secondary' }}>无匹配面板</Typography>
|
||||
) : (
|
||||
<List dense disablePadding>{filtered.map(renderItem)}</List>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{pinnedPanels.length > 0 && (
|
||||
<>
|
||||
{sectionLabel('固定')}
|
||||
<List dense disablePadding>{pinnedPanels.map(renderItem)}</List>
|
||||
<Divider sx={{ mx: 2, my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
{groupOrder.map((name) => {
|
||||
const collapsedGroup = !!groups[name];
|
||||
const items = byGroup[name] || [];
|
||||
return (
|
||||
<Fragment key={name}>
|
||||
<ListItemButton
|
||||
onClick={() => onToggleGroup(name)}
|
||||
aria-expanded={!collapsedGroup}
|
||||
sx={{
|
||||
minHeight: 36, borderRadius: 0, m: 0, px: 1.5,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
<FolderOpenIcon fontSize="small" sx={{ color: 'text.secondary' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={name}
|
||||
primaryTypographyProps={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: 'text.secondary' }}
|
||||
sx={{ my: 0 }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ mr: 0.5, color: 'text.disabled' }}>{items.length}</Typography>
|
||||
{collapsedGroup
|
||||
? <ExpandMoreIcon fontSize="small" sx={{ color: 'text.secondary' }} />
|
||||
: <ExpandLessIcon fontSize="small" sx={{ color: 'text.secondary' }} />}
|
||||
</ListItemButton>
|
||||
<Collapse in={!collapsedGroup} unmountOnExit timeout={150}>
|
||||
<List dense disablePadding>{items.map(renderItem)}</List>
|
||||
</Collapse>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{listBody}
|
||||
</Box>
|
||||
<Divider sx={{ flexShrink: 0 }} />
|
||||
<Box sx={{ p: 1, flexShrink: 0 }}>
|
||||
<Button fullWidth size="small" startIcon={<AddIcon />} onClick={onAdd}>添加面板</Button>
|
||||
</Box>
|
||||
|
||||
{/* 右键菜单:切换打开方式 */}
|
||||
<Menu
|
||||
open={!!ctx}
|
||||
anchorEl={ctx?.anchorEl || null}
|
||||
onClose={() => setCtx(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
{MODE_META.map(({ mode, label, Icon }) => (
|
||||
<MenuItem
|
||||
key={mode}
|
||||
selected={(modes[ctx?.id] || OPEN_MODE_EMBED) === mode}
|
||||
onClick={() => {
|
||||
if (ctx) { onSetMode(ctx.id, mode); onSelect(ctx.id); }
|
||||
setCtx(null);
|
||||
}}
|
||||
>
|
||||
<Icon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
/* ============================================================
|
||||
* SidebarPane:VSCode 风格侧边栏容器(一侧一个)
|
||||
*
|
||||
* - 该侧全部 sidebar 模式视图**常驻挂载**(display 显隐切换),
|
||||
* 切换视图/切标签页不丢编辑状态(记事本草稿、密码箱解锁态)
|
||||
* - 每个视图由 ToolWindow 提供标题栏;内容按 activeView 显示
|
||||
* - 视图处于 tab 模式时该视图不在侧栏 → 显示占位 + 「移回侧边栏」
|
||||
* - 无障碍:容器 id=wb-sidebar-{side} 供活动栏 aria-controls;
|
||||
* aria-live 播报当前视图,配合活动栏焦点提示
|
||||
* ============================================================ */
|
||||
|
||||
export default function SidebarPane({ side, views, activeId, renderView, renderPlaceholder }) {
|
||||
const right = side === 'right';
|
||||
const activeView = views.find((v) => v.id === activeId);
|
||||
return (
|
||||
<Box
|
||||
component="aside"
|
||||
id={`wb-sidebar-${side}`}
|
||||
aria-label={right ? '右侧侧边栏' : '左侧侧边栏'}
|
||||
sx={{
|
||||
height: '100%', minWidth: 0, overflow: 'hidden', position: 'relative',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
{/* 活动栏激活后的屏幕阅读器播报(视觉隐藏) */}
|
||||
<Box
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
position: 'absolute', width: 1, height: 1, overflow: 'hidden',
|
||||
clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', m: -1, p: 0, border: 0,
|
||||
}}
|
||||
>
|
||||
{activeView ? `当前视图:${activeView.label}` : '侧边栏为空'}
|
||||
</Box>
|
||||
|
||||
{views.map((v) => {
|
||||
const active = activeId === v.id;
|
||||
return (
|
||||
<Box
|
||||
key={v.id}
|
||||
sx={{
|
||||
flex: 1, minHeight: 0, display: active ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{renderView(v.id)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{views.length === 0 && (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 3, textAlign: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
从另一侧活动栏拖一个功能标签到这里
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{views.length > 0 && !views.some((v) => v.id === activeId) && renderPlaceholder && (
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3, textAlign: 'center' }}>
|
||||
{renderPlaceholder()}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
/* ============================================================
|
||||
* StatusBar:底部状态栏(可选,VSCode 风格)
|
||||
* 左:当前上下文;右:主机:端口 + 版本
|
||||
* ============================================================ */
|
||||
|
||||
export default function StatusBar({ left, right = null }) {
|
||||
const host = typeof window !== 'undefined' ? window.location.host : '';
|
||||
return (
|
||||
<Box
|
||||
role="status"
|
||||
sx={{
|
||||
height: 24, flexShrink: 0, display: 'flex', alignItems: 'center',
|
||||
px: 1.5, gap: 1.5, bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||
fontSize: 11, borderTop: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden
|
||||
sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: 'success.main', flexShrink: 0 }}
|
||||
/>
|
||||
<Typography noWrap sx={{ fontSize: 11, fontWeight: 500, flex: 1, minWidth: 0 }}>{left}</Typography>
|
||||
<Typography noWrap sx={{ fontSize: 11, opacity: 0.85 }}>{right || host}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
/* ============================================================
|
||||
* TabStrip:主区标签页条(VSCode 风格,浏览器标签交互)
|
||||
*
|
||||
* - tabs:{ key, title, icon?, closable? };iframe 面板与
|
||||
* tab 模式视图共用一套标签(由 Workbench 拼装)
|
||||
* - 键盘:← → 切换、Home/End 首尾(roving tabindex)
|
||||
* - ARIA:每个 tab 与 ContentView 的 tabpanel 通过 id 配对
|
||||
* (tabBtnId / tabPanelId,冒号等字符已做无害化)
|
||||
* ============================================================ */
|
||||
|
||||
/** tab 按钮元素 id:tab.key 可能含 ':',替换为下划线保证合法 */
|
||||
export function tabBtnId(key) {
|
||||
return 'wb-tab-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
/** 对应 tabpanel 元素 id */
|
||||
export function tabPanelId(key) {
|
||||
return 'wb-panel-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
export default function TabStrip({ tabs, activeKey, onSelect, onClose, onAdd }) {
|
||||
const stripRef = useRef(null);
|
||||
|
||||
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
|
||||
const moveTo = (i) => {
|
||||
const n = tabs.length;
|
||||
if (n === 0) return;
|
||||
const j = ((i % n) + n) % n;
|
||||
onSelect(tabs[j].key);
|
||||
tabEls()[j]?.focus();
|
||||
};
|
||||
const handleKeyDown = (e, index) => {
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); moveTo(index + 1); }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveTo(index - 1); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); moveTo(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); moveTo(tabs.length - 1); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={stripRef}
|
||||
role="tablist"
|
||||
aria-label="已打开的面板与视图"
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, py: 0.5,
|
||||
height: 40, flexShrink: 0, bgcolor: 'background.paper',
|
||||
borderBottom: 1, borderColor: 'divider',
|
||||
overflowX: 'auto', overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const active = tab.key === activeKey;
|
||||
return (
|
||||
<Box
|
||||
key={tab.key}
|
||||
id={tabBtnId(tab.key)}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-label={tab.title}
|
||||
aria-controls={tabPanelId(tab.key)}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onSelect(tab.key)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
|
||||
height: 28, borderRadius: 1, cursor: 'pointer', flexShrink: 0, outline: 'none',
|
||||
bgcolor: active ? 'primary.container' : 'transparent',
|
||||
color: active ? 'primary.onContainer' : 'text.secondary',
|
||||
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
|
||||
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
fontSize: 12, fontWeight: active ? 600 : 500, maxWidth: 140,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</Typography>
|
||||
{tab.closable && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(tab.key); }}
|
||||
aria-label={`关闭 ${tab.title}`}
|
||||
sx={{ width: 20, height: 20, borderRadius: 0.5, '& svg': { fontSize: 14 } }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{onAdd && (
|
||||
<Tooltip title="添加面板">
|
||||
<IconButton size="small" onClick={onAdd} sx={{ ml: 0.25, flexShrink: 0 }} aria-label="添加面板">
|
||||
<AddIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||
import TabIcon from '@mui/icons-material/Tab';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js';
|
||||
|
||||
/* ============================================================
|
||||
* ToolWindow:通用可停靠面板(侧边栏 / 标签页双模式 + 浮动占位)
|
||||
*
|
||||
* - 标题栏:图标 + 标题 + 模式切换(侧边栏/标签页)+ 浮动占位按钮
|
||||
* (P2 未实现,点击只触发 onFloat 提示)+ 关闭按钮 + 自定义 actions
|
||||
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入
|
||||
* ============================================================ */
|
||||
|
||||
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, closeTitle = '关闭', actions = null, children }) {
|
||||
const modeBtn = (m, label, MIcon) => (
|
||||
<Tooltip key={m} title={label}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSetMode?.(m)}
|
||||
aria-label={label}
|
||||
aria-pressed={mode === m}
|
||||
sx={{
|
||||
width: 26, height: 26, borderRadius: 1,
|
||||
color: mode === m ? 'primary.main' : 'text.secondary',
|
||||
bgcolor: mode === m ? 'action.selected' : 'transparent',
|
||||
'&:hover': { bgcolor: mode === m ? 'action.selected' : 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<MIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.25,
|
||||
px: 1.25, height: 44, flexShrink: 0,
|
||||
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Typography noWrap sx={{ flex: 1, minWidth: 0, ml: 0.25, fontSize: 13, fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{modeBtn(VIEW_MODE_SIDEBAR, '侧边栏模式', ViewSidebarIcon)}
|
||||
{modeBtn(VIEW_MODE_TAB, '标签页模式', TabIcon)}
|
||||
{onFloat && (
|
||||
<Tooltip title="浮动模式(即将上线)">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onFloat}
|
||||
aria-label="浮动模式"
|
||||
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
||||
>
|
||||
<OpenInFullIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{actions}
|
||||
{onClose && (
|
||||
<Tooltip title={closeTitle}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
aria-label={closeTitle}
|
||||
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import MenuBar from './MenuBar.jsx';
|
||||
import PanelIcon from './PanelIcon.jsx';
|
||||
|
||||
/* ============================================================
|
||||
* Toolbar:VSCode 风格顶栏(高度 56px)
|
||||
*
|
||||
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 地址栏(自适应填满) | 搜索 | 返回
|
||||
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签;
|
||||
* flex:1 填满顶栏剩余空间,左侧内容变宽时自然缩减
|
||||
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
|
||||
* ============================================================ */
|
||||
|
||||
export default function Toolbar({
|
||||
menus = [],
|
||||
canBack = false, canForward = false, onBack, onForward, onRefresh,
|
||||
currentPanel = null, onOpenExternal,
|
||||
search = '', onSearchChange, onSearchEnter,
|
||||
searchInputRef = null, onBackHome,
|
||||
}) {
|
||||
const muiTheme = useTheme();
|
||||
const searchRef = useRef(null);
|
||||
const inputRef = searchInputRef || searchRef;
|
||||
|
||||
const currentUrl = currentPanel ? (currentPanel.embed_url || currentPanel.url || '') : '';
|
||||
const logoGradient = `linear-gradient(135deg, ${muiTheme.palette.primary.main} 0%, ${muiTheme.palette.tertiary.main} 100%)`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
height: 56, flexShrink: 0, display: 'flex', alignItems: 'center',
|
||||
gap: 0.5, px: 1, zIndex: 10,
|
||||
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{/* 品牌:纯文字 Logo,MD3 渐变字重 */}
|
||||
<Box
|
||||
component="span"
|
||||
aria-label="Rain Work"
|
||||
sx={{
|
||||
fontSize: 19.5, fontWeight: 700, letterSpacing: 0.4, mr: 1, flexShrink: 0,
|
||||
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent', color: 'transparent',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
Rain Work
|
||||
</Box>
|
||||
|
||||
<MenuBar menus={menus} />
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||
|
||||
{/* 导航:后退 / 前进 / 刷新 */}
|
||||
<Tooltip title="后退">
|
||||
<span><IconButton size="small" onClick={onBack} disabled={!canBack} aria-label="后退"><ArrowBackIcon fontSize="small" /></IconButton></span>
|
||||
</Tooltip>
|
||||
<Tooltip title="前进">
|
||||
<span><IconButton size="small" onClick={onForward} disabled={!canForward} aria-label="前进"><ArrowForwardIcon fontSize="small" /></IconButton></span>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新当前面板">
|
||||
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
|
||||
</Tooltip>
|
||||
|
||||
{/* 地址栏(只读,flex:1 自适应填满顶栏剩余空间;无 maxWidth 上限,
|
||||
左侧菜单/导航变宽时自然缩减,顶栏始终占满) */}
|
||||
<Box
|
||||
role="textbox"
|
||||
aria-label="当前面板地址"
|
||||
sx={{
|
||||
flex: 1, minWidth: 120, maxWidth: 'none', display: 'flex', alignItems: 'center', gap: 0.75,
|
||||
mx: 0.75, height: 34, px: 1.25,
|
||||
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
|
||||
'&:focus-within': { borderColor: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
{currentPanel && <PanelIcon url={currentUrl} title={currentPanel.title} size={16} />}
|
||||
<InputBase
|
||||
readOnly
|
||||
fullWidth
|
||||
value={currentUrl}
|
||||
placeholder="未打开面板"
|
||||
inputProps={{ 'aria-label': '当前面板地址', style: { cursor: 'default', fontSize: 12.5 } }}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
{currentPanel && (
|
||||
<Tooltip title="在新标签页打开原站">
|
||||
<IconButton size="small" onClick={onOpenExternal} aria-label="在新标签页打开原站">
|
||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 搜索 */}
|
||||
<Box sx={{ position: 'relative', flexShrink: 0 }}>
|
||||
<SearchIcon
|
||||
sx={{
|
||||
position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)',
|
||||
fontSize: 16, color: 'text.secondary', pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<InputBase
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { onSearchEnter?.(); inputRef.current?.blur(); } }}
|
||||
placeholder="搜索面板"
|
||||
inputProps={{ 'aria-label': '搜索面板', style: { paddingLeft: 30, fontSize: 12.5 } }}
|
||||
sx={{
|
||||
height: 34, width: { xs: 100, sm: 150 }, px: 0.5,
|
||||
bgcolor: 'background.default', border: 1, borderColor: 'divider',
|
||||
borderRadius: 20, '&:focus-within': { borderColor: 'primary.main' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 返回前台 */}
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={onBackHome}
|
||||
startIcon={<HomeIcon sx={{ fontSize: 17 }} />}
|
||||
sx={{ ml: 0.5, minWidth: 0, px: { xs: 1, sm: 1.25 }, flexShrink: 0, '& .MuiButton-startIcon': { mr: { xs: 0, sm: 0.5 } } }}
|
||||
>
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' }, fontSize: 13 }}>返回</Box>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import KeyIcon from '@mui/icons-material/Key';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import useVault from '../hooks/useVault.js';
|
||||
import { showSnack } from '../../../admin/snack.jsx';
|
||||
|
||||
/* ============================================================
|
||||
* VaultView:密码箱视图(VSCode 侧边栏 / 标签页内容)
|
||||
*
|
||||
* 由 VaultDrawer 裁剪:去掉 MUI Drawer 壳与自带标题栏/关闭按钮
|
||||
* (由父级 ToolWindow 提供),锁定按钮并入解锁后的搜索行。
|
||||
* 标题栏之外全部逻辑保留:
|
||||
* loading → noPin(引导设置)| locked(PIN 输入)→ unlocked(搜索+条目)
|
||||
* 「锁定」走 lock API;切换视图不锁定,方便取用。
|
||||
* @param {{open: boolean}} open 视图是否处于激活(激活时重新同步服务端状态)
|
||||
* ============================================================ */
|
||||
|
||||
/** 剪贴板写入:优先 Async Clipboard,失败时降级 execCommand(非安全上下文/LAN 部署) */
|
||||
async function copyText(text) {
|
||||
if (!text) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
ta.style.top = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开详情中的单字段行:标签 + 值(网址可点开)+ 复制按钮 */
|
||||
function DetailField({ label, value, href, secret, onCopy }) {
|
||||
// 防注入:仅放行 http(s)/mailto,其余协议降级为纯文本
|
||||
const safeHref = href && /^(https?|mailto):/i.test(href) ? href : null;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.75, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', width: 40, flexShrink: 0 }}>{label}</Typography>
|
||||
{safeHref ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="a"
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={value}
|
||||
sx={{
|
||||
flexGrow: 1, minWidth: 0, color: 'primary.main',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
title={value}
|
||||
sx={{
|
||||
flexGrow: 1, minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
fontFamily: secret ? 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' : undefined,
|
||||
letterSpacing: secret ? '0.04em' : undefined,
|
||||
}}
|
||||
>
|
||||
{value || '—'}
|
||||
</Typography>
|
||||
)}
|
||||
{onCopy ? (
|
||||
<IconButton size="small" aria-label={`复制${label}`} onClick={onCopy} sx={{ flexShrink: 0 }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单条密码:行内标题 + 用户名 + 复制密码,点击展开详情 */
|
||||
function VaultEntryRow({ entry, expanded, onToggle, onCopy }) {
|
||||
return (
|
||||
<ListItem disablePadding sx={{ display: 'block' }}>
|
||||
<ListItemButton
|
||||
onClick={onToggle}
|
||||
selected={expanded}
|
||||
aria-expanded={expanded}
|
||||
sx={{ pr: 1 }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<KeyIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={entry.title}
|
||||
secondary={entry.username || '无用户名'}
|
||||
primaryTypographyProps={{ noWrap: true, fontWeight: 500 }}
|
||||
secondaryTypographyProps={{ noWrap: true }}
|
||||
/>
|
||||
<Tooltip title="复制密码">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`复制 ${entry.title} 的密码`}
|
||||
onClick={(e) => { e.stopPropagation(); onCopy(entry.password, '密码'); }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ExpandMoreIcon
|
||||
fontSize="small"
|
||||
sx={{
|
||||
color: 'text.secondary', ml: 0.25,
|
||||
transform: expanded ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
<Collapse in={expanded} unmountOnExit>
|
||||
<Box sx={{ px: 2, py: 1, mx: 1, mb: 1, bgcolor: 'action.hover', borderRadius: 2 }}>
|
||||
<DetailField
|
||||
label="用户名"
|
||||
value={entry.username}
|
||||
onCopy={entry.username ? () => onCopy(entry.username, '用户名') : undefined}
|
||||
/>
|
||||
<DetailField
|
||||
label="密码"
|
||||
value={entry.password}
|
||||
secret
|
||||
onCopy={entry.password ? () => onCopy(entry.password, '密码') : undefined}
|
||||
/>
|
||||
{entry.url ? (
|
||||
<DetailField label="网址" value={entry.url} href={entry.url} onCopy={() => onCopy(entry.url, '网址')} />
|
||||
) : null}
|
||||
{entry.notes ? <DetailField label="备注" value={entry.notes} /> : null}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码箱视图
|
||||
* @param {boolean} open 视图是否激活(激活时重新同步服务端会话状态)
|
||||
*/
|
||||
export default function VaultView({ open = false }) {
|
||||
const vault = useVault({ open });
|
||||
|
||||
// PIN 输入屏的本地状态
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [showPin, setShowPin] = useState(false);
|
||||
// 展开中的条目 id(重新打开时清空)
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPinInput('');
|
||||
setPinError('');
|
||||
setShowPin(false);
|
||||
setExpandedId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submitUnlock = useCallback(async () => {
|
||||
const pin = pinInput.trim();
|
||||
if (!pin) { setPinError('请输入 PIN 码'); return; }
|
||||
setPinError('');
|
||||
try {
|
||||
await vault.unlock(pin);
|
||||
setPinInput('');
|
||||
setShowPin(false);
|
||||
showSnack('已解锁');
|
||||
} catch (e) {
|
||||
setPinError(e.message || '解锁失败');
|
||||
setPinInput('');
|
||||
}
|
||||
}, [pinInput, vault]);
|
||||
|
||||
const handleLock = useCallback(async () => {
|
||||
try {
|
||||
await vault.lock();
|
||||
setExpandedId(null);
|
||||
showSnack('已锁定');
|
||||
} catch (e) {
|
||||
showSnack(e.message || '锁定失败', 'error');
|
||||
}
|
||||
}, [vault]);
|
||||
|
||||
const copyToClipboard = useCallback(async (text, label) => {
|
||||
const ok = await copyText(text);
|
||||
if (ok) showSnack(`${label} 已复制`);
|
||||
else showSnack('复制失败', 'error');
|
||||
}, []);
|
||||
|
||||
/* ---------- 各状态下的主体 ---------- */
|
||||
|
||||
const renderBody = () => {
|
||||
switch (vault.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 2 }}>
|
||||
<CircularProgress size={28} />
|
||||
<Typography variant="body2" color="text.secondary">正在检查密码库状态…</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
|
||||
<ErrorIcon color="error" sx={{ fontSize: 40 }} />
|
||||
<Typography variant="body2" color="text.secondary">{vault.statusError}</Typography>
|
||||
<Button variant="outlined" onClick={vault.checkStatus} sx={{ mt: 1 }}>重试</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'noPin':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
|
||||
<LockOpenIcon sx={{ fontSize: 44, color: 'text.disabled' }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>未设置 PIN</Typography>
|
||||
<Typography variant="body2" color="text.secondary">请先到密码库页设置 PIN 后再使用</Typography>
|
||||
<Button variant="contained" component="a" href="/passwords.html" sx={{ mt: 1 }}>前往密码库页</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'locked':
|
||||
return (
|
||||
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitUnlock(); }} sx={{ mt: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 500 }}>输入 PIN 码解锁</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
size="small"
|
||||
type={showPin ? 'text' : 'password'}
|
||||
label="PIN 码"
|
||||
value={pinInput}
|
||||
onChange={(e) => { setPinInput(e.target.value); if (pinError) setPinError(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitUnlock(); }}
|
||||
error={!!pinError}
|
||||
helperText={pinError || ' '}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
slotProps={{
|
||||
htmlInput: { maxLength: 20, 'aria-label': 'PIN 码' },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
edge="end"
|
||||
aria-label={showPin ? '隐藏 PIN' : '显示 PIN'}
|
||||
onClick={() => setShowPin((v) => !v)}
|
||||
>
|
||||
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={vault.unlocking || !pinInput}
|
||||
onClick={submitUnlock}
|
||||
startIcon={vault.unlocking ? <CircularProgress size={18} color="inherit" /> : <LockOpenIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{vault.unlocking ? '解锁中…' : '解锁'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'unlocked':
|
||||
default: {
|
||||
const { entries, entriesError, filteredEntries, search, setSearch } = vault;
|
||||
const empty = filteredEntries.length === 0;
|
||||
return (
|
||||
<Box>
|
||||
{/* 搜索 + 锁定(标题栏由 ToolWindow 提供,锁定并入此行) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="搜索标题或用户名"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
htmlInput: { 'aria-label': '搜索密码' },
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" sx={{ color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: search ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" edge="end" aria-label="清空搜索" onClick={() => setSearch('')}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="锁定密码库">
|
||||
<span>
|
||||
<IconButton aria-label="锁定密码库" onClick={handleLock} disabled={vault.locking} size="small">
|
||||
<LockIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{entries === null && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={26} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{entriesError && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, py: 3, textAlign: 'center' }}>
|
||||
<ErrorIcon color="error" />
|
||||
<Typography variant="body2" color="text.secondary">加载失败:{entriesError}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={vault.loadEntries}>重试</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{entries && !entriesError && empty && (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{search ? '无匹配的密码记录' : '暂无密码记录'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{entries && !entriesError && !empty && (
|
||||
<List disablePadding>
|
||||
{filteredEntries.map((e) => (
|
||||
<VaultEntryRow
|
||||
key={e.id}
|
||||
entry={e}
|
||||
expanded={expandedId === e.id}
|
||||
onToggle={() => setExpandedId(expandedId === e.id ? null : e.id)}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="section" aria-label="密码箱" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', px: 1.5, py: 1.5 }}>
|
||||
{renderBody()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as notesApi from '../../../api/notes.js';
|
||||
import { showSnack } from '../../../admin/snack.jsx';
|
||||
|
||||
/* ============================================================
|
||||
* useNotes:工作台「记事本」状态管理
|
||||
*
|
||||
* - 列表加载/错误态、当前编辑文件(activeName)、草稿内容 + 服务端快照
|
||||
* - 自动保存:输入防抖 900ms;Ctrl+S 立即保存(dirty = 草稿 ≠ 快照)
|
||||
* - 新建 / 重命名(新文件 + 删旧文件)/ 删除,全程复用 notes API
|
||||
* - 会话保持(sidebar↔tab 模式切换/切视图会重挂载组件):
|
||||
* · 草稿存模块级 sessionCache,重挂载瞬间恢复,光标上下文不丢
|
||||
* · activeName 持久化到 localStorage(workbench.notesActive),跨刷新恢复上次文件
|
||||
* · 监听 window 'workbench:notes-save-request'(Workbench 切换模式前派发),
|
||||
* 立即 flush 未落盘的 <900ms 草稿
|
||||
* ============================================================ */
|
||||
|
||||
const AUTOSAVE_MS = 900;
|
||||
const NOTES_ACTIVE_KEY = 'workbench.notesActive';
|
||||
|
||||
/** 模块级草稿缓存:组件重挂载后直接恢复,不依赖服务端往返 */
|
||||
const sessionCache = { name: null, text: '' };
|
||||
|
||||
export default function useNotes() {
|
||||
const [notes, setNotes] = useState(null); // null = 加载中
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [activeName, setActiveName] = useState(() => {
|
||||
try { return localStorage.getItem(NOTES_ACTIVE_KEY) || null; } catch { return null; }
|
||||
});
|
||||
// 草稿与快照初始化取自缓存(若缓存有同名文件),避免模式切换丢内容
|
||||
const [content, setContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
|
||||
const [savedContent, setSavedContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
|
||||
const [saving, setSaving] = useState(false); // 保存请求进行中
|
||||
const [busy, setBusy] = useState(false); // 打开/新建/重命名进行中
|
||||
|
||||
const draftRef = useRef({ name: null, text: '' }); // 最新草稿(供防抖回调读取)
|
||||
const debounceRef = useRef(null);
|
||||
const restoredRef = useRef(false); // 恢复会话只执行一次
|
||||
|
||||
// ---- 列表加载(静默刷新:保留现有列表,避免自动保存时闪烁) ----
|
||||
const refresh = useCallback(() => {
|
||||
setLoadError('');
|
||||
return notesApi.listNotes()
|
||||
.then((ls) => {
|
||||
const arr = Array.isArray(ls) ? ls : [];
|
||||
setNotes(arr);
|
||||
setActiveName((prev) => (prev && arr.some((n) => n.name === prev) ? prev : null));
|
||||
})
|
||||
.catch((e) => { setLoadError(e.message || '加载失败'); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// ---- 保存(PUT) ----
|
||||
// 注意:必须声明在引用它的 useEffect(会话恢复 / save-request 监听)之前,
|
||||
// 否则渲染期求值依赖数组会触发 TDZ(Cannot access before initialization)。
|
||||
const saveContent = useCallback((name, text) => {
|
||||
setSaving(true);
|
||||
return notesApi.updateNote(name, text)
|
||||
.then((res) => {
|
||||
setSavedContent(text);
|
||||
sessionCache.text = text;
|
||||
setNotes((prev) => (prev
|
||||
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
|
||||
: prev));
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setSaving(false));
|
||||
}, []);
|
||||
|
||||
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
|
||||
// 无缓存但记住了文件名→从服务端读取 ----
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
if (activeName === null) { restoredRef.current = true; return; }
|
||||
if (sessionCache.name === activeName) {
|
||||
restoredRef.current = true;
|
||||
saveContent(activeName, sessionCache.text);
|
||||
return;
|
||||
}
|
||||
restoredRef.current = true;
|
||||
open(activeName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ---- activeName 持久化(模式切换/刷新后恢复上次文件) ----
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (activeName) localStorage.setItem(NOTES_ACTIVE_KEY, activeName);
|
||||
else localStorage.removeItem(NOTES_ACTIVE_KEY);
|
||||
} catch { /* 隐私模式忽略 */ }
|
||||
}, [activeName]);
|
||||
|
||||
// ---- 模式切换前的显式保存:Workbench 派发 workbench:notes-save-request ----
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
clearTimeout(debounceRef.current);
|
||||
const d = draftRef.current;
|
||||
if (d.name) saveContent(d.name, d.text);
|
||||
};
|
||||
window.addEventListener('workbench:notes-save-request', handler);
|
||||
return () => window.removeEventListener('workbench:notes-save-request', handler);
|
||||
}, [saveContent]);
|
||||
|
||||
// 当前文件被外部删除/失效时清空编辑器
|
||||
useEffect(() => {
|
||||
if (activeName === null) {
|
||||
setContent('');
|
||||
setSavedContent('');
|
||||
draftRef.current = { name: null, text: '' };
|
||||
}
|
||||
}, [activeName]);
|
||||
|
||||
// ---- 打开文件 ----
|
||||
const open = useCallback((name) => {
|
||||
setBusy(true);
|
||||
return notesApi.getNote(name)
|
||||
.then((res) => {
|
||||
const text = res.content || '';
|
||||
setActiveName(name);
|
||||
setContent(text);
|
||||
setSavedContent(text);
|
||||
draftRef.current = { name, text };
|
||||
sessionCache.name = name;
|
||||
sessionCache.text = text;
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setBusy(false));
|
||||
}, []);
|
||||
|
||||
// ---- 编辑(防抖自动保存 + 实时写缓存) ----
|
||||
const updateContent = useCallback((text) => {
|
||||
setContent(text);
|
||||
draftRef.current = { name: activeName, text };
|
||||
sessionCache.name = activeName;
|
||||
sessionCache.text = text;
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const d = draftRef.current;
|
||||
if (d.name) saveContent(d.name, d.text);
|
||||
}, AUTOSAVE_MS);
|
||||
}, [activeName, saveContent]);
|
||||
|
||||
// ---- 立即保存(Ctrl+S / 切换文件前 / save-request) ----
|
||||
const saveNow = useCallback(() => {
|
||||
clearTimeout(debounceRef.current);
|
||||
const d = draftRef.current;
|
||||
if (d.name) return saveContent(d.name, d.text);
|
||||
return Promise.resolve();
|
||||
}, [saveContent]);
|
||||
|
||||
// ---- 新建 ----
|
||||
const create = useCallback((name) => {
|
||||
setBusy(true);
|
||||
return notesApi.createNote(name, '')
|
||||
.then((res) => {
|
||||
setActiveName(res.name);
|
||||
setContent('');
|
||||
setSavedContent('');
|
||||
draftRef.current = { name: res.name, text: '' };
|
||||
sessionCache.name = res.name;
|
||||
sessionCache.text = '';
|
||||
return refresh();
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setBusy(false));
|
||||
}, [refresh]);
|
||||
|
||||
// ---- 重命名(新文件写入旧内容 + 删旧文件) ----
|
||||
const rename = useCallback(async (oldName, newName) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// 若正在编辑该文件,用草稿内容;否则先读服务端内容
|
||||
let text = draftRef.current.text;
|
||||
if (activeName !== oldName) {
|
||||
const res = await notesApi.getNote(oldName);
|
||||
text = res.content || '';
|
||||
}
|
||||
await notesApi.createNote(newName, text);
|
||||
await notesApi.deleteNote(oldName);
|
||||
if (activeName === oldName) {
|
||||
setActiveName(newName);
|
||||
setSavedContent(text);
|
||||
draftRef.current = { name: newName, text };
|
||||
sessionCache.name = newName;
|
||||
sessionCache.text = text;
|
||||
}
|
||||
await refresh();
|
||||
showSnack('重命名成功');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [activeName, refresh]);
|
||||
|
||||
// ---- 删除 ----
|
||||
const remove = useCallback(async (name) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await notesApi.deleteNote(name);
|
||||
if (activeName === name) {
|
||||
clearTimeout(debounceRef.current);
|
||||
setActiveName(null);
|
||||
sessionCache.name = null;
|
||||
sessionCache.text = '';
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [activeName, refresh]);
|
||||
|
||||
// ---- 新建默认名:untitled-N.txt(避开已有名) ----
|
||||
const nextUntitled = useMemo(() => {
|
||||
const names = new Set((notes || []).map((n) => n.name.toLowerCase()));
|
||||
let i = 1;
|
||||
while (names.has(`untitled-${i}.txt`)) i += 1;
|
||||
return `untitled-${i}.txt`;
|
||||
}, [notes]);
|
||||
|
||||
const dirty = activeName !== null && content !== savedContent;
|
||||
|
||||
return {
|
||||
notes, loadError, refresh,
|
||||
activeName, content, dirty, saving, busy,
|
||||
open, updateContent, saveNow, create, rename, remove, nextUntitled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { listAdminLinks } from '../../../api/adminLinks.js';
|
||||
|
||||
/* ============================================================
|
||||
* 工作台面板状态(Lane A 核心 hook)
|
||||
*
|
||||
* 管理:面板列表(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
|
||||
export const OPEN_MODE_TAB = 'tab'; // 新标签页
|
||||
export const OPEN_MODE_MODAL = 'modal'; // 弹窗打开
|
||||
|
||||
const STORE = {
|
||||
open: 'workbench.open', // 打开的面板 id 数组
|
||||
active: 'workbench.active', // 当前面板 id
|
||||
history: 'workbench.history', // { ids: [], index: n } 历史栈
|
||||
groups: 'workbench.collapsedGroups', // { 分组名: true } 折叠
|
||||
pinned: 'workbench.pinned', // 固定面板 id 数组
|
||||
modes: 'workbench.openModes', // { id: 'embed'|'tab'|'modal' }
|
||||
};
|
||||
|
||||
function read(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : JSON.parse(raw);
|
||||
} catch { return fallback; }
|
||||
}
|
||||
|
||||
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('');
|
||||
// 初始化即清洗(治愈已污染的 localStorage 存量数据):
|
||||
// 面板 id 统一为字符串 + 去重。此前 openPanel 被传入数字(p.id)与字符串
|
||||
// (handleSelectTab 的 key.slice) 两种类型,prev.includes 严格比较漏判 →
|
||||
// 同一面板以两份加入 openIds → 双 iframe 并排"分屏"、closePanel 关不掉。
|
||||
const [openIds, setOpenIds] = useState(() => {
|
||||
const a = read(STORE.open, []);
|
||||
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
|
||||
});
|
||||
const [activeId, setActiveId] = useState(() => {
|
||||
const v = read(STORE.active, null);
|
||||
return v == null ? null : String(v);
|
||||
});
|
||||
const [history, setHistory] = useState(() => {
|
||||
const h = read(STORE.history, null);
|
||||
if (h && Array.isArray(h.ids)) {
|
||||
const ids = [...new Set(h.ids.map(String))];
|
||||
return { ids, index: Math.min(Number(h.index) || -1, ids.length - 1) };
|
||||
}
|
||||
return { ids: [], index: -1 };
|
||||
});
|
||||
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
|
||||
const [pinned, setPinned] = useState(() => {
|
||||
const a = read(STORE.pinned, []);
|
||||
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
|
||||
});
|
||||
const [modes, setModes] = useState(() => read(STORE.modes, {}));
|
||||
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// LRU 引用(内存态,不需要持久化):面板 id -> 最近使用时间戳
|
||||
const recency = useRef({});
|
||||
|
||||
// ---- 持久化 ----
|
||||
useEffect(() => write(STORE.open, openIds), [openIds]);
|
||||
useEffect(() => write(STORE.active, activeId), [activeId]);
|
||||
useEffect(() => write(STORE.history, history), [history]);
|
||||
useEffect(() => write(STORE.groups, groups), [groups]);
|
||||
useEffect(() => write(STORE.pinned, pinned), [pinned]);
|
||||
useEffect(() => write(STORE.modes, modes), [modes]);
|
||||
|
||||
// ---- 加载面板列表(reconcile 全部用函数式更新,消除陈旧闭包竞态) ----
|
||||
const reload = useCallback(() => {
|
||||
setLoadError('');
|
||||
setLinks(null);
|
||||
return listAdminLinks()
|
||||
.then((ls) => {
|
||||
const arr = Array.isArray(ls) ? ls.map(normalizePanel) : [];
|
||||
setLinks(arr);
|
||||
const valid = new Set(arr.map((p) => String(p.id)));
|
||||
// String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
|
||||
setOpenIds((prev) => prev.map(String).filter((id) => valid.has(id)));
|
||||
setPinned((prev) => prev.map(String).filter((id) => valid.has(id)));
|
||||
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
|
||||
setHistory((prev) => {
|
||||
const ids = prev.ids.map(String).filter((id) => valid.has(id));
|
||||
return { ids, index: Math.min(prev.index, ids.length - 1) };
|
||||
});
|
||||
return arr;
|
||||
})
|
||||
.catch((e) => { setLoadError(e.message || '加载失败'); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// activeId 兜底(派生逻辑):重载后当前面板已被删除时,
|
||||
// 从历史栈 / 打开集合取最近一个,避免指向不存在的面板
|
||||
useEffect(() => {
|
||||
if (!links) return;
|
||||
const valid = new Set(links.map((p) => String(p.id)));
|
||||
if (activeId && valid.has(String(activeId))) return;
|
||||
const fallback = (history.index >= 0 ? history.ids[history.index] : null)
|
||||
?? openIds[0]
|
||||
?? null;
|
||||
setActiveId(fallback);
|
||||
}, [links, activeId, history, openIds]);
|
||||
|
||||
const touch = useCallback((id) => { recency.current[id] = Date.now(); }, []);
|
||||
|
||||
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
|
||||
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
||||
const openPanel = useCallback((id, panelOverride) => {
|
||||
id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
|
||||
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;
|
||||
if (mode === OPEN_MODE_TAB) {
|
||||
window.open(panel.embed_url || panel.url, '_blank', 'noopener');
|
||||
return false;
|
||||
}
|
||||
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
|
||||
if (activeId === id) return false;
|
||||
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
setHistory((prev) => {
|
||||
const ids = prev.ids.slice(0, prev.index + 1);
|
||||
if (ids[ids.length - 1] === id) return prev;
|
||||
ids.push(id);
|
||||
return { ids, index: ids.length - 1 };
|
||||
});
|
||||
setActiveId(id);
|
||||
return true;
|
||||
}, [activeId, links, modes, touch]);
|
||||
|
||||
// ---- 历史栈:◀ 后退 / ▶ 前进 ----
|
||||
const goBack = useCallback(() => {
|
||||
if (history.index <= 0) return;
|
||||
const index = history.index - 1;
|
||||
const id = history.ids[index];
|
||||
if (id == null) return;
|
||||
setHistory({ ...history, index });
|
||||
setActiveId(id);
|
||||
touch(id);
|
||||
}, [history, touch]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (history.index >= history.ids.length - 1) return;
|
||||
const index = history.index + 1;
|
||||
const id = history.ids[index];
|
||||
if (id == null) return;
|
||||
setHistory({ ...history, index });
|
||||
setActiveId(id);
|
||||
touch(id);
|
||||
}, [history, touch]);
|
||||
|
||||
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
|
||||
const closePanel = useCallback((id) => {
|
||||
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
|
||||
const nextOpen = openIds.filter((x) => x !== id);
|
||||
setOpenIds(nextOpen);
|
||||
delete recency.current[id];
|
||||
const histIds = history.ids.filter((x) => x !== id);
|
||||
let index = history.index;
|
||||
const removedAt = history.ids.indexOf(id);
|
||||
if (removedAt !== -1 && removedAt < index) index -= 1;
|
||||
if (index > histIds.length - 1) index = histIds.length - 1;
|
||||
setHistory({ ids: histIds, index: index < 0 ? -1 : index });
|
||||
const nextActive = histIds[index] ?? nextOpen[0] ?? null;
|
||||
if (nextActive) setActiveId(nextActive); else setActiveId(null);
|
||||
}, [history, openIds]);
|
||||
|
||||
const togglePin = useCallback((id) => {
|
||||
id = String(id); // 防止固定集合再次被数字污染
|
||||
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((name) => {
|
||||
setGroups((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((id, mode) => {
|
||||
setModes((prev) => ({ ...prev, [id]: mode }));
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => setModalId(null), []);
|
||||
|
||||
// ---- LRU 渲染集:当前面板 + 最近用过的 2 个(最多 3 个活跃 iframe) ----
|
||||
const renderedIds = useMemo(() => {
|
||||
const active = activeId ? String(activeId) : null;
|
||||
if (!active) return [];
|
||||
const others = openIds.filter((id) => String(id) !== active);
|
||||
const sorted = others
|
||||
.map((id) => ({ id, ts: recency.current[id] || 0 }))
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
.map((x) => x.id);
|
||||
return [active, ...sorted.slice(0, 2)];
|
||||
}, [activeId, openIds]);
|
||||
|
||||
const panelsById = useMemo(() => {
|
||||
const m = {};
|
||||
(links || []).forEach((p) => { m[p.id] = p; });
|
||||
return m;
|
||||
}, [links]);
|
||||
|
||||
const activePanel = activeId ? (panelsById[activeId] || null) : null;
|
||||
const modalPanel = modalId ? (panelsById[modalId] || null) : null;
|
||||
const openPanels = openIds.map((id) => panelsById[id]).filter(Boolean);
|
||||
|
||||
// ---- 搜索过滤(面板名 / URL / 分组) ----
|
||||
const filteredPanels = useMemo(() => {
|
||||
if (!search.trim()) return links || [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return (links || []).filter((p) =>
|
||||
String(p.title || '').toLowerCase().includes(q)
|
||||
|| String(p.url || '').toLowerCase().includes(q)
|
||||
|| String(p.embed_url || '').toLowerCase().includes(q)
|
||||
|| String(p.category || '').toLowerCase().includes(q)
|
||||
);
|
||||
}, [links, search]);
|
||||
|
||||
// 搜索框 Enter:直达第一个匹配面板(空搜索不触发)
|
||||
const openFirstMatch = useCallback(() => {
|
||||
if (!search.trim()) return false;
|
||||
const first = filteredPanels[0];
|
||||
if (first) { openPanel(first.id); setSearch(''); return true; }
|
||||
return false;
|
||||
}, [filteredPanels, openPanel, search]);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
links, loadError, reload, panelsById,
|
||||
// 打开集合
|
||||
openIds, openCount: openIds.length, openPanels, renderedIds,
|
||||
activeId, activePanel, modalId, modalPanel, closeModal,
|
||||
// 历史栈
|
||||
history,
|
||||
canBack: history.index > 0,
|
||||
canForward: history.index >= 0 && history.index < history.ids.length - 1,
|
||||
goBack, goForward,
|
||||
// 操作
|
||||
openPanel, closePanel, togglePin, toggleGroup, setMode,
|
||||
// 分组 / 固定 / 打开方式
|
||||
pinned, groups, modes,
|
||||
// 搜索
|
||||
search, setSearch, filteredPanels, openFirstMatch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import * as pwApi from '../../../api/passwords.js';
|
||||
|
||||
/**
|
||||
* 密码库抽屉的状态管理(Lane B)。
|
||||
*
|
||||
* 状态机 status:
|
||||
* loading —— 正在检查 PIN 状态(pinStatus)
|
||||
* error —— 检查失败(可重试)
|
||||
* noPin —— 未设置 PIN(引导去密码库页)
|
||||
* locked —— 已设 PIN 但未解锁(PIN 输入屏)
|
||||
* unlocked —— 已解锁(搜索 + 条目列表)
|
||||
*
|
||||
* 约定:关闭抽屉不锁定,只有 lock() 才锁定——方便切面板取用。
|
||||
* 会话在服务端内存中滑动过期(1 小时),每次打开抽屉会重新同步一次状态。
|
||||
*/
|
||||
export default function useVault({ open } = {}) {
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [statusError, setStatusError] = useState('');
|
||||
const [entries, setEntries] = useState(null); // null = 条目加载中
|
||||
const [entriesError, setEntriesError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const [locking, setLocking] = useState(false);
|
||||
|
||||
/** 拉取条目列表(解锁后),失败进入错误态并保留重试按钮 */
|
||||
const loadEntries = useCallback(async () => {
|
||||
setEntries(null);
|
||||
setEntriesError('');
|
||||
try {
|
||||
const list = await pwApi.listEntries();
|
||||
setEntries(list || []);
|
||||
} catch (e) {
|
||||
setEntriesError(e.message || '加载失败');
|
||||
setEntries([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 同步服务端 PIN / 会话状态 */
|
||||
const checkStatus = useCallback(async () => {
|
||||
setStatus('loading');
|
||||
setStatusError('');
|
||||
try {
|
||||
const s = await pwApi.pinStatus();
|
||||
if (!s.hasPin) setStatus('noPin');
|
||||
else if (s.unlocked) { setStatus('unlocked'); loadEntries(); }
|
||||
else setStatus('locked');
|
||||
} catch (e) {
|
||||
setStatusError(e.message || '检查密码库状态失败');
|
||||
setStatus('error');
|
||||
}
|
||||
}, [loadEntries]);
|
||||
|
||||
// 每次抽屉打开(含重新打开)都同步一次状态;
|
||||
// 若父组件保持挂载仅切换 open,也在这里触发。
|
||||
useEffect(() => {
|
||||
if (open) checkStatus();
|
||||
}, [open, checkStatus]);
|
||||
|
||||
/** 解锁:成功则进入条目列表;失败抛出后端错误(401 PIN 错误 / 429 次数过多) */
|
||||
const unlock = useCallback(async (pin) => {
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await pwApi.unlock(pin);
|
||||
setStatus('unlocked');
|
||||
setSearch('');
|
||||
await loadEntries();
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
}, [loadEntries]);
|
||||
|
||||
/** 锁定:清空本地条目与搜索,回到 PIN 输入屏 */
|
||||
const lock = useCallback(async () => {
|
||||
setLocking(true);
|
||||
try {
|
||||
await pwApi.lock();
|
||||
setStatus('locked');
|
||||
setEntries(null);
|
||||
setEntriesError('');
|
||||
setSearch('');
|
||||
} finally {
|
||||
setLocking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 按标题 / 用户名过滤(不区分大小写) */
|
||||
const filteredEntries = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const base = entries || [];
|
||||
if (!q) return base;
|
||||
return base.filter((e) =>
|
||||
(e.title || '').toLowerCase().includes(q) ||
|
||||
(e.username || '').toLowerCase().includes(q));
|
||||
}, [entries, search]);
|
||||
|
||||
return {
|
||||
status, statusError,
|
||||
entries, entriesError,
|
||||
search, setSearch, filteredEntries,
|
||||
unlocking, locking,
|
||||
checkStatus, loadEntries, unlock, lock,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||
import NoteAltIcon from '@mui/icons-material/NoteAlt';
|
||||
import KeyIcon from '@mui/icons-material/Key';
|
||||
|
||||
/* ============================================================
|
||||
* useWorkbenchLayout:VSCode 风格工作台布局状态(Lane A 新增)
|
||||
*
|
||||
* - 视图注册表 VIEWS:左侧主控(面板)+ 右侧功能区(记事本/密码箱)
|
||||
* - order:{ left: [viewId], right: [viewId] } —— 视图在左右活动栏的归属
|
||||
* (dnd-kit 拖拽换边后持久化,key: workbench.viewOrder)
|
||||
* - active:{ left: viewId, right: viewId } —— 每侧当前显示的视图
|
||||
* - mode:{ [viewId]: 'sidebar' | 'tab' | 'float' } —— 展示模式
|
||||
* (sidebar 默认;tab 进主区标签页;float 为 P2 占位,本期不实现)
|
||||
* - terminalOpen:底部终端面板是否展开
|
||||
* ============================================================ */
|
||||
|
||||
/** 视图注册表:id → 元信息(新增视图只需在此登记 + Workbench 提供渲染) */
|
||||
export const VIEWS = {
|
||||
panels: { id: 'panels', label: '面板', Icon: WidgetsIcon, hint: '面板工作台' },
|
||||
notes: { id: 'notes', label: '记事本', Icon: NoteAltIcon, hint: '笔记与文件' },
|
||||
vault: { id: 'vault', label: '密码箱', Icon: KeyIcon, hint: '密码管理' },
|
||||
};
|
||||
export const VIEW_IDS = Object.keys(VIEWS);
|
||||
|
||||
export const VIEW_MODE_SIDEBAR = 'sidebar';
|
||||
export const VIEW_MODE_TAB = 'tab';
|
||||
export const VIEW_MODE_FLOAT = 'float'; // P2:本期仅占位
|
||||
|
||||
const STORE = {
|
||||
order: 'workbench.viewOrder', // { left: [], right: [] }
|
||||
active: 'workbench.viewActive', // { left: id, right: id }
|
||||
mode: 'workbench.viewMode', // { id: 'sidebar'|'tab' }
|
||||
terminal: 'workbench.terminalOpen',
|
||||
};
|
||||
|
||||
const DEFAULT_ORDER = { left: ['panels'], right: ['notes', 'vault'] };
|
||||
const DEFAULT_ACTIVE = { left: 'panels', right: 'notes' };
|
||||
const DEFAULT_MODE = { panels: VIEW_MODE_SIDEBAR, notes: VIEW_MODE_SIDEBAR, vault: VIEW_MODE_SIDEBAR };
|
||||
|
||||
function read(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : JSON.parse(raw);
|
||||
} catch { return fallback; }
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
|
||||
}
|
||||
|
||||
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
|
||||
function sanitizeOrder(saved) {
|
||||
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
|
||||
const seen = new Set();
|
||||
const clean = (arr) => {
|
||||
const out = [];
|
||||
arr.forEach((id) => {
|
||||
if (!VIEW_IDS.includes(id) || seen.has(id)) return; // 先查重,后登记
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const left = clean(saved.left);
|
||||
const right = clean(saved.right);
|
||||
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
|
||||
// 兜底:持久化可能把视图全挪到一侧(或空数组),刷新后另一侧空白打不开。
|
||||
// 保证每侧至少一个视图(panels 优先左侧,notes 次之)。
|
||||
if (left.length === 0 && right.length > 0) {
|
||||
const preferred = ['panels', 'notes'].find((id) => right.includes(id));
|
||||
const move = preferred || right[0];
|
||||
left.push(move);
|
||||
right.splice(right.indexOf(move), 1);
|
||||
}
|
||||
if (right.length === 0 && left.length > 0) {
|
||||
const preferred = ['notes', 'vault'].find((id) => left.includes(id));
|
||||
const move = preferred || left[0];
|
||||
right.push(move);
|
||||
left.splice(left.indexOf(move), 1);
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
/** 校验 active:指向的视图必须仍在本侧,否则回退到该侧第一个 */
|
||||
function sanitizeActive(saved, order) {
|
||||
if (!saved || typeof saved !== 'object') return { ...DEFAULT_ACTIVE };
|
||||
const pick = (side, fallback) => (
|
||||
order[side].includes(saved[side]) ? saved[side] : (order[side][0] || fallback)
|
||||
);
|
||||
return { left: pick('left', 'panels'), right: pick('right', 'notes') };
|
||||
}
|
||||
|
||||
/** 校验 mode:只接受 sidebar/tab(float 为保留值,不持久化激活) */
|
||||
function sanitizeMode(saved) {
|
||||
const out = { ...DEFAULT_MODE };
|
||||
if (saved && typeof saved === 'object') {
|
||||
VIEW_IDS.forEach((id) => {
|
||||
if (saved[id] === VIEW_MODE_SIDEBAR || saved[id] === VIEW_MODE_TAB) out[id] = saved[id];
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function useWorkbenchLayout() {
|
||||
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
|
||||
const [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
|
||||
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
||||
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
||||
|
||||
useEffect(() => write(STORE.order, order), [order]);
|
||||
useEffect(() => write(STORE.active, active), [active]);
|
||||
useEffect(() => write(STORE.mode, mode), [mode]);
|
||||
useEffect(() => write(STORE.terminal, terminalOpen), [terminalOpen]);
|
||||
|
||||
/** 视图 id → 所在侧 */
|
||||
const sideOf = useMemo(() => {
|
||||
const m = {};
|
||||
order.left.forEach((id) => { m[id] = 'left'; });
|
||||
order.right.forEach((id) => { m[id] = 'right'; });
|
||||
return m;
|
||||
}, [order]);
|
||||
|
||||
const viewsOf = useCallback((side) => [...order[side]], [order]);
|
||||
|
||||
/** 每侧有效激活视图(active 指向已移走的视图时回退到第一个) */
|
||||
const activeViewOf = useCallback(
|
||||
(side) => (order[side].includes(active[side]) ? active[side] : order[side][0] || null),
|
||||
[order, active]
|
||||
);
|
||||
|
||||
/** 激活某视图(无论在哪侧) */
|
||||
const activateView = useCallback((id) => {
|
||||
const side = sideOf[id];
|
||||
if (!side) return;
|
||||
setActive((prev) => ({ ...prev, [side]: id }));
|
||||
}, [sideOf]);
|
||||
|
||||
const setViewMode = useCallback((id, m) => {
|
||||
if (!VIEW_IDS.includes(id)) return;
|
||||
setMode((prev) => ({ ...prev, [id]: m }));
|
||||
}, []);
|
||||
|
||||
/** 拖拽换边:视图从一侧移到另一侧,并激活目标侧 */
|
||||
const moveView = useCallback((id, toSide) => {
|
||||
if (toSide !== 'left' && toSide !== 'right') return;
|
||||
setOrder((prev) => {
|
||||
const fromSide = (['left', 'right']).find((s) => prev[s].includes(id));
|
||||
if (!fromSide || fromSide === toSide) return prev;
|
||||
const next = {
|
||||
left: [...prev.left.filter((x) => x !== id)],
|
||||
right: [...prev.right.filter((x) => x !== id)],
|
||||
};
|
||||
next[toSide].push(id);
|
||||
return next;
|
||||
});
|
||||
setActive((prev) => ({ ...prev, [toSide]: id }));
|
||||
}, []);
|
||||
|
||||
/** 同侧排序(dnd-kit arrayMove 语义) */
|
||||
const reorderView = useCallback((id, overId) => {
|
||||
setOrder((prev) => {
|
||||
const side = (['left', 'right']).find((s) => prev[s].includes(id));
|
||||
const overSide = (['left', 'right']).find((s) => prev[s].includes(overId));
|
||||
if (!side || side !== overSide) return prev;
|
||||
const list = prev[side];
|
||||
const from = list.indexOf(id);
|
||||
const to = list.indexOf(overId);
|
||||
if (from < 0 || to < 0 || from === to) return prev;
|
||||
const next = [...list];
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, id);
|
||||
return { ...prev, [side]: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleTerminal = useCallback(() => setTerminalOpen((v) => !v), []);
|
||||
const openTerminal = useCallback(() => setTerminalOpen(true), []);
|
||||
|
||||
return {
|
||||
order, sideOf, viewsOf,
|
||||
active, activeViewOf, activateView,
|
||||
mode, setViewMode,
|
||||
moveView, reorderView,
|
||||
terminalOpen, toggleTerminal, openTerminal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* ============================================================
|
||||
* 工作台模块导出(frontend/src/tools/workbench)
|
||||
* 便于未来升级 / 替换 / 独立打包
|
||||
*
|
||||
* 组件命名与文件名的兼容说明:
|
||||
* - Sidebar.jsx 导出 PanelsView(面板列表视图,VSCode 侧栏内容)
|
||||
* - NoteEditor.jsx 导出 NotesView(记事本视图)
|
||||
* - VaultDrawer.jsx 导出 VaultView(密码箱视图)
|
||||
* ============================================================ */
|
||||
|
||||
export { default as Workbench } from './Workbench.jsx';
|
||||
export { default as Toolbar } from './components/Toolbar.jsx';
|
||||
export { default as PanelFrame } from './components/PanelFrame.jsx';
|
||||
export { default as AddPanelDialog } from './components/AddPanelDialog.jsx';
|
||||
export { default as PanelIcon } from './components/PanelIcon.jsx';
|
||||
export { default as PanelsView } from './components/Sidebar.jsx';
|
||||
export { default as NotesView } from './components/NoteEditor.jsx';
|
||||
export { default as VaultView } from './components/VaultDrawer.jsx';
|
||||
export { default as ActivityBar } from './components/ActivityBar.jsx';
|
||||
export { default as SidebarPane } from './components/SidebarPane.jsx';
|
||||
export { default as ToolWindow } from './components/ToolWindow.jsx';
|
||||
export { default as TabStrip } from './components/TabStrip.jsx';
|
||||
export { default as ContentView } from './components/ContentView.jsx';
|
||||
// 终端面板临时下线:组件归档到 archive/(前端入口已移除,恢复时改回 components/ 路径)
|
||||
export { default as TerminalPanel } from './archive/TerminalPanel.jsx';
|
||||
export { default as StatusBar } from './components/StatusBar.jsx';
|
||||
export { default as MenuBar } from './components/MenuBar.jsx';
|
||||
export { default as useNotes } from './hooks/useNotes.js';
|
||||
export { default as useWorkbenchLayout } from './hooks/useWorkbenchLayout.js';
|
||||
export { VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB, VIEW_MODE_FLOAT } from './hooks/useWorkbenchLayout.js';
|
||||
export {
|
||||
default as usePanels,
|
||||
OPEN_MODE_EMBED,
|
||||
OPEN_MODE_TAB,
|
||||
OPEN_MODE_MODAL,
|
||||
} from './hooks/usePanels.js';
|
||||
@@ -0,0 +1,40 @@
|
||||
// lib/author.js —— 作者显示信息组装(论坛/博客/评论/公开主页共用)
|
||||
//
|
||||
// SQL 侧需为作者 JOIN 补选(见 routes/forum.js / routes/blog.js):
|
||||
// u.username AS author_name, u.nickname AS author_nickname, u.title AS author_title,
|
||||
// u.title_color AS author_title_color, u.role AS author_role
|
||||
//(u.title 用 AS 别名,避免与帖子/文章的 title 列冲突)
|
||||
// 前端最终拿到的作者字段:author_id, author_name(=nickname||username),
|
||||
// author_username(原始登录名,恒存在——站长帖判断等需用它), author_avatar,
|
||||
// author_title, author_title_color, author_role。
|
||||
|
||||
const { authorAvatar } = require('./avatar');
|
||||
|
||||
// 在 JOIN 行上直接写入作者显示字段(幂等:已有 author_avatar / author_username 则保留)
|
||||
function attachAuthor(p) {
|
||||
if (!p) return p;
|
||||
if (p.author_avatar === undefined) p.author_avatar = authorAvatar(p);
|
||||
// 覆盖 author_name 前先保存原始登录名(前端「站长帖」判断等依赖原始 username)
|
||||
if (p.author_username === undefined) p.author_username = String(p.author_name || '').trim();
|
||||
p.author_name = String(p.author_nickname || '').trim() || p.author_name || p.username || '';
|
||||
p.author_title = String(p.author_title || '').trim();
|
||||
p.author_title_color = String(p.author_title_color || '').trim();
|
||||
p.author_role = p.author_role || 'user';
|
||||
return p;
|
||||
}
|
||||
|
||||
// 组装作者显示信息(返回独立对象,供测试或需要独立作者对象的场景)
|
||||
function authorInfo(p) {
|
||||
if (!p) return null;
|
||||
return {
|
||||
id: p.author_id != null ? p.author_id : p.uid,
|
||||
username: String(p.author_username || p.author_name || '').trim(),
|
||||
name: String(p.author_nickname || '').trim() || p.author_name || p.username || '',
|
||||
avatar: p.author_avatar || '',
|
||||
title: String(p.author_title || '').trim(),
|
||||
title_color: String(p.author_title_color || '').trim(),
|
||||
role: p.author_role || 'user',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { attachAuthor, authorInfo };
|
||||
@@ -0,0 +1,31 @@
|
||||
// lib/avatar.js —— 用户头像解析(博客/论坛/评论等各处共用)
|
||||
//
|
||||
// 优先级(用户确认):
|
||||
// 1. RainWeb 自己上传的头像(users.avatar 以 /uploads/ 开头)
|
||||
// 2. QQ 头像(users.qq 字段 → https://q1.qlogo.cn/g?b=qq&nk=<QQ号>&s=640)
|
||||
// 3. QQ 邮箱前缀(users.qq_from_email —— SQL 内 CASE 提取的数字前缀,见 routes/forum.js、routes/blog.js)
|
||||
// 4. @qq.com 邮箱匹配(users.email 本体,仅本地直查场景可用)
|
||||
// 5. RainID 获取的头像(users.avatar 里的 http(s) URL)
|
||||
// 6. 均无 → 空串(前端显示默认头像)
|
||||
|
||||
function resolveAvatar(user) {
|
||||
if (!user) return '';
|
||||
if (user.avatar && String(user.avatar).startsWith('/uploads/')) return user.avatar;
|
||||
// QQ 头像:users.qq 字段 → qq_from_email(SQL 提取)→ @qq.com 邮箱前缀
|
||||
let qq = String(user.qq || '').trim();
|
||||
if (!qq && user.qq_from_email) qq = String(user.qq_from_email).trim();
|
||||
if (!qq && user.email) {
|
||||
const m = String(user.email).toLowerCase().match(/^(\d{5,12})@qq\.com$/);
|
||||
if (m) qq = m[1];
|
||||
}
|
||||
if (qq) return 'https://q1.qlogo.cn/g?b=qq&nk=' + qq + '&s=640';
|
||||
if (user.avatar && /^https?:/i.test(user.avatar)) return user.avatar;
|
||||
return '';
|
||||
}
|
||||
|
||||
// 兼容缺行(匿名作者/已注销用户等 author_id 为空的行)
|
||||
function authorAvatar(authorRow) {
|
||||
return resolveAvatar(authorRow || {});
|
||||
}
|
||||
|
||||
module.exports = { resolveAvatar, authorAvatar };
|
||||
+152
@@ -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 };
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// RainID OIDC 接入共享逻辑(routes/auth.js ROPC 密码登录 / routes/oidc.js 授权码流共用)
|
||||
// 安全要点:
|
||||
// - client_secret 双通道:优先 .env.json(gitignored),回退后台站点设置
|
||||
// site_settings.rainid_client_secret(routes/settings.js ALLOWED_SET 可写、GET 不返回,不泄露)
|
||||
// - rainid_enabled='1' 但 client_id/secret 任一通道均缺失 → enabled=false(fail-closed,本地登录不受影响)
|
||||
// - 影子账号按 sub 绑定(唯一索引兜底);email_verified 是自动绑定闸门(防撞绑)
|
||||
// - 影子账号随机不可登录密码,禁止本地密码登入
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const openidClient = require('openid-client'); // v6 函数式 API(Node >=23 支持 require(esm))
|
||||
const db = require('../db');
|
||||
|
||||
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
|
||||
|
||||
// 机密 client_secret 双通道读取(优先级从高到低):
|
||||
// ① 环境变量 RAINID_CLIENT_SECRET(server.js 启动时从 .env.json 的 cfg.rainid_client_secret 读入)
|
||||
// ② .env.json 直接读取
|
||||
// ③ 后台站点设置 site_settings.rainid_client_secret(可写不可读,GET /api/settings 不返回)
|
||||
function getClientSecret() {
|
||||
if (process.env.RAINID_CLIENT_SECRET) return process.env.RAINID_CLIENT_SECRET;
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '.env.json'), 'utf8'));
|
||||
if (cfg.rainid_client_secret) return cfg.rainid_client_secret;
|
||||
} catch { /* .env.json 缺失/损坏 → 走 settings 回退 */ }
|
||||
return db.getSetting('rainid_client_secret') || '';
|
||||
}
|
||||
|
||||
function getOidcSettings() {
|
||||
const clientId = db.getSetting('rainid_client_id');
|
||||
const clientSecret = getClientSecret();
|
||||
return {
|
||||
// fail-closed:enabled 声明为 true 仅当三项齐全(rainid_enabled + client_id + secret)
|
||||
enabled: db.getSetting('rainid_enabled') === '1' && !!clientId && !!clientSecret,
|
||||
clientId,
|
||||
clientSecret,
|
||||
discoveryUrl: db.getSetting('rainid_discovery_url') || DISCOVERY_DEFAULT,
|
||||
registerRedirect: db.getSetting('rainid_register_redirect') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
// discovery 结果缓存(RainID JWKS 多 key 轮换由 SDK 自动处理;设置变更需重启进程生效)
|
||||
let configCache = null;
|
||||
async function getOidcConfig() {
|
||||
if (configCache) return configCache;
|
||||
const s = getOidcSettings();
|
||||
if (!s.clientId || !s.clientSecret) {
|
||||
const err = new Error('RainID 客户端未配置(rainid_client_id / rainid_client_secret)');
|
||||
err.code = 'RAINID_NOT_CONFIGURED';
|
||||
throw err;
|
||||
}
|
||||
// v6 的 discovery() 第一个参数必须是 URL 实例(传字符串会抛 "server must be an instance of URL")
|
||||
configCache = await openidClient.discovery(new URL(s.discoveryUrl), s.clientId, s.clientSecret);
|
||||
return configCache;
|
||||
}
|
||||
|
||||
// 站点基址:redirect_uri / 回跳前端页面用。优先 site_url 设置,回退请求头(反代注意 X-Forwarded-Proto)
|
||||
function siteBase(req) {
|
||||
const u = db.getSetting('site_url');
|
||||
if (u) return String(u).replace(/\/+$/, '');
|
||||
// M7:Host 头不可信——攻击者可构造 Host: evil.com 把 redirect_uri 变成开放重定向/授权回调投毒。
|
||||
// 仅接受合法域名/IP 格式(允许字母数字、点、连字符、冒号、方括号、下划线),
|
||||
// 拒绝含 @、/、空白、控制字符的 Host;缺失或异常时回退固定值 localhost。
|
||||
const host = req.get('host');
|
||||
const valid = !!host && host.length <= 255
|
||||
&& !/[@\s\x00-\x1f\x7f]/.test(host)
|
||||
&& /^[a-zA-Z0-9._\-:\[\]]+$/.test(host);
|
||||
return (req.protocol || 'http') + '://' + (valid ? host : 'localhost');
|
||||
}
|
||||
|
||||
// 影子账号 查/建(sub 为 OIDC 稳定绑定键):
|
||||
// 1. rainid_user_id = sub 命中 → 直接返回
|
||||
// 2. email_verified=true 且本地存在同 email 且未绑定 → 自动绑定(防撞绑:仅 RainID 已验证邮箱可绑)
|
||||
// 3. 否则创建影子账号(username=preferred_username 或 rainid_<sub前8>,冲突加后缀;
|
||||
// password=随机不可登录;email_verified=1;全站无 admin 时首个用户为 admin)
|
||||
// 4. 并发兜底:唯一索引冲突 → 重查按 sub 返回
|
||||
function findOrCreateRainidUser(sub, profile) {
|
||||
if (!sub) {
|
||||
const e = new Error('RainID userinfo 缺少 sub');
|
||||
e.code = 'RAINID_NO_SUB';
|
||||
throw e;
|
||||
}
|
||||
const email = String(profile.email || '').trim().toLowerCase();
|
||||
|
||||
// 1) 已绑定
|
||||
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||||
if (user) return user;
|
||||
|
||||
// 2) 同 email 自动绑定(条件 UPDATE:仅绑定未绑定的本地号,防并发/防覆盖其他 sub)
|
||||
if (email && profile.email_verified) {
|
||||
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (local && !local.rainid_user_id) {
|
||||
db.run('UPDATE users SET rainid_user_id = ? WHERE id = ? AND rainid_user_id = ?', [sub, local.id, '']);
|
||||
const updated = db.get('SELECT * FROM users WHERE id = ?', [local.id]);
|
||||
if (updated && updated.rainid_user_id === sub) return updated;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 创建影子账号
|
||||
let username = profile.preferred_username || ('rainid_' + String(sub).slice(0, 8));
|
||||
if (db.get('SELECT id FROM users WHERE username = ?', [username])) {
|
||||
username = username + '_' + String(sub).slice(0, 4);
|
||||
}
|
||||
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
|
||||
const adminCount = db.get("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
|
||||
const role = adminCount && adminCount.c > 0 ? 'user' : 'admin';
|
||||
try {
|
||||
const id = db.run(
|
||||
'INSERT INTO users (username, password, email, email_verified, role, avatar, rainid_user_id) VALUES (?, ?, ?, 1, ?, ?, ?)',
|
||||
[username, randomHash, email, role, profile.picture || '', sub]
|
||||
);
|
||||
return db.get('SELECT * FROM users WHERE id = ?', [id]);
|
||||
} catch (e) {
|
||||
// 并发兜底:唯一索引冲突 → 重查按 sub 登录
|
||||
const dup = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||||
if (dup) return dup;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ROPC 密码登录:RainID token 端点 grant_type=password(openid-client genericGrantRequest 自动带 client 认证)
|
||||
// 成功 → 影子账号 → 返回 { ok:true, user };失败 → { ok:false, status, error }
|
||||
async function rainidRopcLogin(username, password) {
|
||||
const s = getOidcSettings();
|
||||
if (!s.enabled) return { ok: false, status: 400, error: 'RainID 登录未启用' };
|
||||
let config;
|
||||
try { config = await getOidcConfig(); }
|
||||
catch (e) {
|
||||
// 区分:配置缺失 vs discovery 失败(端点协议/不可达——RainID 端点必须是 https)
|
||||
if (e && e.code === 'RAINID_NOT_CONFIGURED') {
|
||||
return { ok: false, status: 500, error: 'RainID 客户端未配置(client_id / client_secret)' };
|
||||
}
|
||||
console.error('[RainID] discovery 失败:', e && e.message);
|
||||
return { ok: false, status: 502, error: 'RainID 服务配置错误:Discovery 失败(检查端点是否为 https)' };
|
||||
}
|
||||
try {
|
||||
const tokens = await openidClient.genericGrantRequest(config, 'password', {
|
||||
username,
|
||||
password,
|
||||
scope: 'openid profile email',
|
||||
});
|
||||
// id_token 声明(签名/iss/aud 校验由 SDK 承担);拿不到 sub 则 userinfo 跳过 subject 校验
|
||||
let expectedSub = openidClient.skipSubjectCheck;
|
||||
try {
|
||||
const claims = tokens.claims();
|
||||
if (claims && claims.sub) expectedSub = claims.sub;
|
||||
} catch { /* id_token 解析失败不阻断(userinfo 为准) */ }
|
||||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, expectedSub);
|
||||
const user = findOrCreateRainidUser(userinfo.sub, userinfo);
|
||||
return { ok: true, user };
|
||||
} catch (err) {
|
||||
return mapOidcError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// RainID OAuth 错误 → HTTP 状态 + 文案(RainID 对接文档 §5.3 / §12 错误码总表)
|
||||
function mapOidcError(err) {
|
||||
const code = err && err.error;
|
||||
const desc = (err && err.error_description) || '';
|
||||
switch (code) {
|
||||
case 'invalid_grant':
|
||||
// 2FA 用户 ROPC 被拒 → 固定文案;其余统一"用户名/邮箱或密码错误"(防枚举)
|
||||
return { ok: false, status: 401, error: desc.includes('二次验证')
|
||||
? '该账号已开启二次验证,请使用 RainID 登录'
|
||||
: '用户名/邮箱或密码错误' };
|
||||
case 'rate_limited':
|
||||
return { ok: false, status: 429, error: '尝试过于频繁,请稍后再试' };
|
||||
case 'invalid_client':
|
||||
case 'invalid_scope':
|
||||
case 'invalid_request':
|
||||
return { ok: false, status: 500, error: 'RainID 配置错误,请联系管理员' };
|
||||
default:
|
||||
return { ok: false, status: 502, error: 'RainID 服务暂不可用,请稍后再试' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOidcSettings,
|
||||
getOidcConfig,
|
||||
getClientSecret,
|
||||
siteBase,
|
||||
findOrCreateRainidUser,
|
||||
rainidRopcLogin,
|
||||
mapOidcError,
|
||||
};
|
||||
@@ -3,6 +3,20 @@ const db = require('../db');
|
||||
|
||||
const SECRET = process.env.JWT_SECRET;
|
||||
|
||||
// 最后活跃记录:模块级缓存 <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: '登录已过期' });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user