Compare commits
75
Commits
e7f8b3f173
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,88 +230,70 @@ async function cmdRestart() {
|
||||
await cmdStart();
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
const REPO_URL = 'https://github.com/Xianyunah/rainwebblog.git';
|
||||
const REPO_ZIP = 'https://codeload.github.com/Xianyunah/rainwebblog/zip/refs/heads/master';
|
||||
// === 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, '/')}');
|
||||
const url = '${REPO_ZIP}';
|
||||
https.get(url, r => {
|
||||
let i = 0, u = url;
|
||||
const fol = res => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && i < 5) {
|
||||
i++; u = new URL(res.headers.location, u).href;
|
||||
https.get(u, fol).on('error', () => {});
|
||||
return;
|
||||
}
|
||||
res.pipe(f);
|
||||
};
|
||||
fol(r);
|
||||
});
|
||||
"`, { 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...');
|
||||
@@ -359,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,4 +1,4 @@
|
||||
const initSqlJs = require('sql.js');
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
@@ -7,74 +7,57 @@ 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;
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
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();
|
||||
}
|
||||
db = new Database(DB_PATH);
|
||||
initTables();
|
||||
migrateSchema();
|
||||
seedAdmin();
|
||||
seedDefaults();
|
||||
seedSampleData();
|
||||
saveDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
function saveDb() {
|
||||
if (!db) return;
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const data = db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
} catch (e) {
|
||||
console.error('Save DB failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`);
|
||||
try { db.run('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {}
|
||||
try { db.run('ALTER TABLE forum_categories ADD COLUMN sub_categories TEXT DEFAULT ""'); } catch {}
|
||||
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, sub_category TEXT DEFAULT '',
|
||||
@@ -82,37 +65,45 @@ function initTables() {
|
||||
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.run('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {}
|
||||
try { db.run('ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT ""'); } 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 '',
|
||||
@@ -120,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,
|
||||
@@ -128,21 +119,59 @@ 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.run("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {}
|
||||
try { db.run('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.run("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''"); } catch {}
|
||||
try { db.run('ALTER TABLE blog_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
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 {}
|
||||
} },
|
||||
];
|
||||
for (const m of migrations) {
|
||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin() {
|
||||
@@ -171,6 +200,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',
|
||||
@@ -185,6 +215,12 @@ function seedDefaults() {
|
||||
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])) {
|
||||
@@ -195,11 +231,8 @@ function seedDefaults() {
|
||||
|
||||
function run(sql, params = []) {
|
||||
try {
|
||||
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;
|
||||
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;
|
||||
@@ -208,9 +241,8 @@ function run(sql, params = []) {
|
||||
|
||||
function get(sql, params = []) {
|
||||
try {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
|
||||
stmt.free(); return null;
|
||||
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;
|
||||
@@ -219,10 +251,7 @@ function get(sql, params = []) {
|
||||
|
||||
function all(sql, params = []) {
|
||||
try {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free(); return rows;
|
||||
return db.prepare(sql).all(...params);
|
||||
} catch (e) {
|
||||
console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return [];
|
||||
|
||||
-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,150 @@
|
||||
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 { 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: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||
];
|
||||
|
||||
/** 基本设置 + 页脚设置(v2:页脚样式 / 版权 / Powered by 均支持自定义) */
|
||||
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',
|
||||
});
|
||||
const [saving, setSaving] = 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',
|
||||
}))
|
||||
.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 {
|
||||
await saveSettings(form);
|
||||
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>
|
||||
|
||||
<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,44 @@
|
||||
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');
|
||||
}
|
||||
|
||||
/** 退出登录:后端无对应接口,仅清除本地 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,294 @@
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||||
function ensureThirdPartyScript(type) {
|
||||
if (type === 'recaptcha') {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
if (type === 'recaptcha') {
|
||||
if (typeof window.grecaptcha !== 'undefined') render();
|
||||
else {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
window.recaptchaCallbacks.push(render);
|
||||
ensureThirdPartyScript(type);
|
||||
}
|
||||
} else {
|
||||
if (typeof window.turnstile !== 'undefined') render();
|
||||
else {
|
||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||
window.turnstileCallbacks.push(render);
|
||||
ensureThirdPartyScript(type);
|
||||
}
|
||||
}
|
||||
// 卸载时不触发重复回调
|
||||
return () => { doneRef.current = true; };
|
||||
}, []);
|
||||
|
||||
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,204 @@
|
||||
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();
|
||||
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,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
|
||||
/**
|
||||
* 登录页(迁移自 login.html):
|
||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||
*/
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
captchaRequired('login')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
}, [navigate]);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('login');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
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) {
|
||||
setError(e.message || '登录失败');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<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,178 @@
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
* 注册页(迁移自 register.html):
|
||||
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
||||
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
||||
*/
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
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); }
|
||||
};
|
||||
|
||||
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 || '注册失败');
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
<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';
|
||||
+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
+2597
-12
File diff suppressed because it is too large
Load Diff
+27
-3
@@ -1,21 +1,45 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "1.4.2",
|
||||
"version": "2.2.0",
|
||||
"description": "链接聚合管理平台",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js",
|
||||
"dev": "vite --config vite.config.js",
|
||||
"build": "vite build --config vite.config.js",
|
||||
"preview": "vite preview --config vite.config.js",
|
||||
"cli": "node cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^9.3.1",
|
||||
"@mui/material": "^9.3.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dompurify": "^3.4.13",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"marked": "^18.0.5",
|
||||
"multer": "^2.2.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"sql.js": "^1.11.0"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-resizable-panels": "^4.12.2",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理面板 - RainWeb</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav-bar" id="mainNav"></nav>
|
||||
<main class="page page-wide">
|
||||
<div class="admin-header">
|
||||
<h2 id="adminTitle">管理面板</h2>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('panels')" id="tabPanelsBtn">面板</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('links')" id="tabLinksBtn">面板链接</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('settings')" id="tabSettingsBtn">站点设置</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('theme')" id="tabThemeBtn">主题</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('homepage')" id="tabHomepageBtn">首页</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('attachments')" id="tabAttachmentsBtn">附件</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('forum')" id="tabForumBtn">论坛</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('blog')" id="tabBlogBtn">博客</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('users')" id="tabUsersBtn">用户</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('email')" id="tabEmailBtn">邮件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panels Dashboard -->
|
||||
<div id="tabPanels" class="tab-content">
|
||||
<div id="panelsGrid" class="admin-panels"></div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Links Management -->
|
||||
<div id="tabLinks" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>面板链接管理</h2><button class="btn btn-filled" onclick="openLinkDialog()"><span class="material-icons">add</span> 添加面板</button></div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>排序</th><th>标题</th><th>版本</th><th>URL</th><th>嵌入URL</th><th>分类</th><th style="width:120px">操作</th></tr></thead><tbody id="linksBody"><tr><td colspan="7" class="text-center text-muted">加载中...</td></tr></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- Site Settings -->
|
||||
<div id="tabSettings" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>基本设置</h3>
|
||||
<div class="form-group"><label>网站名称</label><input type="text" id="setSiteName" placeholder="显示在标题和导航栏"></div>
|
||||
<div class="form-group"><label>网站描述(SEO)</label><input type="text" id="setSiteDesc" placeholder="搜索引擎结果中显示的描述"></div>
|
||||
<div class="form-group"><label>网站域名</label><input type="url" id="setSiteUrl" placeholder="https://你的域名.com"></div>
|
||||
<div class="form-group"><label>网站图标 URL</label><input type="url" id="setSiteFavicon" placeholder="https://example.com/favicon.ico"></div>
|
||||
<button class="btn btn-filled" onclick="saveSettings()">保存基本设置</button>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card">
|
||||
<h3>验证码设置</h3>
|
||||
<div class="form-group">
|
||||
<label>验证码类型</label>
|
||||
<select id="captchaType" onchange="toggleCaptchaConfig()">
|
||||
<option value="none">不使用验证码</option>
|
||||
<option value="builtin">使用普通验证码(内置扭曲文字)</option>
|
||||
<option value="recaptcha">使用 Google reCAPTCHA V2</option>
|
||||
<option value="turnstile">使用 Cloudflare Turnstile</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="captchaScopeConfig" style="display:none">
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">登录验证</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="capLogin"> 启用</label>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">注册验证</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="capRegister"> 启用</label>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">发帖验证</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="capForum"> 启用</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recaptchaConfig" style="display:none">
|
||||
<h4>Google reCAPTCHA V2 配置</h4>
|
||||
<div class="form-group"><label>Site Key</label><input type="text" id="setRecaptchaSite" placeholder="6L..."></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="text" id="setRecaptchaSecret" placeholder="6L..."></div>
|
||||
</div>
|
||||
<div id="turnstileConfig" style="display:none">
|
||||
<h4>Cloudflare Turnstile 配置</h4>
|
||||
<div class="form-group"><label>Site Key</label><input type="text" id="setTurnstileSite" placeholder="0x4AAAA..."></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="text" id="setTurnstileSecret" placeholder="0x4AAAA..."></div>
|
||||
</div>
|
||||
<button class="btn btn-filled" onclick="saveSettings()">保存验证码设置</button>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card">
|
||||
<h3>系统更新</h3>
|
||||
<div id="updateInfo" style="margin-bottom:12px;font-size:14px">
|
||||
<span>当前版本: v<strong id="localVersion">-</strong></span>
|
||||
<span style="margin-left:16px">最新版本: v<strong id="remoteVersion">-</strong></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-outline" onclick="checkUpdate()"><span class="material-icons">refresh</span> 检查更新</button>
|
||||
<button class="btn btn-filled" id="updateBtn" style="display:none" onclick="runUpdate()"><span class="material-icons">download</span> 立即更新</button>
|
||||
</div>
|
||||
<div id="updateStatus" style="margin-top:12px;display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card">
|
||||
<h3>数据导入</h3>
|
||||
<p class="text-muted" style="font-size:14px;margin-bottom:12px">上传旧版 RainWeb 的 <code>data.db</code> 文件,将数据导入当前数据库(重复数据自动跳过)。</p>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="file" id="importDbFile" accept=".db" style="flex:1">
|
||||
<button class="btn btn-tonal" onclick="importDatabase()">导入</button>
|
||||
</div>
|
||||
<div id="importResult" style="margin-top:12px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<div id="tabTheme" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>主题色</h3>
|
||||
<div class="form-group"><label>主色调</label><input type="color" id="setPrimaryColor" class="color-input"></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>壁纸背景</h3>
|
||||
<div class="form-group"><label>上传图片</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="file" id="wallpaperFile" accept="image/*" style="flex:1">
|
||||
<button class="btn btn-tonal btn-sm" onclick="uploadWallpaper()">上传</button>
|
||||
</div>
|
||||
<div id="wallpaperPreview" style="margin-top:8px;display:none">
|
||||
<img id="wallpaperPreviewImg" style="width:100%;max-height:120px;object-fit:cover;border-radius:8px;border:1px solid var(--md-ref-outline-variant)">
|
||||
<div style="margin-top:4px;display:flex;gap:8px;align-items:center">
|
||||
<span id="wallpaperFilename" class="text-muted" style="font-size:13px"></span>
|
||||
<button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="removeWallpaper()">移除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>图片 URL</label><input type="url" id="setWallpaper" placeholder="https://example.com/wallpaper.jpg" oninput="previewWallpaperUrl(this.value)"></div>
|
||||
<div class="form-group"><label>缩放方式</label>
|
||||
<select id="setWallpaperScale">
|
||||
<option value="cover">cover - 覆盖填充</option>
|
||||
<option value="contain">contain - 完整显示</option>
|
||||
<option value="repeat">repeat - 平铺重复</option>
|
||||
<option value="stretch">stretch - 拉伸填充</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="uploadedWallpapers" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:8px;margin-bottom:12px"></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>样式设置</h3>
|
||||
<h4>导航栏样式</h4>
|
||||
<div class="toggle-group" id="navStyleGroup">
|
||||
<span class="toggle-btn active" data-val="default" onclick="setNavStyle('default')">默认</span>
|
||||
<span class="toggle-btn" data-val="glass" onclick="setNavStyle('glass')">磨砂玻璃</span>
|
||||
<span class="toggle-btn" data-val="capsule" onclick="setNavStyle('capsule')">胶囊</span>
|
||||
</div>
|
||||
<h4>卡片样式</h4>
|
||||
<div class="toggle-group" id="cardStyleGroup">
|
||||
<span class="toggle-btn active" data-val="default" onclick="setCardStyle('default')">默认</span>
|
||||
<span class="toggle-btn" data-val="glass" onclick="setCardStyle('glass')">磨砂玻璃</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>玻璃效果</h3>
|
||||
<div class="form-group"><label>模糊强度 (px)</label><input type="range" id="setGlassBlur" min="5" max="40" value="20" oninput="document.getElementById('blurVal').textContent=this.value+'px'"><span id="blurVal" class="text-muted" style="font-size:14px">20px</span></div>
|
||||
<div class="form-group"><label>透明度</label><input type="range" id="setGlassOpacity" min="0.1" max="0.95" step="0.05" value="0.6" oninput="document.getElementById('opacityVal').textContent=this.value"><span id="opacityVal" class="text-muted" style="font-size:14px">0.6</span></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>深色模式</h3>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:120px">强制深色模式</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="setForceDark"> 启用后用户无法切换为浅色</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-filled" onclick="saveThemeSettings()">保存</button>
|
||||
</div>
|
||||
|
||||
<!-- Homepage Settings -->
|
||||
<div id="tabHomepage" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>个人信息</h3>
|
||||
<div class="form-group"><label>头像 URL</label><input type="url" id="hpAvatar" placeholder="https://example.com/avatar.jpg"></div>
|
||||
<div class="form-group"><label>个人简介</label><textarea id="hpBio" style="min-height:60px" placeholder="一段简短的自我介绍"></textarea></div>
|
||||
<h4 style="font-size:14px;font-weight:500;margin:12px 0 8px;color:var(--md-ref-on-surface-variant)">联系链接(最多5个)</h4>
|
||||
<div id="contactsEditor" style="display:flex;flex-direction:column;gap:8px;margin-bottom:8px"></div>
|
||||
<button class="btn btn-text btn-sm" onclick="addContactRow({icon:'link',url:'',title:''})"><span class="material-icons" style="font-size:16px">add</span> 添加链接</button>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>主页内容</h3>
|
||||
<div class="form-group"><label>正文 (Markdown)</label>
|
||||
<textarea id="hpContent" style="min-height:300px;font-family:monospace" placeholder="支持 Markdown 语法和 [image:filename] 标签"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn btn-tonal btn-sm" onclick="uploadHomepageFile()"><span class="material-icons">upload</span> 上传附件</button>
|
||||
<span id="hpUploadStatus" class="text-muted" style="font-size:13px"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>音乐嵌入</h3>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">启用音乐</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="musicEmbedEnabled"> 所有页面显示音乐播放器</label>
|
||||
</div>
|
||||
<div class="form-group"><label>嵌入代码</label>
|
||||
<textarea id="musicEmbedCode" style="min-height:80px;font-family:monospace;font-size:13px" placeholder="粘贴网易云音乐 iframe 代码"></textarea>
|
||||
</div>
|
||||
<div class="form-group"><label>显示位置</label>
|
||||
<select id="musicEmbedPosition">
|
||||
<option value="right">右下角</option>
|
||||
<option value="left">左下角</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:100px">自动收缩</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="musicEmbedAutohide"> 无操作后缩小为图标</label>
|
||||
</div>
|
||||
<div class="form-group"><label>空闲超时(秒)</label>
|
||||
<input type="number" id="musicEmbedIdleTimeout" min="3" max="120" value="10" style="width:80px">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-filled" onclick="saveHomepage()">保存</button>
|
||||
</div>
|
||||
|
||||
<!-- Attachments Management -->
|
||||
<div id="tabAttachments" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>附件管理</h2></div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>ID</th><th>文件名</th><th>原始名</th><th>大小</th><th>类型</th><th>上传者</th><th>时间</th><th style="width:80px">操作</th></tr></thead><tbody id="attachmentsBody"></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- Forum Management -->
|
||||
<div id="tabForum" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>论坛管理</h2><button class="btn btn-filled" onclick="openForumCatDialog()"><span class="material-icons">add</span> 添加板块</button></div>
|
||||
<div id="forumCards" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Blog Management -->
|
||||
<div id="tabBlog" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>博客管理</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<a href="/write.html" class="btn btn-filled" target="_blank"><span class="material-icons">edit</span> 写文章</a>
|
||||
<button class="btn btn-outline" onclick="openBlogDialog()"><span class="material-icons">add</span> 快速添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card settings-card" style="max-width:640px;margin-bottom:16px">
|
||||
<h3>博客页面设置</h3>
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center;flex:1"><input type="checkbox" id="setBlogSidebar" checked> 博客页面左侧显示头像和简介</label>
|
||||
<button class="btn btn-filled btn-sm" onclick="saveBlogSidebar()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>标题</th><th>状态</th><th>Markdown</th><th>时间</th><th style="width:120px">操作</th></tr></thead><tbody id="blogBody"></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- User Management -->
|
||||
<div id="tabUsers" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>用户管理</h2><button class="btn btn-filled" onclick="openUserDialog()"><span class="material-icons">person_add</span> 添加用户</button></div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>验证</th><th>角色</th><th>注册时间</th><th style="width:80px">操作</th></tr></thead><tbody id="usersBody"></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- Email Settings -->
|
||||
<div id="tabEmail" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>SMTP 服务器</h3>
|
||||
<div class="form-group"><label>SMTP 主机</label><input type="text" id="setSmtpHost" placeholder="smtp.example.com"></div>
|
||||
<div class="form-group"><label>端口</label><input type="number" id="setSmtpPort" placeholder="587"></div>
|
||||
<div class="form-group"><label>用户名</label><input type="text" id="setSmtpUser"></div>
|
||||
<div class="form-group"><label>密码</label><input type="password" id="setSmtpPass"></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>发件人信息</h3>
|
||||
<div class="form-group"><label>发件人邮箱</label><input type="email" id="setSmtpFrom" placeholder="noreply@example.com"></div>
|
||||
<div class="form-group"><label>发件人名称</label><input type="text" id="setSmtpFromName" placeholder="RainWeb"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-filled" onclick="saveSmtpSettings()">保存邮件配置</button>
|
||||
<button class="btn btn-tonal" onclick="testSmtp()">发送测试邮件</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Dialogs: Link, Forum Cat, Blog, User -->
|
||||
<div class="dialog-overlay" id="linkDialog"><div class="dialog"><h3 id="linkDialogTitle">添加面板</h3><input type="hidden" id="linkId">
|
||||
<div class="form-group"><label>标题 *</label><input type="text" id="linkTitle"></div>
|
||||
<div class="form-group"><label>URL *</label><input type="url" id="linkUrl" placeholder="https://..."></div>
|
||||
<div class="form-group"><label>嵌入 URL(iframe嵌入用)</label><input type="url" id="linkEmbedUrl" placeholder="留空则新标签页打开"></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="linkProxy"> 通过代理嵌入(绕过 X-Frame-Options 限制)</label></div>
|
||||
<div class="form-group"><label>描述</label><input type="text" id="linkDesc"></div>
|
||||
<div class="form-group"><label>图标 (Material图标名)</label><input type="text" id="linkIcon" placeholder="settings, dashboard, ..."></div>
|
||||
<div class="form-group"><label>分类</label><input type="text" id="linkCategory" placeholder="默认"></div>
|
||||
<div class="form-group"><label>版本号(可选)</label><input type="text" id="linkVersion" placeholder="v2.1.0"></div>
|
||||
<div class="form-group"><label>排序</label><input type="number" id="linkSort" value="0"></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('linkDialog')">取消</button><button class="btn btn-filled" onclick="saveLink()">保存</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="forumCatDialog"><div class="dialog"><h3 id="forumCatDialogTitle">添加板块</h3><input type="hidden" id="forumCatId">
|
||||
<div class="form-group"><label>名称 *</label><input type="text" id="forumCatName"></div>
|
||||
<div class="form-group"><label>描述</label><input type="text" id="forumCatDesc"></div>
|
||||
<div class="form-group"><label>板块公告</label><textarea id="forumCatAnnounce" style="min-height:60px" placeholder="板块公告内容,留空不显示"></textarea></div>
|
||||
<div class="form-group"><label>帖子分类(逗号分隔)</label><input type="text" id="forumCatSubCats" placeholder="例如: 求助,分享,讨论,建议"></div>
|
||||
<div class="form-group"><label>排序</label><input type="number" id="forumCatSort" value="0"></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('forumCatDialog')">取消</button><button class="btn btn-filled" onclick="saveForumCat()">保存</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="blogDialog"><div class="dialog" style="max-width:640px"><h3 id="blogDialogTitle">写文章</h3><input type="hidden" id="blogId">
|
||||
<div class="form-group"><label>标题 *</label><input type="text" id="blogTitle"></div>
|
||||
<div class="form-group"><label>摘要</label><input type="text" id="blogExcerpt"></div>
|
||||
<div class="form-group"><label>内容 *</label><textarea id="blogContent" style="min-height:250px;font-family:monospace"></textarea></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
|
||||
<button class="btn btn-tonal btn-sm" onclick="uploadBlogFile()"><span class="material-icons">upload</span> 上传附件</button>
|
||||
<span id="blogUploadStatus" class="text-muted" style="font-size:13px"></span>
|
||||
</div>
|
||||
<div id="blogPreview" class="md-body" style="display:none;padding:16px;background:var(--md-ref-surface-container-low);border-radius:12px;margin-bottom:16px"></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="blogPublished" checked> 发布</label></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('blogDialog')">取消</button><button class="btn btn-filled" onclick="saveBlogPost()">保存</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="userDialog"><div class="dialog"><h3>添加用户</h3>
|
||||
<div class="form-group"><label>用户名 *</label><input type="text" id="newUsername"></div>
|
||||
<div class="form-group"><label>密码 *</label><input type="password" id="newPassword"></div>
|
||||
<div class="form-group"><label>角色</label><select id="newRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('userDialog')">取消</button><button class="btn btn-filled" onclick="saveUser()">创建</button></div></div></div>
|
||||
|
||||
<!-- Reset Password Dialog -->
|
||||
<div class="dialog-overlay" id="resetPwDialog"><div class="dialog"><h3>重置密码</h3><p id="resetPwUser" class="text-muted" style="margin-bottom:16px"></p>
|
||||
<div class="form-group"><label>新密码(至少6位)</label><input type="password" id="resetPwInput"></div>
|
||||
<div class="form-group"><label>确认新密码</label><input type="password" id="resetPwConfirm"></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('resetPwDialog')">取消</button><button class="btn btn-filled" onclick="confirmResetPw()">确认重置</button></div></div></div>
|
||||
|
||||
<!-- Change Role Dialog -->
|
||||
<div class="dialog-overlay" id="roleDialog"><div class="dialog"><h3>修改角色</h3><p id="roleUser" class="text-muted" style="margin-bottom:16px"></p>
|
||||
<div class="form-group"><label>角色</label>
|
||||
<select id="roleSelect"><option value="user">普通用户</option><option value="admin">管理员</option></select></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('roleDialog')">取消</button><button class="btn btn-filled" onclick="confirmRoleChange()">确认修改</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="confirmDialog"><div class="dialog"><h3>确认操作</h3><p id="confirmMsg" style="margin-bottom:24px;font-size:16px"></p><div class="actions"><button class="btn btn-text" onclick="closeDialog('confirmDialog')">取消</button><button class="btn btn-danger" id="confirmBtn">确认删除</button></div></div></div>
|
||||
|
||||
|
||||
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/router.js"></script>
|
||||
<script src="/js/music-embed.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
<script>document.addEventListener('DOMContentLoaded',function(){if(window.ADMIN)ADMIN.init();});</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>博客 - RainWeb</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav-bar" id="mainNav"></nav>
|
||||
<main class="page">
|
||||
<div class="homepage-layout">
|
||||
<aside class="homepage-sidebar" id="blogSidebar">
|
||||
<div class="card" style="text-align:center">
|
||||
<div class="homepage-avatar" id="hpAvatarWrap">
|
||||
<img id="hpAvatar" src="" alt="avatar" style="display:none">
|
||||
<span id="hpAvatarPlaceholder" class="material-icons" style="font-size:64px;color:var(--md-ref-on-surface-variant)">person</span>
|
||||
</div>
|
||||
<div id="hpBio" class="homepage-bio"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="homepage-content">
|
||||
<div id="blogApp">
|
||||
<div id="blogList"><div class="loading"><div class="spinner"></div></div></div>
|
||||
<div id="blogDetail" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/router.js"></script>
|
||||
<script src="/js/music-embed.js"></script>
|
||||
<script src="/js/render.js"></script>
|
||||
<script src="/js/blog.js"></script>
|
||||
<script>document.addEventListener('DOMContentLoaded',function(){if(window.BLOG)BLOG.init();});</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user