Compare commits
89
Commits
608adac6d4
..
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c67bf269a | ||
|
|
ed40a040e3 | ||
|
|
4200826120 | ||
|
|
6ce6438854 | ||
|
|
0def382511 | ||
|
|
636d13407f | ||
|
|
f9abb877e7 | ||
|
|
c947e62364 | ||
|
|
ded6fdb753 | ||
|
|
9c34374b95 | ||
|
|
8a3e213e40 | ||
|
|
2de07710da | ||
|
|
e16130bf8f | ||
|
|
2b748fc48e | ||
|
|
717ebc44f4 | ||
|
|
f2740afe50 | ||
|
|
d9d2ad3f7d | ||
|
|
0ae8815f33 | ||
|
|
9400f76c7e | ||
|
|
03d5c97315 | ||
|
|
2cb9592d56 | ||
|
|
8a08932d78 | ||
|
|
b152a2a9c9 | ||
|
|
4af71185d5 | ||
|
|
6d60b10c14 | ||
|
|
cf4c7d4ca4 | ||
|
|
5acc461e88 | ||
|
|
391cd1bdae | ||
|
|
5b166ef0e2 | ||
|
|
4f89c63b28 | ||
|
|
7bfeffaaa0 | ||
|
|
aff5764b9b | ||
|
|
bb830f779c | ||
|
|
63d329db5f | ||
|
|
e83a4e6ee1 | ||
|
|
0153f770f6 | ||
|
|
789a2d9b33 | ||
|
|
5a878c1aab | ||
|
|
5e4cdf26b9 | ||
|
|
2faace4810 | ||
|
|
45002b809a | ||
|
|
1b62bf99ce | ||
|
|
6fafa6eb00 | ||
|
|
6649eba777 | ||
|
|
6aedc5f632 | ||
|
|
ed82908a0d | ||
|
|
274291f8fe | ||
|
|
588df32306 | ||
|
|
3c98084909 | ||
|
|
1c0339bae1 | ||
|
|
651bbbb22e | ||
|
|
36be795af5 | ||
|
|
632d19f49e | ||
|
|
3cf560f932 | ||
|
|
ad8883cc61 | ||
|
|
f8d495b128 | ||
|
|
c2ca0f2327 | ||
|
|
249ed8cd2c | ||
|
|
a858439e19 | ||
|
|
535e4b8fcd | ||
|
|
75d102d6ee | ||
|
|
f0b642362a | ||
|
|
45f0dc9beb | ||
|
|
b29fbcdcfc | ||
|
|
20deb09dbd | ||
|
|
c71f4f0ed6 | ||
|
|
f7dd4163b2 | ||
|
|
72295ded4d | ||
|
|
f4a5276fb8 | ||
|
|
be6f644154 | ||
|
|
bdd3a068af | ||
|
|
990f6f40d0 | ||
|
|
1bed2dda5f | ||
|
|
b319584012 | ||
|
|
c4c0115ba6 | ||
|
|
f6407a84f7 | ||
|
|
2ac286a4f8 | ||
|
|
91cd009899 | ||
|
|
4d0ad9da68 | ||
|
|
e7f8b3f173 | ||
|
|
a30a628772 | ||
|
|
cd290ff237 | ||
|
|
f7f30af569 | ||
|
|
4465130e60 | ||
|
|
bd0c0442ab | ||
|
|
b80c5a1277 | ||
|
|
73e145df07 | ||
|
|
6ffbf19aff | ||
|
|
2e6eeadfc1 |
@@ -6,3 +6,6 @@ uploads/*
|
||||
.env.json
|
||||
server.pid
|
||||
releases/
|
||||
public/dist/
|
||||
backups/
|
||||
workspace/
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# AGENTS.md
|
||||
|
||||
RainWeb 个人云平台(博客 + 论坛 + 密码管理器 + 管理面板聚合)。Node.js 22+(推荐 22 LTS)、CommonJS、Express 4、better-sqlite3(原生 SQLite)。前端 **React + Vite + React Router**(前台原生样式 Material Design 3,后台 MUI 独立应用)。**无测试、无 lint**——前端改动进 `frontend/src/`,构建产物 `public/dist/`(gitignored)由 server 静态服务,部署必须 `npm run build`。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm start # 生产:node server.js(serve 构建产物 + /api)
|
||||
npm run dev # 开发:vite dev server(5173,/api 与 /uploads 代理到 3101)
|
||||
npm run build # 构建 React 前端 → public/dist/(index.html + admin.html + assets)
|
||||
npm run preview # 预览构建产物
|
||||
node cli.js <cmd> # status | start | stop | restart | port [N] | password [pwd] | config | captcha | upgrade
|
||||
```
|
||||
|
||||
- 端口:`.env.json`(gitignored)`{"port": N}` 或环境变量 `PORT`,默认 3001;vite 代理目标硬编码 3101(vite.config.js),后端端口改了就同步改。
|
||||
- 验证方式:后端改动启动后 `curl` 接口;前端改动 `npm run dev` 热更新,或 `npm run build` 后刷新验证(需强刷一次,见下)。
|
||||
- **部署后浏览器必须拿新 HTML**:`serveIndex` 响应带 `Cache-Control: no-store`;构建产物 assets 文件名带 hash,经 `/assets` 挂载长期缓存(immutable)。若用户白屏且 console 报 "MIME type text/html",先查是否 `npm run build` 缺失/旧产物。
|
||||
- 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete` 用 `setup_complete` 门禁,非 adminOnly——新装无 token)。
|
||||
|
||||
## 数据层(db.js)
|
||||
|
||||
- better-sqlite3 是原生 SQLite:`data/rainweb.db` 单文件持久化,写操作经事务落盘。CLI 与 server 并发写库会等待(busy_timeout 5s)而非互相覆盖,但建议不要同时执行写操作。
|
||||
- 建表在 `initTables()`;列迁移用 try/catch 包裹 `ALTER TABLE ... ADD COLUMN`(幂等、静默失败)确保列存在。**新增列必须沿用此模式**,否则旧库会崩。版本化迁移在 `migrateSchema()`:`PRAGMA user_version` 记录 schema 版本,migrations 数组按 version 升序执行(`current < m.version` 才运行并推进版本号)——**新迁移写进 migrations 数组,不要散落 try/catch**。
|
||||
- 站点设置存 `site_settings`(key/value),通过 `db.getSetting` / `setSetting` 读写。
|
||||
- **cli.js 与 server.js 统一使用 db.js 的 `data/rainweb.db`**;旧版 `data.db` 已由 server.js 首次启动时迁移为 `data/rainweb.db`(旧文件改名 `data.db.bak`)。
|
||||
|
||||
## 路由(server.js)
|
||||
|
||||
- 集中挂载所有 `/api/*`(auth、admin-links、announcements、forum、blog、passwords、settings、email、profile、captcha、upload、setup、proxy、import)。**新路由必须在此挂载**——后面有 SPA catch-all:非 `/api/` 一律回 dist/index.html。
|
||||
- **挂载顺序关键**:`serveIndex('/')` → `/assets`(dist/assets,immutable)→ static(public) → `/uploads` → `/api/*` → `/admin*`(dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。
|
||||
- `middleware/auth.js`:Bearer JWT;`SECRET` = `process.env.JWT_SECRET`(**无硬编码回退**——server.js 启动时若 `.env.json` 无 `jwt_secret` 则随机生成写入并设置环境变量);`adminOnly` 会查库复查角色(用户被删/降权立即失效)。
|
||||
- `routes/setup.js`:`/complete` 用 `setup_complete` 门禁('1' 后 403),新密码禁止等于默认 `admin123`。
|
||||
- `routes/proxy.js`:面板嵌入代理——支持 `Authorization` header 或 `?token=` query(iframe 无法带 header);有内网地址拦截(SSRF)。
|
||||
- 验证码:内置 SVG + reCAPTCHA/Turnstile 第三方,服务端统一 `resolveCaptcha` 校验(builtin 走 proof JWT,第三方走 siteverify)。
|
||||
|
||||
## 前端(React + Vite)
|
||||
|
||||
- 源码在 `frontend/`:`src/main.jsx`(前台入口)、`src/App.jsx`(前台布局 Layout + 路由表)、`src/pages/`(前台页面)、`src/components/`(Layout/MarkdownRenderer/CaptchaModal/MusicEmbed/BlogSidebar)、`src/admin/`(**后台独立应用** MUI:main.jsx + AdminLayout + pages/)、`src/api/`(按模块封装的 fetch 层,`client.js` 管 token)、`src/lib/utils.js`、`src/theme.jsx`。
|
||||
- 前后台是**两个独立入口**(Vite 多入口),仅通过整页跳转连接:前台导航「管理后台」=`<a href="/admin">`(后台路由 basename `/admin`)。
|
||||
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html`、`/forum.html` 等),SPA fallback 已支持,链接不用改。
|
||||
- `frontend/index.html` 含 `${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。
|
||||
- 主题:`data-theme="dark"` 属性在 `<html>` 上(`public/css/style.css` 的 `[data-theme="dark"]` 选择器),非 `.dark` 类,非 prefers-color-scheme。切换逻辑在 `src/theme.jsx`。
|
||||
- 构建:`npm run build` → `public/dist/`(gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。
|
||||
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`(marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
|
||||
- 登录态:token 存 localStorage(key `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js` 的 `notifyAuthChange`)。
|
||||
|
||||
## SSR 与 SEO
|
||||
|
||||
- `ssr.js`:`/blog/:id`、`/forum/:id`、`/sitemap.xml`(robots.txt 的域名取自 `site_url` 设置)。SSR 页引用 `/css/style.css`(public/css 保留,勿删)。
|
||||
- 已发布博文(`published=1`)才会 SSR 渲染,未发布返回 404。
|
||||
|
||||
## 版本与更新
|
||||
|
||||
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
|
||||
- **Web 更新接口已移除**(P0 删除 `/api/update/check`、`/api/update/run`,无 RCE 面)。升级只走 `cli.js upgrade`:git pull(本地 origin:git.rainnya.asia 镜像)+ npm install + 重启。
|
||||
- 上传附件在 `uploads/`(gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。
|
||||
|
||||
## 约定
|
||||
|
||||
- UI 文案、代码注释、commit message 均为中文,保持一致。
|
||||
- 不要提交:`node_modules/`、`data/*`、`uploads/*`、`.env.json`、`server.pid`、`releases/`、`public/dist/`。
|
||||
@@ -1,6 +1,5 @@
|
||||
# 🌧️ RainWeb - 个人云管理平台
|
||||
|
||||
[](https://github.com/Xianyunah/rainwebblog)
|
||||
[](LICENSE)
|
||||
|
||||
一体化个人云平台,集成博客、论坛、密码管理器、管理后台聚合等功能,Material Design 3 风格,支持深色/浅色切换。
|
||||
@@ -25,7 +24,7 @@
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Node.js** 16.x 或更高版本(推荐 20.x LTS)
|
||||
- **Node.js** 22 或更高版本(推荐 22 LTS)
|
||||
- **npm** 随 Node.js 安装
|
||||
|
||||
### 下载安装
|
||||
@@ -45,14 +44,7 @@
|
||||
unzip rainweb-source.zip -d rainweb
|
||||
cd rainweb
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
**从 GitHub 安装:**
|
||||
```bash
|
||||
git clone https://github.com/Xianyunah/rainwebblog.git
|
||||
cd rainwebblog
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
@@ -65,7 +57,8 @@ npm start
|
||||
- **启动文件**: `server.js`
|
||||
- **端口**: `3001`
|
||||
3. 提交后宝塔自动 `npm install` 并启动
|
||||
4. 如需域名访问,配置 Nginx 反向代理:
|
||||
4. 若未自动构建,手动执行 `npm run build`(构建 React 前端到 `public/dist/`)
|
||||
5. 如需域名访问,配置 Nginx 反向代理:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -90,7 +83,7 @@ server {
|
||||
|
||||
```bash
|
||||
# 使用 Node.js 官方镜像
|
||||
docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:20 bash -c "
|
||||
docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:22 bash -c "
|
||||
cd /app && npm install && node server.js
|
||||
"
|
||||
```
|
||||
@@ -126,8 +119,8 @@ npm run cli -- <command>
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Node.js + Express
|
||||
- **数据库**: SQLite (sql.js)
|
||||
- **前端**: 原生 HTML/CSS/JS + Material Design 3
|
||||
- **数据库**: SQLite (better-sqlite3)
|
||||
- **前端**: React + Vite + React Router(前台原生样式 / 后台 MUI)
|
||||
- **加密**: AES-256-GCM, PBKDF2, bcrypt
|
||||
- **验证码**: SVG 扭曲文字 / Google reCAPTCHA V2
|
||||
- **文件上传**: multer
|
||||
@@ -139,6 +132,7 @@ npm run cli -- <command>
|
||||
```
|
||||
rainweb/
|
||||
├── server.js # 主入口
|
||||
├── vite.config.js # Vite 构建配置
|
||||
├── cli.js # CLI 工具
|
||||
├── db.js # 数据库层
|
||||
├── middleware/
|
||||
@@ -156,29 +150,20 @@ rainweb/
|
||||
│ ├── setup.js # 初始化向导
|
||||
│ ├── announcements.js # 公告
|
||||
│ └── profile.js # 个人资料
|
||||
├── frontend/ # React 前端源码(Vite 根目录)
|
||||
│ ├── index.html # 前台入口(含 ${site_name} 占位符)
|
||||
│ ├── admin.html # 后台入口
|
||||
│ └── src/
|
||||
│ ├── main.jsx # 前台入口
|
||||
│ ├── App.jsx # 前台布局 + 路由表
|
||||
│ ├── admin/ # 后台入口(MUI)
|
||||
│ ├── api/ # fetch 封装
|
||||
│ ├── lib/ # 工具函数
|
||||
│ ├── theme.jsx # 主题切换(data-theme)
|
||||
│ └── pages/ # 前台页面
|
||||
├── public/
|
||||
│ ├── index.html # 博客首页
|
||||
│ ├── login.html # 登录
|
||||
│ ├── register.html # 注册
|
||||
│ ├── admin.html # 管理后台
|
||||
│ ├── forum.html # 论坛
|
||||
│ ├── blog.html # 博客页
|
||||
│ ├── passwords.html # 密码管理器
|
||||
│ ├── embed.html # 网页嵌入
|
||||
│ ├── profile.html # 个人中心
|
||||
│ ├── setup.html # 初始化向导
|
||||
│ ├── dist/ # Vite 构建产物(npm run build 生成,serveIndex 读取)
|
||||
│ ├── css/style.css # 全局样式 + 主题
|
||||
│ ├── js/
|
||||
│ │ ├── api.js # API 封装
|
||||
│ │ ├── nav.js # 导航栏 + 主题
|
||||
│ │ ├── captcha.js # 验证码前端
|
||||
│ │ ├── theme.js # 深浅色切换
|
||||
│ │ ├── main.js # 博客首页
|
||||
│ │ ├── admin.js # 管理后台
|
||||
│ │ ├── forum.js # 论坛
|
||||
│ │ ├── blog.js # 博客
|
||||
│ │ ├── passwords.js # 密码管理器
|
||||
│ │ └── ... # 其他页面逻辑
|
||||
│ └── wallpaper/ # 上传的壁纸
|
||||
└── package.json
|
||||
```
|
||||
@@ -211,11 +196,20 @@ rainweb/
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 开发模式启动
|
||||
node server.js
|
||||
# 开发模式(Vite dev server,端口 5173,/api 与 /uploads 代理到本地后端 3101)
|
||||
npm run dev
|
||||
|
||||
# 构建生产产物到 public/dist/
|
||||
npm run build
|
||||
|
||||
# 预览构建产物
|
||||
npm run preview
|
||||
|
||||
# 生产运行(Express 服务 public/ 与构建产物)
|
||||
npm start
|
||||
```
|
||||
|
||||
项目不依赖构建工具,直接修改 `public/` 目录下的文件即可。
|
||||
前端为 React + Vite,开发时前端改动由 Vite 热更新,无需刷新;生产部署必须先在服务器执行 `npm run build` 生成 `public/dist/`。
|
||||
|
||||
## 升级
|
||||
|
||||
@@ -226,6 +220,7 @@ node cli.js upgrade
|
||||
# 方法2: 手动
|
||||
git pull
|
||||
npm install
|
||||
npm run build
|
||||
node cli.js restart
|
||||
```
|
||||
|
||||
|
||||
@@ -6,49 +6,9 @@ const readline = require('readline');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const http = require('http');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data.db');
|
||||
const CONFIG_PATH = path.join(__dirname, '.env.json');
|
||||
const PKG = require('./package.json');
|
||||
|
||||
// Lazy-load db
|
||||
let _db = null;
|
||||
async function getDb() {
|
||||
if (_db) return _db;
|
||||
const initSqlJs = require('sql.js');
|
||||
const SQL = await initSqlJs();
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
_db = new SQL.Database(buffer);
|
||||
} else {
|
||||
console.error('data.db not found. Run the server first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
function dbRun(sql, params = []) {
|
||||
if (!_db) return;
|
||||
_db.run(sql, params);
|
||||
const r = _db.exec("SELECT last_insert_rowid()");
|
||||
const data = _db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
return r && r[0] && r[0].values ? r[0].values[0][0] : 0;
|
||||
}
|
||||
|
||||
function dbGet(sql, params = []) {
|
||||
if (!_db) return null;
|
||||
const stmt = _db.prepare(sql); stmt.bind(params);
|
||||
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
|
||||
stmt.free(); return null;
|
||||
}
|
||||
|
||||
function dbAll(sql, params = []) {
|
||||
if (!_db) return [];
|
||||
const stmt = _db.prepare(sql); stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free(); return rows;
|
||||
}
|
||||
const db = require('./db');
|
||||
|
||||
const HELP = `
|
||||
RainWeb CLI v${PKG.version}
|
||||
@@ -63,6 +23,7 @@ Commands:
|
||||
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
|
||||
|
||||
@@ -71,6 +32,7 @@ Examples:
|
||||
node cli.js port 8080
|
||||
node cli.js password MyNewP@ss123
|
||||
node cli.js captcha
|
||||
node cli.js backup
|
||||
node cli.js upgrade
|
||||
`;
|
||||
|
||||
@@ -86,6 +48,7 @@ async function main() {
|
||||
case 'password': return cmdPassword();
|
||||
case 'captcha': return cmdCaptcha();
|
||||
case 'config': return cmdConfig();
|
||||
case 'backup': return cmdBackup();
|
||||
case 'upgrade': return cmdUpgrade();
|
||||
case 'help':
|
||||
default:
|
||||
@@ -98,7 +61,11 @@ async function cmdStatus() {
|
||||
console.log(`RainWeb v${PKG.version}`);
|
||||
console.log(`Node.js: ${process.version}`);
|
||||
console.log(`Platform: ${process.platform}`);
|
||||
console.log(`Data DB: ${fs.existsSync(DB_PATH) ? fs.statSync(DB_PATH).size + ' bytes' : 'NOT FOUND'}`);
|
||||
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 {
|
||||
@@ -110,8 +77,8 @@ async function cmdStatus() {
|
||||
|
||||
// Show admin info
|
||||
try {
|
||||
const db = await getDb();
|
||||
const admin = dbGet("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
|
||||
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'})`);
|
||||
}
|
||||
@@ -150,6 +117,7 @@ function cmdPort() {
|
||||
|
||||
// === Password ===
|
||||
async function cmdPassword() {
|
||||
await warnIfServerRunning();
|
||||
let newPass = process.argv[3];
|
||||
if (!newPass) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -163,44 +131,45 @@ async function cmdPassword() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await getDb();
|
||||
const admin = dbGet("SELECT id FROM users WHERE role = 'admin'");
|
||||
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);
|
||||
dbRun('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
||||
db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
||||
console.log('Admin password updated successfully.');
|
||||
}
|
||||
|
||||
// === Captcha ===
|
||||
async function cmdCaptcha() {
|
||||
await getDb();
|
||||
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 = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
|
||||
const v = db.get("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
|
||||
console.log(` ${k}: ${v ? v.value : '0'}`);
|
||||
});
|
||||
const type = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_type'");
|
||||
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) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
|
||||
if (typeAns) db.run("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
|
||||
|
||||
for (const scope of ['login', 'register', 'forum']) {
|
||||
const current = dbGet("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'");
|
||||
const 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'}]: `);
|
||||
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']);
|
||||
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): ');
|
||||
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
|
||||
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) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
||||
if (threshold) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
@@ -209,8 +178,8 @@ async function cmdCaptcha() {
|
||||
|
||||
// === Config ===
|
||||
async function cmdConfig() {
|
||||
await getDb();
|
||||
const rows = dbAll("SELECT key, value FROM site_settings ORDER BY key");
|
||||
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 => {
|
||||
@@ -261,76 +230,70 @@ async function cmdRestart() {
|
||||
await cmdStart();
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
const REPO_URL = 'https://github.com/Xianyunah/rainwebblog.git';
|
||||
const REPO_ZIP = 'https://github.com/Xianyunah/rainwebblog/archive/refs/heads/main.zip';
|
||||
// === Backup ===
|
||||
async function cmdBackup() {
|
||||
const srcPath = path.join(__dirname, 'data', 'rainweb.db');
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.error('数据库不存在:' + srcPath + '(首次启动 server 后才会创建)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backupDir = path.join(__dirname, 'backups');
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const destPath = path.join(backupDir,
|
||||
`rainweb-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.db`);
|
||||
|
||||
// 只读打开源库,不写数据,安全(server 运行中也可执行)
|
||||
// better-sqlite3 v13:src.backup(destPath) 接受目标文件路径(string),返回 Promise
|
||||
const Database = require('better-sqlite3');
|
||||
const src = new Database(srcPath, { readonly: true });
|
||||
try {
|
||||
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');
|
||||
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
||||
|
||||
if (isGitRepo) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
console.log('1. Downloading latest release...');
|
||||
try {
|
||||
const tmpDir = path.join(__dirname, '.upgrade-tmp');
|
||||
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
if (!fs.existsSync(path.join(__dirname, '.git'))) {
|
||||
console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Download zip using fetch or curl
|
||||
const zipPath = path.join(tmpDir, 'rainweb.zip');
|
||||
try {
|
||||
execSync(`curl -L "${REPO_ZIP}" -o "${zipPath}"`, { stdio: 'pipe' });
|
||||
} catch {
|
||||
// Fallback to wget or node https
|
||||
execSync(`node -e "
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const f = fs.createWriteStream('${zipPath.replace(/\\/g, '/')}');
|
||||
https.get('${REPO_ZIP}', r => r.pipe(f));
|
||||
"`, { stdio: 'pipe', timeout: 60000 });
|
||||
}
|
||||
|
||||
// Extract (requires unzip or 7z)
|
||||
const extractDir = path.join(tmpDir, 'extracted');
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
execSync(`tar -xf "${zipPath}" -C "${extractDir}"`, { stdio: 'pipe' });
|
||||
|
||||
// Find the inner directory (github adds a prefix dir)
|
||||
const inner = fs.readdirSync(extractDir).filter(f => fs.statSync(path.join(extractDir, f)).isDirectory())[0];
|
||||
const srcDir = inner ? path.join(extractDir, inner) : extractDir;
|
||||
|
||||
// Copy files, excluding data.db and node_modules
|
||||
const exclude = ['data.db', 'node_modules', '.env.json', 'server.pid', 'releases'];
|
||||
const cpDir = (src, dest) => {
|
||||
fs.readdirSync(src).forEach(f => {
|
||||
if (exclude.includes(f)) return;
|
||||
const s = path.join(src, f), d = path.join(dest, f);
|
||||
if (fs.statSync(s).isDirectory()) {
|
||||
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
||||
cpDir(s, d);
|
||||
} else {
|
||||
fs.copyFileSync(s, d);
|
||||
}
|
||||
});
|
||||
};
|
||||
cpDir(srcDir, __dirname);
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
console.log(' Download & extract complete.');
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e.message);
|
||||
console.log(' Fallback: manually download from ' + REPO_URL);
|
||||
process.exit(1);
|
||||
}
|
||||
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...');
|
||||
@@ -347,6 +310,14 @@ async function cmdUpgrade() {
|
||||
}
|
||||
|
||||
// === Helper ===
|
||||
// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行
|
||||
async function warnIfServerRunning() {
|
||||
try {
|
||||
await httpGet('http://localhost:' + (getConfigPort()));
|
||||
console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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)); })
|
||||
|
||||
@@ -1,103 +1,109 @@
|
||||
const initSqlJs = require('sql.js');
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data', 'rainweb.db');
|
||||
const DATA_DIR = path.dirname(DB_PATH);
|
||||
let db = null;
|
||||
|
||||
async function getDb() {
|
||||
function getDb() {
|
||||
if (db) return db;
|
||||
const SQL = await initSqlJs();
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
db = new SQL.Database(buffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
db = new Database(DB_PATH);
|
||||
initTables();
|
||||
migrateSchema();
|
||||
seedAdmin();
|
||||
seedDefaults();
|
||||
seedSampleData();
|
||||
saveDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
function saveDb() {
|
||||
if (!db) return;
|
||||
const data = db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
}
|
||||
|
||||
function initTables() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
|
||||
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS pending_users (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS pending_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS admin_links (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS admin_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
|
||||
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
// Migration: add columns if missing
|
||||
try { db.run('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.run('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS announcements (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS announcements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '',
|
||||
content TEXT NOT NULL, active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL,
|
||||
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)`);
|
||||
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0,
|
||||
announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`);
|
||||
try { db.exec('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_categories ADD COLUMN sub_categories TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_posts (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL,
|
||||
use_markdown INTEGER DEFAULT 1,
|
||||
use_markdown INTEGER DEFAULT 1, sub_category TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
try { db.run('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blog_comments (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS blog_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL, author_id INTEGER,
|
||||
author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')),
|
||||
author_name TEXT DEFAULT '', parent_id INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'approved',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (post_id) REFERENCES blog_posts(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL, author_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (post_id) REFERENCES forum_posts(id),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||
content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL,
|
||||
published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1,
|
||||
tags TEXT DEFAULT '', views INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS post_likes (
|
||||
post_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (post_id, user_id))`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL,
|
||||
pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS password_entries (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS password_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, username TEXT DEFAULT '',
|
||||
encrypted_password TEXT NOT NULL, url TEXT DEFAULT '', notes TEXT DEFAULT '',
|
||||
@@ -105,7 +111,7 @@ function initTables() {
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS attachments (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL, size INTEGER NOT NULL,
|
||||
mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL,
|
||||
@@ -113,15 +119,64 @@ function initTables() {
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
|
||||
|
||||
// 审计日志表(终端会话/密码失败等安全事件落库)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
user_id INTEGER DEFAULT 0,
|
||||
username TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)');
|
||||
// 审计保留策略:启动时惰性清理 90 天前的记录,防止无限增长
|
||||
//(写入路径上 terminal.js 每 50 条也会触发一次同规则清理)
|
||||
db.exec("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
|
||||
|
||||
// Indexes for performance
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)');
|
||||
|
||||
// Legacy migrations for old database compatibility
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec('ALTER TABLE blog_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
}
|
||||
|
||||
// 版本化迁移框架:PRAGMA user_version 记录当前 schema 版本,
|
||||
// migrations 数组按 version 升序执行,仅当 current < m.version 时运行并推进版本号。
|
||||
// 现有 try/catch ALTER 保留作为"确保列存在"的幂等层,未来新列迁移写入 migrations。
|
||||
function migrateSchema() {
|
||||
const current = db.pragma('user_version', { simple: true });
|
||||
const migrations = [
|
||||
// v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑,
|
||||
// 默认 100000:存量用户保持旧迭代数,旧密文仍可解密)
|
||||
{ version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } },
|
||||
// v2: 博客增强——博文标签/阅读量、评论嵌套与审核、点赞表
|
||||
{ version: 2, up: () => {
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN tags TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN views INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
|
||||
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
|
||||
} },
|
||||
// v3: RainID 单点登录——影子账号绑定键(sub)。部分唯一索引:空串(本地账号)不参与唯一约束
|
||||
{ version: 3, up: () => {
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''"); } catch {}
|
||||
} },
|
||||
];
|
||||
for (const m of migrations) {
|
||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin() {
|
||||
@@ -136,9 +191,12 @@ function seedDefaults() {
|
||||
const defaults = {
|
||||
site_name: 'RainWeb',
|
||||
site_description: '个人云平台',
|
||||
site_url: '',
|
||||
primary_color: '#6750a4',
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: '',
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
smtp_host: '',
|
||||
smtp_port: '587',
|
||||
smtp_user: '',
|
||||
@@ -147,6 +205,7 @@ function seedDefaults() {
|
||||
smtp_from_name: 'RainWeb',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: '1',
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
@@ -155,6 +214,22 @@ function seedDefaults() {
|
||||
captcha_register: '0',
|
||||
captcha_forum: '0',
|
||||
captcha_type: 'builtin',
|
||||
rainid_enabled: '0',
|
||||
rainid_client_id: '',
|
||||
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: '0',
|
||||
site_favicon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>',
|
||||
music_embed_enabled: '0',
|
||||
music_embed_code: '',
|
||||
music_embed_position: 'right',
|
||||
music_embed_autohide: '0',
|
||||
music_embed_idle_timeout: '10',
|
||||
footer_style: 'classic',
|
||||
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
|
||||
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
|
||||
footer_desc: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
};
|
||||
for (const [k, v] of Object.entries(defaults)) {
|
||||
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
|
||||
@@ -164,24 +239,32 @@ function seedDefaults() {
|
||||
}
|
||||
|
||||
function run(sql, params = []) {
|
||||
db.run(sql, params);
|
||||
const r = db.exec("SELECT last_insert_rowid()");
|
||||
const rowid = r && r[0] && r[0].values ? r[0].values[0][0] : 0;
|
||||
saveDb();
|
||||
return rowid;
|
||||
try {
|
||||
const info = db.prepare(sql).run(...params);
|
||||
return Number(info.lastInsertRowid) || 0;
|
||||
} catch (e) {
|
||||
console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function get(sql, params = []) {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
|
||||
stmt.free(); return null;
|
||||
try {
|
||||
const row = db.prepare(sql).get(...params);
|
||||
return row || null;
|
||||
} catch (e) {
|
||||
console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free(); return rows;
|
||||
try {
|
||||
return db.prepare(sql).all(...params);
|
||||
} catch (e) {
|
||||
console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getSetting(key) {
|
||||
@@ -208,8 +291,8 @@ function seedSampleData() {
|
||||
if (adminUser) {
|
||||
const aid = adminUser.id;
|
||||
// Forum posts
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid]);
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid]);
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid, '分享']);
|
||||
run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid, '讨论']);
|
||||
|
||||
// Blog posts
|
||||
const blogMarkdown = `## 欢迎使用 RainWeb\n\nRainWeb 是一个多功能的个人云平台,集成了 **博客、论坛、密码管理器** 等功能。\n\n- 🎨 Material Design 3 风格\n- 🌓 深色/浅色主题切换\n- 📧 邮箱验证注册\n- 🔒 密码管理器 (AES-256-GCM 加密)\n\n### 快速开始\n\n1. 点击右上角「登录」使用默认账号 \`admin / admin123\`\n2. 在管理后台配置 SMTP 邮件和 reCAPTCHA\n3. 在「面板链接」中添加你的各个管理后台\n4. 发布你的第一篇博客文章!`;
|
||||
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
@echo off
|
||||
title RainWeb Deploy
|
||||
|
||||
set REPO_URL=https://github.com/Xianyunah/rainwebblog.git
|
||||
set INSTALL_DIR=rainweb
|
||||
|
||||
echo ====================================
|
||||
echo RainWeb - One-Click Deploy
|
||||
echo Repo: %REPO_URL%
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
REM Check Node.js
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Node.js is not installed. Install from https://nodejs.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Node.js:
|
||||
node -v
|
||||
|
||||
REM Check git
|
||||
where git >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Git is not installed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Clone or pull
|
||||
if exist "%INSTALL_DIR%\.git" (
|
||||
echo [1/4] Updating existing installation...
|
||||
cd "%INSTALL_DIR%"
|
||||
git pull
|
||||
) else (
|
||||
echo [1/4] Cloning repository...
|
||||
git clone "%REPO_URL%" "%INSTALL_DIR%"
|
||||
cd "%INSTALL_DIR%"
|
||||
)
|
||||
|
||||
REM Install dependencies
|
||||
echo.
|
||||
echo [2/4] Installing dependencies...
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] npm install failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Dependencies installed.
|
||||
|
||||
REM Create wallpaper folder
|
||||
if not exist "public\wallpaper" mkdir "public\wallpaper"
|
||||
|
||||
REM Start
|
||||
echo.
|
||||
echo [3/4] Starting server...
|
||||
echo.
|
||||
echo ====================================
|
||||
echo Open http://localhost:3001
|
||||
echo Default admin: admin / admin123
|
||||
echo ====================================
|
||||
echo.
|
||||
start "" http://localhost:3001
|
||||
node server.js
|
||||
|
||||
pause
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
REPO_URL="https://github.com/Xianyunah/rainwebblog.git"
|
||||
INSTALL_DIR="${1:-rainweb}"
|
||||
|
||||
echo "===================================="
|
||||
echo " RainWeb - One-Click Deploy"
|
||||
echo " Repo: $REPO_URL"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Check Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "[ERROR] Node.js is not installed. Install from https://nodejs.org (LTS 20.x+)"
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Node.js: $(node -v)"
|
||||
|
||||
# Check git
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "[ERROR] Git is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clone or pull
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
echo "[1/4] Updating existing installation..."
|
||||
cd "$INSTALL_DIR"
|
||||
git pull
|
||||
else
|
||||
echo "[1/4] Cloning repository..."
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
echo ""
|
||||
echo "[2/4] Installing dependencies..."
|
||||
npm install --production
|
||||
echo "[OK] Dependencies installed."
|
||||
|
||||
# Create wallpaper directory
|
||||
mkdir -p public/wallpaper
|
||||
|
||||
# Start
|
||||
echo ""
|
||||
echo "[3/4] Starting server..."
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo " Open http://localhost:3001"
|
||||
echo " Default admin: admin / admin123"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
if command -v xdg-open &> /dev/null; then
|
||||
xdg-open http://localhost:3001 2>/dev/null || true
|
||||
elif command -v open &> /dev/null; then
|
||||
open http://localhost:3001 2>/dev/null || true
|
||||
fi
|
||||
|
||||
node server.js
|
||||
@@ -0,0 +1,174 @@
|
||||
# RainWeb 优化重构实施计划(P0 / P1 / P2)
|
||||
|
||||
> 依据:数据库优化师(better-sqlite3 选型调研)、后端架构师(架构评审)、应用安全工程师(安全审计)、前端路由专项深挖 四份报告合并。
|
||||
> 数据库路线已定:**better-sqlite3 + Node 22 LTS**(放弃 sql.js 与 PostgreSQL)。
|
||||
> 前端路由:用户确认问题很大 → 按「方案 A 止血 → 方案 B 协议收编」两步走(见 P1-14 / P2-19)。
|
||||
> 本计划仅执行顺序与细节,**未开始任何代码改动**。
|
||||
|
||||
## 全局前提(每阶段执行前)
|
||||
|
||||
1. 备份:`cp -r data data.bak-<日期>`(`data/rainweb.db` 是唯一数据文件)
|
||||
2. 每阶段一个 git commit,commit message 中文,如 `P0: 删除 Web 更新链路,修复鉴权漏洞`
|
||||
3. 无测试环境,验证方式 = curl 接口 + 浏览器刷新 + `node cli.js` 命令
|
||||
|
||||
---
|
||||
|
||||
## P0 紧急止损(独立项,可并行,约半天)
|
||||
|
||||
### P0-1 删除 Web 更新链路(消除 R6 + .git 覆盖风险)
|
||||
- `server.js`:删除 84-165 行 `/api/update/check` + `/api/update/run`,保留 `/api/version`(81-82 行)
|
||||
- `public/js/admin.js`:删除 checkUpdate/runUpdate 函数及更新按钮 UI(约 692-731 行区域)
|
||||
- `cli.js`:`cmdUpgrade` 保留 git pull 分支(走本地 Gitea origin),删除非 git 仓库的 zip 下载分支(266-346 行区域)
|
||||
- 理由:用户即上游,zip 覆盖式更新是错误范式;`/api/update/run` 无鉴权=RCE 面,且 exclude 未排除 `.git` 会冲掉 git 历史
|
||||
- 风险:低。注意前端引用一并删除,避免 404
|
||||
- 验证:`curl /api/update/check` → 404;admin 面板打开控制台无报错;`node cli.js upgrade` 走 git pull 分支正常
|
||||
|
||||
### P0-2 JWT_SECRET 随机化(消除 R4)
|
||||
- `middleware/auth.js`:改为 `process.env.JWT_SECRET` 必须存在(长度 ≥32),否则启动失败;删除硬编码回退
|
||||
- `server.js` `start()`:首次启动若无 `JWT_SECRET`,用 `crypto.randomBytes(48).toString('hex')` 生成并写入 `.env.json`
|
||||
- 风险:低;已签发旧 token 全部失效(自部署可接受)
|
||||
- 验证:删 `.env.json` 重启 → 自动生成 secret;登录 → `curl /api/auth/me` 200
|
||||
|
||||
### P0-3 setup 接管漏洞(消除 R5)
|
||||
- `routes/setup.js`:`POST /complete` 加 `authMiddleware, adminOnly`;`GET /setup/status` 响应移除 `default_password` 字段(只保留 `setup_complete`)
|
||||
- 风险:低
|
||||
- 验证:未登录 `curl -X POST /api/setup/complete` → 401;登录后正常
|
||||
|
||||
### P0-4 proxy SSRF 最小防护(消除 R3 的 SSRF 部分)
|
||||
- `routes/proxy.js`:`GET /fetch` 加 `authMiddleware, adminOnly`;`new URL(target)` 后校验 hostname 非 `127.0.0.1`/`::1`/`localhost`/`10.`/`192.168.`/`172.16-31.`/`169.254.`/`fc00:`/`fe80:`
|
||||
- 风险:低(本功能只服务于 admin_links 面板嵌入)
|
||||
- 验证:未登录请求 → 401;`/api/proxy/fetch?url=http://127.0.0.1:3001/` → 拒绝
|
||||
|
||||
### P0-5 邮件验证码回显与滥用(消除 R7)
|
||||
- `routes/email.js`:`send-verify` 响应删除 `code` 回显;验证码生成改 `crypto.randomInt(10000000, 100000000)`;补回"删除 pending 后重建"的 bug(或废弃该接口由 register 内联重发)
|
||||
- 风险:低
|
||||
- 验证:调 send-verify → 响应无 code 字段;邮件仍能收到验证码
|
||||
|
||||
---
|
||||
|
||||
## P1 安全加固 + 数据层统一(依赖顺序:11 → 12,其余独立)
|
||||
|
||||
### P1-6 上传白名单 + /uploads 防护头(消除 R1)
|
||||
- `routes/upload.js`:`shaFileUpload` 内校验 `path.extname(...).toLowerCase()` 在 `ALLOWED_EXT = ['.png','.jpg','.jpeg','.gif','.webp','.pdf','.zip','.txt','.md']` 内,否则报"不支持的文件类型";图片额外校验 `file.mimetype` 以 `image/` 开头
|
||||
- `server.js:63`:`/uploads` 静态服务加 `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox; default-src 'none'`;非白名单扩展名强制 `Content-Disposition: attachment`
|
||||
- 风险:低;已有历史上传文件不受影响(仅新上传受限)
|
||||
- 验证:登录后上传 `x.html` → 400;上传 png → 200;访问 `/uploads/<hash>.png` 响应头含 nosniff
|
||||
|
||||
### P1-7 内容净化双端统一(消除 R2)
|
||||
- `ssr.js`:引入 `dompurify + jsdom`(服务端),`renderSSR` 中 `marked.parse` 结果过 `DOMPurify.sanitize`;`ssrPage` 中 `title/siteName/ogDesc` 全部 HTML 转义,JSON-LD 用 `JSON.stringify` 序列化
|
||||
- `public/js/render.js`:引入 DOMPurify(本地化到 public/js/vendor/ 或 CDN + SRI),`marked.parse` 结果同样过 sanitize
|
||||
- 风险:中(渲染路径改动,需走查博客/论坛/SEO 页)
|
||||
- 验证:发帖内容含 `<img src=x onerror=alert(1)>` → SSR 页与 SPA 页均不弹窗、标签被剥;正常 markdown 渲染不变
|
||||
|
||||
### P1-8 登录限流 + 服务端验证码(消除 R8)
|
||||
- 新增 `express-rate-limit` 依赖:`/api/auth/login` 15 分钟 10 次;`/api/email/send-verify` 60s/次
|
||||
- `routes/auth.js`:login/register 按 `captcha_login`/`captcha_register` 设置服务端校验 captcha token(与 captcha.js 的 verify 存储联动,token 与 username 绑定);`routes/forum.js` 发帖同理(`captcha_forum`)
|
||||
- 删除 `verifyCaptchaToken` 桩函数(auth.js:39-52)
|
||||
- 风险:中(验证码链路前后端要同步改)
|
||||
- 验证:连错密码 11 次 → 429;开启 captcha_login 后登录需先过验证码
|
||||
|
||||
### P1-9 密码箱解锁限流 + KDF 加固(消除 M1)
|
||||
- `routes/passwords.js`:`/unlock` 加失败计数(3 次/5 分钟,指数退避);解锁会话滑动过期替换一次性 setTimeout
|
||||
- PBKDF2 迭代 100k → 600k(OWASP 2023 建议);注意同步执行阻塞事件循环,可接受(单用户站)
|
||||
- 风险:低
|
||||
- 验证:连续输错 PIN 3 次 → 锁定提示;正确解锁正常;旧 PIN 密文在 KDF 迭代变更后需重设(set-pin 已存在)
|
||||
|
||||
### P1-10 鉴权与信息收敛(消除 M2-M7)
|
||||
- `middleware/auth.js`:`adminOnly` 增加 `db.get('SELECT role FROM users WHERE id=?')` 复查(M2)
|
||||
- `routes/blog.js`:`?all=1` 加 `authMiddleware, adminOnly`;`GET /posts/:id` 未发布仅 admin/作者可见(M3)
|
||||
- 错误信息收敛:`routes/forum.js:21,35`、`server.js:163`、`routes/email.js:54,93` 对外统一 `{ error: '操作失败' }`,详情仅 console.error(M4)
|
||||
- `routes/email.js:17`、`auth.js:93`、`profile.js:25`、`proxy.js:22` 的 `rejectUnauthorized:false` 收敛为配置项(默认关闭校验仅当 SMTP 自签时开启)(M5)
|
||||
- `public/js/passwords.js:113`、`forum.js:76`:内联 onclick 字符串插值改 `data-id` + `addEventListener` 委托(M6)
|
||||
- **S3-3 escapeHtml 引号问题**:`escapeHtml` 不转义 `'`/`"`,用于属性/JS 字符串上下文可注入断链(forum.js:76、passwords.js:113、admin.js:74/402-404)——新增 `escapeAttr`(全转义)用于属性上下文,或随 M6 的 data-id 改造一并消除;帖子标题/分类为他人可见,属存储型 XSS 面
|
||||
- `routes/profile.js`:avatar 仅接受 `/uploads/avatars/` 站内路径(M7)
|
||||
- 风险:中;验证:逐项 curl 测试(未登录 401/403、草稿对匿名 404、报错无堆栈详情)
|
||||
|
||||
### P1-11 cli.js 统一数据层(ora-2 P1-1)
|
||||
- `cli.js`:删除 9-51 行重复封装(DB_PATH/getDb/dbRun/dbGet/dbAll),改为 `const db = require('./db')`;`cmdStatus/cmdPassword/cmdCaptcha/cmdConfig` 改用 `db.get/db.all`;各命令 `await getDb()`
|
||||
- `data.db` 旧路径彻底退役(server.js 启动迁移逻辑保留,把旧文件搬进 data/)
|
||||
- 风险:中低;验证:备份后依次跑 `node cli.js status/config/password/captcha` 全部成功且落库
|
||||
|
||||
### P1-12 schema_version 迁移框架(ora-2 P1-2,依赖 P1-11)
|
||||
- `db.js`:`initTables()` 收敛——"确保列存在"函数(幂等补列,逻辑同现有 try/catch)+ `PRAGMA user_version` 记录迁移版本;未来迁移写成 `{ version, up() }` 数组顺序执行
|
||||
- 风险:中;验证:旧库启动自动补列无报错;连续启动两次幂等
|
||||
|
||||
### P1-13 deploy 脚本处置(ora-2 P1-3)
|
||||
- 删除 `deploy.sh` + `deploy.bat`(指向 GitHub 旧仓库,对自托管用户无意义);README 相关段落同步清理
|
||||
- 风险:低;验证:`ls` 确认删除;README 无残留引用
|
||||
|
||||
### P1-14 前端路由止血(方案 A,约 0.5 天)
|
||||
> 专项深挖结论:PJAX 只替换 `<main>`、目标页 `<script>` 从不执行,而 5 个 PJAX 壳页各自加载不同共享脚本子集 → 从某些页面进入时目标页依赖缺失(结构性缺陷 S1-3)。已确认 bug:S1-1 首页空白、S1-2 个人中心空白、S1-3 脚本依赖矩阵、S2-1 监听器重复累积、S2-2 论坛详情后退进 SSR 页、S2-4 滚动不恢复、S2-5 登录态/设置变化后导航不刷新、S2-6 login/register 白跳。
|
||||
- **A1 PJAX_PATHS 收敛**:`router.js` 只保留 5 个有 `_pageConfig` 的页面(`/`、`/blog.html`、`/forum.html`、`/admin.html`、`/passwords.html`);移除 `login.html`/`register.html`/`profile.html`(整页导航约定,根治 S1-2、S2-6)
|
||||
- **A2 统一共享脚本集**:5 个 PJAX 壳页 html 全部加载完整共享集(marked + render + captcha + music-embed + theme + api + nav + router),消除 S1-3 全部矩阵缺口
|
||||
- **A3 首页逻辑外部化**:`public/index.html:54-96` 内联 HOMEPAGE 抽为 `public/js/homepage.js` 并注册进 `_pageConfig`(修 S1-1;顺带合并 S3-4 与 blog.js 的 loadSidebar 重复)
|
||||
- **A4 `_loadScript` 判重**:`router.js:91-99` 加已加载检测,修 S2-1 监听器累积
|
||||
- **A5 popstate 分支 + 滚动**:popstate 识别 `e.state.forumPostId` 直接还原论坛详情(修 S2-2 不再 reload 进 SSR 页);navigate 时 `scrollTo(0,0)`(修 S2-4)
|
||||
- **A6 导航重渲染钩子**:loadPage 完成后调 `NAV.render?.()`(修 S2-5)
|
||||
- 风险:低(login/register/profile 当前本就走整页兜底,无回归面);A2 可能触发 S3-2 重复定义,但各文件实现相同无碍
|
||||
- 验证:passwords.html 整页进入 → PJAX 依次进博客(markdown 正常)/论坛(发帖弹验证码、详情正常)/首页;论坛详情后退回 SPA 列表无 reload;循环 PJAX 5 次 forum.js 只加载一次
|
||||
|
||||
---
|
||||
|
||||
## P2 数据库换轨 + 工程卫生(依赖 P1-11/P1-12)
|
||||
|
||||
### P2-15 better-sqlite3 迁移(lib-1 调研结论)
|
||||
- **前置**:Node 升 22 LTS(宝塔 Node 版本管理器 / nvm);README 与 Docker 示例同步(`node:20` → `node:22`)
|
||||
- 依赖:`npm install better-sqlite3`(v13,预编译随包,宝塔 glibc x64 免编译)
|
||||
- `db.js`:`initSqlJs()` 异步初始化 → `new Database(DB_PATH)` 同步;`run/get/all` 重写:
|
||||
- `run` → `db.prepare(sql).run(...params)` + `info.lastInsertRowid`
|
||||
- `get` → `stmt.get(...params)`;`all` → `stmt.all(...params)`
|
||||
- 删除 `saveDb()`/`db.export()`(SQLite 事务原生落盘);返回签名不变,40+ 业务调用点零改动
|
||||
- `initTables()` 的 CREATE TABLE IF NOT EXISTS 与幂等补列原样保留(兼容旧库)
|
||||
- `cli.js`:同套改写(P1-11 统一后只需改 db.js 一处)
|
||||
- `routes/import.js`:上传文件先落临时路径再 `new Database(tmpPath)` 只读打开,`exec` 数组结果改对象结果,用后 close
|
||||
- **数据迁移**:零迁移——现有 `data/rainweb.db` 是标准 SQLite 文件,直接打开
|
||||
- 风险:中;验证:备份 data/ 后启动 → 管理后台 CRUD 走一遍 → `node cli.js status/password` → `sqlite3 data/rainweb.db "PRAGMA integrity_check;"`
|
||||
|
||||
### P2-16 死代码 + 前端重复清理
|
||||
- 删除 `routes/links.js`(引用不存在的 links 表,挂载即崩)、`public/js/main.js`(无引用)、`public/js/passwords.js` 内重复的 `deleteFromDetail`
|
||||
- S3-1:`register.html:85` 与 `:92` 重复调用 `CAPTCHA.checkRequired('register')`,删一行
|
||||
- S3-2:`escapeHtml/closeDialog/openDialog/showSnackbar` 在 nav/render/blog/forum/admin/passwords/index/profile 各定义一份 → 收敛到 nav.js(或公共 util)单份定义;admin.js 同文件内 6 个函数重复定义(setNavStyle/setCardStyle/previewWallpaperUrl/removeWallpaper/loadWallpaperList/selectUploadedWallpaper/uploadWallpaper,admin.js:179/263 等)删后一份
|
||||
- 风险:低;验证:全站走查控制台无 404、无重复定义报错
|
||||
|
||||
### P2-17 版本单源 + 文档同步
|
||||
- `VERSION` 文件为唯一源(server.js:81 已读它);`cli.js` HELP 版本改读 VERSION;`package.json` version 与 VERSION 同步
|
||||
- `README.md`:Node 要求改 `>= 22 LTS`;删除更新/部署章节中 GitHub 引用
|
||||
- `AGENTS.md`:数据层章节重写(better-sqlite3、无 saveDb、单文件直写、cli.js 已统一)
|
||||
- 风险:低;验证:`node cli.js help` 与 `/api/version` 显示一致
|
||||
|
||||
### P2-18 可选加固(H 系列,按需确认)
|
||||
- H1:`helmet()` + 收紧 CSP(CDN 引入 marked 的 5 个页面加 SRI)——建议做
|
||||
- H3:验证码 `Math.random()` → `crypto.randomInt`(auth.js:80、email.js:63、profile.js:68)——建议做,改动极小
|
||||
- H9:bcrypt cost 10 → 12;注册用户名格式校验——建议做
|
||||
- H2:JWT 从 localStorage 改 HttpOnly cookie——改动大(前端 api.js 全量),**默认不做**,列入后续
|
||||
- H4:首启随机管理员密码——与 setup 向导冲突,**默认不做**(P0-3 已封堵接管面)
|
||||
|
||||
### P2-19 前端路由协议收编(方案 B,约 1.5-2 天,依赖 P1-14)
|
||||
> 方案 A 修完当前全部可复现 bug;方案 B 是防复发投资——解决"下一次加页面还会不会踩坑"。
|
||||
- **B1 单一注册表**:`PAGES` 配置对象 `path → { pjax, title, scripts, global, init }`,`PJAX_PATHS`/`_pageConfig`/脚本依赖全部由它派生;命中且 `pjax:true` 才拦截,其余整页导航(无兜底分支)
|
||||
- **B2 页面结构规范**:每个 PJAX 页 `<main data-page="blog">`,router 读 `data-page` 查注册表,不再硬编码路径数组;共享依赖在注册表声明,`_loadScript` 按"已加载集合"增量补齐
|
||||
- **B3 页面逻辑全外部化**:HOMEPAGE(P1-14 A3 已抽)、profile 逻辑(profile.html:43-180)抽为独立 js;内联 `<script>` 仅保留极少量数据初始化;统一 `window[global].init()` 协议
|
||||
- **B4 pushState/popstate 统一由 router 管理**:页面不再自行 pushState 非标准 state;论坛/博客详情作为"子路由"由注册表定义,popstate 由 router 解析还原(根治 S2-2/S2-3)
|
||||
- **B5 生命周期钩子**:注册表条目可选 `destroy()`,音乐嵌入/定时器/事件解绑在此处理(根治 S2-1 结构性累积)
|
||||
- 整页应用(login/register/write/embed/forum-manage/setup)标记 `pjax:false` 不参与 PJAX,保留内联脚本
|
||||
- 风险:中(popstate 语义变化与音乐嵌入销毁/重建是重点回归面;11 个 html 全动,必须全导航路径走查)
|
||||
- 验证:全导航路径走查(每页进出、后退/前进、刷新直链、登录/退出、音乐嵌入显示与自动隐藏);控制台零报错
|
||||
- 明确不做(方案 C):引入构建链/框架——违反无构建约定,对单人无测试项目负收益
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
P0-1..P0-5 (独立,并行) ≈ 半天
|
||||
P1-6..P1-10 (安全加固,独立并行)
|
||||
P1-11 cli统一 → P1-12 schema_version ≈ 1-2 天
|
||||
P1-13 / P1-14(路由止血,独立) ≈ 0.5 天
|
||||
P2-15 better-sqlite3(依赖 P1-11/12)≈ 1 天
|
||||
P2-16 / P2-17 / P2-18(独立)
|
||||
P2-19 路由协议收编(依赖 P1-14) ≈ 1.5-2 天
|
||||
```
|
||||
|
||||
> 若时间有限:P0 + P1 全部完成后项目即处于"安全 + 可正常使用"状态(路由 8 个 bug 全修);P2-19 是防复发投资,可最后做或延后。
|
||||
|
||||
## 交付节奏
|
||||
|
||||
每个 P 阶段结束:跑通验证清单 → git commit → 可随时暂停(数据文件备份在手,sql.js→better-sqlite3 前任何时刻可回滚)。
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# RainWeb v2 全面升级计划
|
||||
|
||||
> v1(原生 HTML/CSS/JS + PJAX)→ v2(React 前后台分离 + 后端改造)。用户确认的选型全部定案,本计划为唯一执行依据。
|
||||
> 旧 `docs/refactor-plan.md` 的后端部分(P0/P1/P2 中安全与数据库项)并入本计划;其前端路由项(P1-14 方案 A、P2-19 方案 B)**作废**——由 React Router 取代。
|
||||
> 本计划未开始任何代码改动,待用户确认后按阶段执行。
|
||||
|
||||
## 0. 选型定案(已确认,不再变更)
|
||||
|
||||
| 项 | 决定 |
|
||||
|---|---|
|
||||
| 前端框架 | **React + Vite**(TS 可选,见 §4.1) |
|
||||
| 前台 UI | **不引 UI 库**,手写组件 + 复用 `public/css/style.css`,**UI 100% 还原** |
|
||||
| 后台 | **独立应用**(独立入口 `/admin`),引入 **MUI** |
|
||||
| 工程组织 | **单 Vite 项目多入口**(前台 + 后台两个 HTML 入口) |
|
||||
| SEO | **保留 ssr.js 双轨**(/blog/:id、/forum/:id、sitemap.xml 不变) |
|
||||
| 数据库 | **better-sqlite3 + Node 22 LTS**(替换 sql.js) |
|
||||
| 后端改造 | 原 P0 止损 + P1 安全加固照旧执行 |
|
||||
|
||||
## 1. 目标架构
|
||||
|
||||
```
|
||||
浏览器
|
||||
├── 前台 SPA(/) React + React Router,构建产物 + style.css,占位符机制保留
|
||||
│ └── 导航「管理后台」按钮 → 整页跳转 /admin(浏览器级导航,非 SPA 路由、非 iframe)
|
||||
├── 后台 SPA(/admin) React + MUI,完全独立入口/独立路由/独立登录态,外部面板嵌入保留
|
||||
└── SEO 直链(/blog/:id 等) ssr.js 服务端渲染(不变)
|
||||
↓
|
||||
Express 4(server.js)
|
||||
├── /api/* 15 个路由模块(契约不变,前端重写以现有 API 为基)
|
||||
├── 静态:构建产物 + uploads/ + public/wallpaper/
|
||||
└── SPA fallback:/ → 前台 index.html;/admin* → 后台 index.html
|
||||
↓
|
||||
better-sqlite3(data/rainweb.db,零迁移直接打开)
|
||||
```
|
||||
|
||||
**前后台关系**:两个独立 React 入口(单 Vite 多入口构建),互相之间仅通过整页跳转连接。前台无后台路由、后台无前台路由;登录态共用(同一 JWT,localStorage)。
|
||||
|
||||
## 2. v1 → v2 保留 / 废弃清单
|
||||
|
||||
**保留**:后端全部(routes/、middleware/、db.js 逻辑、ssr.js、cli.js 改造后)、`public/css/style.css`(搬入前台)、`public/wallpaper/`、`uploads/`、API 契约。
|
||||
|
||||
**废弃**:`public/index.html` 等全部页面 HTML(改 React 组件)、`public/js/` 全部(api/nav/router/theme/captcha/render/music-embed/main/blog/forum/admin/passwords → 拆成 React 模块)、PJAX 机制(router.js)、`public/js/main.js` 等死代码。
|
||||
|
||||
**保留功能点(迁移时必须覆盖)**:博客瀑布流、论坛多分类/子分类/发帖回复、密码管理器(PIN + AES-256-GCM,见 §4.4)、管理面板聚合(iframe 嵌入 + proxy 代理)、壁纸/磨砂玻璃/深浅色主题、音乐嵌入(自动隐藏)、验证码(内置/recaptcha)、邮箱验证注册、公告、附件上传([image:]/[file:] 标签渲染)。
|
||||
|
||||
## 3. 阶段划分(每阶段验证 + commit,可随时暂停)
|
||||
|
||||
### Phase 0 后端止损(≈半天,独立)
|
||||
- P0-1 删除 Web 更新链路(server.js /api/update/check + /api/update/run、admin.js 前端引用、cli.js zip 分支)→ 消除 RCE 面 + `.git` 覆盖风险
|
||||
- P0-2 JWT_SECRET 启动生成随机值写入 `.env.json`(消除 R4 硬编码密钥)
|
||||
- P0-3 /api/setup/complete 加 adminOnly;/setup/status 移除 default_password(消除 R5 接管)
|
||||
- P0-4 /api/proxy/fetch 加 adminOnly + 私网地址过滤(消除 R3 SSRF)
|
||||
- P0-5 邮件验证码不回显 + 修复 pending 重建 bug(消除 R7)
|
||||
- 验证:curl 逐项(未登录 401、内网 URL 拒绝、响应无 code)
|
||||
|
||||
### Phase 1 后端安全加固(≈1-1.5 天)
|
||||
- P1-1 上传白名单 + /uploads 防护头(R1)
|
||||
- P1-2 内容净化:ssr.js + 后端出口统一 DOMPurify/转义(R2 服务端侧;前端侧随 v2 组件实现)
|
||||
- P1-3 登录/发帖限流 + 服务端验证码校验(R8,express-rate-limit)
|
||||
- P1-4 密码箱解锁限流 + PBKDF2 600k(M1)
|
||||
- P1-5 adminOnly 查库复查、草稿权限、错误信息收敛、rejectUnauthorized 收敛、avatar 白名单(M2-M7 中后端项)
|
||||
- P1-6 cli.js 统一到 db.js(消除双封装 + data.db 路径分裂)
|
||||
- P1-7 schema_version 迁移框架(幂等补列 + PRAGMA user_version)
|
||||
- 验证:curl 安全用例 + `node cli.js status/config/password` 正常
|
||||
|
||||
### Phase 2 数据层换轨 better-sqlite3(≈1 天,依赖 P1-6/P1-7)
|
||||
- 前置:Node 升 22 LTS(宝塔 Node 版本管理器);README/Docker 示例同步
|
||||
- db.js:`new Database(DB_PATH)` 同步初始化;run/get/all 重写(prepare + info.lastInsertRowid);删 saveDb/export;initTables 保留幂等补列
|
||||
- routes/import.js:上传文件落临时路径只读打开
|
||||
- 数据零迁移:现有 data/rainweb.db 直接打开;`PRAGMA integrity_check` 预检
|
||||
- 验证:备份 → 启动 → CRUD 走查 → cli 命令 → sqlite3 命令行直查
|
||||
|
||||
### Phase 3 前端工程化搭建(≈1 天)
|
||||
- 项目根新建 `frontend/`(Vite + React + React Router;多入口:`index.html`(前台)+ `admin.html`(后台))
|
||||
- 共享层:api 客户端(封装现有 /api/*,token 仍 localStorage,错误处理统一)、工具(escapeHtml/日期/分页)、主题(深浅色状态管理)
|
||||
- `style.css` 迁入前台入口;`${site_name}` 等占位符保留在构建模板中(serveIndex 运行时替换机制不变)
|
||||
- Vite 配置:构建产物输出到 `public/dist/`;`base: '/'`;多入口 rollupOptions
|
||||
- package.json scripts:`dev`(vite)、`build`(vite build)、`start`(server.js)
|
||||
- 验证:dev server 起得来、两个入口都能打开、HMR 正常
|
||||
|
||||
### Phase 4 前台页面迁移(≈3-4 天,核心工作量)
|
||||
按页面迁移为 React 组件,**视觉逐页对照还原**:
|
||||
1. 布局壳(导航栏/主题切换/壁纸/磨砂玻璃)+ React Router 路由表(/、/blog、/forum、/passwords、/login、/register、/profile、/write、/forum-manage、/embed、/setup);导航栏「管理后台」按钮 = 整页跳转 `/admin`(`<a href="/admin">`,不走 SPA 路由)
|
||||
2. 首页(瀑布流、侧栏头像/简介、最新文章)→ 原 HOMEPAGE 逻辑
|
||||
3. 博客(列表/详情/编辑,markdown + [image:]/[file:] 渲染 → 组件化 DOMPurify 净化)
|
||||
4. 论坛(分类/子分类/发帖/回复/详情)
|
||||
5. 密码管理器(PIN 解锁、加密交互,见 §4.4)
|
||||
6. 登录/注册(验证码、邮箱验证流程)
|
||||
7. 个人中心(头像上传、资料编辑)
|
||||
8. 音乐嵌入、公告、其他全局组件
|
||||
- 验证:逐页对照 v1 截图/行为走查;无控制台报错
|
||||
|
||||
### Phase 5 后台独立应用(≈2 天)
|
||||
- MUI 后台(/admin):完全独立入口与路由;入口方式 = 前台导航「管理后台」按钮整页跳转(Phase 4 已建);后台内可返回前台(跳转 /)
|
||||
- 登录态守卫(未登录跳转 /login,JWT 与前台共用);布局(侧栏导航 + 顶栏)
|
||||
- 页面:仪表盘、站点设置(含主题/壁纸/玻璃参数)、博客管理、论坛管理、用户管理、公告、面板链接(嵌入 + proxy 代理保留)、验证码/邮件配置、上传管理、密码箱(admin 视角)、更新检查入口移除(P0-1 已删)
|
||||
- 原 admin.js 600 行的逻辑按 MUI 组件重写
|
||||
- 验证:后台全功能走查 + 前台联动(改设置 → 前台生效)
|
||||
|
||||
### Phase 6 集成上线(≈1 天)
|
||||
- server.js:静态服务指向构建产物;SPA fallback 分流(/admin* → 后台入口,其余 → 前台入口);serveIndex 占位符对构建产物生效
|
||||
- 旧 public/*.html 与 public/js/ 全部退役删除(ssr.js 引用除外——核对 ssr.js 是否引用 style.css/占位符,保留所需)
|
||||
- SEO 双轨验证:/blog/:id、/forum/:id、sitemap.xml、robots.txt 行为不变
|
||||
- 部署流程更新:宝塔 = npm install → npm run build → npm start
|
||||
- README/AGENTS.md 全量更新(v2 架构、命令、构建链说明)
|
||||
|
||||
## 4. 关键技术决策
|
||||
|
||||
### 4.1 TypeScript?
|
||||
默认**不用 TS**(v1 全 JS,单人无测试,减少迁移摩擦);如用户要 TS 可启用(React + Vite TS 模板),代价是迁移工作量 +20%。→ 计划默认 JS,待确认。
|
||||
|
||||
### 4.2 构建产物与占位符
|
||||
Vite 构建产物 index.html 保留 `${site_name}`/`${site_description}`/`${site_favicon}` 文本,serveIndex 运行时替换机制不变(构建模板中写占位符即可)。后台入口同理。
|
||||
|
||||
### 4.3 路由与 fallback
|
||||
- 前台:React Router(history 模式),server.js `app.get('*')` 回前台入口
|
||||
- 后台:/admin 前缀路由,server.js 增加 `/admin*` → 后台入口(注意在 SPA catch-all 前)
|
||||
- SSR 路由(/blog/:id、/forum/:id)优先级高于 SPA fallback(现状已如此,保持)
|
||||
|
||||
### 4.4 密码管理器加密
|
||||
v1 为"服务端保管密钥"模型(PIN 明文到服务端派生)。v2 前台重写时**默认保持行为不变**(服务端派生 + PBKDF2 600k,Phase 1 已加固)。真正 E2E(WebCrypto 客户端派生)列为 v2 后续可选项,不在本次范围。
|
||||
|
||||
### 4.5 音乐嵌入
|
||||
v1 的 music-embed(自动隐藏、位置、超时)迁移为 React 全局组件,行为参数从 site_settings 读取,逻辑照搬。
|
||||
|
||||
### 4.6 验证码
|
||||
captcha.js 前端逻辑(内置 SVG 验证码 + recaptcha)React 化;服务端校验在 Phase 1 已接通(P1-3)。
|
||||
|
||||
## 5. 验证策略(无测试环境)
|
||||
|
||||
- 每阶段:curl API 用例 + 浏览器手动走查 + `node cli.js` 命令
|
||||
- Phase 4/5:逐页对照 v1 行为清单(§2 保留功能点逐项打勾)
|
||||
- 上线前:完整回归清单(登录/发帖/上传/密码箱/主题/面板嵌入/SEO 直链)
|
||||
- 可选:引入 Vitest + React Testing Library 对共享层(api 客户端、渲染函数)补少量单测——默认不做,待确认
|
||||
|
||||
## 6. 风险与回滚
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 前端重写回归(11 页功能面广) | 阶段化推进、§2 功能清单逐项走查;v1 代码保留到 Phase 6 验收通过再删 |
|
||||
| 密码管理器迁移出错 | 加密逻辑独立模块化迁移,Phase 4 单独验收 |
|
||||
| better-sqlite3 换轨 | 数据零迁移 + 备份 + PRAGMA 预检;失败可随时回滚 db.js(v1 代码在 git 历史) |
|
||||
| 构建链引入部署变化 | Phase 6 单独验收部署流程;dev(vite dev)+ prod(build)双路径文档化 |
|
||||
| SEO 回归 | ssr.js 双轨不变,Phase 6 专门验证直链 |
|
||||
|
||||
## 7. 工时总览
|
||||
|
||||
| 阶段 | 内容 | 工时 |
|
||||
|---|---|---|
|
||||
| Phase 0 | 后端止损 | 0.5 天 |
|
||||
| Phase 1 | 后端安全加固 + cli 统一 + 迁移框架 | 1-1.5 天 |
|
||||
| Phase 2 | better-sqlite3 换轨 | 1 天 |
|
||||
| Phase 3 | 前端工程化搭建 | 1 天 |
|
||||
| Phase 4 | 前台页面迁移 | 3-4 天 |
|
||||
| Phase 5 | 后台独立应用(MUI) | 2 天 |
|
||||
| Phase 6 | 集成上线 + 文档 | 1 天 |
|
||||
| **合计** | | **9.5-11.5 天** |
|
||||
|
||||
每阶段结束:验证清单通过 → git commit → 可暂停验收。
|
||||
@@ -0,0 +1,83 @@
|
||||
# 工作台模块项目计划(tools/workbench)
|
||||
|
||||
> 独立完整项目流程:调研 ✓ → 设计规范 ✓ → 本计划 → 实现 → 审查 → 验证。
|
||||
> 布局调研:lib-1(VSCode 四层 / Dashy Workspace / Vivaldi Web Panels / Arc pinned / Homepage groups + iframe 技术建议)。
|
||||
> 设计规范:frontend-pack/design skill(8px 网格、语义色 token、层次/留白/焦点环/深浅色/加载空错误态)。
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
「面板工作台」:浏览器式多面板聚合界面(左侧侧边栏 + 顶部工具栏 + 右侧 iframe 内容区),叠加密码库快捷取用。仅管理员(adminOnly)可进入。
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
| 项 | 决定 |
|
||||
|---|---|
|
||||
| 风格 | **MUI**(与后台一致,MD3 主题复用 frontend/src/admin/theme.js) |
|
||||
| 路由 | 独立页面 `/workbench`(React Router,挂管理后台路由树外或独立 BrowserRouter 区——用后台同一入口 admin 应用内加路由最简,basename /admin 下 `/admin/workbench`?——**决定:独立入口**(admin.html 已有),路由挂 `/admin/workbench`,与 Dashboard 等并列,顶栏入口按钮跳转;整页全屏布局不带 AdminLayout 侧栏(工作台自带侧边栏) |
|
||||
| 代码位置 | `frontend/src/tools/workbench/`(独立工具区,页面+组件+密码库组件+工具函数全在这,方便后续升级) |
|
||||
| 鉴权 | 入口守卫:无 token → 跳 /login.html;非 admin → 403 提示返回(复用后台守卫模式) |
|
||||
| 数据 | admin_links API(复用 frontend/src/api/adminLinks.js)+ passwords API(复用) |
|
||||
| proxy | `/api/proxy/fetch?url=&token=`(iframe query token,复用 Embed.jsx 的 needsProxy 逻辑) |
|
||||
|
||||
## 3. 文件结构
|
||||
|
||||
```
|
||||
frontend/src/tools/workbench/
|
||||
├── Workbench.jsx # 页面入口(路由挂载点 + 鉴权守卫 + 布局组装)
|
||||
├── components/
|
||||
│ ├── Toolbar.jsx # 顶部工具栏(◀▶↻ 地址栏 搜索 添加 密码库 侧栏开关 返回)
|
||||
│ ├── Sidebar.jsx # 左侧边栏(固定区 + 分组列表 + 折叠 + 拖拽调宽可选)
|
||||
│ ├── PanelFrame.jsx # iframe 内容区(加载态/超时/失败回退/LRU 保留)
|
||||
│ ├── AddPanelDialog.jsx # 添加面板弹窗(URL/命名/分组/图标 + 打开方式)
|
||||
│ └── VaultDrawer.jsx # 密码库抽屉(PIN 解锁/搜索/复制,MUI)
|
||||
├── hooks/
|
||||
│ ├── usePanels.js # 面板数据/打开集合/历史栈/持久化(localStorage)
|
||||
│ └── useVault.js # 密码库解锁状态/条目加载(复用 passwords API)
|
||||
└── index.js # 模块导出(便于未来升级替换)
|
||||
```
|
||||
|
||||
## 4. 功能清单
|
||||
|
||||
### 核心(必须)
|
||||
- [ ] 鉴权守卫(adminOnly)
|
||||
- [ ] 工具栏:◀ 后退 / ▶ 前进(当前面板历史栈)、↻ 刷新、只读地址栏、🔍 搜索(面板名/URL/分组)、+添加、🔑 密码库、侧边栏折叠、返回前台
|
||||
- [ ] 侧边栏:admin_links 按 category 分组 + 固定区(pin),点击切换,当前项左侧 2px primary 指示条 + surface-container 底(MUI 化:ListItemButton selected 态)
|
||||
- [ ] iframe 区:懒加载(首次激活创建)、display:none 切换保留状态、**超时+load 双保险加载态**、失败(超时/空白启发式)显示「在新标签打开」回退按钮
|
||||
- [ ] 打开方式:内嵌(默认)/ 新标签(target=_blank)/ 弹窗(modal)——面板项右键或添加时选择,存面板设置
|
||||
- [ ] 添加面板弹窗:URL + 标题 + 分组 + 图标(favicon 预览)+ 打开方式;保存到 admin_links(复用现有 API)
|
||||
- [ ] localStorage 持久化:打开面板集合、当前面板、历史栈、侧边栏折叠态
|
||||
- [ ] 密码库抽屉:PIN 解锁(复用 /api/passwords/unlock,服务端会话互通)→ 条目列表(标题/用户名)+ 搜索 + 详情(密码明文)+ 一键复制(clipboard);未设 PIN 引导去 /passwords.html;只读取用不编辑
|
||||
- [ ] 空态/加载态/错误态:无面板引导添加、加载骨架、加载失败重试
|
||||
|
||||
### 进阶(尽力)
|
||||
- [ ] LRU iframe 保留(最多 3 个活跃,超出销毁最久未用,记录 URL 重建)
|
||||
- [ ] 每面板定时刷新(可选 30s/5min/30min)
|
||||
- [ ] 侧边栏拖拽调宽 + 记忆
|
||||
- [ ] 分组折叠状态持久化
|
||||
|
||||
## 5. 设计规范(design skill + 调研要点)
|
||||
|
||||
- 8px 网格间距;一屏内不超过 2 种圆角(MUI 默认 8px/20px pill)
|
||||
- 语义色 token(MD3):工具栏/侧边栏 surface-container、选中 primary-container、hover surface-variant
|
||||
- 选中态:左侧 2px primary 指示条(VSCode 模式)或 MUI selected 态,二选一统一
|
||||
- 深色模式默认适配(后台已有 data-theme 机制)
|
||||
- 焦点环(focus-visible)、语义 HTML(nav/main/aside/button)、触控目标 ≥40px
|
||||
- 加载态用骨架/居中 spinner;空态带图标+CTA;错误态带重试
|
||||
- iframe title 属性必须;allow 权限策略按面板
|
||||
- 工具栏高度 48-56px、侧边栏默认 240px(可 180-360 拖拽)
|
||||
|
||||
## 6. 实施与质量流程
|
||||
|
||||
1. 派 designer 按本计划实现(一个会话,MUI + 复用后台主题/api)
|
||||
2. ora 审查(鉴权/安全/iframe 处理/状态管理/无障碍)→ 修复
|
||||
3. 验证:npm run build + 后台入口跳转 + admin 权限拦截 + 面板切换/proxy/密码库全链路冒烟
|
||||
4. commit + 用户验收
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] 非 admin 无法进入(无 token 跳登录、非 admin 403)
|
||||
- [ ] 面板分组/固定/切换/关闭/添加/持久化全部可用
|
||||
- [ ] proxy 面板正常嵌入;X-Frame-Options 拒绝的面板有回退提示
|
||||
- [ ] 密码库解锁→搜索→复制链路通(与服务端会话互通)
|
||||
- [ ] 深浅色、320/768/1440 三档响应式正常
|
||||
- [ ] build 通过、无 console 错误
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${site_name}</title>
|
||||
<meta name="description" content="${site_description}">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="icon" href="${site_favicon}">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider } from './theme.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import Blog from './pages/Blog.jsx';
|
||||
import BlogDetail from './pages/BlogDetail.jsx';
|
||||
import Tag from './pages/Tag.jsx';
|
||||
import Archive from './pages/Archive.jsx';
|
||||
import Forum from './pages/Forum.jsx';
|
||||
import ForumDetail from './pages/ForumDetail.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';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Routes>
|
||||
{/* 前台布局壳:导航 / 壁纸 / 页脚 / 音乐嵌入 / 验证码弹窗 */}
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/blog.html" element={<Blog />} />
|
||||
<Route path="/blog/:id" element={<BlogDetail />} />
|
||||
<Route path="/tag/:name" element={<Tag />} />
|
||||
<Route path="/archive.html" element={<Archive />} />
|
||||
<Route path="/forum.html" element={<Forum />} />
|
||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||
<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 />} />
|
||||
<Route path="/embed.html" element={<Embed />} />
|
||||
<Route path="/setup.html" element={<Setup />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTheme as useMuiTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import 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 { logout } from '../api/auth.js';
|
||||
import SnackHost, { showSnack } from './snack.jsx';
|
||||
|
||||
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 /> },
|
||||
];
|
||||
|
||||
/** 后台布局:AppBar(返回前台 + 退出)+ Drawer 导航 + 内容区 Outlet */
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const muiTheme = useMuiTheme();
|
||||
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const drawerContent = (
|
||||
<List>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => { navigate(item.path); setMobileOpen(false); }}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
showSnack('已退出登录');
|
||||
setTimeout(() => { window.location.href = '/login.html'; }, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 1 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography>
|
||||
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
|
||||
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
|
||||
</Button>
|
||||
<Button color="inherit" href="/" title="返回前台">
|
||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />退出
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
|
||||
{isMobile ? (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
|
||||
>
|
||||
<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 />
|
||||
<Outlet />
|
||||
</Box>
|
||||
<SnackHost />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
/** 通用确认弹窗 */
|
||||
export default function ConfirmDialog({ open, title = '确认操作', message, onClose, onConfirm, confirmText = '确认', danger = true }) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent><Typography variant="body1">{message}</Typography></DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button color={danger ? 'error' : 'primary'} variant="contained" onClick={onConfirm}>{confirmText}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { createMD3Theme } from './theme.js';
|
||||
import './theme.css';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getSettings } from '../api/settings.js';
|
||||
import AdminLayout from './AdminLayout.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import CaptchaSettings from './pages/CaptchaSettings.jsx';
|
||||
import ThemeSettings from './pages/ThemeSettings.jsx';
|
||||
import Homepage from './pages/Homepage.jsx';
|
||||
import EmailSettings from './pages/EmailSettings.jsx';
|
||||
import BlogManage from './pages/BlogManage.jsx';
|
||||
import CommentManage from './pages/CommentManage.jsx';
|
||||
import ForumManage from './pages/ForumManage.jsx';
|
||||
import 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';
|
||||
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
||||
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
||||
|
||||
// 生产:/admin/* 由后端 fallback 到 dist/admin.html(Phase 6 需求),basename=/admin;
|
||||
// 开发:vite 多入口直接访问 /admin.html,basename 自适应为空。
|
||||
const pathname = window.location.pathname;
|
||||
const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/admin' : '';
|
||||
|
||||
function AdminApp() {
|
||||
const [status, setStatus] = useState('checking'); // checking | ok
|
||||
const [primary, setPrimary] = useState('#6750a4');
|
||||
|
||||
// 深色模式跟随前台偏好:整页跳转后 data-theme 属性不保留,
|
||||
// 以 localStorage 的 theme 为兜底(前台 theme.jsx 持久化同一 key)。
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
|| localStorage.getItem('theme') === 'dark';
|
||||
useEffect(() => {
|
||||
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}, [dark]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') {
|
||||
window.alert('需要管理员权限');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
setStatus('ok');
|
||||
})
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
getSettings()
|
||||
.then((s) => { if (s.primary_color) setPrimary(s.primary_color); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (status !== 'ok') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const theme = createMD3Theme({ mode: dark ? 'dark' : 'light', primary });
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter basename={basename}>
|
||||
<Routes>
|
||||
{/* 工作台:独立全屏布局,不套 AdminLayout(自带 Toolbar+Sidebar+内容区);懒加载分包 */}
|
||||
<Route
|
||||
path="/workbench"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/captcha" element={<CaptchaSettings />} />
|
||||
<Route path="/theme" element={<ThemeSettings />} />
|
||||
<Route path="/homepage" element={<Homepage />} />
|
||||
<Route path="/email" element={<EmailSettings />} />
|
||||
<Route path="/blog" element={<BlogManage />} />
|
||||
<Route path="/comments" element={<CommentManage />} />
|
||||
<Route path="/forum" element={<ForumManage />} />
|
||||
<Route path="/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="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<AdminApp />);
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { listAll, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = { title: '', content: '', active: true };
|
||||
|
||||
/** 公告管理:公告 CRUD(v2 新增模块,v1 无) */
|
||||
export default function Announcements() {
|
||||
const [list, setList] = useState(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAll()
|
||||
.then((rows) => setList(rows || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (a) => {
|
||||
setEditingId(a.id);
|
||||
setForm({ title: a.title || '', content: a.content, active: !!a.active });
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.content.trim()) { showSnack('内容不能为空', 'error'); return; }
|
||||
const data = { title: form.title.trim(), content: form.content, active: form.active };
|
||||
try {
|
||||
if (editingId) await updateAnnouncement(editingId, data);
|
||||
else await createAnnouncement(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAnnouncement(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">公告管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加公告</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>内容</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list === null ? (
|
||||
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5}>暂无公告</TableCell></TableRow>
|
||||
) : list.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{a.title || '(无标题)'}</Box></TableCell>
|
||||
<TableCell sx={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'text.secondary' }}>{a.content}</TableCell>
|
||||
<TableCell><Chip size="small" label={a.active ? '启用' : '停用'} color={a.active ? 'primary' : 'default'} variant={a.active ? 'filled' : 'outlined'} /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{a.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(a)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: a.id, label: a.title || a.content })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑公告' : '添加公告'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="标题" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="内容 *" multiline rows={4} value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} margin="normal" />
|
||||
<FormControlLabel control={<Switch checked={form.active} onChange={(e) => setForm((p) => ({ ...p, active: e.target.checked }))} />} label="启用" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listPosts, updatePost, deletePost } from '../../api/blog.js';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 博客管理:博客侧栏开关 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
|
||||
export default function BlogManage() {
|
||||
const [posts, setPosts] = useState(null);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [confirm, setConfirm] = useState(null); // { id, title }
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listPosts(true)
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
getSettings().then((s) => setShowSidebar(s.blog_show_sidebar !== '0')).catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
const saveSidebar = async () => {
|
||||
try {
|
||||
await saveSettings({ blog_show_sidebar: showSidebar ? '1' : '0' });
|
||||
showSnack('已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublish = async (p) => {
|
||||
try {
|
||||
await updatePost(p.id, {
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
excerpt: p.excerpt || '',
|
||||
published: p.published ? 0 : 1,
|
||||
use_markdown: p.use_markdown,
|
||||
});
|
||||
showSnack(p.published ? '已转为草稿' : '已发布');
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePost(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h5">博客管理</Typography>
|
||||
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2, maxWidth: 640 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
|
||||
label="博客页面左侧显示头像和简介"
|
||||
/>
|
||||
<Button variant="outlined" size="small" onClick={saveSidebar}>保存</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>格式</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{posts === null ? (
|
||||
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
|
||||
) : posts.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5}>暂无文章</TableCell></TableRow>
|
||||
) : posts.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{p.title}</Box></TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={p.published ? '已发布' : '草稿'}
|
||||
color={p.published ? 'primary' : 'default'}
|
||||
onClick={() => togglePublish(p)}
|
||||
title="点击切换发布状态"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><Chip size="small" label={p.use_markdown ? 'Markdown' : '纯文本'} variant="outlined" /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{p.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" component="a" href={`/write.html?edit=${p.id}`} target="_blank" title="编辑(前台)"><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: p.id, title: p.title })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.title : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText={deleting ? '删除中...' : '确认删除'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 验证码设置:类型/登录注册发帖开关/reCAPTCHA+Turnstile keys(支持 builtin/recaptcha/turnstile/both) */
|
||||
export default function CaptchaSettings() {
|
||||
const [form, setForm] = useState({
|
||||
captcha_type: 'none',
|
||||
captcha_login: false,
|
||||
captcha_register: false,
|
||||
captcha_forum: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: '',
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
captcha_type: s.captcha_type || 'none',
|
||||
captcha_login: s.captcha_login === '1',
|
||||
captcha_register: s.captcha_register === '1',
|
||||
captcha_forum: s.captcha_forum === '1',
|
||||
recaptcha_site_key: s.recaptcha_site_key || '',
|
||||
recaptcha_secret_key: s.recaptcha_secret_key || '',
|
||||
turnstile_site_key: s.turnstile_site_key || '',
|
||||
turnstile_secret_key: s.turnstile_secret_key || '',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
captcha_type: form.captcha_type,
|
||||
captcha_login: form.captcha_login ? '1' : '0',
|
||||
captcha_register: form.captcha_register ? '1' : '0',
|
||||
captcha_forum: form.captcha_forum ? '1' : '0',
|
||||
recaptcha_site_key: form.recaptcha_site_key.trim(),
|
||||
recaptcha_secret_key: form.recaptcha_secret_key.trim(),
|
||||
turnstile_site_key: form.turnstile_site_key.trim(),
|
||||
turnstile_secret_key: form.turnstile_secret_key.trim(),
|
||||
});
|
||||
showSnack('验证码设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const showScope = form.captcha_type !== 'none';
|
||||
const showRecaptcha = form.captcha_type === 'recaptcha' || form.captcha_type === 'both';
|
||||
const showTurnstile = form.captcha_type === 'turnstile' || form.captcha_type === 'both';
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>验证码类型</InputLabel>
|
||||
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
|
||||
<MenuItem value="none">不使用验证码</MenuItem>
|
||||
<MenuItem value="builtin">内置验证码(扭曲文字)</MenuItem>
|
||||
<MenuItem value="recaptcha">Google reCAPTCHA V2</MenuItem>
|
||||
<MenuItem value="turnstile">Cloudflare Turnstile</MenuItem>
|
||||
<MenuItem value="both">两者都启用(任一通过即可)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{showScope && (
|
||||
<FormGroup>
|
||||
<FormControlLabel control={<Switch checked={form.captcha_login} onChange={setSwitch('captcha_login')} />} label="登录验证" />
|
||||
<FormControlLabel control={<Switch checked={form.captcha_register} onChange={setSwitch('captcha_register')} />} label="注册验证" />
|
||||
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{showRecaptcha && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Google reCAPTCHA V2 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.recaptcha_site_key} onChange={set('recaptcha_site_key')} margin="normal" placeholder="6L..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.recaptcha_secret_key} onChange={set('recaptcha_secret_key')} margin="normal" placeholder="6L..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showTurnstile && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Cloudflare Turnstile 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.turnstile_site_key} onChange={set('turnstile_site_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.turnstile_secret_key} onChange={set('turnstile_secret_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存验证码设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import 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 { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 时间格式化:yyyy-mm-dd hh:mm */
|
||||
function fmtTime(t) {
|
||||
if (!t) return '';
|
||||
return String(t).replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
/** 评论管理:待审核队列 + 全部评论占位(后端暂无全量评论列表接口) */
|
||||
export default function CommentManage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [pending, setPending] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setBusy(true);
|
||||
listPendingComments()
|
||||
.then((list) => setPending(list || []))
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setBusy(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (id, fn, okMsg) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn(id);
|
||||
setPending((list) => list.filter((c) => c.id !== id));
|
||||
showSnack(okMsg);
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
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>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
|
||||
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
|
||||
<Tab label="全部评论" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{tab === 0 ? (
|
||||
pending.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" sx={{ mb: 0.5 }}>暂无待审核评论</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
开启「评论审核模式」后,新评论会先进这里等待审核
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper>
|
||||
<List disablePadding>
|
||||
{pending.map((c) => (
|
||||
<ListItem
|
||||
key={c.id}
|
||||
divider
|
||||
alignItems="flex-start"
|
||||
sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, py: 2, px: { xs: 2, md: 3 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{c.author_name || '匿名'}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={`文章:${c.post_title || `#${c.post_id}`}`} />
|
||||
<Typography variant="caption" color="text.secondary">{fmtTime(c.created_at)}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'text.primary', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{c.content}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<CheckIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => act(c.id, approveComment, '已通过审核')}
|
||||
>通过</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<CloseIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => act(c.id, rejectComment, '已拒绝')}
|
||||
>拒绝</Button>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)
|
||||
) : (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
当前后端仅提供「待审核」评论的管理接口;已通过 / 已拒绝评论可在对应文章页查看。
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { listAdminLinks } from '../../api/adminLinks.js';
|
||||
import { listPosts as listBlogPosts } from '../../api/blog.js';
|
||||
import { listPosts as listForumPosts } from '../../api/forum.js';
|
||||
import { listUsers } from '../../api/auth.js';
|
||||
import { listAttachments } from '../../api/upload.js';
|
||||
|
||||
/** 仪表盘:版本 + 状态概览 + 面板链接(分组卡片,迁移自 v1 loadPanels) */
|
||||
export default function Dashboard() {
|
||||
const [version, setVersion] = useState('');
|
||||
const [stats, setStats] = useState({ blog: 0, forum: 0, users: 0, attachments: 0 });
|
||||
const [links, setLinks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/version')
|
||||
.then((r) => r.json())
|
||||
.then((v) => { if (v && v.version) setVersion(v.version); })
|
||||
.catch(() => {});
|
||||
Promise.allSettled([
|
||||
listAdminLinks(), listBlogPosts(true), listForumPosts(), listUsers(), listAttachments(),
|
||||
]).then(([l, b, f, u, a]) => {
|
||||
setLinks((l.value) || []);
|
||||
setStats({
|
||||
blog: (b.value || []).length,
|
||||
forum: (f.value || []).length,
|
||||
users: (u.value || []).length,
|
||||
attachments: (a.value || []).length,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const statCards = [
|
||||
{ label: '博客文章', value: stats.blog },
|
||||
{ label: '论坛帖子', value: stats.forum },
|
||||
{ label: '用户', value: stats.users },
|
||||
{ label: '附件', value: stats.attachments },
|
||||
];
|
||||
|
||||
// 面板链接按分类分组
|
||||
const cats = {};
|
||||
links.forEach((l) => { (cats[l.category || '默认'] = cats[l.category || '默认'] || []).push(l); });
|
||||
const sortedCats = Object.keys(cats).sort();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5">仪表盘</Typography>
|
||||
<Chip label={`RainWeb v${version}`} size="small" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{statCards.map((s) => (
|
||||
<Grid item xs={6} sm={3} key={s.label}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600 }}>{s.value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{s.label}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>面板</Typography>
|
||||
{links.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">暂无面板,请先在"面板链接"中添加</Typography>
|
||||
) : (
|
||||
sortedCats.map((cat) => (
|
||||
<Box key={cat} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600, mb: 1 }}>{cat}</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{cats[cat].map((l) => {
|
||||
const embedUrl = l.embed_url || l.url;
|
||||
const proxyParam = l.use_proxy ? '&proxy=1' : '';
|
||||
const href = embedUrl
|
||||
? `/embed.html?url=${encodeURIComponent(embedUrl)}&title=${encodeURIComponent(l.title)}${proxyParam}`
|
||||
: l.url;
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={l.id}>
|
||||
<Card>
|
||||
<CardActionArea component="a" href={href} target={embedUrl ? undefined : '_blank'} rel="noopener">
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>{l.icon ? l.icon.charAt(0).toUpperCase() : '🔗'}</Avatar>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" noWrap>
|
||||
{l.title}
|
||||
{l.version ? <Typography component="span" variant="caption" color="text.secondary"> {l.version}</Typography> : null}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>{l.description || l.url}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { testSmtp } from '../../api/email.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 邮件配置:SMTP + 发件人信息 + 测试发送(迁移自 v1 邮件卡片) */
|
||||
export default function EmailSettings() {
|
||||
const [form, setForm] = useState({
|
||||
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '',
|
||||
smtp_from_email: '', smtp_from_name: 'RainWeb',
|
||||
});
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
smtp_host: s.smtp_host || '',
|
||||
smtp_port: s.smtp_port || '587',
|
||||
smtp_user: s.smtp_user || '',
|
||||
smtp_pass: s.smtp_pass || '',
|
||||
smtp_from_email: s.smtp_from_email || '',
|
||||
smtp_from_name: s.smtp_from_name || 'RainWeb',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
smtp_host: form.smtp_host.trim(),
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_user: form.smtp_user.trim(),
|
||||
smtp_pass: form.smtp_pass,
|
||||
smtp_from_email: form.smtp_from_email.trim(),
|
||||
smtp_from_name: form.smtp_from_name.trim(),
|
||||
});
|
||||
showSnack('邮件配置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!testEmail.trim()) { showSnack('请输入测试邮箱地址', 'error'); return; }
|
||||
setTesting(true);
|
||||
try {
|
||||
await testSmtp(testEmail.trim());
|
||||
showSnack('测试邮件已发送至 ' + testEmail.trim());
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
|
||||
<Paper 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 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>发件人信息</Typography>
|
||||
<TextField fullWidth label="发件人邮箱" type="email" value={form.smtp_from_email} onChange={set('smtp_from_email')} margin="normal" placeholder="noreply@example.com" />
|
||||
<TextField fullWidth label="发件人名称" value={form.smtp_from_name} onChange={set('smtp_from_name')} margin="normal" placeholder="RainWeb" />
|
||||
</Paper>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存邮件配置</Button>
|
||||
<TextField size="small" label="测试接收邮箱" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} placeholder="your@email.com" />
|
||||
<Button variant="outlined" onClick={test} disabled={testing}>发送测试邮件</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import 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 { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
|
||||
|
||||
/** 论坛管理:板块卡片(帖子数)+ 添加/编辑弹窗 + 删除(MUI 版,同前台 ForumManage API) */
|
||||
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 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]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (c) => {
|
||||
setEditingId(c.id);
|
||||
setForm({
|
||||
name: c.name,
|
||||
description: c.description || '',
|
||||
announcement: c.announcement || '',
|
||||
sub_categories: c.sub_categories || '',
|
||||
sort_order: String(c.sort_order || 0),
|
||||
});
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name.trim()) { showSnack('名称不能为空', 'error'); return; }
|
||||
setSaving(true);
|
||||
const data = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
announcement: form.announcement.trim(),
|
||||
sub_categories: form.sub_categories.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await updateCategory(editingId, data);
|
||||
else await createCategory(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteCategory(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">论坛管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
|
||||
</Box>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">暂无板块,点击"添加板块"创建</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{cats.map((c) => (
|
||||
<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>
|
||||
<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>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(c)} title="编辑"><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: c.id, name: c.name })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="名称 *" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="板块公告" multiline rows={2} value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="帖子分类(逗号分隔)" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} margin="normal" placeholder="例: 求助,分享,讨论,建议" />
|
||||
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadFile } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 首页设置:个人信息(头像/简介/联系链接)/主页内容(Markdown)/音乐嵌入(迁移自 v1 首页卡片) */
|
||||
export default function Homepage() {
|
||||
const [form, setForm] = useState({
|
||||
homepage_avatar: '',
|
||||
homepage_bio: '',
|
||||
homepage_content: '',
|
||||
music_embed_enabled: false,
|
||||
music_embed_code: '',
|
||||
music_embed_position: 'right',
|
||||
music_embed_autohide: false,
|
||||
music_embed_idle_timeout: '10',
|
||||
});
|
||||
const [contacts, setContacts] = useState([{ icon: 'link', url: '', title: '' }]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => {
|
||||
let parsed = [];
|
||||
try { parsed = JSON.parse(s.homepage_contacts || '[]'); } catch { parsed = []; }
|
||||
setForm({
|
||||
homepage_avatar: s.homepage_avatar || '',
|
||||
homepage_bio: s.homepage_bio || '',
|
||||
homepage_content: s.homepage_content || '',
|
||||
music_embed_enabled: s.music_embed_enabled === '1',
|
||||
music_embed_code: s.music_embed_code || '',
|
||||
music_embed_position: s.music_embed_position || 'right',
|
||||
music_embed_autohide: s.music_embed_autohide === '1',
|
||||
music_embed_idle_timeout: s.music_embed_idle_timeout || '10',
|
||||
});
|
||||
setContacts(parsed.length > 0 ? parsed : [{ icon: 'link', url: '', title: '' }]);
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
|
||||
|
||||
const updateContact = (i, k) => (e) => {
|
||||
const next = [...contacts];
|
||||
next[i] = { ...next[i], [k]: e.target.value };
|
||||
setContacts(next);
|
||||
};
|
||||
const addContact = () => {
|
||||
if (contacts.length >= 5) { showSnack('最多 5 个联系链接', 'error'); return; }
|
||||
setContacts([...contacts, { icon: 'link', url: '', title: '' }]);
|
||||
};
|
||||
const removeContact = (i) => setContacts(contacts.filter((_, idx) => idx !== i));
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await uploadFile(file);
|
||||
setForm((p) => ({ ...p, homepage_content: p.homepage_content + '\n' + data.tag + '\n' }));
|
||||
setUploadStatus('已插入: ' + data.tag);
|
||||
} catch (err) {
|
||||
showSnack(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const validContacts = contacts.filter((c) => c.url.trim());
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
homepage_avatar: form.homepage_avatar.trim(),
|
||||
homepage_bio: form.homepage_bio,
|
||||
homepage_content: form.homepage_content,
|
||||
homepage_contacts: JSON.stringify(validContacts),
|
||||
music_embed_enabled: form.music_embed_enabled ? '1' : '0',
|
||||
music_embed_code: form.music_embed_code.trim(),
|
||||
music_embed_position: form.music_embed_position,
|
||||
music_embed_autohide: form.music_embed_autohide ? '1' : '0',
|
||||
music_embed_idle_timeout: form.music_embed_idle_timeout,
|
||||
});
|
||||
showSnack('首页设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
|
||||
|
||||
<Paper 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="URL" value={c.url} onChange={updateContact(i, 'url')} sx={{ flex: 1 }} placeholder="https://..." />
|
||||
<TextField size="small" label="标题(选填)" value={c.title} onChange={updateContact(i, 'title')} sx={{ width: 120 }} />
|
||||
<IconButton size="small" color="error" onClick={() => removeContact(i)} title="删除"><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主页内容</Typography>
|
||||
<TextField fullWidth label="正文 (Markdown)" multiline rows={10} value={form.homepage_content} onChange={set('homepage_content')} margin="normal" placeholder="支持 Markdown 语法和 [image:filename] 标签" />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button variant="outlined" size="small" onClick={() => fileRef.current && fileRef.current.click()}>
|
||||
上传附件
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
{uploadStatus && <Typography variant="caption" color="text.secondary">{uploadStatus}</Typography>}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
|
||||
<FormControlLabel control={<Switch checked={form.music_embed_enabled} onChange={setSwitch('music_embed_enabled')} />} label="所有页面显示音乐播放器" />
|
||||
<TextField fullWidth label="嵌入代码" multiline rows={3} value={form.music_embed_code} onChange={set('music_embed_code')} margin="normal" placeholder="粘贴网易云音乐 iframe 代码" />
|
||||
<TextField select label="显示位置" value={form.music_embed_position} onChange={set('music_embed_position')} margin="normal" sx={{ maxWidth: 200 }}>
|
||||
<option value="right">右下角</option>
|
||||
<option value="left">左下角</option>
|
||||
</TextField>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControlLabel control={<Switch checked={form.music_embed_autohide} onChange={setSwitch('music_embed_autohide')} />} label="无操作后缩小为图标" />
|
||||
<TextField size="small" label="空闲超时(秒)" type="number" inputProps={{ min: 3, max: 120 }} value={form.music_embed_idle_timeout} onChange={set('music_embed_idle_timeout')} sx={{ width: 140 }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存首页设置</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import { getToken } from '../../api/client.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 数据导入:上传旧版 data.db,POST /api/import/database(FormData + Bearer) */
|
||||
export default function ImportDb() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState(null); // { total, details, errors } | { error }
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const doImport = async () => {
|
||||
const file = fileRef.current && fileRef.current.files && fileRef.current.files[0];
|
||||
if (!file) { showSnack('请选择 data.db 文件', 'error'); return; }
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch('/api/import/database', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '导入失败');
|
||||
setResult(data);
|
||||
showSnack('导入完成,共 ' + data.total + ' 条记录');
|
||||
} catch (e) {
|
||||
setResult({ error: e.message });
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>数据导入</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
上传旧版 RainWeb 的 <code>data.db</code> 文件,将数据导入当前数据库(重复数据自动跳过)。
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button variant="contained" component="label">
|
||||
选择文件
|
||||
<input type="file" hidden accept=".db" ref={fileRef} onChange={() => setResult(null)} />
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{fileRef.current && fileRef.current.files && fileRef.current.files[0] ? fileRef.current.files[0].name : '未选择文件'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="outlined" onClick={doImport} disabled={busy}>
|
||||
{busy ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||
导入
|
||||
</Button>
|
||||
</Box>
|
||||
{result && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{result.error ? (
|
||||
<Alert severity="error">导入失败: {result.error}</Alert>
|
||||
) : (
|
||||
<Alert severity="success">
|
||||
<Box>导入完成,共 {result.total} 条记录</Box>
|
||||
{Object.entries(result.details || {}).map(([table, count]) => (
|
||||
<Box key={table} component="div" sx={{ fontSize: 13 }}>{table}: {count} 条</Box>
|
||||
))}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<Box component="div" sx={{ mt: 1, color: 'warning.main', fontSize: 13 }}>
|
||||
警告:<br />
|
||||
{result.errors.slice(0, 5).map((e, i) => <Box key={i}>{e}</Box>)}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.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',
|
||||
};
|
||||
|
||||
/** 面板链接管理:CRUD + 代理嵌入开关(迁移自 v1 面板链接卡片) */
|
||||
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 load = useCallback(() => {
|
||||
listAdminLinks()
|
||||
.then((ls) => setLinks(ls || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (l) => {
|
||||
setEditingId(l.id);
|
||||
setForm({
|
||||
title: l.title,
|
||||
url: l.url,
|
||||
embed_url: l.embed_url || '',
|
||||
use_proxy: !!l.use_proxy,
|
||||
description: l.description || '',
|
||||
icon: l.icon || '',
|
||||
category: l.category || '默认',
|
||||
version: l.version || '',
|
||||
sort_order: String(l.sort_order || 0),
|
||||
});
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.title.trim() || !form.url.trim()) { showSnack('标题和链接不能为空', 'error'); return; }
|
||||
const data = {
|
||||
title: form.title.trim(),
|
||||
url: form.url.trim(),
|
||||
embed_url: form.embed_url.trim(),
|
||||
use_proxy: form.use_proxy ? 1 : 0,
|
||||
description: form.description.trim(),
|
||||
icon: form.icon.trim(),
|
||||
category: form.category.trim() || '默认',
|
||||
version: form.version.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await updateAdminLink(editingId, data);
|
||||
else await createAdminLink(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAdminLink(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">面板链接管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加面板</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>排序</TableCell>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>版本</TableCell>
|
||||
<TableCell>URL</TableCell>
|
||||
<TableCell>嵌入URL</TableCell>
|
||||
<TableCell>分类</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{links === null ? (
|
||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
||||
) : links.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
|
||||
) : links.map((l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>{l.sort_order}</TableCell>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</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><Chip size="small" label={l.category} variant="outlined" /></TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: l.id, label: l.title })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="标题 *" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="URL *" type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} margin="normal" placeholder="https://..." />
|
||||
<TextField fullWidth label="嵌入 URL(iframe嵌入用)" type="url" value={form.embed_url} onChange={(e) => setForm((p) => ({ ...p, embed_url: e.target.value }))} margin="normal" placeholder="留空则新标签页打开" />
|
||||
<FormControlLabel control={<Switch checked={form.use_proxy} onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))} />} label="通过代理嵌入(绕过 X-Frame-Options 限制)" />
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="图标 (Material图标名)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="settings, dashboard, ..." />
|
||||
<TextField fullWidth label="分类" value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
|
||||
<TextField fullWidth label="版本号(可选)" value={form.version} onChange={(e) => setForm((p) => ({ ...p, version: e.target.value }))} margin="normal" placeholder="v2.1.0" />
|
||||
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
|
||||
const FOOTER_STYLES = [
|
||||
{ value: 'classic', label: '经典左右', desc: '左版权右版本,信息密度低,一眼扫过' },
|
||||
{ value: 'columns', label: '分栏导航', desc: '品牌 + 固定导航栏目 + 底部版权条,内容型站点首选' },
|
||||
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||
];
|
||||
|
||||
/** 基本设置 + 页脚设置 + 面板代理 + RainID 单点登录(v2) */
|
||||
export default function Settings() {
|
||||
const [form, setForm] = useState({
|
||||
site_name: '',
|
||||
site_description: '',
|
||||
site_url: '',
|
||||
site_favicon: '',
|
||||
footer_style: 'classic',
|
||||
footer_copyright: '',
|
||||
footer_powered: '',
|
||||
footer_desc: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
proxy_allowed_hosts: '',
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
site_name: s.site_name || '',
|
||||
site_description: s.site_description || '',
|
||||
site_url: s.site_url || '',
|
||||
site_favicon: s.site_favicon || '',
|
||||
footer_style: s.footer_style || 'classic',
|
||||
footer_copyright: s.footer_copyright || '',
|
||||
footer_powered: s.footer_powered || '',
|
||||
footer_desc: s.footer_desc || '',
|
||||
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
|
||||
comment_notify: s.comment_notify === '1' ? '1' : '0',
|
||||
proxy_allowed_hosts: s.proxy_allowed_hosts || '',
|
||||
rainid_enabled: s.rainid_enabled === '1' ? '1' : '0',
|
||||
rainid_client_id: s.rainid_client_id || '',
|
||||
// secret 后端不回读(ALLOWED_SET 可写不可读),始终为空,留空提交=不修改
|
||||
rainid_client_secret: '',
|
||||
rainid_discovery_url: s.rainid_discovery_url || 'https://rainid.rainnya.asia/oauth',
|
||||
rainid_register_redirect: s.rainid_register_redirect === '1' ? '1' : '0',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const toggle = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked ? '1' : '0' }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { ...form };
|
||||
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
|
||||
if (!body.rainid_client_secret) delete body.rainid_client_secret;
|
||||
await saveSettings(body);
|
||||
showSnack('设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const footStyle = form.footer_style;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
|
||||
|
||||
<Paper 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 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
|
||||
|
||||
<FormControl component="fieldset" sx={{ mb: 1 }}>
|
||||
<RadioGroup value={footStyle} onChange={set('footer_style')}>
|
||||
{FOOTER_STYLES.map((o) => (
|
||||
<FormControlLabel
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
control={<Radio />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>{o.label}</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>{o.desc}</Box>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{footStyle === 'columns' ? (
|
||||
<>
|
||||
<TextField fullWidth label="网站简介(页脚品牌区)" value={form.footer_desc} onChange={set('footer_desc')} margin="normal" placeholder="留空则自动使用网站描述" multiline minRows={2} />
|
||||
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
|
||||
<FormHelperText sx={{ mt: 1 }}>
|
||||
导航栏目固定不可编辑(首页 / 博客 / 论坛 / 管理后台 / 个人中心)
|
||||
</FormHelperText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
|
||||
<TextField fullWidth label="Powered by 文本" value={form.footer_powered} onChange={set('footer_powered')} margin="normal" placeholder="Powered by RainnyaWeb" />
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>面板代理</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="代理允许的内网地址(白名单)"
|
||||
value={form.proxy_allowed_hosts}
|
||||
onChange={set('proxy_allowed_hosts')}
|
||||
margin="normal"
|
||||
multiline
|
||||
minRows={3}
|
||||
placeholder={'每行或逗号分隔一个地址/网段,例如:\n192.168.0.0/16\n10.0.0.0/8\n192.168.3.1:8080'}
|
||||
helperText="https 页面无法嵌入 http 内网面板,把可信内网地址/网段加入白名单后可经面板代理放行。支持单 IP、IPv4 CIDR(如 192.168.0.0/16)与主机名;默认拦截所有内网地址,请谨慎配置。"
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>RainID 单点登录</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
|
||||
通过 RainID 统一身份认证,支持 ROPC 密码登录与授权码 SSO
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.rainid_enabled === '1'} onChange={toggle('rainid_enabled')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>启用 RainID 登录</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>登录页显示「使用 RainID 登录」,密码登录自动转发 ROPC 校验</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.rainid_register_redirect === '1'} onChange={toggle('rainid_register_redirect')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>注册跳转 RainID</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后注册页直接跳转 RainID 注册,不再显示本地注册表单</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField fullWidth label="Client ID" value={form.rainid_client_id} onChange={set('rainid_client_id')} margin="normal" placeholder="RainID 应用 Client ID(非机密)" />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Client Secret(机密)"
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={form.rainid_client_secret}
|
||||
onChange={set('rainid_client_secret')}
|
||||
margin="normal"
|
||||
placeholder="••••••••••••"
|
||||
autoComplete="new-password"
|
||||
helperText="保存在服务端且不回读;留空提交则保持原值不变"
|
||||
slotProps={{
|
||||
htmlInput: { 'aria-label': 'RainID Client Secret' },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label={showSecret ? '隐藏 Secret' : '显示 Secret'}
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
>
|
||||
{showSecret ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField fullWidth label="Discovery URL" type="url" value={form.rainid_discovery_url} onChange={set('rainid_discovery_url')} margin="normal" placeholder="https://rainid.rainnya.asia/oauth" helperText="RainID OIDC 配置地址,默认即可" />
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadWallpaper } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */
|
||||
export default function ThemeSettings() {
|
||||
const [form, setForm] = useState({
|
||||
primary_color: '#6750a4',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: true,
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
glass_opacity: '0.6',
|
||||
theme_force_dark: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
primary_color: s.primary_color || '#6750a4',
|
||||
theme_wallpaper: s.theme_wallpaper || '',
|
||||
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
|
||||
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
|
||||
nav_style: s.nav_style || 'default',
|
||||
card_style: s.card_style || 'default',
|
||||
glass_blur: s.glass_blur || '20',
|
||||
glass_opacity: s.glass_opacity || '0.6',
|
||||
theme_force_dark: s.theme_force_dark === '1',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const handleWallpaperUpload = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await uploadWallpaper(file);
|
||||
setForm((p) => ({ ...p, theme_wallpaper: data.url }));
|
||||
showSnack('壁纸已上传');
|
||||
} catch (err) {
|
||||
showSnack(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
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,
|
||||
glass_blur: form.glass_blur,
|
||||
glass_opacity: form.glass_opacity,
|
||||
});
|
||||
showSnack('主题设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="color" value={form.primary_color} onChange={set('primary_color')} style={{ width: 48, height: 48, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||
<TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" sx={{ width: 160 }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
||||
label="启用壁纸背景"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
|
||||
<Typography variant="caption" color="text.secondary">上传后自动填入 URL</Typography>
|
||||
</Box>
|
||||
<TextField fullWidth label="图片 URL" type="url" value={form.theme_wallpaper} onChange={set('theme_wallpaper')} margin="normal" placeholder="https://example.com/wallpaper.jpg" />
|
||||
{form.theme_wallpaper && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<img src={form.theme_wallpaper} alt="wallpaper" style={{ width: '100%', maxHeight: 120, objectFit: 'cover', borderRadius: 8 }} />
|
||||
</Box>
|
||||
)}
|
||||
<TextField fullWidth label="缩放方式" select value={form.theme_wallpaper_scale} onChange={set('theme_wallpaper_scale')} margin="normal" sx={{ maxWidth: 280 }}>
|
||||
<option value="cover">cover - 覆盖填充</option>
|
||||
<option value="contain">contain - 完整显示</option>
|
||||
<option value="repeat">repeat - 平铺重复</option>
|
||||
<option value="stretch">stretch - 拉伸填充</option>
|
||||
</TextField>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>样式设置</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>导航栏样式</Typography>
|
||||
<ToggleButtonGroup exclusive value={form.nav_style} onChange={(e, v) => v && setForm((p) => ({ ...p, nav_style: v }))} size="small" sx={{ mb: 2 }}>
|
||||
<ToggleButton value="default">默认</ToggleButton>
|
||||
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
|
||||
<ToggleButton value="capsule">胶囊</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>卡片样式</Typography>
|
||||
<ToggleButtonGroup exclusive value={form.card_style} onChange={(e, v) => v && setForm((p) => ({ ...p, card_style: v }))} size="small">
|
||||
<ToggleButton value="default">默认</ToggleButton>
|
||||
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>玻璃效果</Typography>
|
||||
<Typography variant="body2" color="text.secondary">模糊强度: {form.glass_blur}px</Typography>
|
||||
<Slider min={5} max={40} value={parseInt(form.glass_blur, 10) || 20} onChange={(e, v) => setForm((p) => ({ ...p, glass_blur: String(v) }))} sx={{ maxWidth: 400 }} />
|
||||
<Typography variant="body2" color="text.secondary">透明度: {form.glass_opacity}</Typography>
|
||||
<Slider min={0.1} max={0.95} step={0.05} value={parseFloat(form.glass_opacity) || 0.6} onChange={(e, v) => setForm((p) => ({ ...p, glass_opacity: String(v) }))} sx={{ maxWidth: 400 }} />
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
|
||||
label="强制深色模式(启用后用户无法切换为浅色)"
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存主题设置</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listAttachments, deleteAttachment } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 附件管理:列表/删除(迁移自 v1 附件卡片) */
|
||||
export default function Uploads() {
|
||||
const [list, setList] = useState(null);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAttachments()
|
||||
.then((rows) => setList(rows || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAttachment(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>附件管理</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>文件名</TableCell>
|
||||
<TableCell>原始名</TableCell>
|
||||
<TableCell>大小</TableCell>
|
||||
<TableCell>类型</TableCell>
|
||||
<TableCell>上传者</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list === null ? (
|
||||
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8}>暂无附件</TableCell></TableRow>
|
||||
) : list.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell>{f.id}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 160 }}>
|
||||
<Box component="a" href={`/uploads/${f.filename}`} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{f.filename}</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</TableCell>
|
||||
<TableCell>{((f.size || 0) / 1024).toFixed(0)} KB</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.mime_type || '-'}</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary' }}>UID {f.user_id}</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.created_at}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: f.id, label: f.filename })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import { listUsers, deleteUser, resetUserPassword, setUserRole, registerByAdmin } from '../../api/auth.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 用户管理:列表/添加/改密/改权/删除(迁移自 v1 用户卡片) */
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
|
||||
const [pwDialog, setPwDialog] = useState(null); // { id, username }
|
||||
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
|
||||
const [roleDialog, setRoleDialog] = useState(null); // { id, username, role }
|
||||
const [confirm, setConfirm] = useState(null); // { id, username }
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listUsers()
|
||||
.then((us) => setUsers(us || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addUser = async () => {
|
||||
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await registerByAdmin(addForm.username.trim(), addForm.password, addForm.role);
|
||||
showSnack('用户已创建');
|
||||
setAddOpen(false);
|
||||
setAddForm({ username: '', password: '', role: 'user' });
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const resetPw = async () => {
|
||||
if (!pwDialog) 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);
|
||||
showSnack('密码已重置');
|
||||
setPwDialog(null);
|
||||
setPwForm({ p1: '', p2: '' });
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const changeRole = async () => {
|
||||
if (!roleDialog) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setUserRole(roleDialog.id, roleDialog.role);
|
||||
showSnack('角色已更新');
|
||||
setRoleDialog(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">用户管理</Typography>
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)}>添加用户</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>用户名</TableCell>
|
||||
<TableCell>邮箱</TableCell>
|
||||
<TableCell>验证</TableCell>
|
||||
<TableCell>角色</TableCell>
|
||||
<TableCell>注册时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users === null ? (
|
||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>暂无用户</TableCell></TableRow>
|
||||
) : users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>{u.id}</TableCell>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{u.username}</Box></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary' }}>{u.email || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.email_verified ? '已验证' : '未验证'} color={u.email_verified ? 'primary' : 'default'} variant={u.email_verified ? 'filled' : 'outlined'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.role === 'admin' ? '管理员' : '用户'} color={u.role === 'admin' ? 'secondary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Button size="small" 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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>添加用户</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="用户名 *" value={addForm.username} onChange={(e) => setAddForm((p) => ({ ...p, username: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="密码 *" type="password" value={addForm.password} onChange={(e) => setAddForm((p) => ({ ...p, password: e.target.value }))} margin="normal" />
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>角色</InputLabel>
|
||||
<Select value={addForm.role} onChange={(e) => setAddForm((p) => ({ ...p, role: e.target.value }))} label="角色">
|
||||
<MenuItem value="user">用户</MenuItem>
|
||||
<MenuItem value="admin">管理员</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={addUser} disabled={busy}>创建</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 重置密码 */}
|
||||
<Dialog open={!!pwDialog} onClose={() => setPwDialog(null)} fullWidth maxWidth="xs">
|
||||
<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>
|
||||
|
||||
{/* 修改角色 */}
|
||||
<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>
|
||||
<InputLabel>角色</InputLabel>
|
||||
<Select value={roleDialog ? roleDialog.role : 'user'} onChange={(e) => setRoleDialog((p) => (p ? { ...p, role: e.target.value } : p))} label="角色">
|
||||
<MenuItem value="user">普通用户</MenuItem>
|
||||
<MenuItem value="admin">管理员</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRoleDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={changeRole} disabled={busy}>确认修改</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.username : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
// 模块级单例:admin 任意处调用 showSnack,由 AdminLayout 内的 SnackHost 消费
|
||||
let showFn = null;
|
||||
|
||||
export function showSnack(msg, severity = 'success') {
|
||||
if (showFn) showFn(msg, severity);
|
||||
else window.alert(msg);
|
||||
}
|
||||
|
||||
export default function SnackHost() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [severity, setSeverity] = useState('success');
|
||||
|
||||
showFn = (m, s) => { setMsg(m); setSeverity(s || 'success'); setOpen(true); };
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={severity} variant="filled" onClose={() => setOpen(false)}>{msg}</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* ============================================================
|
||||
* 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节
|
||||
* 前台 token 变量未注入后台页面,这里按明暗各写一份。
|
||||
* ============================================================ */
|
||||
|
||||
/* 内容区入场动效(对应前台 contentFadeIn) */
|
||||
main {
|
||||
animation: md3ContentFadeIn 0.28s ease;
|
||||
}
|
||||
|
||||
@keyframes md3ContentFadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* 明暗切换过渡 */
|
||||
body {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 滚动条(对应前台细滚动条) */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cac4d0; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #79747e; }
|
||||
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; }
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; }
|
||||
|
||||
/* 减弱动效偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { createTheme, alpha } from '@mui/material/styles';
|
||||
|
||||
/* ============================================================
|
||||
* 后台 Material Design 3 主题
|
||||
*
|
||||
* - 与前台 public/css/style.css 的 MD3 token 保持同源:
|
||||
* 默认主色 #6750a4 时直接复用前台 token 值(浅/深两套),
|
||||
* 自定义主色时用 tonal ramp 近似生成整套 token。
|
||||
* - 组件形态按 MD3 规范覆盖:pill 按钮、大圆角卡片/弹窗、
|
||||
* surface 色阶、弱边框 + 低阴影(surface tint)。
|
||||
* ============================================================ */
|
||||
|
||||
const DEFAULT_SEED = '#6750a4';
|
||||
|
||||
/* ---------- 颜色工具 ---------- */
|
||||
|
||||
function clamp(v) { return Math.min(255, Math.max(0, Math.round(v))); }
|
||||
|
||||
function hexToRgb(hex) {
|
||||
let h = String(hex).replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
const n = parseInt(h, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
const to = (v) => clamp(v).toString(16).padStart(2, '0');
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
function mix(a, b, w) {
|
||||
const ca = hexToRgb(a);
|
||||
const cb = hexToRgb(b);
|
||||
return rgbToHex(
|
||||
ca[0] + (cb[0] - ca[0]) * w,
|
||||
ca[1] + (cb[1] - ca[1]) * w,
|
||||
ca[2] + (cb[2] - ca[2]) * w
|
||||
);
|
||||
}
|
||||
|
||||
function hexToHsl(hex) {
|
||||
const [r, g, b] = hexToRgb(hex).map((v) => v / 255);
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
}
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let rgb;
|
||||
if (h < 60) rgb = [c, x, 0];
|
||||
else if (h < 120) rgb = [x, c, 0];
|
||||
else if (h < 180) rgb = [0, c, x];
|
||||
else if (h < 240) rgb = [0, x, c];
|
||||
else if (h < 300) rgb = [x, 0, c];
|
||||
else rgb = [c, 0, x];
|
||||
return rgbToHex((rgb[0] + m) * 255, (rgb[1] + m) * 255, (rgb[2] + m) * 255);
|
||||
}
|
||||
|
||||
function grayscale(hex) {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
const v = clamp(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
return rgbToHex(v, v, v);
|
||||
}
|
||||
|
||||
/* MD3 tonal ramp 近似:tone t = 向白/黑按比例混合(t40 即 seed 本身) */
|
||||
function tone(seed, t) {
|
||||
if (t >= 40) return mix(seed, '#ffffff', (t - 40) / 60);
|
||||
return mix(seed, '#000000', (40 - t) / 40);
|
||||
}
|
||||
|
||||
/* 派生色:可旋转色相 / 降低饱和度后再套 tone */
|
||||
function tonal(seed, t, { hue = 0, sat = 1 } = {}) {
|
||||
let base = seed;
|
||||
if (hue) {
|
||||
const [h, s, l] = hexToHsl(base);
|
||||
base = hslToHex(h + hue, s, l);
|
||||
}
|
||||
if (sat < 1) base = mix(base, grayscale(base), 1 - sat);
|
||||
return tone(base, t);
|
||||
}
|
||||
|
||||
/* ---------- 前台同源 token(默认主色 #6750a4,取自 public/css/style.css) ---------- */
|
||||
|
||||
const LIGHT_TOKENS = {
|
||||
primary: '#6750a4',
|
||||
onPrimary: '#ffffff',
|
||||
primaryContainer: '#eadfff',
|
||||
onPrimaryContainer: '#21005d',
|
||||
secondary: '#625b71',
|
||||
onSecondary: '#ffffff',
|
||||
secondaryContainer: '#e8def8',
|
||||
onSecondaryContainer: '#1d192b',
|
||||
tertiary: '#7d5260',
|
||||
onTertiary: '#ffffff',
|
||||
tertiaryContainer: '#ffd8e4',
|
||||
onTertiaryContainer: '#31111d',
|
||||
surface: '#fffbfe',
|
||||
onSurface: '#1c1b1f',
|
||||
surfaceVariant: '#e7e0ec',
|
||||
onSurfaceVariant: '#49454f',
|
||||
surfaceContainerLow: '#f7f2fa',
|
||||
surfaceContainer: '#f3edf7',
|
||||
surfaceContainerHigh: '#ece6f0',
|
||||
outline: '#79747e',
|
||||
outlineVariant: '#cac4d0',
|
||||
error: '#b3261e',
|
||||
onError: '#ffffff',
|
||||
inverseSurface: '#313033',
|
||||
inverseOnSurface: '#f4eff4',
|
||||
};
|
||||
|
||||
const DARK_TOKENS = {
|
||||
primary: '#d0bcff',
|
||||
onPrimary: '#381e72',
|
||||
primaryContainer: '#4f378b',
|
||||
onPrimaryContainer: '#eadfff',
|
||||
secondary: '#ccc2dc',
|
||||
onSecondary: '#332d41',
|
||||
secondaryContainer: '#4a4458',
|
||||
onSecondaryContainer: '#e8def8',
|
||||
tertiary: '#efb8c8',
|
||||
onTertiary: '#492532',
|
||||
tertiaryContainer: '#61343f',
|
||||
onTertiaryContainer: '#ffd8e4',
|
||||
surface: '#1c1b1f',
|
||||
onSurface: '#e6e1e5',
|
||||
surfaceVariant: '#49454f',
|
||||
onSurfaceVariant: '#cac4d0',
|
||||
surfaceContainerLow: '#211f26',
|
||||
surfaceContainer: '#25232a',
|
||||
surfaceContainerHigh: '#2b2930',
|
||||
outline: '#938f99',
|
||||
outlineVariant: '#49454f',
|
||||
error: '#f2b8b5',
|
||||
onError: '#601410',
|
||||
inverseSurface: '#e6e1e5',
|
||||
inverseOnSurface: '#313033',
|
||||
};
|
||||
|
||||
function buildPalette(seed, dark) {
|
||||
if (String(seed).toLowerCase() === DEFAULT_SEED) return dark ? DARK_TOKENS : LIGHT_TOKENS;
|
||||
// 自定义主色:surface 色阶保持 MD3 基线,色相相关 token 由 tonal ramp 派生
|
||||
const sat = 0.55; // secondary 适度去饱和,贴近 MD3 参考值
|
||||
const hue = 32; // tertiary 色相偏移
|
||||
if (dark) {
|
||||
return {
|
||||
primary: tone(seed, 80),
|
||||
onPrimary: tone(seed, 20),
|
||||
primaryContainer: tone(seed, 30),
|
||||
onPrimaryContainer: tone(seed, 90),
|
||||
secondary: tonal(seed, 80, { sat }),
|
||||
onSecondary: tonal(seed, 20, { sat }),
|
||||
secondaryContainer: tonal(seed, 30, { sat }),
|
||||
onSecondaryContainer: tonal(seed, 90, { sat }),
|
||||
tertiary: tonal(seed, 80, { hue }),
|
||||
onTertiary: tonal(seed, 20, { hue }),
|
||||
tertiaryContainer: tonal(seed, 30, { hue }),
|
||||
onTertiaryContainer: tonal(seed, 90, { hue }),
|
||||
...DARK_TOKENS_DERIVED,
|
||||
};
|
||||
}
|
||||
return {
|
||||
primary: tone(seed, 40),
|
||||
onPrimary: '#ffffff',
|
||||
primaryContainer: tone(seed, 90),
|
||||
onPrimaryContainer: tone(seed, 10),
|
||||
secondary: tonal(seed, 40, { sat }),
|
||||
onSecondary: '#ffffff',
|
||||
secondaryContainer: tonal(seed, 90, { sat }),
|
||||
onSecondaryContainer: tonal(seed, 10, { sat }),
|
||||
tertiary: tonal(seed, 40, { hue }),
|
||||
onTertiary: '#ffffff',
|
||||
tertiaryContainer: tonal(seed, 90, { hue }),
|
||||
onTertiaryContainer: tonal(seed, 10, { hue }),
|
||||
...LIGHT_TOKENS_DERIVED,
|
||||
};
|
||||
}
|
||||
|
||||
// 派生模式下 surface 相关与 error 沿用 MD3 基线
|
||||
const LIGHT_TOKENS_DERIVED = {
|
||||
surface: '#fffbfe',
|
||||
onSurface: '#1c1b1f',
|
||||
surfaceVariant: '#e7e0ec',
|
||||
onSurfaceVariant: '#49454f',
|
||||
surfaceContainerLow: '#f7f2fa',
|
||||
surfaceContainer: '#f3edf7',
|
||||
surfaceContainerHigh: '#ece6f0',
|
||||
outline: '#79747e',
|
||||
outlineVariant: '#cac4d0',
|
||||
error: '#b3261e',
|
||||
onError: '#ffffff',
|
||||
inverseSurface: '#313033',
|
||||
inverseOnSurface: '#f4eff4',
|
||||
};
|
||||
|
||||
const DARK_TOKENS_DERIVED = {
|
||||
surface: '#1c1b1f',
|
||||
onSurface: '#e6e1e5',
|
||||
surfaceVariant: '#49454f',
|
||||
onSurfaceVariant: '#cac4d0',
|
||||
surfaceContainerLow: '#211f26',
|
||||
surfaceContainer: '#25232a',
|
||||
surfaceContainerHigh: '#2b2930',
|
||||
outline: '#938f99',
|
||||
outlineVariant: '#49454f',
|
||||
error: '#f2b8b5',
|
||||
onError: '#601410',
|
||||
inverseSurface: '#e6e1e5',
|
||||
inverseOnSurface: '#313033',
|
||||
};
|
||||
|
||||
/* ---------- MD3 柔和阴影(surface tint 而非硬投影) ---------- */
|
||||
|
||||
function md3Shadows() {
|
||||
const shadows = ['none'];
|
||||
for (let i = 1; i <= 24; i += 1) {
|
||||
const a = Math.min(0.26, 0.05 + i * 0.008).toFixed(3);
|
||||
const b = Math.min(0.15, 0.03 + i * 0.005).toFixed(3);
|
||||
const y1 = Math.min(3, 1 + Math.floor(i / 8));
|
||||
const y2 = Math.min(2, 1 + Math.floor(i / 12));
|
||||
const b1 = Math.min(8, 2 + Math.floor(i / 3));
|
||||
const b2 = Math.min(6, 1 + Math.floor(i / 5));
|
||||
shadows.push(`0px ${y1}px ${b1}px rgba(0,0,0,${a}), 0px ${y2}px ${b2}px rgba(0,0,0,${b})`);
|
||||
}
|
||||
return shadows;
|
||||
}
|
||||
|
||||
/* ---------- 主题工厂 ---------- */
|
||||
|
||||
export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) {
|
||||
const dark = mode === 'dark';
|
||||
const p = buildPalette(primary, dark);
|
||||
const shadows = md3Shadows();
|
||||
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
|
||||
|
||||
// filled / tonal 按钮的 hover 色(MD3 hover = 相邻 tone)
|
||||
const hoverPrimary = dark ? tone(p.primary, 90) : tone(p.primary, 35);
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: p.primary,
|
||||
contrastText: p.onPrimary,
|
||||
container: p.primaryContainer,
|
||||
onContainer: p.onPrimaryContainer,
|
||||
},
|
||||
secondary: {
|
||||
main: p.secondary,
|
||||
contrastText: p.onSecondary,
|
||||
container: p.secondaryContainer,
|
||||
onContainer: p.onSecondaryContainer,
|
||||
},
|
||||
tertiary: {
|
||||
main: p.tertiary,
|
||||
contrastText: p.onTertiary,
|
||||
container: p.tertiaryContainer,
|
||||
onContainer: p.onTertiaryContainer,
|
||||
},
|
||||
error: { main: p.error, contrastText: p.onError },
|
||||
background: { default: p.surface, paper: p.surfaceContainerLow },
|
||||
text: {
|
||||
primary: p.onSurface,
|
||||
secondary: p.onSurfaceVariant,
|
||||
disabled: alpha(p.onSurface, 0.38),
|
||||
},
|
||||
divider: p.outlineVariant,
|
||||
action: {
|
||||
hover: alpha(p.onSurface, 0.08),
|
||||
selected: alpha(p.onSurface, 0.08),
|
||||
disabled: alpha(p.onSurface, 0.38),
|
||||
disabledBackground: alpha(p.onSurface, 0.12),
|
||||
},
|
||||
},
|
||||
shape: { borderRadius: 8 },
|
||||
typography: {
|
||||
fontFamily,
|
||||
h4: { fontSize: 28, fontWeight: 500, letterSpacing: 0 },
|
||||
h5: { fontSize: 24, fontWeight: 500, letterSpacing: 0 },
|
||||
h6: { fontSize: 22, fontWeight: 500, letterSpacing: 0.15 },
|
||||
subtitle1: { fontSize: 16, fontWeight: 500, letterSpacing: 0.15 },
|
||||
body1: { fontSize: 16, lineHeight: 1.5 },
|
||||
body2: { fontSize: 14, lineHeight: 1.5 },
|
||||
button: { fontSize: 14, fontWeight: 500, letterSpacing: 0.1 },
|
||||
caption: { fontSize: 12, fontWeight: 400, letterSpacing: 0.4 },
|
||||
},
|
||||
shadows,
|
||||
components: {
|
||||
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
|
||||
MuiAppBar: {
|
||||
defaultProps: { color: 'inherit', elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: p.surfaceContainer,
|
||||
color: p.onSurface,
|
||||
boxShadow: 'none',
|
||||
borderBottom: `1px solid ${p.outlineVariant}`,
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 导航抽屉:surfaceContainerLow + 右侧细边框 */
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
borderRight: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 导航项:胶囊选中态(secondaryContainer) */
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 24,
|
||||
margin: '4px 10px',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
'& .MuiListItemIcon-root': { color: p.onSurfaceVariant, minWidth: 40 },
|
||||
'& .MuiListItemText-primary': { fontWeight: 500, fontSize: 14 },
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
'&:hover': { backgroundColor: p.secondaryContainer },
|
||||
'& .MuiListItemIcon-root': { color: p.onSecondaryContainer },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 按钮:pill 圆角、无阴影、文字不转大写 */
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.1px',
|
||||
height: 40,
|
||||
padding: '0 24px',
|
||||
},
|
||||
sizeSmall: { height: 32, padding: '0 16px', fontSize: 13 },
|
||||
sizeLarge: { height: 48, padding: '0 28px', fontSize: 15 },
|
||||
containedPrimary: {
|
||||
backgroundColor: p.primary,
|
||||
color: p.onPrimary,
|
||||
'&:hover': { backgroundColor: hoverPrimary, boxShadow: shadows[1] },
|
||||
},
|
||||
containedError: { boxShadow: 'none' },
|
||||
outlined: { borderColor: p.outline },
|
||||
outlinedPrimary: {
|
||||
borderColor: p.outline,
|
||||
'&:hover': { borderColor: p.onSurface, backgroundColor: alpha(p.primary, 0.08) },
|
||||
},
|
||||
outlinedError: { color: p.error, borderColor: p.outline, '&:hover': { backgroundColor: alpha(p.error, 0.08) } },
|
||||
textPrimary: { color: p.primary, '&:hover': { backgroundColor: alpha(p.primary, 0.08) } },
|
||||
textError: { color: p.error },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 卡片:surfaceContainerLow + 弱边框 + 12px 圆角 */
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* 设置页的普通 Paper(默认 elevation=1)同样 MD3 化 */
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
rounded: { borderRadius: 12 },
|
||||
elevation1: {
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
outlined: { borderColor: p.outlineVariant },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 弹窗:28px 大圆角 + surfaceContainerHigh */
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRadius: 28,
|
||||
backgroundColor: p.surfaceContainerHigh,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: shadows[4],
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialogTitle: {
|
||||
styleOverrides: { root: { fontSize: 22, fontWeight: 500, padding: '24px 24px 8px' } },
|
||||
},
|
||||
MuiDialogContent: {
|
||||
styleOverrides: { root: { padding: '8px 24px', color: p.onSurface } },
|
||||
},
|
||||
MuiDialogActions: {
|
||||
styleOverrides: { root: { padding: '16px 24px 24px', gap: 8 } },
|
||||
},
|
||||
|
||||
/* MD3 输入框:4px 圆角 + 中性 outline 边框 */
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 4 },
|
||||
notchedOutline: { borderColor: p.outline },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: p.onSurface },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 Chip:8px 圆角 + outline 边框 */
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 8, fontWeight: 500 },
|
||||
outlined: { borderColor: p.outline },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 表格:outline-variant 分隔线、紧凑密度 */
|
||||
MuiTableCell: {
|
||||
styleOverrides: {
|
||||
root: { borderBottom: `1px solid ${p.outlineVariant}`, padding: '14px 16px', fontSize: 14 },
|
||||
head: { color: p.onSurfaceVariant, fontWeight: 600, fontSize: 13 },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 开关:开 = primary 轨道 + 白 thumb,关 = surfaceContainerHigh 轨道 */
|
||||
MuiSwitch: {
|
||||
styleOverrides: {
|
||||
root: {},
|
||||
switchBase: {
|
||||
color: p.outline,
|
||||
'&.Mui-checked': { color: p.primary },
|
||||
'&.Mui-checked + .MuiSwitch-track': { backgroundColor: p.primary, opacity: 1 },
|
||||
},
|
||||
track: { backgroundColor: p.surfaceContainerHigh, opacity: 1 },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 分段按钮:secondaryContainer 选中态 */
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
textTransform: 'none',
|
||||
borderColor: p.outline,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
borderColor: p.outline,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 头像:primaryContainer 底 + onPrimaryContainer 字 */
|
||||
MuiAvatar: {
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: p.primaryContainer, color: p.onPrimaryContainer },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 提示:inverseSurface 底 */
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
backgroundColor: p.inverseSurface,
|
||||
color: p.inverseOnSurface,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
padding: '6px 8px',
|
||||
},
|
||||
arrow: { color: p.inverseSurface },
|
||||
},
|
||||
},
|
||||
|
||||
/* 图标按钮去底色圆角适配 */
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createMD3Theme;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// 面板链接(routes/admin-links.js)
|
||||
export function listAdminLinks() {
|
||||
return request('/admin-links');
|
||||
}
|
||||
/** data = { title, url, embed_url?, description?, icon?, category?, sort_order?, use_proxy?, version? } */
|
||||
export function createAdminLink(data) {
|
||||
return request('/admin-links', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateAdminLink(id, data) {
|
||||
return request('/admin-links/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteAdminLink(id) {
|
||||
return request('/admin-links/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 启用的公告列表(active=1) */
|
||||
export function listActive() {
|
||||
return request('/announcements');
|
||||
}
|
||||
|
||||
// ── 管理员管理 ──────────────────────────────────
|
||||
export function listAll() {
|
||||
return request('/announcements?all=1');
|
||||
}
|
||||
export function createAnnouncement(data) {
|
||||
return request('/announcements', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateAnnouncement(id, data) {
|
||||
return request('/announcements/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteAnnouncement(id) {
|
||||
return request('/announcements/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { request, setToken, notifyAuthChange } from './client.js';
|
||||
import { applyCaptchaResult } from './captcha.js';
|
||||
|
||||
/** 登录;captcha 为 showCaptcha 的结果({type,value},可选):proof → captcha_proof,recaptcha/turnstile → 对应 token */
|
||||
export function login(username, password, captcha) {
|
||||
const body = { username, password };
|
||||
applyCaptchaResult(body, captcha);
|
||||
return request('/auth/login', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 注册;data = { username, password, email, captcha? }(captcha 同 login) */
|
||||
export function register(data) {
|
||||
const body = { username: data.username, password: data.password, email: data.email };
|
||||
applyCaptchaResult(body, data.captcha);
|
||||
return request('/auth/register', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息(需登录) */
|
||||
export function me() {
|
||||
return request('/auth/me');
|
||||
}
|
||||
|
||||
// ── RainID 单点登录(OIDC) ──────────────────────────────
|
||||
/** 发起授权码登录:整页跳转到后端 /api/auth/oidc/login(后端 302 至 RainID) */
|
||||
export function oidcLoginRedirect() {
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
}
|
||||
|
||||
/** RainID 回跳:用一次性过渡码(30s)换取会话 token → {token, username, role} */
|
||||
export function oidcExchange(ticket) {
|
||||
return request('/auth/oidc/exchange', { method: 'POST', body: { ticket } });
|
||||
}
|
||||
|
||||
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
|
||||
export function logout() {
|
||||
setToken(null);
|
||||
notifyAuthChange();
|
||||
}
|
||||
|
||||
// ── 管理员用户管理 ──────────────────────────────
|
||||
export function registerByAdmin(username, password, role) {
|
||||
return request('/auth/register-by-admin', { method: 'POST', body: { username, password, role } });
|
||||
}
|
||||
export function listUsers() {
|
||||
return request('/auth/users');
|
||||
}
|
||||
export function deleteUser(id) {
|
||||
return request('/auth/users/' + id, { method: 'DELETE' });
|
||||
}
|
||||
export function resetUserPassword(id, newPassword) {
|
||||
return request('/auth/users/' + id + '/password', { method: 'PUT', body: { newPassword } });
|
||||
}
|
||||
export function setUserRole(id, role) {
|
||||
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { role } });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 文章列表;all=true 返回全部(含草稿,仅管理员) */
|
||||
export function listPosts(all) {
|
||||
return request('/blog/posts' + (all ? '?all=1' : ''));
|
||||
}
|
||||
export function getPost(id) {
|
||||
return request('/blog/posts/' + id);
|
||||
}
|
||||
/** data = { title, content, excerpt?, published?, use_markdown? }(仅管理员) */
|
||||
export function createPost(data) {
|
||||
return request('/blog/posts', { method: 'POST', body: data });
|
||||
}
|
||||
export function updatePost(id, data) {
|
||||
return request('/blog/posts/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deletePost(id) {
|
||||
return request('/blog/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 搜索 / 标签 / 归档 / 上下篇 / 点赞(博客增强 B2)──────────
|
||||
export function searchPosts(q) {
|
||||
return request('/blog/search?q=' + encodeURIComponent(q || ''));
|
||||
}
|
||||
export function getTags() {
|
||||
return request('/blog/tags');
|
||||
}
|
||||
export function getTagPosts(name) {
|
||||
return request('/blog/tag/' + encodeURIComponent(name));
|
||||
}
|
||||
export function getArchive() {
|
||||
return request('/blog/archive');
|
||||
}
|
||||
export function getPrevNext(id) {
|
||||
return request('/blog/posts/' + id + '/prevnext');
|
||||
}
|
||||
export function getLikeState(id) {
|
||||
return request('/blog/posts/' + id + '/like');
|
||||
}
|
||||
export function likePost(id) {
|
||||
return request('/blog/posts/' + id + '/like', { method: 'POST' });
|
||||
}
|
||||
export function unlikePost(id) {
|
||||
return request('/blog/posts/' + id + '/like', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 评论 ────────────────────────────────────────
|
||||
export function listComments(postId) {
|
||||
return request('/blog/comments/' + postId);
|
||||
}
|
||||
/** content 评论正文;parentId>0 表示回复某条评论(嵌套) */
|
||||
export function createComment(postId, content, parentId = 0) {
|
||||
return request('/blog/comments/' + postId, { method: 'POST', body: { content, parent_id: parentId || 0 } });
|
||||
}
|
||||
export function deleteComment(id) {
|
||||
return request('/blog/comments/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 评论审核(管理员)──────────────────────────
|
||||
export function listPendingComments() {
|
||||
return request('/blog/comments/pending');
|
||||
}
|
||||
export function approveComment(id) {
|
||||
return request('/blog/comments/' + id + '/approve', { method: 'POST' });
|
||||
}
|
||||
export function rejectComment(id) {
|
||||
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 获取内置 SVG 验证码:返回 { token, svg, expires_in } */
|
||||
export function getImage() {
|
||||
return request('/captcha/image');
|
||||
}
|
||||
|
||||
/** 校验答案:返回 { success, proof?, error? },成功时 proof 为一次性验证码证明令牌 */
|
||||
export function verify(token, answer) {
|
||||
return request('/captcha/verify', { method: 'POST', body: { token, answer } });
|
||||
}
|
||||
|
||||
/** 检查某个动作(login/register/forum)是否需要验证码:返回 { required, type } */
|
||||
export function required(action) {
|
||||
return request('/captcha/required', { method: 'POST', body: { action } });
|
||||
}
|
||||
|
||||
// ── Proof of Work(智能验证,内置验证码的防刷前置) ──
|
||||
export function powChallenge() {
|
||||
return request('/captcha/pow-challenge');
|
||||
}
|
||||
export function powVerify(token, nonce) {
|
||||
return request('/captcha/pow-verify', { method: 'POST', body: { token, nonce: String(nonce) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 showCaptcha 的结果按 type 写入请求体:
|
||||
* - proof → captcha_proof(内置验证码一次性证明令牌)
|
||||
* - recaptcha → recaptcha_token(reCAPTCHA siteverify token)
|
||||
* - turnstile → turnstile_token(Turnstile siteverify token)
|
||||
*/
|
||||
export function applyCaptchaResult(body, result) {
|
||||
if (!body || !result || !result.type || !result.value) return body;
|
||||
if (result.type === 'proof') body.captcha_proof = result.value;
|
||||
else if (result.type === 'recaptcha') body.recaptcha_token = result.value;
|
||||
else if (result.type === 'turnstile') body.turnstile_token = result.value;
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(t) {
|
||||
t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** 登录态变更通知:登录/退出/头像更新后触发,Layout 监听后重新拉取当前用户 */
|
||||
export function notifyAuthChange() {
|
||||
window.dispatchEvent(new Event('authchange'));
|
||||
}
|
||||
|
||||
export async function request(path, { method = 'GET', body, auth = true } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (auth && getToken()) headers.Authorization = 'Bearer ' + getToken();
|
||||
const res = await fetch('/api' + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { const err = new Error(data.error || '请求失败'); err.status = res.status; throw err; }
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 重新发送注册邮箱验证码(8 位数字,存入 pending_users) */
|
||||
export function sendVerify(email, username) {
|
||||
return request('/email/send-verify', { method: 'POST', body: { email, username } });
|
||||
}
|
||||
|
||||
/** 输入 8 位验证码完成注册:{ code, username, password } */
|
||||
export function completeRegister(code, username, password) {
|
||||
return request('/email/complete-register', { method: 'POST', body: { code, username, password } });
|
||||
}
|
||||
|
||||
/** 发送 SMTP 测试邮件(仅管理员) */
|
||||
export function testSmtp(email) {
|
||||
return request('/email/test', { method: 'POST', body: { email } });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 分类 ────────────────────────────────────────
|
||||
export function listCategories() {
|
||||
return request('/forum/categories');
|
||||
}
|
||||
export function createCategory(data) {
|
||||
return request('/forum/categories', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateCategory(id, data) {
|
||||
return request('/forum/categories/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteCategory(id) {
|
||||
return request('/forum/categories/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 帖子 ────────────────────────────────────────
|
||||
/** 帖子列表;categoryId 可选 */
|
||||
export function listPosts(categoryId) {
|
||||
return request('/forum/posts' + (categoryId ? '?category_id=' + encodeURIComponent(categoryId) : ''));
|
||||
}
|
||||
export function getPost(id) {
|
||||
return request('/forum/posts/' + id);
|
||||
}
|
||||
/** data = { category_id, title, content, use_markdown?, sub_category?, captcha_proof? }(需登录) */
|
||||
export function createPost(data) {
|
||||
return request('/forum/posts', { method: 'POST', body: data });
|
||||
}
|
||||
export function reply(postId, content) {
|
||||
return request('/forum/posts/' + postId + '/replies', { method: 'POST', body: { content } });
|
||||
}
|
||||
export function deletePost(id) {
|
||||
return request('/forum/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
export function deleteReply(id) {
|
||||
return request('/forum/replies/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 工作台「记事本」(routes/notes.js,仅管理员) ────────────
|
||||
/** 文件列表:[{ name, size, mtime }],按修改时间倒序 */
|
||||
export function listNotes() {
|
||||
return request('/notes');
|
||||
}
|
||||
/** 读取内容:{ name, content, size, mtime } */
|
||||
export function getNote(filename) {
|
||||
return request('/notes/' + encodeURIComponent(filename));
|
||||
}
|
||||
/** 创建:{ filename, content },同名返回 409 */
|
||||
export function createNote(filename, content) {
|
||||
return request('/notes', { method: 'POST', body: { filename, content } });
|
||||
}
|
||||
/** 更新内容:{ content } */
|
||||
export function updateNote(filename, content) {
|
||||
return request('/notes/' + encodeURIComponent(filename), { method: 'PUT', body: { content } });
|
||||
}
|
||||
/** 删除 */
|
||||
export function deleteNote(filename) {
|
||||
return request('/notes/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── PIN / 解锁 ──────────────────────────────────
|
||||
export function pinStatus() {
|
||||
return request('/passwords/pin-status');
|
||||
}
|
||||
export function setPin(pin) {
|
||||
return request('/passwords/set-pin', { method: 'POST', body: { pin } });
|
||||
}
|
||||
export function unlock(pin) {
|
||||
return request('/passwords/unlock', { method: 'POST', body: { pin } });
|
||||
}
|
||||
export function lock() {
|
||||
return request('/passwords/lock', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 密码条目(需已解锁) ───────────────────────
|
||||
export function listEntries() {
|
||||
return request('/passwords');
|
||||
}
|
||||
/** data = { title, username?, password, url?, notes? } */
|
||||
export function createEntry(data) {
|
||||
return request('/passwords', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateEntry(id, data) {
|
||||
return request('/passwords/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteEntry(id) {
|
||||
return request('/passwords/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 获取当前用户资料(需登录) */
|
||||
export function getProfile() {
|
||||
return request('/profile');
|
||||
}
|
||||
|
||||
/** 更新资料(需登录);目前仅支持 avatar = /uploads/avatars/xxx 站内路径 */
|
||||
export function updateProfile(data) {
|
||||
return request('/profile', { method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
/** 发送修改密码的邮箱验证码(需登录) */
|
||||
export function sendPwCode() {
|
||||
return request('/profile/send-pw-code', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** 校验验证码并修改密码:{ code, oldPassword, newPassword } */
|
||||
export function changePassword(data) {
|
||||
return request('/profile/password', { method: 'PUT', body: data });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */
|
||||
export function getPublicSettings() {
|
||||
return request('/settings/public');
|
||||
}
|
||||
|
||||
/** 全量设置(仅管理员) */
|
||||
export function getSettings() {
|
||||
return request('/settings');
|
||||
}
|
||||
|
||||
/** 保存设置(仅管理员) */
|
||||
export function saveSettings(data) {
|
||||
return request('/settings', { method: 'PUT', body: data });
|
||||
}
|
||||
@@ -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,58 @@
|
||||
import { request, getToken } from './client.js';
|
||||
|
||||
// client.request 走 JSON,上传必须独立走 fetch + FormData + Bearer
|
||||
|
||||
async function upload(path, file, extra) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (extra) {
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
|
||||
}
|
||||
}
|
||||
const headers = {};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
const res = await fetch('/api/upload' + path, { method: 'POST', headers, body: fd });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || '上传失败');
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 普通文件上传;opts 可选 { ref_type, ref_id }。返回 { id, url, tag, ... },tag 为 [image:]/[file:] 插入文本 */
|
||||
export function uploadFile(file, opts) {
|
||||
return upload('/file', file, opts);
|
||||
}
|
||||
|
||||
/** 头像上传,成功后直接更新用户 avatar,返回 { url } */
|
||||
export function uploadAvatar(file) {
|
||||
return upload('/avatar', file);
|
||||
}
|
||||
|
||||
/** 壁纸上传,返回 { url, filename } */
|
||||
export function uploadWallpaper(file) {
|
||||
return upload('/wallpaper', file);
|
||||
}
|
||||
|
||||
/** 按 uid 获取头像地址(支持 QQ 自动头像) */
|
||||
export function avatarUrl(uid) {
|
||||
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
|
||||
}
|
||||
|
||||
/** 附件列表;可按 ref_type + ref_id 过滤 */
|
||||
export function listAttachments(refType, refId) {
|
||||
const q = [];
|
||||
if (refType && refId) {
|
||||
q.push('ref_type=' + encodeURIComponent(refType), 'ref_id=' + encodeURIComponent(refId));
|
||||
}
|
||||
return request('/upload/list' + (q.length ? '?' + q.join('&') : ''));
|
||||
}
|
||||
|
||||
/** 删除附件(本人或管理员) */
|
||||
export function deleteAttachment(id) {
|
||||
return request('/upload/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { listPosts } from '../api/blog.js';
|
||||
|
||||
/**
|
||||
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
|
||||
* 头像 / 简介 / 联系方式来自站点设置(/api/settings/public);
|
||||
* showRecent=true 时附加"最新文章"卡片(首页用)。
|
||||
* settings 由父组件从 /api/settings/public 取得后传入,避免重复请求。
|
||||
*/
|
||||
export default function BlogSidebar({ settings = {}, showRecent = false, forceShow = false }) {
|
||||
const [recent, setRecent] = useState(null); // null=加载中
|
||||
const [recentError, setRecentError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRecent) return;
|
||||
setRecent(null);
|
||||
setRecentError(false);
|
||||
listPosts()
|
||||
.then((posts) => setRecent((posts || []).slice(0, 8)))
|
||||
.catch(() => { setRecentError(true); setRecent([]); });
|
||||
}, [showRecent]);
|
||||
|
||||
// blog_show_sidebar=0 时整栏隐藏(仅博客页;首页 forceShow 强制显示)
|
||||
if (!forceShow && settings.blog_show_sidebar === '0') return null;
|
||||
|
||||
let contacts = [];
|
||||
if (settings.homepage_contacts) {
|
||||
try { contacts = JSON.parse(settings.homepage_contacts); } catch { contacts = []; }
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="homepage-sidebar" id="blogSidebar">
|
||||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<div className="homepage-avatar">
|
||||
{settings.homepage_avatar
|
||||
? <img src={settings.homepage_avatar} alt="avatar" />
|
||||
: <span className="material-icons" style={{ fontSize: 64, color: 'var(--md-ref-on-surface-variant)' }}>person</span>}
|
||||
</div>
|
||||
<div className="homepage-bio">{settings.homepage_bio || ''}</div>
|
||||
{contacts.length > 0 && (
|
||||
<div className="homepage-contacts">
|
||||
{contacts.map((l, i) => (
|
||||
<a key={i} href={l.url} target="_blank" rel="noopener" className="hp-contact-link" title={l.title || ''}>
|
||||
<span className="material-icons">{l.icon || 'link'}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecent && (
|
||||
<div className="card">
|
||||
<div className="homepage-recent-header">最新文章</div>
|
||||
<div className="homepage-recent-list">
|
||||
{recentError
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载失败</div>
|
||||
: recent === null
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载中...</div>
|
||||
: recent.length === 0
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>暂无文章</div>
|
||||
: recent.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="homepage-recent-item">
|
||||
<span className="homepage-recent-title">{p.title}</span>
|
||||
<span className="homepage-recent-date">{(p.created_at || '').slice(0, 10)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as captchaApi from '../api/captcha.js';
|
||||
import { useDialog } from '../lib/utils.js';
|
||||
|
||||
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
|
||||
let pending = null; // { action, type, resolve }
|
||||
let listener = null;
|
||||
|
||||
function notifyHost() {
|
||||
if (listener) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码流程入口(迁移自 public/js/captcha.js):
|
||||
* - null 无需验证 / 用户取消
|
||||
* - object { type: 'proof' | 'recaptcha' | 'turnstile', value }
|
||||
* proof → 内置验证码通过,value 为后端签发的 captcha_proof
|
||||
* recaptcha → reCAPTCHA 验证通过,value 为 siteverify token
|
||||
* turnstile → Turnstile 验证通过,value 为 siteverify token
|
||||
* 调用方提交时按 type 放入 captcha_proof / recaptcha_token / turnstile_token 字段。
|
||||
*/
|
||||
export function showCaptcha(action) {
|
||||
return new Promise((resolve) => {
|
||||
captchaApi.required(action)
|
||||
.then((r) => {
|
||||
if (!r || !r.required || r.type === 'none') { resolve(null); return; }
|
||||
pending = { action, type: r.type || 'builtin', resolve };
|
||||
notifyHost();
|
||||
})
|
||||
.catch(() => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
// 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用)
|
||||
function flushCallbacks(type) {
|
||||
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||
const q = window[key] || [];
|
||||
window[key] = [];
|
||||
q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } });
|
||||
}
|
||||
|
||||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||||
// 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪,
|
||||
// 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。
|
||||
function ensureThirdPartyScript(type) {
|
||||
if (type === 'recaptcha') {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
if (!window.onRecaptchaLoad) {
|
||||
window.onRecaptchaLoad = () => flushCallbacks('recaptcha');
|
||||
}
|
||||
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
||||
s.async = true; s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
} else {
|
||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||
if (!window.onTurnstileLoad) {
|
||||
window.onTurnstileLoad = () => flushCallbacks('turnstile');
|
||||
}
|
||||
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
||||
s.async = true; s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
|
||||
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
|
||||
function queueRender(type, render) {
|
||||
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
|
||||
if (typeof window[lib] !== 'undefined') { render(); return; }
|
||||
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||
window[key] = window[key] || [];
|
||||
window[key].push(render);
|
||||
ensureThirdPartyScript(type);
|
||||
setTimeout(() => {
|
||||
if (typeof window[lib] !== 'undefined') flushCallbacks(type);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// SHA-256(Proof of Work 使用)
|
||||
async function sha256(str) {
|
||||
const buf = new TextEncoder().encode(str);
|
||||
const hash = await crypto.subtle.digest('SHA-256', buf);
|
||||
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// 内置 SVG 验证码弹窗
|
||||
function BuiltinCaptcha({ onFinish }) {
|
||||
const [svg, setSvg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [powDone, setPowDone] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const tokenRef = useRef(null);
|
||||
const imgRef = useRef(null);
|
||||
|
||||
const loadImage = async () => {
|
||||
setError('');
|
||||
setAnswer('');
|
||||
const data = await captchaApi.getImage();
|
||||
if (data && data.token) {
|
||||
tokenRef.current = data.token;
|
||||
setSvg(data.svg);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadImage();
|
||||
// 智能验证(Proof of Work)后台运行,20s 内找到满足难度的 nonce
|
||||
(async () => {
|
||||
try {
|
||||
const chal = await captchaApi.powChallenge();
|
||||
if (!chal || !chal.token) return;
|
||||
const target = '0'.repeat(chal.difficulty);
|
||||
const start = Date.now();
|
||||
let nonce = 0;
|
||||
while (Date.now() - start < 20000) {
|
||||
const hash = await sha256(chal.prefix + nonce);
|
||||
if (hash.startsWith(target)) {
|
||||
await captchaApi.powVerify(chal.token, String(nonce));
|
||||
setPowDone(true);
|
||||
return;
|
||||
}
|
||||
nonce++;
|
||||
}
|
||||
} catch { /* 失败不影响主流程 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 让后端返回的 SVG 自适应弹窗宽度(照搬 captcha.js 的样式处理)
|
||||
useEffect(() => {
|
||||
if (svg && imgRef.current) {
|
||||
const s = imgRef.current.querySelector('svg');
|
||||
if (s) s.setAttribute('style', 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block');
|
||||
}
|
||||
}, [svg]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!powDone) { setError('智能验证尚未完成,请稍候...'); return; }
|
||||
if (!answer.trim() || !tokenRef.current) { setError('请输入验证码'); return; }
|
||||
setVerifying(true);
|
||||
const v = await captchaApi.verify(tokenRef.current, answer.trim());
|
||||
setVerifying(false);
|
||||
if (v.success) {
|
||||
onFinish({ type: 'proof', value: v.proof || '' });
|
||||
} else {
|
||||
setError(v.error || '验证码错误');
|
||||
tokenRef.current = null;
|
||||
setAnswer('');
|
||||
loadImage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 12 }}>验证码</h3>
|
||||
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
|
||||
{svg
|
||||
? <div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
: <div className="spinner" style={{ width: 24, height: 24, margin: '16px auto' }} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
placeholder="输入验证码"
|
||||
aria-label="输入验证码"
|
||||
maxLength={6}
|
||||
autoComplete="off"
|
||||
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
|
||||
<span className="material-icons">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
{error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
|
||||
{powDone ? (
|
||||
<>
|
||||
<span className="material-icons" style={{ fontSize: 16, color: '#4caf50' }}>check_circle</span>
|
||||
<span style={{ color: '#4caf50' }}>智能验证通过</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>smart_toy</span>
|
||||
<span>智能验证中...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="actions" style={{ justifyContent: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||||
<button type="button" className="btn btn-filled" onClick={submit} disabled={verifying}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
|
||||
function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
const siteKey = type === 'recaptcha'
|
||||
? (window._recaptchaSiteKey || '')
|
||||
: (window._turnstileSiteKey || '');
|
||||
const [status, setStatus] = useState('正在加载...');
|
||||
const widgetRef = useRef(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) { onFinish(null); return; }
|
||||
const container = widgetRef.current;
|
||||
const render = () => {
|
||||
if (doneRef.current) return; // 弹窗已取消/卸载,不再向已移除的容器渲染
|
||||
try {
|
||||
if (type === 'recaptcha') {
|
||||
const wid = window.grecaptcha.render(container, {
|
||||
sitekey: siteKey,
|
||||
callback: () => {
|
||||
setStatus('验证通过');
|
||||
let token = '';
|
||||
try { token = window.grecaptcha.getResponse(wid); } catch { /* 忽略 */ }
|
||||
setTimeout(() => onFinish({ type: 'recaptcha', value: token || '' }), 300);
|
||||
},
|
||||
'expired-callback': () => setStatus('验证已过期'),
|
||||
});
|
||||
} else {
|
||||
const wid = window.turnstile.render(container, {
|
||||
sitekey: siteKey,
|
||||
callback: () => {
|
||||
setStatus('验证通过');
|
||||
let token = '';
|
||||
try { token = window.turnstile.getResponse(wid); } catch { /* 忽略 */ }
|
||||
setTimeout(() => onFinish({ type: 'turnstile', value: token || '' }), 300);
|
||||
},
|
||||
'expired-callback': () => setStatus('验证已过期'),
|
||||
});
|
||||
}
|
||||
setStatus('请完成验证');
|
||||
} catch {
|
||||
setStatus('加载失败');
|
||||
setTimeout(() => onFinish(null), 2000);
|
||||
}
|
||||
};
|
||||
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
|
||||
queueRender(type, render);
|
||||
// 卸载时不触发重复回调
|
||||
return () => { doneRef.current = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
||||
<div ref={widgetRef} style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }} />
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>{status}</p>
|
||||
<div className="actions" style={{ justifyContent: 'center' }}>
|
||||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局挂载的验证码弹窗宿主:放入 Layout 内,配合 showCaptcha(action) 使用。
|
||||
*/
|
||||
export default function CaptchaModalHost() {
|
||||
const [request, setRequest] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
listener = () => setRequest(pending);
|
||||
return () => { listener = null; };
|
||||
}, []);
|
||||
|
||||
const finish = (proof) => {
|
||||
if (request) {
|
||||
const resolve = request.resolve;
|
||||
pending = null;
|
||||
setRequest(null);
|
||||
resolve(proof);
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
|
||||
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="验证码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{request.type === 'recaptcha' ? (
|
||||
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
||||
) : request.type === 'turnstile' ? (
|
||||
<ThirdPartyCaptcha type="turnstile" onFinish={finish} />
|
||||
) : (
|
||||
<BuiltinCaptcha onFinish={finish} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
|
||||
* 纯文本(不含 `<`)按原样文本渲染;含 `<` 时按 HTML+CSS 渲染(self-XSS 与音乐嵌入同级)。
|
||||
* 仅用于这三个字段,不扩大到其他数据。
|
||||
*/
|
||||
function HtmlOrText({ value, className }) {
|
||||
if (typeof value === 'string' && value.includes('<')) {
|
||||
return <span className={className} dangerouslySetInnerHTML={{ __html: value }} />;
|
||||
}
|
||||
return <span className={className}>{value}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台页脚(按 settings.footer_style 渲染 3 种样式,视觉对齐 /tmp/footer-demo.html 的 02/03/04):
|
||||
* - classic 经典左右:左 Powered + 版权,右 站点名 v{version} 胶囊徽章
|
||||
* - columns 分栏导航:品牌 + 固定导航栏 + 底部版权条
|
||||
* - glass 玻璃卡片:磨砂玻璃卡片(backdrop-filter blur + 半透明底 + 细边框)
|
||||
* 所有样式都保留站点名 + v{version} 元素;footer_desc 为空时回退到 site_description。
|
||||
*/
|
||||
export default function Footer({ settings = {}, version = '' }) {
|
||||
const siteName = settings.site_name || 'RainWeb';
|
||||
const style = settings.footer_style || 'classic';
|
||||
const copyright = settings.footer_copyright || '© 2026 Rainnya Blog. All rights reserved.';
|
||||
const powered = settings.footer_powered || 'Powered by RainnyaWeb';
|
||||
const desc = settings.footer_desc || settings.site_description || '';
|
||||
|
||||
if (style === 'columns') {
|
||||
// 导航栏目固定不可编辑(后台提示),首页/博客/论坛走 SPA,管理后台整页跳转
|
||||
const navCols = [
|
||||
{ title: '导航', links: [
|
||||
{ to: '/', label: '首页' },
|
||||
{ to: '/blog.html', label: '博客' },
|
||||
{ to: '/forum.html', label: '论坛' },
|
||||
] },
|
||||
{ title: '其他', links: [
|
||||
{ to: '/admin', label: '管理后台', external: true },
|
||||
{ to: '/profile.html', label: '个人中心' },
|
||||
] },
|
||||
];
|
||||
return (
|
||||
<footer className="footer-columns">
|
||||
<div className="fc-grid">
|
||||
<div className="fc-brand">
|
||||
<div className="brand-row">
|
||||
<div className="brand-mark">{siteName.charAt(0)}</div>
|
||||
<div className="brand-name">{siteName}</div>
|
||||
{version && <span className="brand-version">v{version}</span>}
|
||||
</div>
|
||||
{desc && <HtmlOrText value={desc} className="brand-desc" />}
|
||||
</div>
|
||||
{navCols.map((col) => (
|
||||
<nav key={col.title} 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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
))}
|
||||
</div>
|
||||
<div className="fc-bottom">
|
||||
<HtmlOrText value={powered} />
|
||||
<span className="sep" />
|
||||
<HtmlOrText value={copyright} />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === 'glass') {
|
||||
return (
|
||||
<div className="footer-glass-wrap">
|
||||
<span className="blob blob-1"></span>
|
||||
<span className="blob blob-2"></span>
|
||||
<span className="blob blob-3"></span>
|
||||
<footer className="footer-glass">
|
||||
<div className="fg-left">
|
||||
<div className="fg-logo">{siteName.charAt(0)}</div>
|
||||
<div>
|
||||
<div className="fg-name">{siteName}</div>
|
||||
<div className="fg-tag">
|
||||
{desc && <HtmlOrText value={desc} />}
|
||||
{desc && <span> · </span>}v{version}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fg-right">
|
||||
<HtmlOrText value={copyright} className="fg-copyright" />
|
||||
<HtmlOrText value={powered} className="fg-powered" />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// classic(默认):经典左右
|
||||
return (
|
||||
<footer className="footer-classic">
|
||||
<div className="fc-left">
|
||||
<HtmlOrText value={powered} />
|
||||
<span className="sep" />
|
||||
<HtmlOrText value={copyright} />
|
||||
</div>
|
||||
<div className="fc-right">
|
||||
<span>{siteName}</span>
|
||||
{version && <span className="fc-version">v{version}</span>}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useEffect, 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 MusicEmbed from './MusicEmbed.jsx';
|
||||
import CaptchaModalHost from './CaptchaModal.jsx';
|
||||
import Footer from './Footer.jsx';
|
||||
|
||||
/** QQ 邮箱自动头像(迁移自 nav.js) */
|
||||
function qqAvatar(user) {
|
||||
const m = user && user.email ? String(user.email).match(/^(\d+)@qq\.com$/i) : null;
|
||||
return m ? 'https://q1.qlogo.cn/g?b=qq&nk=' + m[1] + '&s=100' : '';
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { theme, toggleTheme, setTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const [settings, setSettings] = useState({});
|
||||
const [version, setVersion] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// 初始化向导:未完成安装时跳转 /setup.html(迁移自 nav.js)
|
||||
useEffect(() => {
|
||||
fetch('/api/setup/status')
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
if (s && !s.setup_complete && location.pathname !== '/setup.html') {
|
||||
window.location.href = '/setup.html';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新)
|
||||
useEffect(() => {
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
// 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js)
|
||||
window._recaptchaSiteKey = s.recaptcha_site_key || '';
|
||||
window._turnstileSiteKey = s.turnstile_site_key || '';
|
||||
})
|
||||
.catch(() => {});
|
||||
fetch('/api/version')
|
||||
.then((r) => r.json())
|
||||
.then((v) => { if (v && v.version) setVersion(v.version); })
|
||||
.catch(() => {});
|
||||
const loadUser = () => {
|
||||
if (getToken()) {
|
||||
authApi.me().then(setUser).catch(() => { setToken(null); setUser(null); });
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
window.addEventListener('authchange', loadUser);
|
||||
return () => window.removeEventListener('authchange', loadUser);
|
||||
}, []);
|
||||
|
||||
// 强制深色模式(迁移自 nav.js applyTheme)
|
||||
useEffect(() => {
|
||||
if (settings.theme_force_dark === '1') {
|
||||
setTheme('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
}, [settings.theme_force_dark, setTheme]);
|
||||
|
||||
// 壁纸背景 + 玻璃磨砂(迁移自 nav.js applyTheme,直接作用于 body)
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
const wallpaper = settings.theme_wallpaper || '';
|
||||
const scale = settings.theme_wallpaper_scale || 'cover';
|
||||
|
||||
if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
|
||||
body.classList.add('has-wallpaper');
|
||||
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
|
||||
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
|
||||
const bgRepeatMap = { repeat: 'repeat', stretch: 'no-repeat', cover: 'no-repeat', contain: 'no-repeat' };
|
||||
body.style.backgroundSize = bgSizeMap[scale] || 'cover';
|
||||
body.style.backgroundRepeat = bgRepeatMap[scale] || 'no-repeat';
|
||||
document.documentElement.style.setProperty('--wallpaper-overlay',
|
||||
theme === 'dark' ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.15)');
|
||||
document.documentElement.style.setProperty('--wallpaper-text',
|
||||
theme === 'dark' ? '#e6e1e5' : '#1c1b1f');
|
||||
} else {
|
||||
body.classList.remove('has-wallpaper');
|
||||
body.style.removeProperty('--wallpaper');
|
||||
body.style.backgroundSize = '';
|
||||
body.style.backgroundRepeat = '';
|
||||
document.documentElement.style.removeProperty('--wallpaper-overlay');
|
||||
document.documentElement.style.removeProperty('--wallpaper-text');
|
||||
}
|
||||
document.documentElement.style.setProperty('--glass-blur', (settings.glass_blur || '20') + 'px');
|
||||
}, [settings, theme]);
|
||||
|
||||
// 卡片玻璃样式(导航/路由切换后对卡片类名统一应用)
|
||||
useEffect(() => {
|
||||
const cs = settings.card_style || 'default';
|
||||
document.querySelectorAll('.card, .blog-card, .panel-card, .link-card, .forum-post-card, .password-card, .forum-cat-item, .chip').forEach((el) => {
|
||||
el.classList.toggle('glass-card', cs === 'glass');
|
||||
});
|
||||
}, [settings.card_style, location.pathname]);
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (settings.theme_force_dark === '1') {
|
||||
showSnackbar('已强制启用深色模式无法更改');
|
||||
return;
|
||||
}
|
||||
toggleTheme();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
notifyAuthChange();
|
||||
if (settings.rainid_enabled === '1') {
|
||||
// RainID 单点登录:清本地后联动登出 RainID 端会话(整页跳转,不显示本地提示)
|
||||
window.location.href = '/api/auth/oidc/logout';
|
||||
return;
|
||||
}
|
||||
showSnackbar('已退出登录');
|
||||
};
|
||||
|
||||
const siteName = settings.site_name || 'RainWeb';
|
||||
const isAdmin = user && user.role === 'admin';
|
||||
|
||||
const navClass = 'nav-bar'
|
||||
+ (settings.nav_style === 'glass' ? ' nav-glass' : '')
|
||||
+ (settings.nav_style === 'capsule' ? ' nav-capsule' : '');
|
||||
|
||||
// 导航标签(迁移自 nav.js 的显隐逻辑)
|
||||
const tabs = [
|
||||
{ to: '/', label: '首页', end: true, show: true },
|
||||
{ to: '/blog.html', label: '博客', show: true },
|
||||
{ to: '/forum.html', label: '论坛', show: !!user },
|
||||
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
||||
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
|
||||
];
|
||||
|
||||
let avatarUrl = user ? (user.avatar || '') : '';
|
||||
if (user && !avatarUrl) avatarUrl = qqAvatar(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className={navClass} id="mainNav">
|
||||
<div className="nav-left">
|
||||
<Link to="/" className="nav-brand">{siteName}</Link>
|
||||
<span className="nav-version">v{version}</span>
|
||||
<div className="nav-tabs">
|
||||
{tabs.filter((t) => t.show).map((t) =>
|
||||
t.fullPage ? (
|
||||
<a key={t.to} href={t.to} className="nav-tab">{t.label}</a>
|
||||
) : (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) => 'nav-tab' + (isActive ? ' active' : '')}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-right">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleToggleTheme}
|
||||
aria-label="切换主题"
|
||||
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
|
||||
>
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
</button>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/profile.html" className="btn btn-tonal btn-sm" 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}
|
||||
</Link>
|
||||
<button className="btn btn-text btn-sm" onClick={handleLogout}>退出</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login.html" className="btn btn-tonal btn-sm">登录</Link>
|
||||
<Link to="/register.html" className="btn btn-filled btn-sm">注册</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<Footer settings={settings} version={version} />
|
||||
|
||||
<MusicEmbed settings={settings} />
|
||||
<CaptchaModalHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { escapeHtml } from '../lib/utils.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
|
||||
/** [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">`;
|
||||
}
|
||||
|
||||
/** [file:文件名] → 带 token 的下载链接 */
|
||||
function fileTag(filename) {
|
||||
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">
|
||||
<span class="material-icons" style="font-size:18px">attachment</span> ${escapeHtml(filename)}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
|
||||
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
|
||||
*/
|
||||
export function renderContent(content, useMarkdown) {
|
||||
if (content == null) return '';
|
||||
|
||||
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(/\n/g, '<br>');
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// Markdown 模式:先抽取自定义标签,避免被 marked 转义
|
||||
const images = [];
|
||||
const files = [];
|
||||
let html = content.replace(/\[image:([^\]]+)\]/g, (m, f) => { images.push(f); return `\x00IMG${images.length - 1}\x00`; });
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => { files.push(f); return `\x00FILE${files.length - 1}\x00`; });
|
||||
|
||||
html = marked.parse(html, { breaks: true, gfm: true });
|
||||
|
||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
|
||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(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 }} />;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 音乐嵌入(迁移自 public/js/music-embed.js):
|
||||
* 读取 settings 的 music_embed_enabled / music_embed_code / music_embed_position /
|
||||
* music_embed_autohide / music_embed_idle_timeout,支持自动隐藏与悬停展开。
|
||||
* 全局组件,挂在 Layout 内。
|
||||
*/
|
||||
export default function MusicEmbed({ settings }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const enabled = settings.music_embed_enabled === '1';
|
||||
const code = settings.music_embed_code || '';
|
||||
const position = settings.music_embed_position || 'right';
|
||||
const autoHide = settings.music_embed_autohide === '1';
|
||||
const idleTimeout = (parseInt(settings.music_embed_idle_timeout, 10) || 10) * 1000;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
|
||||
};
|
||||
|
||||
const expand = () => {
|
||||
clearTimer();
|
||||
setCollapsed(false);
|
||||
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
};
|
||||
|
||||
const startLeaveTimer = () => {
|
||||
if (!autoHide) return;
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !code) return;
|
||||
setCollapsed(false);
|
||||
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
return clearTimer;
|
||||
}, [enabled, code, autoHide, idleTimeout]);
|
||||
|
||||
if (!enabled || !code) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`music-embed ${position} ${collapsed ? 'collapsed' : 'expanded'}`}
|
||||
onMouseEnter={expand}
|
||||
onMouseLeave={startLeaveTimer}
|
||||
>
|
||||
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
|
||||
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
|
||||
<button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
|
||||
<span className="material-icons">music_note</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/** HTML 转义:& < > " ' 全转义 */
|
||||
export function escapeHtml(str) {
|
||||
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
||||
switch (ch) {
|
||||
case '&': return '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
case "'": return ''';
|
||||
default: return ch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 格式化时间为 YYYY-MM-DD HH:mm */
|
||||
export function formatDate(input) {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 简单节流:fn 在 wait 毫秒内最多执行一次 */
|
||||
export function throttle(fn, wait = 200) {
|
||||
let last = 0;
|
||||
return function (...args) {
|
||||
const now = Date.now();
|
||||
if (now - last >= wait) {
|
||||
last = now;
|
||||
return fn.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 底部 toast(依赖 index.html 中的 #snackbar,类名沿用 v1) */
|
||||
export function showSnackbar(msg) {
|
||||
const el = document.getElementById('snackbar');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hide');
|
||||
el.classList.add('show');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => {
|
||||
el.classList.add('hide');
|
||||
setTimeout(() => el.classList.remove('show', 'hide'), 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button) */
|
||||
export function focusDialog(container) {
|
||||
if (!container) return;
|
||||
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
|
||||
if (el && typeof el.focus === 'function') el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗键盘/焦点管理(B3):
|
||||
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
|
||||
* - Esc 关闭弹窗
|
||||
* - 关闭后焦点还给触发元素
|
||||
* 返回 { dialogRef, onKeyDown }:dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
|
||||
*/
|
||||
export function useDialog(open, onClose) {
|
||||
const dialogRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement;
|
||||
focusDialog(dialogRef.current);
|
||||
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
if (onClose) onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return { dialogRef, onKeyDown };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getArchive, listPosts } from '../api/blog.js';
|
||||
|
||||
/** 归档页(路由 /archive.html):
|
||||
* archive API 只返回 {month, count},无按月文章列表接口;
|
||||
* 列表接口 /blog/posts 无分页返回全部已发布文章(文章量小),
|
||||
* 前端按 created_at 客户端分组,配合 archive API 的月份与计数展示。
|
||||
*/
|
||||
export default function Archive() {
|
||||
const [months, setMonths] = useState([]); // [{month, count, posts:[]}]
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getArchive(), listPosts()])
|
||||
.then(([arc, posts]) => {
|
||||
const byMonth = new Map();
|
||||
(posts || []).forEach((p) => {
|
||||
const m = (p.created_at || '').slice(0, 7); // YYYY-MM
|
||||
if (!m) return;
|
||||
if (!byMonth.has(m)) byMonth.set(m, []);
|
||||
byMonth.get(m).push(p);
|
||||
});
|
||||
// 以 archive API 的月份顺序为准(desc),count 以实际分组统计为准
|
||||
const countMap = new Map();
|
||||
(arc || []).forEach((a) => countMap.set(a.month, a.count));
|
||||
const list = Array.from(byMonth.entries()).map(([month, ps]) => ({
|
||||
month,
|
||||
count: ps.length,
|
||||
posts: ps,
|
||||
}));
|
||||
// 补齐 archive 里有、分组里没有的月份(理论上不会出现)
|
||||
(arc || []).forEach((a) => {
|
||||
if (!byMonth.has(a.month)) list.push({ month: a.month, count: a.count, posts: [] });
|
||||
});
|
||||
list.sort((a, b) => (a.month < b.month ? 1 : -1));
|
||||
setMonths(list);
|
||||
})
|
||||
.catch((e) => setError(e.message || '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const fmtMonth = (m) => {
|
||||
const [y, mm] = m.split('-');
|
||||
return `${y}年${mm}月`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
|
||||
</Link>
|
||||
<h1 className="article-title" style={{ fontSize: 26 }}>
|
||||
文章归档
|
||||
{!loading && months.length > 0 && (
|
||||
<span className="tag-title-count">(共 {months.reduce((s, m) => s + m.count, 0)} 篇)</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{loading && <div className="loading"><div className="spinner"></div></div>}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{!loading && !error && months.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🗂️</div><p>暂无文章</p></div>
|
||||
)}
|
||||
|
||||
<div className="archive-list">
|
||||
{months.map((m) => (
|
||||
<section key={m.month} className="archive-month">
|
||||
<h2 className="archive-month-title">
|
||||
{fmtMonth(m.month)}
|
||||
<span className="archive-month-count">{m.count} 篇</span>
|
||||
</h2>
|
||||
{m.posts.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>暂无文章</p>
|
||||
) : (
|
||||
m.posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="archive-item">
|
||||
<span className="archive-item-title">{p.title}</span>
|
||||
<span className="archive-item-date">{(p.created_at || '').slice(0, 10)}</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import BlogSidebar from '../components/BlogSidebar.jsx';
|
||||
import { listPosts, searchPosts, getTags } from '../api/blog.js';
|
||||
import { getPublicSettings } from '../api/settings.js';
|
||||
|
||||
/** 摘要:优先取 excerpt,否则从内容中剥离 markdown 符号截取(照搬 blog.js) */
|
||||
function excerptOf(p) {
|
||||
if (p.excerpt) return p.excerpt;
|
||||
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
|
||||
}
|
||||
|
||||
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
|
||||
* 顶部搜索框(/api/blog/search)+ 标签云(/api/blog/tags,点击进 /tag/:name)+ 归档入口 */
|
||||
export default function Blog() {
|
||||
const [settings, setSettings] = useState({});
|
||||
const [posts, setPosts] = useState(null); // null=加载中
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 搜索状态
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState(null); // null=未搜索
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
// 标签云
|
||||
const [tags, setTags] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings().then(setSettings).catch(() => {});
|
||||
listPosts()
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => setError(e.message || '加载失败'));
|
||||
getTags()
|
||||
.then((ts) => setTags(ts || []))
|
||||
.catch(() => setTags([]));
|
||||
}, []);
|
||||
|
||||
const doSearch = async (e) => {
|
||||
e && e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q) { setResults(null); return; }
|
||||
setSearching(true);
|
||||
try {
|
||||
const rs = await searchPosts(q);
|
||||
setResults(rs || []);
|
||||
} catch (err) {
|
||||
setResults([]);
|
||||
setError(err.message || '搜索失败');
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// 搜索结果以列表呈现(区别于瀑布流卡片)
|
||||
const renderResults = () => (
|
||||
<div className="search-results">
|
||||
<div className="search-results-head">
|
||||
<span>搜索结果({results.length})</span>
|
||||
<button className="btn btn-text btn-sm" onClick={clearSearch}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>close</span> 清空
|
||||
</button>
|
||||
</div>
|
||||
{results.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">🔍</div><p>没有找到相关内容</p></div>
|
||||
) : (
|
||||
<div className="search-results-list">
|
||||
{results.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="search-result-item">
|
||||
<div className="sri-title">{p.title}</div>
|
||||
{p.excerpt && <div className="sri-excerpt">{p.excerpt}</div>}
|
||||
<div className="sri-meta">
|
||||
<span>{p.author_name || '管理员'}</span>
|
||||
<span className="sep">·</span>
|
||||
<span>{p.created_at}</span>
|
||||
{p.tags && <span className="sri-tags">{p.tags}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="homepage-layout blog-layout">
|
||||
<h1 className="sr-only">博客</h1>
|
||||
<BlogSidebar settings={settings} />
|
||||
<div className="homepage-content">
|
||||
{/* 工具条:搜索框 + 归档 */}
|
||||
<form className="blog-toolbar" onSubmit={doSearch}>
|
||||
<div className="search-box">
|
||||
<span className="material-icons">search</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索文章标题 / 内容..."
|
||||
aria-label="搜索文章"
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" className="search-clear" onClick={() => setQuery('')} aria-label="清空输入">
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn-tonal btn-sm" disabled={searching}>
|
||||
{searching ? '搜索中...' : '搜索'}
|
||||
</button>
|
||||
<Link to="/archive.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
|
||||
</Link>
|
||||
</form>
|
||||
|
||||
{/* 标签云 */}
|
||||
{tags.length > 0 && (
|
||||
<div className="tag-cloud card">
|
||||
<div className="tag-cloud-title">
|
||||
<span className="material-icons">local_offer</span> 标签
|
||||
</div>
|
||||
<div className="tag-cloud-body">
|
||||
{tags.map((t) => (
|
||||
<Link key={t.name} to={`/tag/${encodeURIComponent(t.name)}`} className="tag-chip" style={{ fontSize: tagSize(t.count) }}>
|
||||
{t.name}
|
||||
<span className="tag-count">{t.count}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索结果 / 瀑布流 */}
|
||||
{results !== null ? (
|
||||
renderResults()
|
||||
) : (
|
||||
<>
|
||||
{!posts && !error && (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{posts && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="blog-waterfall">
|
||||
{posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
|
||||
<div className="blog-featured"></div>
|
||||
<div className="blog-title">{p.title}</div>
|
||||
<div className="blog-excerpt">{excerptOf(p)}</div>
|
||||
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签字号随文章数渐变(经典标签云效果):1 篇 ~12px,8 篇及以上 ~18px */
|
||||
function tagSize(count) {
|
||||
const c = Number(count) || 1;
|
||||
const size = 12 + Math.min(c, 8) * 0.75;
|
||||
return size.toFixed(1) + 'px';
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import * as blogApi from '../api/blog.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
|
||||
function countWords(content) {
|
||||
const plain = String(content || '')
|
||||
.replace(/\[image:[^\]]*\]|\[file:[^\]]*\]/g, '')
|
||||
.replace(/[#*`\[\]()>|~_!-]/g, '');
|
||||
const cjk = (plain.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const words = plain.replace(/[\u4e00-\u9fff\u3400-\u4dbf]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
|
||||
return cjk + words;
|
||||
}
|
||||
|
||||
/**
|
||||
* 博客详情页(路由 /blog/:id):
|
||||
* 正文(MarkdownRenderer)+ 阅读量/标签/点赞 + 目录/字数 + 上一篇/下一篇 +
|
||||
* 嵌套评论(parent_id 回复)。
|
||||
*/
|
||||
export default function BlogDetail() {
|
||||
const { id } = useParams();
|
||||
const [post, setPost] = useState(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [prevnext, setPrevnext] = useState(null);
|
||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||
const [toc, setToc] = useState([]);
|
||||
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setReplyTo(null);
|
||||
try {
|
||||
const [p, cs, pn, lk] = await Promise.all([
|
||||
blogApi.getPost(id),
|
||||
blogApi.listComments(id),
|
||||
blogApi.getPrevNext(id),
|
||||
blogApi.getLikeState(id),
|
||||
]);
|
||||
setPost(p);
|
||||
setComments(cs || []);
|
||||
setPrevnext(pn);
|
||||
setLike(lk || { liked: false, count: 0 });
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
||||
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
||||
|
||||
// 目录:从已渲染的 .md-body 提取 h2(仅一级章节)打锚点;短文章(<500 字)不提取
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
if (wordCount < 500) { setToc([]); return; }
|
||||
const body = document.querySelector('.blog-article .md-body');
|
||||
if (!body) { setToc([]); return; }
|
||||
const items = [...body.querySelectorAll('h2')].map((h, i) => {
|
||||
if (!h.id) h.id = 'toc-' + i;
|
||||
return { id: h.id, text: (h.textContent || '').trim(), level: 2 };
|
||||
});
|
||||
setToc(items);
|
||||
}, [post, wordCount]);
|
||||
|
||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
||||
|
||||
// ── 点赞(乐观更新)──
|
||||
const toggleLike = async () => {
|
||||
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
||||
const next = !like.liked;
|
||||
setLike((s) => ({ liked: next, count: Math.max(0, s.count + (next ? 1 : -1)) }));
|
||||
try {
|
||||
const r = next ? await blogApi.likePost(id) : await blogApi.unlikePost(id);
|
||||
setLike({ liked: r.liked, count: r.count });
|
||||
} catch (e) {
|
||||
setLike((s) => ({ liked: !s.liked, count: Math.max(0, s.count + (s.liked ? 1 : -1)) }));
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 嵌套评论 ──
|
||||
const childrenOf = useCallback((pid) => comments.filter((c) => c.parent_id === pid), [comments]);
|
||||
const rootComments = comments.filter((c) => !c.parent_id);
|
||||
|
||||
const renderComment = (c, depth = 0) => {
|
||||
const kids = childrenOf(c.id);
|
||||
return (
|
||||
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
|
||||
<div className="reply-meta">
|
||||
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
|
||||
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
|
||||
</div>
|
||||
<div className="reply-body">{c.content}</div>
|
||||
{user && (
|
||||
<button
|
||||
className="btn btn-text btn-sm reply-btn"
|
||||
onClick={() => {
|
||||
setReplyTo({ id: c.id, name: c.author_name || '游客' });
|
||||
setCommentText(`@${c.author_name || '游客'} `);
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 14 }}>reply</span> 回复
|
||||
</button>
|
||||
)}
|
||||
{kids.length > 0 && (
|
||||
<div className="reply-children">
|
||||
{kids.map((k) => renderComment(k, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
const content = commentText.trim();
|
||||
if (!content) { showSnackbar('评论不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await blogApi.createComment(id, content, replyTo ? replyTo.id : 0);
|
||||
showSnackbar('评论已发表');
|
||||
setCommentText('');
|
||||
setReplyTo(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{error || '文章不存在'}</p>
|
||||
<Link to="/blog.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回博客</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="article-layout">
|
||||
<div className="blog-article article-main">
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
|
||||
</Link>
|
||||
|
||||
<h1 className="article-title">{post.title}</h1>
|
||||
|
||||
<div className="article-meta">
|
||||
{post.author_name || '管理员'} · {post.created_at}
|
||||
<span className="meta-stat" title="阅读量">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
|
||||
</span>
|
||||
<span className="meta-stat" title="字数">约 {wordCount} 字</span>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="article-tags">
|
||||
{tags.map((t) => (
|
||||
<Link key={t} to={`/tag/${encodeURIComponent(t)}`} className="chip article-tag-chip">{t}</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-actions">
|
||||
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||
<span className="like-count">{like.count}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
||||
|
||||
{/* 上一篇 / 下一篇 */}
|
||||
{prevnext && (prevnext.prev || prevnext.next) && (
|
||||
<nav className="prevnext-nav">
|
||||
<div className="pn-col pn-prev">
|
||||
{prevnext.prev ? (
|
||||
<Link to={`/blog/${prevnext.prev.id}`}>
|
||||
<span className="pn-label">上一篇</span>
|
||||
<span className="pn-title">{prevnext.prev.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">上一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pn-col pn-next">
|
||||
{prevnext.next ? (
|
||||
<Link to={`/blog/${prevnext.next.id}`}>
|
||||
<span className="pn-label">下一篇</span>
|
||||
<span className="pn-title">{prevnext.next.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">下一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
|
||||
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
|
||||
) : (
|
||||
<div className="comment-list">
|
||||
{rootComments.map((c) => renderComment(c))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
<div className="comment-form">
|
||||
{replyTo && (
|
||||
<div className="reply-to-hint">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>reply</span>
|
||||
回复 <strong>@{replyTo.name}</strong>
|
||||
<button
|
||||
className="btn btn-text btn-sm"
|
||||
onClick={() => { setReplyTo(null); setCommentText(''); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>close</span> 取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: replyTo ? 8 : 0 }}>
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={replyTo ? `回复 @${replyTo.name}...` : '写下你的评论...'}
|
||||
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
|
||||
/>
|
||||
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
|
||||
发表评论
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}>
|
||||
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="article-toc-side">
|
||||
<div className="toc-title">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
|
||||
目录 <span className="toc-count">{toc.length}</span>
|
||||
</div>
|
||||
<ul className="toc-list">
|
||||
{toc.map((t) => (
|
||||
<li key={t.id} className="toc-item">
|
||||
<a
|
||||
href={'#' + t.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(t.id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTheme } from '../theme.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
|
||||
/**
|
||||
* 面板嵌入页(迁移自 embed.html):
|
||||
* 入口参数 ?url=&title=&proxy=1 —— iframe 展示目标站点;
|
||||
* proxy=1 或 HTTPS 页内嵌 HTTP 目标时走 /api/proxy/fetch?url=(代理剥 X-Frame-Options/CSP)。
|
||||
* 工具栏:关闭(有来源则 history.back)、标题、原站(新标签)、主题切换。
|
||||
*/
|
||||
export default function Embed() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const url = searchParams.get('url');
|
||||
const title = searchParams.get('title') || url || '嵌入';
|
||||
const useProxy = searchParams.get('proxy') === '1';
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
|
||||
const isHttpsPage = window.location.protocol === 'https:';
|
||||
const isHttpTarget = url ? url.startsWith('http:') : false;
|
||||
const needsProxy = useProxy || (isHttpsPage && isHttpTarget);
|
||||
// proxy 模式:iframe 无法携带 Authorization header,改用 ?token= 认证(后端 queryTokenAuth 转写)
|
||||
const iframeSrc = url
|
||||
? (needsProxy
|
||||
? '/api/proxy/fetch?url=' + encodeURIComponent(url) + '&token=' + encodeURIComponent(getToken() || '')
|
||||
: url)
|
||||
: '';
|
||||
|
||||
// 非代理嵌入时 2s 后提示"无法嵌入?安装扩展"(照 v1)
|
||||
useEffect(() => {
|
||||
if (!url || needsProxy) return;
|
||||
const t = setTimeout(() => setShowHint(true), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [url, needsProxy]);
|
||||
|
||||
const handleClose = (e) => {
|
||||
// 有来源页时返回上一页,否则跳首页
|
||||
if (document.referrer && document.referrer !== window.location.href) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<h1 className="sr-only">嵌入页面</h1>
|
||||
<div
|
||||
className="embed-toolbar"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
|
||||
>
|
||||
<a href="/" className="btn-icon" onClick={handleClose} title="关闭"><span className="material-icons">close</span></a>
|
||||
<span className="embed-title" style={{ flex: 1, fontWeight: 500, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
|
||||
{url && (
|
||||
<a href={url} target="_blank" rel="noopener" className="btn btn-text btn-sm" title="在新标签页中打开">
|
||||
<span className="material-icons">open_in_new</span> 原站
|
||||
</a>
|
||||
)}
|
||||
<button className="btn-icon" onClick={toggleTheme} title="切换主题">
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showHint && (
|
||||
<div style={{ padding: '8px 16px', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', fontSize: 13, textAlign: 'center', flexShrink: 0 }}>
|
||||
无法嵌入?试试安装{' '}
|
||||
<a href="https://chromewebstore.google.com/search/ignore%20x-frame" target="_blank" rel="noopener" style={{ color: 'inherit', fontWeight: 600, textDecoration: 'underline' }}>
|
||||
Ignore X-Frame-Headers
|
||||
</a>{' '}
|
||||
扩展,或点击右上角「原站」在新标签页打开
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHint(false)}
|
||||
aria-label="关闭提示"
|
||||
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{iframeSrc ? (
|
||||
<iframe
|
||||
className="embed-container"
|
||||
title={title}
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
|
||||
loading="lazy"
|
||||
src={iframeSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state" style={{ flex: 1 }}>
|
||||
<div className="empty-icon">🔗</div>
|
||||
<p>缺少 url 参数,无法加载嵌入内容</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useCallback, useEffect, useRef, 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 { uploadFile } from '../api/upload.js';
|
||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/** 板块的子分类列表 */
|
||||
function subCatsOf(cat) {
|
||||
if (!cat || !cat.sub_categories) return [];
|
||||
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
|
||||
}
|
||||
|
||||
/** 论坛首页:分类导航(侧栏 + 子分类 chips 筛选)+ 帖子列表 + 发帖弹窗(含验证码) */
|
||||
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 [loadError, setLoadError] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// 发帖弹窗状态
|
||||
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([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
} else {
|
||||
navigate('/login.html');
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (f) doUpload(f);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
await forumApi.createPost(base);
|
||||
showSnackbar('发布成功');
|
||||
setShowNewPost(false);
|
||||
if (currentCatId) selectCategory(currentCatId);
|
||||
else showAllPosts();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const currentCat = categories.find((c) => c.id === currentCatId);
|
||||
const subCats = subCatsOf(currentCat);
|
||||
|
||||
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
|
||||
|
||||
return (
|
||||
<div className="forum-layout">
|
||||
<h1 className="sr-only">论坛</h1>
|
||||
<aside className="forum-sidebar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
||||
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
|
||||
{user ? '发帖' : '登录发帖'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="forum-cat-list">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.id}
|
||||
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
|
||||
onClick={() => selectCategory(c.id)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>forum</span> {c.name}
|
||||
{c.announcement ? (
|
||||
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
|
||||
onClick={showAllPosts}
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div className="forum-content">
|
||||
{currentCat && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
|
||||
<span className="chip chip-static">板块</span>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
|
||||
{currentCat.announcement && (
|
||||
<div className="announcement-bar">
|
||||
<span className="material-icons ann-icon">campaign</span>
|
||||
<span className="ann-content">{currentCat.announcement}</span>
|
||||
</div>
|
||||
)}
|
||||
{subCats.length > 0 && (
|
||||
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
|
||||
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
|
||||
{subCats.map((s) => (
|
||||
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
|
||||
{loadError && <div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败</p></div>}
|
||||
{posts !== null && !loadError && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="forum-post-list">
|
||||
{posts.map((p) => {
|
||||
const catName = categories.find((c) => c.id === p.category_id)?.name || '';
|
||||
return (
|
||||
<Link key={p.id} to={`/forum/${p.id}`} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
|
||||
<div className="post-title">{p.title}</div>
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{p.author_name || '匿名'}</span>
|
||||
<span>{p.created_at}</span>
|
||||
<span>{p.reply_count || 0} 回复</span>
|
||||
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
|
||||
<span className="chip chip-static">{catName}</span>
|
||||
{p.sub_category ? (
|
||||
<span className="chip chip-tonal">{p.sub_category}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 发帖弹窗 */}
|
||||
{showNewPost && (
|
||||
<div
|
||||
ref={npDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="发布新帖"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
||||
onKeyDown={npDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>发布新帖</h3>
|
||||
<div className="form-group">
|
||||
<label>板块</label>
|
||||
<select value={npCategory} onChange={handleCategoryChange}>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>帖子分类(可选)</label>
|
||||
<select value={npSub} onChange={(e) => setNpSub(e.target.value)}>
|
||||
<option value="">无</option>
|
||||
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={npTitle} onChange={(e) => setNpTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>内容 *</label>
|
||||
<textarea value={npContent} onChange={(e) => setNpContent(e.target.value)} style={{ minHeight: 150, fontFamily: 'monospace' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
|
||||
<span className="material-icons">upload</span> 上传附件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||||
<span className="text-muted" style={{ fontSize: 13 }}>{npStatus}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShowNewPost(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={submitPost} disabled={submitting}>发布</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, 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 MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 论坛帖子详情(路由 /forum/:id,SPA 内路由):
|
||||
* 标题/meta/正文(MarkdownRenderer)+ 回复列表/回复框(登录检查)+ 删除按钮(作者/admin)。
|
||||
* 回复无需验证码(照 v1:仅发帖走 captcha_forum)。
|
||||
*/
|
||||
export default function ForumDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [post, setPost] = useState(null);
|
||||
const [replies, setReplies] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await forumApi.getPost(id);
|
||||
setPost(data.post);
|
||||
setReplies(data.replies || []);
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
||||
else setUser(null);
|
||||
}, [load]);
|
||||
|
||||
const canDeletePost = user && post && (user.id === post.author_id || user.role === 'admin');
|
||||
|
||||
const submitReply = async () => {
|
||||
const content = replyText.trim();
|
||||
if (!content) { showSnackbar('回复内容不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await forumApi.reply(id, content);
|
||||
showSnackbar('回复成功');
|
||||
setReplyText('');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const deletePost = async () => {
|
||||
if (!window.confirm('确定删除?')) return;
|
||||
try {
|
||||
await forumApi.deletePost(post.id);
|
||||
showSnackbar('已删除');
|
||||
navigate('/forum.html');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReply = async (replyId) => {
|
||||
if (!window.confirm('确定删除?')) return;
|
||||
try {
|
||||
await forumApi.deleteReply(replyId);
|
||||
showSnackbar('已删除');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{error || '帖子不存在'}</p>
|
||||
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回论坛</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="post-detail">
|
||||
<div className="post-header">
|
||||
<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> 返回
|
||||
</Link>
|
||||
{canDeletePost && (
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 'auto' }} onClick={deletePost}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{post.author_name || '匿名'}</span>
|
||||
<span>{post.created_at}</span>
|
||||
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
|
||||
{post.category_name || ''}
|
||||
</span>
|
||||
{post.sub_category && (
|
||||
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
|
||||
{post.sub_category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '2px solid var(--md-ref-primary-container)', margin: '24px 0', borderRadius: 2 }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>回复 ({replies.length})</h4>
|
||||
|
||||
{user ? (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="写下你的回复...(支持 Markdown)"
|
||||
style={{ flex: 1, minHeight: 60, fontSize: 14, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitReply} disabled={submitting}>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>
|
||||
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以回复
|
||||
</p>
|
||||
)}
|
||||
|
||||
{replies.length === 0 ? (
|
||||
<div className="text-muted" style={{ padding: 16 }}>暂无回复</div>
|
||||
) : (
|
||||
replies.map((r) => {
|
||||
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin');
|
||||
return (
|
||||
<div className="reply-item" key={r.id}>
|
||||
<div className="reply-meta">
|
||||
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
|
||||
{canDeleteReply && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除回复"
|
||||
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
||||
onClick={() => deleteReply(r.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="reply-body">{r.content}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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,33 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import BlogSidebar from '../components/BlogSidebar.jsx';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { getPublicSettings } from '../api/settings.js';
|
||||
|
||||
/**
|
||||
* 首页(迁移自 index.html 的内联 HOMEPAGE):
|
||||
* 侧栏(头像/简介/联系方式 + 最新文章)+ 主页内容(settings.homepage_content,Markdown)。
|
||||
*/
|
||||
export default function Home() {
|
||||
const [settings, setSettings] = useState({});
|
||||
const [contentError, setContentError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings()
|
||||
.then((s) => { setSettings(s); setContentError(false); })
|
||||
.catch(() => { setSettings({}); setContentError(true); });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="homepage-layout">
|
||||
<h1 className="sr-only">首页</h1>
|
||||
<BlogSidebar settings={settings} showRecent forceShow />
|
||||
<div className="homepage-content">
|
||||
<div className="card">
|
||||
{contentError
|
||||
? <p style={{ color: 'var(--md-ref-on-surface-variant)' }}>内容加载失败</p>
|
||||
: <MarkdownRenderer content={settings.homepage_content || ''} useMarkdown />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
|
||||
/** RainID OIDC 回跳错误码 → 用户可读文案 */
|
||||
const OIDC_ERROR_TEXT = {
|
||||
access_denied: '已在 RainID 拒绝授权,可重新登录',
|
||||
consent_required: '请重新完整走 RainID 登录',
|
||||
login_required: '请重新完整走 RainID 登录',
|
||||
invalid_grant: '登录状态已失效,请重新登录',
|
||||
rate_limited: '请求过于频繁,请稍后再试',
|
||||
invalid_scope: '配置错误,请联系管理员',
|
||||
invalid_request: '配置错误,请联系管理员',
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录页(迁移自 login.html):
|
||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error)
|
||||
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
|
||||
*/
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
const [oidcBusy, setOidcBusy] = useState(false);
|
||||
|
||||
/** 清理地址栏的 oidc 过渡参数(替换为纯路径,避免残留/刷新重复兑换) */
|
||||
const clearOidcParams = () => {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
};
|
||||
|
||||
/** RainID 回跳收尾:一次性过渡码换 token → 复用本地登录成功逻辑 */
|
||||
const handleOidcTicket = async (ticket) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.oidcExchange(ticket);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
clearOidcParams();
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
clearOidcParams();
|
||||
setError(e.message || 'RainID 登录失败,请重试');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 1) RainID 回跳优先处理(一次性码 30s 有效,须先兑换)
|
||||
// 注意:OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次;
|
||||
// 兑换后 navigate 触发 location 变化时不再重跑(避免覆盖 from 目标)。
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('oidc_ticket');
|
||||
const oidcError = params.get('oidc_error');
|
||||
if (ticket) { handleOidcTicket(ticket); return; }
|
||||
if (oidcError) {
|
||||
setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败,请重试');
|
||||
clearOidcParams();
|
||||
return;
|
||||
}
|
||||
// 2) 已登录直接回首页
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
// 3) 公开设置:rainid_enabled → 显示 RainID 按钮
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => { if (s.rainid_enabled === '1') setRainidEnabled(true); })
|
||||
.catch(() => {});
|
||||
// 4) 人机验证需求
|
||||
captchaRequired('login')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('login');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
/** RainID 整页跳转(loading 防重复点击) */
|
||||
const handleRainId = () => {
|
||||
if (oidcBusy) return;
|
||||
setOidcBusy(true);
|
||||
authApi.oidcLoginRedirect();
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.login(username.trim(), password, capResult || undefined);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录")
|
||||
setError(e.message || '登录失败');
|
||||
setBusy(false);
|
||||
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
|
||||
// 重置「已验证」标记允许用户重新完成验证,否则按钮保持 disabled 死锁。
|
||||
setCapDone(false);
|
||||
setCapResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card login-card">
|
||||
<h1>登录</h1>
|
||||
<p className="subtitle">欢迎回到 RainWeb</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="输入用户名"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') document.getElementById('loginPw').focus(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id="loginPw"
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="输入密码"
|
||||
autoComplete="current-password"
|
||||
style={{ width: '100%', paddingRight: 44 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex="-1"
|
||||
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: 'var(--md-ref-on-surface-variant)', cursor: 'pointer', padding: 4, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{showPw ? 'visibility' : 'visibility_off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{capRequired && (
|
||||
<button
|
||||
className="btn w-full"
|
||||
onClick={doCaptcha}
|
||||
disabled={capDone}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||
: { background: '#fff', color: '#333', border: '1px solid #333' }),
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{rainidEnabled && (
|
||||
<button
|
||||
className="btn btn-outline w-full"
|
||||
onClick={handleRainId}
|
||||
disabled={busy || oidcBusy}
|
||||
style={{ display: 'inline-flex', marginBottom: 8, justifyContent: 'center', height: 44, gap: 8, alignItems: 'center' }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>fingerprint</span>
|
||||
<span>{oidcBusy ? '正在跳转 RainID…' : '使用 RainID 登录'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||||
{busy ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<span className="text-muted">没有账户?</span>
|
||||
<Link to="/register.html" className="btn-text btn" style={{ fontSize: 14 }}>注册</Link>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}>← 返回主页</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as pwApi from '../api/passwords.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
|
||||
* 可选长度与字符集,确保每个选中的字符集至少出现一次后洗牌。
|
||||
*/
|
||||
function makePassword({ length = 16, lower = true, upper = true, digits = true, symbols = true } = {}) {
|
||||
const sets = [];
|
||||
if (lower) sets.push('abcdefghijklmnopqrstuvwxyz');
|
||||
if (upper) sets.push('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
||||
if (digits) sets.push('0123456789');
|
||||
if (symbols) sets.push('!@#$%^&*()_+');
|
||||
if (sets.length === 0) return '';
|
||||
const all = sets.join('');
|
||||
const total = Math.max(length, sets.length);
|
||||
const rand = new Uint32Array(total);
|
||||
crypto.getRandomValues(rand);
|
||||
const chars = [];
|
||||
let idx = 0;
|
||||
// 每个选中字符集至少贡献一个字符,保证复杂度
|
||||
for (const s of sets) chars.push(s[rand[idx++] % s.length]);
|
||||
for (; idx < length; idx++) chars.push(all[rand[idx] % all.length]);
|
||||
// Fisher-Yates 洗牌(同样走 crypto)
|
||||
const shuf = new Uint32Array(chars.length);
|
||||
crypto.getRandomValues(shuf);
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = shuf[i] % (i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码箱(迁移自 passwords.html + passwords.js):
|
||||
* PIN 状态检查 → 未设 PIN 引导设置 / 已设解锁 / 解锁后条目列表 + 增删改 + 复制 + 生成器 + 锁定。
|
||||
* 全部交互用 React 数据绑定,无字符串拼接事件(修复 v1 M6 onclick 注入点)。
|
||||
* 注意:GET /api/passwords/ 返回的是会话解锁后服务端解密出的明文密码。
|
||||
*/
|
||||
export default function Passwords() {
|
||||
const navigate = useNavigate();
|
||||
const [phase, setPhase] = useState('loading'); // loading | setup | locked | unlocked
|
||||
const [entries, setEntries] = useState(null); // null=加载中
|
||||
const [entriesError, setEntriesError] = useState('');
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [pinBusy, setPinBusy] = useState(false);
|
||||
|
||||
// 设置/修改 PIN 弹窗
|
||||
const [pinDialog, setPinDialog] = useState(false);
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [confirmPin, setConfirmPin] = useState('');
|
||||
const [pinSaving, setPinSaving] = useState(false);
|
||||
|
||||
// 修改 PIN 警告确认(修改后旧密文无法解密,需重新添加条目)
|
||||
const [pinChangeWarn, setPinChangeWarn] = useState(false);
|
||||
|
||||
// 添加/编辑条目弹窗
|
||||
const [pwDialog, setPwDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [fTitle, setFTitle] = useState('');
|
||||
const [fUsername, setFUsername] = useState('');
|
||||
const [fPassword, setFPassword] = useState('');
|
||||
const [fUrl, setFUrl] = useState('');
|
||||
const [fNotes, setFNotes] = useState('');
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
// 详情 / 生成器 / 删除确认
|
||||
const [detailId, setDetailId] = useState(null);
|
||||
const [genDialog, setGenDialog] = useState(false);
|
||||
const [genLen, setGenLen] = useState(16);
|
||||
const [genLower, setGenLower] = useState(true);
|
||||
const [genUpper, setGenUpper] = useState(true);
|
||||
const [genDigits, setGenDigits] = useState(true);
|
||||
const [genSymbols, setGenSymbols] = useState(true);
|
||||
const [genResult, setGenResult] = useState('');
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { showSnackbar('请先登录'); setTimeout(() => navigate('/login.html'), 1000); return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') {
|
||||
showSnackbar('需要管理员权限');
|
||||
setTimeout(() => navigate('/'), 1000);
|
||||
return;
|
||||
}
|
||||
pwApi.pinStatus()
|
||||
.then((s) => {
|
||||
if (!s.hasPin) setPhase('setup');
|
||||
else if (s.unlocked) { setPhase('unlocked'); loadEntries(); }
|
||||
else setPhase('locked');
|
||||
})
|
||||
.catch((e) => showSnackbar(e.message));
|
||||
})
|
||||
.catch(() => navigate('/login.html'));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadEntries = async () => {
|
||||
setEntries(null);
|
||||
setEntriesError('');
|
||||
try {
|
||||
const list = await pwApi.listEntries();
|
||||
setEntries(list || []);
|
||||
} catch (e) {
|
||||
setEntriesError(e.message || '加载失败');
|
||||
setEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
const savePin = async () => {
|
||||
// 校验文案与后端 setPin 保持一致(PIN ≥6 位,建议含字母;v1 的"至少4位"已修正)
|
||||
if (!newPin || newPin.length < 6) { showSnackbar('PIN 至少 6 位,建议包含字母'); return; }
|
||||
if (newPin !== confirmPin) { showSnackbar('两次输入的 PIN 不一致'); return; }
|
||||
setPinSaving(true);
|
||||
try {
|
||||
await pwApi.setPin(newPin);
|
||||
showSnackbar('PIN 设置成功');
|
||||
setPinDialog(false);
|
||||
setNewPin(''); setConfirmPin('');
|
||||
setPhase('unlocked');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPinSaving(false);
|
||||
};
|
||||
|
||||
const submitPin = async () => {
|
||||
if (!pinInput) { showSnackbar('请输入 PIN 码'); return; }
|
||||
setPinBusy(true);
|
||||
try {
|
||||
await pwApi.unlock(pinInput);
|
||||
showSnackbar('已解锁');
|
||||
setPinInput('');
|
||||
setPinError('');
|
||||
setPhase('unlocked');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
// 后端错误直显:401 'PIN 错误' / 429 '尝试次数过多,请稍后再试'(3 次/5 分钟锁定)
|
||||
setPinError(e.message || '解锁失败');
|
||||
setPinInput('');
|
||||
}
|
||||
setPinBusy(false);
|
||||
};
|
||||
|
||||
const lockVault = async () => {
|
||||
try {
|
||||
await pwApi.lock();
|
||||
setPhase('locked');
|
||||
setPinInput('');
|
||||
setPinError('');
|
||||
setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false); setPinChangeWarn(false);
|
||||
showSnackbar('已锁定');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const openPinDialog = () => {
|
||||
setNewPin('');
|
||||
setConfirmPin('');
|
||||
setPinDialog(true);
|
||||
};
|
||||
|
||||
// 修改 PIN:先弹警告确认,确认后再打开设置弹窗
|
||||
const openPinChange = () => {
|
||||
setPinChangeWarn(true);
|
||||
};
|
||||
|
||||
const proceedPinChange = () => {
|
||||
setPinChangeWarn(false);
|
||||
openPinDialog();
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setEditingId(null);
|
||||
setFTitle(''); setFUsername(''); setFPassword(''); setFUrl(''); setFNotes('');
|
||||
setPwDialog(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (entry) => {
|
||||
setEditingId(entry.id);
|
||||
setFTitle(entry.title);
|
||||
setFUsername(entry.username || '');
|
||||
setFPassword(entry.password || '');
|
||||
setFUrl(entry.url || '');
|
||||
setFNotes(entry.notes || '');
|
||||
setPwDialog(true);
|
||||
};
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!fTitle.trim() || !fPassword) { showSnackbar('标题和密码不能为空'); return; }
|
||||
const data = {
|
||||
title: fTitle.trim(),
|
||||
username: fUsername.trim(),
|
||||
password: fPassword,
|
||||
url: fUrl.trim(),
|
||||
notes: fNotes.trim(),
|
||||
};
|
||||
setPwSaving(true);
|
||||
try {
|
||||
if (editingId) await pwApi.updateEntry(editingId, data);
|
||||
else await pwApi.createEntry(data);
|
||||
showSnackbar('保存成功');
|
||||
setPwDialog(false);
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwSaving(false);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setConfirmBusy(true);
|
||||
try {
|
||||
await pwApi.deleteEntry(detailId);
|
||||
showSnackbar('已删除');
|
||||
setConfirmDialog(false);
|
||||
setDetailId(null);
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setConfirmBusy(false);
|
||||
};
|
||||
|
||||
const openGenerator = () => {
|
||||
setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }));
|
||||
setGenDialog(true);
|
||||
};
|
||||
|
||||
const useGenerated = () => {
|
||||
setFPassword(genResult);
|
||||
setGenDialog(false);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, label) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => showSnackbar(label + ' 已复制'))
|
||||
.catch(() => showSnackbar('复制失败'));
|
||||
};
|
||||
|
||||
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
|
||||
|
||||
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
|
||||
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
|
||||
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
|
||||
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
|
||||
|
||||
// 加载中
|
||||
if (phase === 'loading') {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* PIN 屏(设置 或 解锁)——全屏覆盖页,非弹窗 */}
|
||||
{phase === 'setup' || phase === 'locked' ? (
|
||||
<div className="pin-overlay">
|
||||
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
{phase === 'setup' ? '首次使用,请设置 PIN 码' : '请输入 PIN 码解锁'}
|
||||
</div>
|
||||
{pinError && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 8 }}>{pinError}</div>}
|
||||
{phase === 'locked' && (
|
||||
<input
|
||||
type="password"
|
||||
className="pin-input"
|
||||
maxLength={6}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={pinInput}
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button className="btn btn-filled" onClick={phase === 'setup' ? openPinDialog : submitPin} disabled={pinBusy}>
|
||||
{phase === 'setup' ? '设置' : (pinBusy ? '解锁中...' : '解锁')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 已解锁:密码箱 */
|
||||
<div>
|
||||
<div className="admin-header">
|
||||
<h2>已保存的密码</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-tonal" onClick={lockVault}>
|
||||
<span className="material-icons">lock</span> 锁定
|
||||
</button>
|
||||
<button className="btn btn-text" onClick={openPinChange}>
|
||||
<span className="material-icons">edit</span> 修改 PIN
|
||||
</button>
|
||||
<button className="btn btn-filled" onClick={openAddDialog}>
|
||||
<span className="material-icons">add</span> 添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries === null && <div className="loading"><div className="spinner"></div></div>}
|
||||
{entriesError && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败: {entriesError}</p></div>
|
||||
)}
|
||||
{entries && entries.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🔒</div><p>暂无密码记录</p></div>
|
||||
)}
|
||||
{entries && entries.length > 0 && (
|
||||
<div className="password-grid">
|
||||
{entries.map((p) => (
|
||||
<div key={p.id} className="password-card-wrap">
|
||||
<button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||
<div className="pw-title">{p.title}</div>
|
||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||
</button>
|
||||
<div className="pw-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ width: 40, height: 40, fontSize: 16 }}
|
||||
title="复制密码"
|
||||
aria-label={`复制 ${p.title} 的密码`}
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 弹窗统一挂载在顶层(setup/locked 阶段也能弹出——功能修复关键,勿移入 phase 分支) ===== */}
|
||||
{/* 结构与论坛发帖弹窗一致:.dialog-overlay + .dialog,MD3 CSS 变量天然适配浅/深色模式 */}
|
||||
|
||||
{/* 设置/修改 PIN 弹窗 */}
|
||||
{pinDialog && (
|
||||
<div
|
||||
ref={pinDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置 PIN 码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
|
||||
onKeyDown={pinDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 360 }}>
|
||||
<h3>设置 PIN 码</h3>
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>PIN 码用于加密保护您的密码数据。</p>
|
||||
<div className="form-group">
|
||||
<label>PIN 码 *(至少6位,建议包含字母)</label>
|
||||
<input type="password" value={newPin} onChange={(e) => setNewPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认 PIN 码 *</label>
|
||||
<input type="password" value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPinDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={savePin} disabled={pinSaving}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑条目弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editingId ? '编辑密码' : '添加密码'}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={fTitle} onChange={(e) => setFTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" value={fUsername} onChange={(e) => setFUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码 *</label>
|
||||
<input type="text" value={fPassword} onChange={(e) => setFPassword(e.target.value)} />
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button className="btn btn-text btn-sm" onClick={openGenerator}>生成随机密码</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>网址</label>
|
||||
<input type="url" value={fUrl} onChange={(e) => setFUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea value={fNotes} onChange={(e) => setFNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={savePassword} disabled={pwSaving}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detailEntry && (
|
||||
<div
|
||||
ref={detailDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={detailEntry.title}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
|
||||
onKeyDown={detailDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 500 }}>
|
||||
<h3>{detailEntry.title}</h3>
|
||||
<div className="password-detail">
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">用户名</span>
|
||||
<span className="pw-value">{detailEntry.username || ''}</span>
|
||||
<button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">密码</span>
|
||||
<span className="pw-value">{detailEntry.password}</span>
|
||||
<button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
{detailEntry.url ? (
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">网址</span>
|
||||
<span className="pw-value">
|
||||
<a href={detailEntry.url} target="_blank" rel="noopener" style={{ color: 'var(--md-ref-primary)' }}>{detailEntry.url}</a>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{detailEntry.notes ? (
|
||||
<div className="pw-field" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||
<span className="pw-label">备注</span>
|
||||
<span style={{ fontSize: 14 }}>{detailEntry.notes}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="actions" style={{ marginTop: 16 }}>
|
||||
<button className="btn btn-text" onClick={() => { setDetailId(null); openEditDialog(detailEntry); }}>编辑</button>
|
||||
<button className="btn btn-text" style={{ color: 'var(--md-ref-error)' }} onClick={() => { setDetailId(null); setConfirmDialog(true); }}>删除</button>
|
||||
<button className="btn btn-text" onClick={() => setDetailId(null)}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 密码生成器弹窗 */}
|
||||
{genDialog && (
|
||||
<div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
|
||||
<div className="dialog" style={{ maxWidth: 380 }}>
|
||||
<h3>生成随机密码</h3>
|
||||
<div className="form-group">
|
||||
<label>长度</label>
|
||||
<input type="number" min={6} max={64} value={genLen} onChange={(e) => setGenLen(parseInt(e.target.value, 10) || 16)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genLower} onChange={(e) => setGenLower(e.target.checked)} /> 小写</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genUpper} onChange={(e) => setGenUpper(e.target.checked)} /> 大写</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genDigits} onChange={(e) => setGenDigits(e.target.checked)} /> 数字</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genSymbols} onChange={(e) => setGenSymbols(e.target.checked)} /> 符号</label>
|
||||
</div>
|
||||
{genResult && (
|
||||
<div style={{ padding: 12, borderRadius: 8, background: 'var(--md-ref-surface-container)', fontFamily: 'monospace', fontSize: 16, textAlign: 'center', marginBottom: 12, wordBreak: 'break-all' }}>
|
||||
{genResult}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setGenDialog(false)}>取消</button>
|
||||
<button className="btn btn-text" onClick={() => setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }))}>重新生成</button>
|
||||
<button className="btn btn-filled" onClick={useGenerated} disabled={!genResult}>使用此密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改 PIN 警告确认弹窗 */}
|
||||
{pinChangeWarn && (
|
||||
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认修改 PIN</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>⚠️ 修改 PIN 后,现有密码条目将无法解密,需要重新添加。确定继续?</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPinChangeWarn(false)}>取消</button>
|
||||
<button className="btn btn-danger" onClick={proceedPinChange}>确定继续</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDialog && (
|
||||
<div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认操作</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录?</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setConfirmDialog(false)}>取消</button>
|
||||
<button className="btn btn-danger" onClick={confirmDelete} disabled={confirmBusy}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Placeholder({ name }) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card" style={{ textAlign: 'center', padding: '64px 16px' }}>
|
||||
<h2 style={{ marginBottom: '12px' }}>{name}</h2>
|
||||
<p style={{ color: 'var(--md-ref-on-surface-variant)' }}>建设中</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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 { avatarUrl, uploadAvatar } from '../api/upload.js';
|
||||
import { getToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||
|
||||
/** FileReader + Image 加载图片(头像裁剪前读取) */
|
||||
function loadImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = reject;
|
||||
i.src = r.result;
|
||||
};
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** 个人中心(迁移自 profile.html):资料 / 头像上传(512x512 裁剪)/ 邮箱状态 / 修改密码 / 退出 */
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
// 修改密码弹窗(两步:发送验证码 → 输入验证码/新旧密码)
|
||||
const [pwDialog, setPwDialog] = useState(false);
|
||||
const [pwStep, setPwStep] = useState(1);
|
||||
const [pwCode, setPwCode] = useState('');
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwBusy, setPwBusy] = useState(false);
|
||||
|
||||
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
|
||||
useEffect(() => {
|
||||
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
|
||||
}, [pwDialog, pwStep]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const u = await profileApi.getProfile();
|
||||
setUser(u);
|
||||
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
|
||||
const av = await avatarUrl(u.id);
|
||||
setAvatar(av.url || '');
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
if (e.status === 401) { navigate('/login.html'); return; }
|
||||
}
|
||||
setLoading(false);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) { navigate('/login.html'); return; }
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
|
||||
const handleAvatarChange = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
// 客户端裁剪为 512x512(照 profile.html)
|
||||
const img = await loadImage(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, 512, 512);
|
||||
const s = Math.min(img.width, img.height);
|
||||
const sx = (img.width - s) / 2;
|
||||
const sy = (img.height - s) / 2;
|
||||
ctx.drawImage(img, sx, sy, s, s, 0, 0, 512, 512);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
||||
// 用 File 携带文件名,满足后端扩展名/MIME 校验
|
||||
const avatarFile = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
await uploadAvatar(avatarFile);
|
||||
showSnackbar('头像已更新');
|
||||
notifyAuthChange(); // 刷新全局登录态头像
|
||||
load();
|
||||
} catch (err) {
|
||||
showSnackbar(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const sendPwCode = async () => {
|
||||
setPwBusy(true);
|
||||
try {
|
||||
await profileApi.sendPwCode();
|
||||
showSnackbar('验证码已发送');
|
||||
setPwStep(2);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwBusy(false);
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!pwCode || pwCode.length !== 8) { showSnackbar('请输入完整的 8 位验证码'); return; }
|
||||
if (!oldPw || !newPw) { showSnackbar('请填写完整'); return; }
|
||||
if (newPw.length < 6) { showSnackbar('新密码至少6位'); return; }
|
||||
if (newPw !== confirmPw) { showSnackbar('两次密码不一致'); return; }
|
||||
setPwBusy(true);
|
||||
try {
|
||||
await profileApi.changePassword({ code: pwCode.trim(), oldPassword: oldPw, newPassword: newPw });
|
||||
showSnackbar('密码已修改');
|
||||
setPwDialog(false);
|
||||
setPwStep(1);
|
||||
setPwCode(''); setOldPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<h1 className="page-title">个人中心</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
) : error || !user ? (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error || '加载失败'}</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="avatar" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 64, height: 64, borderRadius: '50%', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, fontWeight: 600 }}>
|
||||
{(user.username || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="更换头像"
|
||||
onClick={() => fileRef.current && fileRef.current.click()}
|
||||
style={{ position: 'absolute', bottom: -4, right: -4, width: 28, height: 28, background: 'var(--md-ref-surface-container)', fontSize: 14, boxShadow: '0 2px 8px var(--md-shadow)' }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>camera_alt</span>
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarChange} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 20, fontWeight: 600 }}>{user.username}</h3>
|
||||
<span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>邮箱(注册后不可修改)</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px', borderRadius: 12, background: 'var(--md-ref-surface-container)', fontSize: 15 }}>
|
||||
<span>{user.email || '未设置'}</span>
|
||||
{user.email_verified
|
||||
? <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)' }}>已验证</span>
|
||||
: <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-error)', color: 'var(--md-ref-on-error)' }}>未验证</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '24px 0' }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 12 }}>安全设置</h4>
|
||||
<button className="btn btn-outline" onClick={() => { setPwStep(1); setPwDialog(true); }}>修改密码</button>
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 8 }} onClick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ textAlign: 'center', padding: 16, color: 'var(--md-ref-on-surface-variant)', fontSize: 13 }}>
|
||||
UID: {user.id} · 注册时间: {user.created_at}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="修改密码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>修改密码</h3>
|
||||
{pwStep === 1 ? (
|
||||
<>
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>验证码将发送到您的注册邮箱</p>
|
||||
<button className="btn btn-filled w-full" onClick={sendPwCode} disabled={pwBusy}>
|
||||
{pwBusy ? '发送中...' : '发送验证码'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>验证码</label>
|
||||
<input type="text" value={pwCode} onChange={(e) => setPwCode(e.target.value)} placeholder="8 位验证码" maxLength={8} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>原密码</label>
|
||||
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>新密码(至少6位)</label>
|
||||
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={changePassword} disabled={pwBusy}>确认修改</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as emailApi from '../api/email.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/** RainID 注册页地址(后端 rainid_register_redirect=1 时整页跳转) */
|
||||
const RAINID_REGISTER_URL = 'https://rainid.rainnya.asia/register';
|
||||
|
||||
/**
|
||||
* 注册页(迁移自 register.html):
|
||||
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
||||
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
||||
* RainID:rainid_register_redirect=1 → 整页跳转 RainID 注册(不显示本地表单);
|
||||
* 否则保留本地注册,表单下方提供「使用 RainID 登录」入口。
|
||||
*/
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [verifyMode, setVerifyMode] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||
const [redirecting, setRedirecting] = useState(false); // 正在跳转 RainID 注册
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
if (s.rainid_register_redirect === '1') {
|
||||
// 注册托管给 RainID:短暂提示后整页替换(避免白屏感)
|
||||
setRedirecting(true);
|
||||
setTimeout(() => { window.location.replace(RAINID_REGISTER_URL); }, 400);
|
||||
return;
|
||||
}
|
||||
if (s.rainid_enabled === '1') setRainidEnabled(true);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
captchaRequired('register')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('register');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
/** RainID 登录入口(与登录页同逻辑) */
|
||||
const handleRainId = () => {
|
||||
authApi.oidcLoginRedirect();
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
||||
if (password.length < 6) { setError('密码至少6位'); return; }
|
||||
if (password !== confirm) { setError('两次密码不一致'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.register({
|
||||
username: username.trim(),
|
||||
password,
|
||||
email: email.trim(),
|
||||
captcha: capResult || undefined,
|
||||
});
|
||||
if (data.requires_verification) {
|
||||
setVerifyMode(true); // 进入邮箱验证步骤
|
||||
} else {
|
||||
showSnackbar('注册成功,请登录');
|
||||
setTimeout(() => navigate('/login.html'), 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || '注册失败');
|
||||
// 与登录一致:验证码 proof 一次性消费,注册失败后重置验证状态允许重新验证
|
||||
setCapDone(false);
|
||||
setCapResult(null);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!code || code.length !== 8) { setError('请输入完整的 8 位验证码'); return; }
|
||||
setError('');
|
||||
try {
|
||||
await emailApi.completeRegister(code.trim(), username.trim(), password);
|
||||
showSnackbar('注册成功,请登录');
|
||||
setTimeout(() => navigate('/login.html'), 1000);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const resendCode = async () => {
|
||||
try {
|
||||
await emailApi.sendVerify(email.trim(), username.trim());
|
||||
showSnackbar('验证码已重新发送');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// RainID 注册托管:整页跳转前短暂显示提示
|
||||
if (redirecting) {
|
||||
return (
|
||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
|
||||
<span className="material-icons" style={{ fontSize: 36, color: 'var(--md-ref-primary)' }}>fingerprint</span>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 500, marginTop: 12 }}>正在跳转 RainID 注册…</h1>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: 8 }}>
|
||||
本平台注册已由 RainID 单点登录托管,即将跳转
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 8 }}>
|
||||
{verifyMode ? '邮箱验证' : '创建账户'}
|
||||
</h1>
|
||||
<p className="subtitle" style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
|
||||
{verifyMode ? '请输入邮箱中的验证码' : '加入 RainWeb'}
|
||||
</p>
|
||||
|
||||
{!verifyMode && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>用户名 *</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="用户名" autoComplete="username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>邮箱 *</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" autoComplete="email" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码 *</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认密码 *</label>
|
||||
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="再次输入密码" autoComplete="new-password" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{verifyMode && (
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>邮箱验证码</label>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>验证码已发送到您的邮箱,请输入 8 位数字验证码</p>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="输入 8 位验证码"
|
||||
maxLength={8}
|
||||
inputMode="numeric"
|
||||
style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={handleVerify} disabled={busy}>验证并完成注册</button>
|
||||
<button className="btn btn-text btn-sm mt-16" onClick={resendCode} style={{ marginTop: 8 }}>重新发送验证码</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{!verifyMode && (
|
||||
<>
|
||||
{capRequired && (
|
||||
<button
|
||||
className="btn w-full"
|
||||
onClick={doCaptcha}
|
||||
disabled={capDone}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||
: { background: '#fff', color: '#333', border: '1px solid #333' }),
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-filled w-full" onClick={handleRegister} disabled={busy}>
|
||||
{busy ? '注册中...' : '注册'}
|
||||
</button>
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<span className="text-muted">已有账户?</span>
|
||||
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
||||
</div>
|
||||
{rainidEnabled && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text btn-sm"
|
||||
onClick={handleRainId}
|
||||
style={{ fontSize: 14, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>fingerprint</span>
|
||||
已有 RainID 账号?使用 RainID 登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
|
||||
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}>← 返回主页</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { request } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
const THEMES = [
|
||||
{ color: '#6750a4', name: 'default', label: '默认' },
|
||||
{ color: '#0288d1', name: 'ocean', label: '海洋' },
|
||||
{ color: '#2e7d32', name: 'nature', label: '自然' },
|
||||
{ color: '#e65100', name: 'sunset', label: '日落' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 初始化向导(迁移自 setup.html + routes/setup.js 三步流程):
|
||||
* 1. 管理员密码(≥6 位、两次一致、不能等于 admin123)→ 2. 网站名称 + 主题色 → 3. 确认并完成。
|
||||
* 提交 POST /api/setup/complete;已完成初始化时提示并跳首页。
|
||||
*/
|
||||
export default function Setup() {
|
||||
const [status, setStatus] = useState(null); // null=检查中
|
||||
const [step, setStep] = useState(1);
|
||||
const [adminPw, setAdminPw] = useState('');
|
||||
const [adminPwConfirm, setAdminPwConfirm] = useState('');
|
||||
const [siteName, setSiteName] = useState('RainWeb');
|
||||
const [theme, setTheme] = useState({ color: '#6750a4', name: 'default', label: '默认' });
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request('/setup/status')
|
||||
.then((s) => setStatus(s || {}))
|
||||
.catch(() => setStatus({ setup_complete: true })); // 接口异常不阻塞,视为已完成
|
||||
}, []);
|
||||
|
||||
const next = (n) => {
|
||||
setError('');
|
||||
if (n === 2) {
|
||||
if (adminPw.length < 6) { setError('密码至少6位'); return; }
|
||||
if (adminPw !== adminPwConfirm) { setError('两次密码不一致'); return; }
|
||||
if (adminPw === 'admin123') { setError('新密码不能与默认密码相同'); return; }
|
||||
}
|
||||
setStep(n);
|
||||
};
|
||||
|
||||
const complete = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await request('/setup/complete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
password: adminPw,
|
||||
site_name: siteName.trim() || 'RainWeb',
|
||||
primary_color: theme.color,
|
||||
theme_preset: theme.name,
|
||||
},
|
||||
});
|
||||
showSnackbar('初始化完成!');
|
||||
setTimeout(() => { window.location.href = '/'; }, 1000);
|
||||
} catch (e) {
|
||||
setError(e.message || '初始化失败');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === null) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
// 已完成初始化:提示并跳首页
|
||||
if (status.setup_complete) {
|
||||
return (
|
||||
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>✅ 已完成初始化</h1>
|
||||
<p className="text-muted" style={{ marginBottom: 24, fontSize: 14 }}>RainWeb 已初始化完成,可正常使用。</p>
|
||||
<a href="/" className="btn btn-filled w-full">进入首页</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stepDot = (n) => ({
|
||||
width: n === step ? 28 : 10,
|
||||
height: 10,
|
||||
borderRadius: n === step ? 5 : '50%',
|
||||
background: n < step ? 'var(--md-ref-primary-container)' : (n === step ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 480, padding: '40px 32px' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 4 }}>🌧️ RainWeb</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
|
||||
欢迎使用!请完成以下初始化设置
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 24 }}>
|
||||
{[1, 2, 3].map((n) => <div key={n} style={stepDot(n)} />)}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>设置管理员密码</h3>
|
||||
<div className="form-group">
|
||||
<label>新密码 *</label>
|
||||
<input type="password" value={adminPw} onChange={(e) => setAdminPw(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认密码 *</label>
|
||||
<input type="password" value={adminPwConfirm} onChange={(e) => setAdminPwConfirm(e.target.value)} placeholder="再次输入" autoComplete="new-password" onKeyDown={(e) => { if (e.key === 'Enter') next(2); }} />
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={() => next(2)}>下一步</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>网站设置</h3>
|
||||
<div className="form-group">
|
||||
<label>网站名称</label>
|
||||
<input type="text" value={siteName} onChange={(e) => setSiteName(e.target.value)} placeholder="我的网站" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>选择主题色</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
||||
{THEMES.map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
onClick={() => setTheme({ color: t.color, name: t.name, label: t.label })}
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
border: '2px solid ' + (theme.name === t.name ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
background: theme.name === t.name ? 'var(--md-ref-primary-container)' : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', margin: '0 auto 8px', background: t.color }} />
|
||||
<div>{t.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={() => next(3)}>下一步</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>确认并完成</h3>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 14 }}>
|
||||
<div style={{ marginBottom: 8 }}><strong>管理员密码</strong>: 已设置</div>
|
||||
<div style={{ marginBottom: 8 }}><strong>网站名称</strong>: {siteName.trim() || 'RainWeb'}</div>
|
||||
<div><strong>主题色</strong>: {theme.label}</div>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={complete} disabled={busy}>
|
||||
{busy ? '处理中...' : '完成初始化'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginTop: 12 }}>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { getTagPosts } from '../api/blog.js';
|
||||
|
||||
/** 摘要:优先 excerpt,否则剥离 markdown 符号截取 */
|
||||
function excerptOf(p) {
|
||||
if (p.excerpt) return p.excerpt;
|
||||
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
|
||||
}
|
||||
|
||||
/** 标签页(路由 /tag/:name):该标签下的文章列表,复用博客卡片样式 */
|
||||
export default function Tag() {
|
||||
const { name } = useParams();
|
||||
const [posts, setPosts] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setPosts(null);
|
||||
setError('');
|
||||
getTagPosts(name)
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => setError(e.message || '加载失败'));
|
||||
}, [name]);
|
||||
|
||||
const displayName = name || '';
|
||||
|
||||
return (
|
||||
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
|
||||
</Link>
|
||||
<h1 className="article-title" style={{ fontSize: 26 }}>
|
||||
标签:{displayName}
|
||||
{posts && <span className="tag-title-count">({posts.length} 篇)</span>}
|
||||
</h1>
|
||||
|
||||
{!posts && !error && (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{posts && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🏷️</div><p>该标签下暂无文章</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="blog-waterfall">
|
||||
{posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
|
||||
<div className="blog-featured"></div>
|
||||
<div className="blog-title">{p.title}</div>
|
||||
<div className="blog-excerpt">{excerptOf(p)}</div>
|
||||
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useRef, 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 { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 写文章页(路由 /write.html;?edit=id 进入编辑模式):
|
||||
* 标题/摘要/内容(Markdown)/发布开关;附件上传用 uploadFile + [image:]/[file:] 标签插入;
|
||||
* 支持拖拽图片上传与预览(迁移自 write.html 内联脚本)。
|
||||
*/
|
||||
export default function Write() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const editId = searchParams.get('edit') ? parseInt(searchParams.get('edit'), 10) : null;
|
||||
|
||||
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 fileRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
// 编辑模式:加载已有文章
|
||||
useEffect(() => {
|
||||
if (!editId) return;
|
||||
blogApi.getPost(editId)
|
||||
.then((p) => {
|
||||
setTitle(p.title);
|
||||
setExcerpt(p.excerpt || '');
|
||||
setTags(p.tags || '');
|
||||
setContent(p.content);
|
||||
setPublished(!!p.published);
|
||||
})
|
||||
.catch((e) => setLoadError(e.message || '加载文章失败'));
|
||||
}, [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);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
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 submit = async () => {
|
||||
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||||
setSaving(true);
|
||||
const data = {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
excerpt: excerpt.trim(),
|
||||
tags: tags.trim(),
|
||||
published,
|
||||
use_markdown: 1,
|
||||
};
|
||||
try {
|
||||
let id = editId;
|
||||
if (editId) {
|
||||
await blogApi.updatePost(editId, data);
|
||||
showSnackbar('已更新');
|
||||
} else {
|
||||
const created = await blogApi.createPost(data);
|
||||
id = created.id;
|
||||
showSnackbar('已发布');
|
||||
}
|
||||
navigate('/blog/' + id);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="write-page" style={{ maxWidth: 800, 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> 返回博客
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
{loadError ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{loadError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="文章标题" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>摘要</label>
|
||||
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标签</label>
|
||||
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="多个标签用英文逗号分隔,如:技术,生活,随笔" />
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>多个标签用英文逗号分隔,用于标签云与标签页</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>内容 *</label>
|
||||
<textarea
|
||||
ref={contentRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{ minHeight: 350, fontFamily: 'monospace', fontSize: 14 }}
|
||||
placeholder="支持 Markdown 语法,拖入图片自动上传"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (contentRef.current) contentRef.current.style.borderColor = 'var(--md-ref-primary)';
|
||||
}}
|
||||
onDragLeave={() => { if (contentRef.current) contentRef.current.style.borderColor = ''; }}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
</div>
|
||||
<div 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 ? '保存修改' : '发布文章'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制
|
||||
// (public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类)
|
||||
const THEME_KEY = 'theme';
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
if (saved === 'dark' || saved === 'light') return saved;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
toggleTheme: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
|
||||
setTheme,
|
||||
}),
|
||||
[theme]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
@@ -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,192 @@
|
||||
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)
|
||||
* ============================================================ */
|
||||
|
||||
const MODE_META = [
|
||||
{ mode: OPEN_MODE_EMBED, label: '内嵌', Icon: WebAssetIcon },
|
||||
{ mode: OPEN_MODE_TAB, label: '新标签', Icon: OpenInNewIcon },
|
||||
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
|
||||
];
|
||||
|
||||
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: '',
|
||||
});
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
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,
|
||||
description: '',
|
||||
icon: '',
|
||||
sort_order: 0,
|
||||
version: '',
|
||||
});
|
||||
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
|
||||
setErr('');
|
||||
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
|
||||
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>
|
||||
)}
|
||||
|
||||
{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,266 @@
|
||||
import React, { 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
|
||||
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
|
||||
* - proxy 模式:use_proxy=1 或 HTTPS 页内嵌 HTTP 目标时走
|
||||
* /api/proxy/fetch?url=&token=(proxy 剥 X-Frame-Options/CSP)。
|
||||
* token 用 /api/proxy/token 签发的 5 分钟短 TTL JWT,避免把 7 天
|
||||
* 主 token 暴露在 iframe URL 中(模块级缓存,4 分钟刷新一次)。
|
||||
* - 站内 URL(/ 开头)为同源内容:剥离 allow-same-origin 防其访问
|
||||
* 父页面 DOM;跨源面板保留 allow-same-origin(多数站点依赖)。
|
||||
* - LRU 由父级 Workbench 控制挂载/卸载(此组件不自行卸载)
|
||||
* ============================================================ */
|
||||
|
||||
const LOAD_TIMEOUT_MS = 15000;
|
||||
const TOKEN_TTL_MS = 4 * 60 * 1000; // 缓存 4 分钟(短 token 5 分钟有效)
|
||||
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
|
||||
|
||||
/* 短 TTL proxy token 的模块级缓存:多个 PanelFrame 共享,避免每帧都请求 */
|
||||
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;
|
||||
}
|
||||
|
||||
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:'));
|
||||
// 站内 URL(同源内容):沙箱剥离 allow-same-origin,内容无法操作父页面 DOM
|
||||
const isInternal = targetUrl.startsWith('/');
|
||||
const sandbox = isInternal
|
||||
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
|
||||
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
// proxy 模式:先取 5 分钟短 TTL token 再拼 iframe URL(失败进入超时回退态)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
|
||||
setProxySrc('');
|
||||
fetchProxyToken()
|
||||
.then((tok) => {
|
||||
if (alive) {
|
||||
setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) {
|
||||
statusRef.current = 'timeout';
|
||||
setStatus('timeout');
|
||||
}
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [needsProxy, targetUrl]);
|
||||
|
||||
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]);
|
||||
|
||||
// 刷新信号(工具栏 ↻):仅对激活面板 reload,且重新进入加载态
|
||||
const lastNonce = useRef(refreshNonce);
|
||||
useEffect(() => {
|
||||
if (!active || refreshNonce === lastNonce.current) return;
|
||||
lastNonce.current = refreshNonce;
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !src) return;
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
startTimer();
|
||||
try { iframe.contentWindow.location.reload(); }
|
||||
catch { iframe.src = iframe.src; }
|
||||
// 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 = () => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !src) return;
|
||||
statusRef.current = 'loading';
|
||||
setStatus('loading');
|
||||
startTimer();
|
||||
try { iframe.contentWindow.location.reload(); }
|
||||
catch { iframe.src = iframe.src; }
|
||||
};
|
||||
|
||||
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 && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={panel.title || '面板'}
|
||||
src={src}
|
||||
onLoad={handleLoad}
|
||||
sandbox={sandbox}
|
||||
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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,254 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { listAdminLinks } from '../../../api/adminLinks.js';
|
||||
|
||||
/* ============================================================
|
||||
* 工作台面板状态(Lane A 核心 hook)
|
||||
*
|
||||
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
|
||||
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
|
||||
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStorage(key: workbench.*)。
|
||||
* ============================================================ */
|
||||
|
||||
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 { /* 隐私模式等场景忽略 */ }
|
||||
}
|
||||
|
||||
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 : [];
|
||||
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 = 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';
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
// 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;
|
||||
}
|
||||
configCache = await openidClient.discovery(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(/\/+$/, '');
|
||||
return (req.protocol || 'http') + '://' + (req.get('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 { return { ok: false, status: 500, error: 'RainID 客户端未配置' }; }
|
||||
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,
|
||||
};
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
|
||||
const SECRET = process.env.JWT_SECRET || 'rainweb-secret-key-2024';
|
||||
const SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
@@ -17,7 +18,9 @@ function authMiddleware(req, res, next) {
|
||||
}
|
||||
|
||||
function adminOnly(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
// 查库复查角色,防止 JWT 中过期/被篡改的角色信息直接放行
|
||||
const u = db.get('SELECT role FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!u || u.role !== 'admin') {
|
||||
return res.status(403).json({ error: '需要管理员权限' });
|
||||
}
|
||||
next();
|
||||
|
||||
Generated
+2630
-13
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user