22 Commits
Author SHA1 Message Date
miaomiao 249ed8cd2c feat: 后台 MUI 主题 MD3 化(与前台同源 token/大圆角/surface 边框)+ 深色模式修复 2026-08-07 00:27:00 +08:00
miaomiao a858439e19 fix: 首页侧栏强制显示(blog_show_sidebar 开关仅影响博客页);v2.0.0 版本号 2026-08-07 00:11:15 +08:00
miaomiao 535e4b8fcd v2.0.0: 大版本号更新(v1 原生前端 → v2 React 前后台) 2026-08-06 23:59:48 +08:00
miaomiao 75d102d6ee docs: AGENTS.md 更新至 v2 现状(assets 挂载/无更新接口/新路由顺序等) 2026-08-06 23:56:34 +08:00
miaomiao f0b642362a fix: /assets 挂载构建产物(vite base='/' 与 dist/assets 路径不匹配导致 MIME text/html 白屏) 2026-08-06 23:55:03 +08:00
miaomiao 45f0dc9beb fix: serveIndex 加 no-store 缓存头(防止旧 index.html 缓存导致 assets 404 白屏) 2026-08-06 23:28:52 +08:00
miaomiao b29fbcdcfc fix: 面板嵌入 proxy 模式支持 query token(iframe 无法带 header) 2026-08-06 23:20:25 +08:00
miaomiao 20deb09dbd P6: 集成上线 - dist 切换/后台路由/Setup+Embed/v1 退役/文档更新
- server.js: serveIndex 改读构建产物 dist/index.html(占位符替换保留);新增 /admin* → dist/admin.html;/forum/manage/:id 改 302 重定向
- Setup 初始化向导三步迁移;Embed 面板嵌入迁移(proxy 代理)
- v1 前端全部退役(12 html + public/js/ 12 文件 + deploy 脚本)
- README/AGENTS.md 更新为 v2 架构(React+Vite、better-sqlite3、构建流程)
2026-08-06 23:16:18 +08:00
miaomiao c71f4f0ed6 P5: 后台独立应用(MUI)- 13 模块/布局/守卫/复用前台基建 2026-08-06 23:10:09 +08:00
miaomiao f7dd4163b2 P4: 审查收尾 - 第三方验证码闭环/导航指向/验证码统一校验 2026-08-06 23:01:52 +08:00
miaomiao 72295ded4d P4: 密码箱迁移(PIN/CRUD/生成器/安全加固) 2026-08-06 22:51:17 +08:00
miaomiao f4a5276fb8 P4: 登录/注册/个人中心迁移(验证码 proof/邮箱验证/头像/改密) 2026-08-06 22:48:48 +08:00
miaomiao be6f644154 P4: 论坛迁移(列表/发帖/详情回复/板块管理) 2026-08-06 22:45:31 +08:00
miaomiao bdd3a068af P4: 首页/博客/写文章迁移(列表/详情/评论/编辑/上传) 2026-08-06 22:42:25 +08:00
miaomiao 990f6f40d0 P4: 基础设施 - api 层/布局壳/公共组件(Markdown/Captcha/音乐嵌入) 2026-08-06 22:38:21 +08:00
miaomiao 1bed2dda5f P3: 前端工程化搭建 - Vite + React 多入口脚手架
- vite.config.js 双入口(前台 index + 后台 admin),产物输出 public/dist
- 前台 App 布局壳 + 11 条路由占位;api client/theme/utils 共享层
- index.html 保留 ${site_name} 等占位符与 style.css 引用(UI 100% 还原基础)
- dev 代理 3101,构建产物 gitignored
2026-08-06 22:29:03 +08:00
miaomiao b319584012 chore: 换轨遗留清理 - Node>=22 文档/sql.js 卸载/cli 并发写警告 2026-08-06 22:24:00 +08:00
miaomiao c4c0115ba6 P2: 数据层换轨 better-sqlite3(sql.js → 原生 SQLite)
- db.js: getDb 同步化 + new Database(DB_PATH) 直接打开;run/get/all 重写为 prepare API;删除 saveDb 全量导出;22 处 DDL 改 exec
- migrateSchema 填实 version 1(user_settings.pin_iter),passwords.js 删除私有 ensureSchema
- routes/import.js: better-sqlite3 只读打开上传文件(PRAGMA table_info + all() 对象行),finally 关闭
- 数据零迁移:现有 data/rainweb.db 直接打开,播种数据完整,CRUD/SSR/cli 全通
2026-08-06 22:19:57 +08:00
miaomiao f6407a84f7 fix: 审查修复 - setup 死锁/验证码前后端对接/SPA 侧 XSS
- setup /complete 改 setup_complete 门禁(修复新装死锁),新密码禁止等于默认
- 验证码 proof 全链路贯通:前端 captcha.js/api/login/register/forum 传 captcha_proof;consumeProof 一次性消费 + jti 防同秒碰撞;删除死端点与死代码
- render.js 引入 DOMPurify 净化 markdown(SPA 侧存储型 XSS)
- email.js SMTP 检查提前;avatar 强正则白名单;register 验证码 crypto.randomInt
2026-08-06 22:11:43 +08:00
miaomiao 2ac286a4f8 P1: 后端安全加固 - 上传白名单/SSR净化/限流/密码箱/鉴权收敛/数据层统一
- 上传扩展名白名单 + MIME 校验;/uploads 加 nosniff + CSP sandbox 防护头
- ssr.js 全插值转义 + DOMPurify 净化 + JSON-LD JSON.stringify 序列化(消除存储型 XSS)
- 登录/发帖限流(express-rate-limit);验证码 proof token 体系(5 分钟 JWT + 防重放)
- 密码箱:PBKDF2 600k(pin_iter 列兼容旧密文)、解锁 3 次/5 分钟锁定、滑动过期会话、PIN >= 6 位
- adminOnly 查库复查;草稿仅 admin/作者可见;错误信息收敛;avatar 站内路径白名单;验证码 crypto.randomInt
- cli.js 删除重复 sql.js 封装统一到 db.js(data/rainweb.db 单路径);db.js 新增 migrateSchema 迁移框架
2026-08-06 21:32:06 +08:00
miaomiao 91cd009899 P0: 删除 Web 更新链路,修复 setup/proxy/email 安全漏洞
- 删除 /api/update/check 与 /api/update/run(无鉴权 RCE 面 + .git 覆盖风险)
- cli.js upgrade 仅保留 git pull 分支,删除 GitHub zip 下载分支
- admin 前端移除更新按钮与 checkUpdate/runUpdate
- JWT_SECRET 启动时随机生成写入 .env.json,删除硬编码回退
- /api/setup/complete 加 adminOnly;/setup/status 移除 default_password 泄露
- /api/proxy/fetch 加 adminOnly + 内网地址拦截(SSRF)
- send-verify 验证码不再回显,改 crypto.randomInt,修复 pending 重建 bug
2026-08-06 21:22:04 +08:00
miaomiao 4d0ad9da68 docs: 添加 AGENTS.md 与 v2 重构计划文档 2026-08-06 21:22:04 +08:00
107 changed files with 9458 additions and 4452 deletions
+1
View File
@@ -6,3 +6,4 @@ uploads/*
.env.json
server.pid
releases/
public/dist/
+62
View File
@@ -0,0 +1,62 @@
# 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.jsserve 构建产物 + /api
npm run dev # 开发:vite dev server5173/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`,默认 3001vite 代理目标硬编码 3101vite.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/assetsimmutable)→ static(public) → `/uploads``/api/*``/admin*`dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。
- `routes/links.js` 未被挂载,是死代码(勿依赖)。
- `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=` queryiframe 无法带 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/`**后台独立应用** MUImain.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 存 localStoragekey `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(本地 origingit.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/`
+33 -38
View File
@@ -1,6 +1,5 @@
# 🌧️ RainWeb - 个人云管理平台
[![GitHub](https://img.shields.io/badge/GitHub-Xianyunah%2Frainwebblog-blue?logo=github)](https://github.com/Xianyunah/rainwebblog)
[![License](https://img.shields.io/badge/license-MIT-green)](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
```
+1 -1
View File
@@ -1 +1 @@
1.4.2
2.0.0
+37 -134
View File
@@ -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}
@@ -98,7 +58,7 @@ 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'}`);
// Check if server is running
try {
@@ -110,8 +70,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 +110,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 +124,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 +171,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 => {
@@ -262,87 +224,20 @@ async function cmdRestart() {
}
// === Upgrade ===
const REPO_URL = 'https://github.com/Xianyunah/rainwebblog.git';
const REPO_ZIP = 'https://codeload.github.com/Xianyunah/rainwebblog/zip/refs/heads/master';
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 +254,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)); })
+53 -62
View File
@@ -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,24 +65,24 @@ 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')),
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,
@@ -107,12 +90,12 @@ function initTables() {
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 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 +103,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 +111,36 @@ 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 '')`);
// 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_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 {} } },
];
for (const m of migrations) {
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
}
}
function seedAdmin() {
@@ -195,11 +193,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 +203,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 +213,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
View File
@@ -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
-62
View File
@@ -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
+174
View File
@@ -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 commitcommit 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 → 600kOWASP 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.errorM4
- `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/uploadWallpaperadmin.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()` + 收紧 CSPCDN 引入 marked 的 5 个页面加 SRI)——建议做
- H3:验证码 `Math.random()``crypto.randomInt`auth.js:80、email.js:63、profile.js:68)——建议做,改动极小
- H9bcrypt cost 10 → 12;注册用户名格式校验——建议做
- H2JWT 从 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 页面逻辑全外部化**HOMEPAGEP1-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
View File
@@ -0,0 +1,159 @@
# RainWeb v2 全面升级计划
> v1(原生 HTML/CSS/JS + PJAX)→ v2React 前后台分离 + 后端改造)。用户确认的选型全部定案,本计划为唯一执行依据。
> 旧 `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 4server.js
├── /api/* 15 个路由模块(契约不变,前端重写以现有 API 为基)
├── 静态:构建产物 + uploads/ + public/wallpaper/
└── SPA fallback/ → 前台 index.html/admin* → 后台 index.html
better-sqlite3data/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 登录/发帖限流 + 服务端验证码校验(R8express-rate-limit
- P1-4 密码箱解锁限流 + PBKDF2 600kM1
- 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/exportinitTables 保留幂等补列
- 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 Routerhistory 模式),server.js `app.get('*')` 回前台入口
- 后台:/admin 前缀路由,server.js 增加 `/admin*` → 后台入口(注意在 SPA catch-all 前)
- SSR 路由(/blog/:id、/forum/:id)优先级高于 SPA fallback(现状已如此,保持)
### 4.4 密码管理器加密
v1 为"服务端保管密钥"模型(PIN 明文到服务端派生)。v2 前台重写时**默认保持行为不变**(服务端派生 + PBKDF2 600kPhase 1 已加固)。真正 E2EWebCrypto 客户端派生)列为 v2 后续可选项,不在本次范围。
### 4.5 音乐嵌入
v1 的 music-embed(自动隐藏、位置、超时)迁移为 React 全局组件,行为参数从 site_settings 读取,逻辑照搬。
### 4.6 验证码
captcha.js 前端逻辑(内置 SVG 验证码 + recaptchaReact 化;服务端校验在 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 单独验收部署流程;devvite dev+ prodbuild)双路径文档化 |
| 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 → 可暂停验收。
+12
View File
@@ -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>
+19
View File
@@ -0,0 +1,19 @@
<!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="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="root"></div>
<div id="musicEmbed"></div>
<div id="snackbar" class="snackbar"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
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 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="/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>
);
}
+125
View File
@@ -0,0 +1,125 @@
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 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 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: '/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" 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>
);
}
+21
View File
@@ -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>
);
}
+101
View File
@@ -0,0 +1,101 @@
import React, { 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 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/* 由后端 fallback 到 dist/admin.htmlPhase 6 需求),basename=/admin
// 开发:vite 多入口直接访问 /admin.htmlbasename 自适应为空。
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>
<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="/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 />);
+140
View File
@@ -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>
);
}
+146
View File
@@ -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>
);
}
+111
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+87
View File
@@ -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.dbPOST /api/import/databaseFormData + 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>
);
}
+175
View File
@@ -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="嵌入 URLiframe嵌入用)" 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>
);
}
+53
View File
@@ -0,0 +1,53 @@
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 { showSnack } from '../snack.jsx';
/** 基本设置:站名/描述/站点URL/favicon(迁移自 v1 设置卡片) */
export default function Settings() {
const [form, setForm] = useState({ site_name: '', site_description: '', site_url: '', site_favicon: '' });
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 || '',
}))
.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(form);
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 }}>
<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" />
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={save} disabled={saving}>保存基本设置</Button>
</Box>
</Paper>
</Box>
);
}
+148
View File
@@ -0,0 +1,148 @@
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',
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',
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_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>
<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>
);
}
+93
View File
@@ -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>
);
}
+214
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+36
View File
@@ -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;
}
}
+515
View File
@@ -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 Chip8px 圆角 + 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;
+16
View File
@@ -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' });
}
+20
View File
@@ -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' });
}
+44
View File
@@ -0,0 +1,44 @@
import { request, setToken, notifyAuthChange } from './client.js';
import { applyCaptchaResult } from './captcha.js';
/** 登录;captcha 为 showCaptcha 的结果({type,value},可选):proof → captcha_proofrecaptcha/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 } });
}
+30
View File
@@ -0,0 +1,30 @@
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' });
}
// ── 评论 ────────────────────────────────────────
export function listComments(postId) {
return request('/blog/comments/' + postId);
}
export function createComment(postId, content) {
return request('/blog/comments/' + postId, { method: 'POST', body: { content } });
}
export function deleteComment(id) {
return request('/blog/comments/' + id, { method: 'DELETE' });
}
+38
View File
@@ -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_tokenreCAPTCHA siteverify token
* - turnstile → turnstile_tokenTurnstile 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;
}
+23
View File
@@ -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;
}
+16
View File
@@ -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 } });
}
+37
View File
@@ -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' });
}
+30
View File
@@ -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' });
}
+21
View File
@@ -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 });
}
+16
View File
@@ -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 });
}
+58
View File
@@ -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' });
}
+73
View File
@@ -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>
);
}
+284
View File
@@ -0,0 +1,284 @@
import React, { useEffect, useRef, useState } from 'react';
import * as captchaApi from '../api/captcha.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-256Proof 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="输入验证码"
maxLength={6}
autoComplete="off"
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
/>
<button type="button" className="btn btn-icon" title="刷新" onClick={loadImage} style={{ flexShrink: 0 }}>
<span className="material-icons">refresh</span>
</button>
</div>
{error && <div 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);
}
};
if (!request) return null;
return (
<div
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
>
{request.type === 'recaptcha' ? (
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
) : request.type === 'turnstile' ? (
<ThirdPartyCaptcha type="turnstile" onFinish={finish} />
) : (
<BuiltinCaptcha onFinish={finish} />
)}
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
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';
/** 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) {
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}
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
style={{
textAlign: 'center',
padding: '20px 16px 64px',
fontSize: 13,
color: 'var(--md-ref-outline)',
}}
>
{siteName} v{version}
</footer>
<MusicEmbed settings={settings} />
<CaptchaModalHost />
</>
);
}
@@ -0,0 +1,52 @@
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)]));
return DOMPurify.sanitize(html);
}
export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
const html = useMemo(() => renderContent(content, useMarkdown), [content, useMarkdown]);
return <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />;
}
+57
View File
@@ -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 }} />
<div className="music-embed-icon" onClick={expand} title="展开播放器">
<span className="material-icons">music_note</span>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
/** HTML 转义:& < > " ' 全转义 */
export function escapeHtml(str) {
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#39;';
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);
}
+10
View File
@@ -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>
);
+54
View File
@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import BlogSidebar from '../components/BlogSidebar.jsx';
import { listPosts } 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):瀑布流卡片,点击进详情 */
export default function Blog() {
const [settings, setSettings] = useState({});
const [posts, setPosts] = useState(null); // null=加载中
const [error, setError] = useState('');
useEffect(() => {
getPublicSettings().then(setSettings).catch(() => {});
listPosts()
.then((ps) => setPosts(ps || []))
.catch((e) => setError(e.message || '加载失败'));
}, []);
return (
<div className="homepage-layout">
<BlogSidebar settings={settings} />
<div className="homepage-content">
{!posts && !error && (
<div className="loading"><div className="spinner"></div></div>
)}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>加载失败</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>
);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, 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';
/**
* 博客详情页(路由 /blog/:id,SPA 内走前端路由,SSR 直链由后端处理):
* 文章正文(MarkdownRenderer+ 评论列表/发表 + 编辑按钮(作者/管理员可见)。
*/
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 load = useCallback(async () => {
setLoading(true);
setError('');
try {
const p = await blogApi.getPost(id);
setPost(p);
const cs = await blogApi.listComments(id);
setComments(cs || []);
} 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 submitComment = async () => {
const content = commentText.trim();
if (!content) { showSnackbar('评论不能为空'); return; }
setSubmitting(true);
try {
await blogApi.createComment(id, content);
showSnackbar('评论已发表');
setCommentText('');
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="blog-article">
<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}
{canEdit && (
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm" style={{ marginLeft: 12 }}>编辑</Link>
)}
</div>
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4>
{comments.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
) : (
comments.map((c) => (
<div className="reply-item" key={c.id}>
<div className="reply-meta"><strong>{c.author_name || '游客'}</strong> · {c.created_at}</div>
<div className="reply-body">{c.content}</div>
</div>
))
)}
{user ? (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="写下你的评论..."
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
/>
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
发表评论
</button>
</div>
) : (
<p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}>
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
</p>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
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' }}>
<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>{' '}
扩展或点击右上角原站在新标签页打开
<span onClick={() => setShowHint(false)} style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600 }}></span>
</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>
);
}
+271
View File
@@ -0,0 +1,271 @@
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 } 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);
return (
<div className="forum-layout">
<aside className="forum-sidebar">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
{user ? '发帖' : '登录发帖'}
</button>
</div>
<div className="forum-cat-list">
{categories.map((c) => (
<div
key={c.id}
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
onClick={() => selectCategory(c.id)}
>
<span className="material-icons" style={{ fontSize: 18 }}>forum</span> {c.name}
{c.announcement ? (
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
) : null}
</div>
))}
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
<div
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
onClick={showAllPosts}
style={{ fontWeight: 500 }}
>
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
</div>
</aside>
<div className="forum-content">
{currentCat && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
<span className="chip" style={{ cursor: 'default', fontSize: 12, padding: '1px 8px', color: 'var(--md-ref-on-surface-variant)', border: '1px solid var(--md-ref-outline-variant)' }}>板块</span>
</div>
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
{currentCat.announcement && (
<div className="announcement-bar">
<span className="material-icons ann-icon">campaign</span>
<span className="ann-content">{currentCat.announcement}</span>
</div>
)}
{subCats.length > 0 && (
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
<span className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</span>
{subCats.map((s) => (
<span key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</span>
))}
</div>
)}
</div>
)}
{posts === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
{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" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'transparent', border: '1px solid var(--md-ref-outline-variant)', color: 'var(--md-ref-on-surface-variant)' }}>{catName}</span>
{p.sub_category ? (
<span className="chip" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>{p.sub_category}</span>
) : null}
</div>
</Link>
);
})}
</div>
)}
</div>
{/* 发帖弹窗 */}
{showNewPost && (
<div
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
>
<div className="dialog">
<h3>发布新帖</h3>
<div className="form-group">
<label>板块</label>
<select value={npCategory} onChange={handleCategoryChange}>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>帖子分类可选</label>
<select value={npSub} onChange={(e) => setNpSub(e.target.value)}>
<option value=""></option>
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="form-group">
<label>标题 *</label>
<input type="text" value={npTitle} onChange={(e) => setNpTitle(e.target.value)} />
</div>
<div className="form-group">
<label>内容 *</label>
<textarea value={npContent} onChange={(e) => setNpContent(e.target.value)} style={{ minHeight: 150, fontFamily: 'monospace' }} />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
<span className="material-icons">upload</span> 上传附件
</button>
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
<span className="text-muted" style={{ fontSize: 13 }}>{npStatus}</span>
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setShowNewPost(false)}>取消</button>
<button className="btn btn-filled" onClick={submitPost} disabled={submitting}>发布</button>
</div>
</div>
</div>
)}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
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>
<h3 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h3>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{post.author_name || '匿名'}</span>
<span>{post.created_at}</span>
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
{post.category_name || ''}
</span>
{post.sub_category && (
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
{post.sub_category}
</span>
)}
</div>
</div>
<div className="post-body">
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
</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 && (
<span
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
onClick={() => deleteReply(r.id)}
>
删除
</span>
)}
</div>
<div className="reply-body">{r.content}</div>
</div>
);
})
)}
</div>
);
}
+191
View File
@@ -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>
);
}
+32
View File
@@ -0,0 +1,32 @@
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_contentMarkdown)。
*/
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">
<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>
);
}
+132
View File
@@ -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>
);
}
+479
View File
@@ -0,0 +1,479 @@
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 } 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);
// 添加/编辑条目弹窗
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);
showSnackbar('已锁定');
} catch (e) {
showSnackbar(e.message);
}
};
const openPinDialog = () => {
setNewPin('');
setConfirmPin('');
setPinDialog(true);
};
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;
// 加载中
if (phase === 'loading') {
return <div className="loading"><div className="spinner"></div></div>;
}
// PIN 屏(设置 或 解锁)
if (phase === 'setup' || phase === 'locked') {
return (
<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>
);
}
// 已解锁:密码箱
return (
<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={openPinDialog}>
<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="card password-card" onClick={() => setDetailId(p.id)}>
<div className="pw-title">{p.title}</div>
<div className="pw-username">{p.username || '无用户名'}</div>
<div className="pw-actions">
<button
className="btn-icon"
style={{ width: 32, height: 32, fontSize: 16 }}
title="复制密码"
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
>
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
</button>
</div>
</div>
))}
</div>
)}
{/* 设置/修改 PIN 弹窗 */}
{pinDialog && (
<div
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
>
<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
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
>
<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
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
>
<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>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span>
</div>
<div className="pw-field">
<span className="pw-label">密码</span>
<span className="pw-value">{detailEntry.password}</span>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span>
</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 className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
<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>
)}
{/* 删除确认弹窗 */}
{confirmDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
<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>
)}
</div>
);
}
+12
View File
@@ -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>
);
}
+231
View File
@@ -0,0 +1,231 @@
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 } 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);
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
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
>
<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>
);
}
+178
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+170
View File
@@ -0,0 +1,170 @@
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 [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 || '');
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(),
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>
<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>
);
}
+44
View File
@@ -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;
}
+5 -2
View File
@@ -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();
+2458 -8
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -1,21 +1,37 @@
{
"name": "rainweb-links",
"version": "1.4.2",
"version": "2.0.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": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.3.1",
"@mui/material": "^9.3.1",
"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",
"nodemailer": "^9.0.1",
"sql.js": "^1.11.0"
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.5",
"vite": "^8.2.1"
}
}
-344
View File
@@ -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>嵌入 URLiframe嵌入用)</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>
-43
View File
@@ -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>
-67
View File
@@ -1,67 +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">
<style>
body { overflow: hidden; }
</style>
</head>
<body>
<div class="embed-page">
<div class="embed-toolbar">
<a href="/" class="btn-icon" id="closeBtn"><span class="material-icons">close</span></a>
<span class="embed-title" id="embedTitle">加载中...</span>
<a id="originalLink" href="#" target="_blank" class="btn btn-text btn-sm" title="在新标签页中打开">
<span class="material-icons">open_in_new</span> 原站
</a>
<button class="btn-icon" onclick="toggleTheme()"><span class="material-icons">dark_mode</span></button>
</div>
<div id="embedHint" style="display:none;padding:8px 16px;background:var(--md-ref-primary-container);color:var(--md-ref-on-primary-container);font-size:13px;text-align:center;flex-shrink:0">
无法嵌入?试试安装
<a href="https://chromewebstore.google.com/search/ignore%20x-frame" target="_blank" style="color:inherit;font-weight:600;text-decoration:underline">Ignore X-Frame-Headers</a>
扩展,或点击右上角「原站」在新标签页打开
<span onclick="this.parentElement.style.display='none'" style="cursor:pointer;margin-left:8px;font-weight:600"></span>
</div>
<iframe class="embed-container" id="embedFrame" sandbox="allow-scripts allow-forms allow-same-origin allow-popups" loading="lazy"></iframe>
</div>
<script src="/js/theme.js"></script>
<script>
const params = new URLSearchParams(location.search);
let url = params.get('url');
const title = params.get('title') || url;
const useProxy = params.get('proxy') === '1';
if (url) {
document.getElementById('embedTitle').textContent = title;
const isHttpsPage = location.protocol === 'https:';
const isHttpTarget = url.startsWith('http:');
const needsProxy = useProxy || (isHttpsPage && isHttpTarget);
if (needsProxy) {
document.getElementById('embedFrame').src = '/api/proxy/fetch?url=' + encodeURIComponent(url);
document.getElementById('originalLink').href = url;
} else {
document.getElementById('embedFrame').src = url;
document.getElementById('originalLink').href = url;
// Show "安装扩展" hint if iframe likely blocked by X-Frame-Options
setTimeout(() => {
const hint = document.getElementById('embedHint');
if (hint && !needsProxy) hint.style.display = 'block';
}, 2000);
}
}
document.querySelector('.embed-toolbar a:first-child').onclick = function(e) {
if (document.referrer && document.referrer !== location.href) {
e.preventDefault();
history.back();
}
};
</script>
</body>
</html>
-132
View File
@@ -1,132 +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">
<style>
.manage-tabs { display:flex;gap:0;margin-bottom:24px;border-bottom:2px solid var(--md-ref-outline-variant) }
.manage-tab { padding:10px 24px;cursor:pointer;font-size:14px;font-weight:500;color:var(--md-ref-on-surface-variant);border-bottom:2px solid transparent;margin-bottom:-2px;transition:all 0.2s;display:flex;align-items:center;gap:6px }
.manage-tab:hover { color:var(--md-ref-primary) }
.manage-tab.active { color:var(--md-ref-primary);border-bottom-color:var(--md-ref-primary) }
.tab-content { display:none }
.tab-content.active { display:block }
</style>
</head>
<body>
<nav class="nav-bar" id="mainNav"></nav>
<main class="page" style="max-width:800px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<a href="/admin.html?tab=forum" class="btn-icon"><span class="material-icons">arrow_back</span></a>
<h1 class="page-title" style="margin:0" id="boardTitle">板块管理</h1>
</div>
<div class="manage-tabs">
<div class="manage-tab active" onclick="switchManageTab('settings')"><span class="material-icons" style="font-size:18px">settings</span> 论坛设置</div>
<div class="manage-tab" onclick="switchManageTab('posts')"><span class="material-icons" style="font-size:18px">forum</span> 帖子管理</div>
</div>
<!-- Tab1: Settings -->
<div id="manageTabSettings" class="tab-content active">
<div class="card" style="padding:24px">
<div class="form-group"><label>板块名称 *</label><input type="text" id="mCatName"></div>
<div class="form-group"><label>描述</label><input type="text" id="mCatDesc"></div>
<div class="form-group"><label>板块公告</label><textarea id="mCatAnnounce" style="min-height:60px"></textarea></div>
<div class="form-group"><label>帖子分类(逗号分隔)</label><input type="text" id="mCatSubCats" placeholder="例: 求助,分享,讨论,建议"></div>
<div class="form-group"><label>排序</label><input type="number" id="mCatSort" value="0"></div>
<button class="btn btn-filled" onclick="saveBoardSettings()">保存设置</button>
</div>
</div>
<!-- Tab2: Posts -->
<div id="manageTabPosts" class="tab-content">
<div class="admin-header"><h2>帖子列表</h2>
<div style="display:flex;gap:8px">
<select id="postFilterSub" onchange="loadBoardPosts()" style="width:auto">
<option value="">全部分类</option>
</select>
</div>
</div>
<div id="boardPostsBody"></div>
</div>
</main>
<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>
const boardId = parseInt(location.pathname.match(/\/forum\/manage\/(\d+)/)?.[1] || '0');
let boardData = null;
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function switchManageTab(tab) {
document.querySelectorAll('.manage-tab').forEach(el => el.classList.toggle('active', el.textContent.includes(tab === 'settings' ? '论坛设置' : '帖子管理')));
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.getElementById('manageTab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active');
}
async function loadBoard() {
if (!boardId) { document.getElementById('boardTitle').textContent = '无效的板块'; return; }
const cats = await API.getForumCategories();
boardData = cats.find(c => c.id === boardId);
if (!boardData) { document.getElementById('boardTitle').textContent = '板块不存在'; return; }
document.getElementById('boardTitle').textContent = '管理: ' + boardData.name;
document.getElementById('mCatName').value = boardData.name;
document.getElementById('mCatDesc').value = boardData.description || '';
document.getElementById('mCatAnnounce').value = boardData.announcement || '';
document.getElementById('mCatSubCats').value = boardData.sub_categories || '';
document.getElementById('mCatSort').value = boardData.sort_order || 0;
// Populate sub-category filter
const sc = boardData.sub_categories ? boardData.sub_categories.split(',').filter(Boolean).map(t => t.trim()) : [];
const filterSel = document.getElementById('postFilterSub');
filterSel.innerHTML = '<option value="">全部分类</option>' + sc.map(s => '<option value="' + escapeHtml(s) + '">' + escapeHtml(s) + '</option>').join('');
loadBoardPosts();
}
async function saveBoardSettings() {
const data = {
name: document.getElementById('mCatName').value.trim(),
description: document.getElementById('mCatDesc').value.trim(),
announcement: document.getElementById('mCatAnnounce').value.trim(),
sub_categories: document.getElementById('mCatSubCats').value.trim(),
sort_order: parseInt(document.getElementById('mCatSort').value) || 0
};
if (!data.name) { showSnackbar('名称不能为空'); return; }
try {
await API.updateForumCategory(boardId, data);
showSnackbar('保存成功');
loadBoard();
} catch (e) { showSnackbar(e.message); }
}
async function loadBoardPosts() {
const container = document.getElementById('boardPostsBody');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const posts = await API.getForumPosts(boardId);
const filterSub = document.getElementById('postFilterSub').value;
const filtered = filterSub ? posts.filter(p => p.sub_category === filterSub) : posts;
if (filtered.length === 0) { container.innerHTML = '<div class="empty-state"><div class="empty-icon">📝</div><p>暂无帖子</p></div>'; return; }
container.innerHTML = '<div class="table-wrapper"><table><thead><tr><th>标题</th><th>分类</th><th>作者</th><th>回复</th><th>时间</th><th style="width:80px">操作</th></tr></thead><tbody>' +
filtered.map(p => `<tr><td><strong>${escapeHtml(p.title)}</strong></td><td>${p.sub_category ? `<span class="chip" style="cursor:default;font-size:12px">${escapeHtml(p.sub_category)}</span>` : '-'}</td><td>${escapeHtml(p.author_name||'')}</td><td>${p.reply_count||0}</td><td class="text-muted" style="font-size:13px">${p.created_at}</td><td><button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="deleteBoardPost(${p.id})">删除</button></td></tr>`).join('') +
'</tbody></table></div>';
} catch { container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></div>'; }
}
async function deleteBoardPost(id) {
if (!confirm('确定删除此帖子?')) return;
try { await API.deleteForumPost(id); showSnackbar('已删除'); loadBoardPosts(); } catch (e) { showSnackbar(e.message); }
}
document.addEventListener('DOMContentLoaded', async () => {
const token = localStorage.getItem('token');
if (!token) { window.location.href = '/login.html'; return; }
try { const me = await API.getMe(); if (me.role !== 'admin') { showSnackbar('需要管理员权限'); setTimeout(() => window.location.href = '/', 1000); return; } } catch { window.location.href = '/login.html'; return; }
loadBoard();
});
</script>
</body>
</html>
-56
View File
@@ -1,56 +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 id="forumApp">
<div class="forum-layout">
<aside class="forum-sidebar">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<span style="font-weight:600;font-size:16px">板块</span>
<button class="btn btn-filled btn-sm" id="newPostBtn" onclick="showNewPost()">发帖</button>
</div>
<div id="categoryList" class="forum-cat-list"></div>
<hr style="border:none;border-top:1px solid var(--md-ref-outline-variant);margin:12px 0">
<div class="forum-cat-item active" onclick="showAllPosts()" style="font-weight:500">
<span class="material-icons" style="font-size:18px">dynamic_feed</span> 全部最新
</div>
</aside>
<div id="forumContent" class="forum-content"></div>
</div>
</div>
</main>
<div class="dialog-overlay" id="newPostDialog"><div class="dialog"><h3>发布新帖</h3>
<div class="form-group"><label>板块</label><select id="postCategory"></select></div>
<div class="form-group"><label>帖子分类(可选)</label>
<select id="postSubCategory"><option value=""></option><option value="求助">求助</option><option value="分享">分享</option><option value="讨论">讨论</option><option value="建议">建议</option></select>
</div>
<div class="form-group"><label>标题 *</label><input type="text" id="postTitle"></div>
<div class="form-group"><label>内容 *</label><textarea id="postContent" style="min-height:150px;font-family:monospace"></textarea></div>
<div id="forumCaptcha" style="display:none"></div>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px">
<button class="btn btn-tonal btn-sm" onclick="uploadForumFile()"><span class="material-icons">upload</span> 上传附件</button>
<span id="forumUploadStatus" class="text-muted" style="font-size:13px"></span>
</div>
<div class="actions"><button class="btn btn-text" onclick="closeDialog('newPostDialog')">取消</button><button class="btn btn-filled" onclick="submitPost()">发布</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/captcha.js"></script>
<script src="/js/render.js"></script>
<script src="/js/forum.js"></script>
<script>document.addEventListener('DOMContentLoaded',function(){if(window.FORUM)FORUM.init();});</script>
</body>
</html>
-100
View File
@@ -1,100 +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>${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="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">
<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 id="hpContacts" class="homepage-contacts"></div>
</div>
<div class="card" id="hpRecentCard">
<div class="homepage-recent-header">最新文章</div>
<div id="hpRecentPosts" class="homepage-recent-list"></div>
</div>
</aside>
<div class="homepage-content">
<div class="card">
<div class="md-body" id="hpContent"></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>
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
var HOMEPAGE = {
init: async function () {
try {
var s = await API.getPublicSettings();
if (s.homepage_avatar) {
document.getElementById('hpAvatar').src = s.homepage_avatar;
document.getElementById('hpAvatar').style.display = 'block';
document.getElementById('hpAvatarPlaceholder').style.display = 'none';
}
document.getElementById('hpBio').textContent = s.homepage_bio || '';
var contactsEl = document.getElementById('hpContacts');
if (s.homepage_contacts) {
try {
var links = JSON.parse(s.homepage_contacts);
if (links.length > 0) {
contactsEl.innerHTML = links.map(function (l) {
return '<a href="' + escapeHtml(l.url) + '" target="_blank" rel="noopener" class="hp-contact-link" title="' + escapeHtml(l.title || '') + '"><span class="material-icons">' + escapeHtml(l.icon || 'link') + '</span></a>';
}).join('');
}
} catch (e) {}
}
var body = renderContent(s.homepage_content || '', true);
document.getElementById('hpContent').innerHTML = body;
} catch (e) {
document.getElementById('hpContent').innerHTML = '<p style="color:var(--md-ref-on-surface-variant)">内容加载失败</p>';
}
try {
var posts = await API.getBlogPosts(false);
var recent = posts.slice(0, 8);
var container = document.getElementById('hpRecentPosts');
if (recent.length === 0) {
container.innerHTML = '<div class="text-muted" style="font-size:13px;text-align:center;padding:8px 0">暂无文章</div>';
} else {
container.innerHTML = recent.map(function (p) {
return '<a href="/blog/' + p.id + '" class="homepage-recent-item"><span class="homepage-recent-title">' + escapeHtml(p.title) + '</span><span class="homepage-recent-date">' + (p.created_at ? p.created_at.slice(0,10) : '') + '</span></a>';
}).join('');
}
} catch (e) {
document.getElementById('hpRecentPosts').innerHTML = '<div class="text-muted" style="font-size:13px;text-align:center;padding:8px 0">加载失败</div>';
}
}
};
document.addEventListener('DOMContentLoaded', function () { HOMEPAGE.init(); });
</script>
</body>
</html>
-799
View File
@@ -1,799 +0,0 @@
let currentTab = 'panels';
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
function closeDialog(id) { document.getElementById(id).classList.remove('active'); }
function openDialog(id) { document.getElementById(id).classList.add('active'); }
// === Auth Check ===
async function checkAuth() {
const token = localStorage.getItem('token');
if (!token) { window.location.href = '/login.html'; return; }
try {
const me = await API.getMe();
if (me.role !== 'admin') { showSnackbar('需要管理员权限'); setTimeout(() => window.location.href = '/', 1000); }
return me;
} catch { localStorage.removeItem('token'); window.location.href = '/login.html'; }
}
// === Tab Switching ===
function switchTab(tab) {
currentTab = tab;
document.getElementById('adminTitle').textContent =
({ panels: '管理面板', links: '面板链接', settings: '站点设置', forum: '论坛管理', blog: '博客管理', users: '用户管理', email: '邮件配置', homepage: '首页设置' })[tab] || '管理面板';
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).style.display = 'block';
const actions = { panels: loadPanels, links: loadLinks, settings: loadSettings, theme: loadThemeSettings, homepage: loadHomepage, attachments: loadAttachments, forum: loadForumCards, blog: () => { loadBlogPosts(); loadBlogSidebar(); }, users: loadUsers, email: loadEmailSettings };
if (actions[tab]) actions[tab]();
}
// === Panel Dashboard ===
async function loadPanels() {
const grid = document.getElementById('panelsGrid');
grid.innerHTML = '<div class="loading" style="grid-column:1/-1"><div class="spinner"></div></div>';
try {
const links = await API.getAdminLinks();
const cats = {};
links.forEach(l => { if (!cats[l.category]) cats[l.category] = []; cats[l.category].push(l); });
const sortedCats = Object.keys(cats).sort();
let html = '';
for (const cat of sortedCats) {
html += `<div style="grid-column:1/-1;font-size:16px;font-weight:600;margin:8px 0 4px;color:var(--md-ref-on-surface-variant)">${escapeHtml(cat)}</div>`;
html += cats[cat].map(l => {
const icon = l.icon ? `<span class="material-icons" style="font-size:24px">${escapeHtml(l.icon)}</span>` : '<span style="font-size:20px">🔗</span>';
const embedUrl = l.embed_url || l.url;
const proxyParam = l.use_proxy ? '&proxy=1' : '';
return `<a href="${embedUrl ? '/embed.html?url=' + encodeURIComponent(embedUrl) + '&title=' + encodeURIComponent(l.title) + proxyParam : l.url}" target="${embedUrl ? '' : '_blank'}" class="card card-hover panel-card">
<div class="panel-icon">${icon}</div>
<div class="panel-info"><div class="panel-title">${escapeHtml(l.title)}${l.version ? ' <span style="font-size:12px;color:var(--md-ref-on-surface-variant);font-weight:400">' + escapeHtml(l.version) + '</span>' : ''}</div><div class="panel-desc">${escapeHtml(l.description || l.url)}</div></div>
<div class="panel-embed"><span class="material-icons">${embedUrl ? 'open_in_new' : 'launch'}</span></div>
</a>`;
}).join('');
}
if (links.length === 0) html = '<div class="empty-state" style="grid-column:1/-1"><div class="empty-icon">📋</div><p>暂无面板,请先在"面板链接"中添加</p></div>';
grid.innerHTML = html;
} catch (e) { grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="empty-icon">⚠️</div><p>' + escapeHtml(e.message) + '</p></div>'; }
}
// === Admin Links ===
async function loadLinks() {
const tbody = document.getElementById('linksBody');
try {
const links = await API.getAdminLinks();
if (links.length === 0) { tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">暂无面板</td></tr>'; return; }
tbody.innerHTML = links.map(l => `<tr>
<td>${l.sort_order}</td>
<td><strong>${escapeHtml(l.title)}</strong></td>
<td class="text-muted" style="font-size:13px">${l.version ? escapeHtml(l.version) : '-'}</td>
<td class="truncate" style="max-width:180px"><a href="${escapeHtml(l.url)}" target="_blank" style="color:var(--md-ref-primary)">${escapeHtml(l.url)}</a></td>
<td class="truncate" style="max-width:150px;font-size:13px;color:var(--md-ref-on-surface-variant)">${escapeHtml(l.embed_url || '-')}</td>
<td><span class="chip" style="cursor:default;font-size:12px">${escapeHtml(l.category)}</span></td>
<td><button class="btn btn-text btn-sm" onclick="editLink(${l.id})">编辑</button><button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="confirmDelete('link',${l.id},'${escapeHtml(l.title)}')"></button></td>
</tr>`).join('');
} catch (e) { tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">加载失败</td></tr>'; }
}
function openLinkDialog(data) {
['linkId','linkTitle','linkUrl','linkDesc','linkIcon','linkCategory','linkEmbedUrl','linkSort'].forEach(id => {
const el = document.getElementById(id); if (!el) return;
if (id === 'linkId') el.value = data ? data.id : '';
else if (id === 'linkSort') el.value = data ? data.sort_order : 0;
else el.value = data ? (data[id.replace('link', '').replace(/^(.)/, c => c.toLowerCase())] || '') : '';
});
// fix mapping
if (data) {
document.getElementById('linkTitle').value = data.title || '';
document.getElementById('linkUrl').value = data.url || '';
document.getElementById('linkDesc').value = data.description || '';
document.getElementById('linkIcon').value = data.icon || '';
document.getElementById('linkCategory').value = data.category || '默认';
document.getElementById('linkEmbedUrl').value = data.embed_url || '';
document.getElementById('linkProxy').checked = !!data.use_proxy;
document.getElementById('linkVersion').value = data.version || '';
document.getElementById('linkSort').value = data.sort_order || 0;
}
document.getElementById('linkDialogTitle').textContent = data ? '编辑面板' : '添加面板';
openDialog('linkDialog');
}
async function saveLink() {
const id = document.getElementById('linkId').value;
const data = { title: document.getElementById('linkTitle').value.trim(), url: document.getElementById('linkUrl').value.trim(),
description: document.getElementById('linkDesc').value.trim(), icon: document.getElementById('linkIcon').value.trim(),
category: document.getElementById('linkCategory').value.trim() || '默认',
embed_url: document.getElementById('linkEmbedUrl').value.trim(),
use_proxy: document.getElementById('linkProxy').checked ? 1 : 0,
version: document.getElementById('linkVersion').value.trim(),
sort_order: parseInt(document.getElementById('linkSort').value) || 0 };
if (!data.title || !data.url) { showSnackbar('标题和链接不能为空'); return; }
try {
if (id) await API.updateAdminLink(id, data);
else await API.createAdminLink(data);
showSnackbar('保存成功'); closeDialog('linkDialog'); loadLinks();
} catch (e) { showSnackbar(e.message); }
}
function editLink(id) { API.getAdminLinks().then(links => { const l = links.find(x => x.id === id); if (l) openLinkDialog(l); }); }
// === Settings ===
async function loadSettings() {
try {
const s = await API.getSettings();
document.getElementById('setSiteName').value = s.site_name || '';
document.getElementById('setSiteDesc').value = s.site_description || '';
document.getElementById('setSiteUrl').value = s.site_url || '';
document.getElementById('setSiteFavicon').value = s.site_favicon || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>';
document.getElementById('setRecaptchaSite').value = s.recaptcha_site_key || '';
document.getElementById('setRecaptchaSecret').value = '';
document.getElementById('setTurnstileSite').value = s.turnstile_site_key || '';
document.getElementById('setTurnstileSecret').value = '';
document.getElementById('captchaType').value = s.captcha_type || 'none';
document.getElementById('capLogin').checked = s.captcha_login === '1';
document.getElementById('capRegister').checked = s.captcha_register === '1';
document.getElementById('capForum').checked = s.captcha_forum === '1';
toggleCaptchaConfig();
} catch (e) { showSnackbar(e.message); }
}
async function saveSettings() {
try {
await API.saveSettings({
site_name: document.getElementById('setSiteName').value.trim(),
site_description: document.getElementById('setSiteDesc').value.trim(),
site_url: document.getElementById('setSiteUrl').value.trim(),
site_favicon: document.getElementById('setSiteFavicon').value.trim(),
recaptcha_site_key: document.getElementById('setRecaptchaSite').value.trim(),
recaptcha_secret_key: document.getElementById('setRecaptchaSecret').value.trim(),
turnstile_site_key: document.getElementById('setTurnstileSite').value.trim(),
turnstile_secret_key: document.getElementById('setTurnstileSecret').value.trim(),
captcha_type: document.getElementById('captchaType').value,
captcha_login: document.getElementById('capLogin').checked ? '1' : '0',
captcha_register: document.getElementById('capRegister').checked ? '1' : '0',
captcha_forum: document.getElementById('capForum').checked ? '1' : '0',
});
showSnackbar('设置已保存');
if (window.NAV) NAV.init();
} catch (e) { showSnackbar(e.message); }
}
async function loadThemeSettings() {
try {
const s = await API.getSettings();
document.getElementById('setPrimaryColor').value = s.primary_color || '#6750a4';
document.getElementById('setWallpaper').value = s.theme_wallpaper || '';
document.getElementById('setWallpaperScale').value = s.theme_wallpaper_scale || 'cover';
document.getElementById('setGlassBlur').value = s.glass_blur || '20';
document.getElementById('blurVal').textContent = (s.glass_blur || '20') + 'px';
document.getElementById('setGlassOpacity').value = s.glass_opacity || '0.6';
document.getElementById('opacityVal').textContent = s.glass_opacity || '0.6';
loadWallpaperList();
if (s.theme_wallpaper) previewWallpaperUrl(s.theme_wallpaper);
document.getElementById('setForceDark').checked = s.theme_force_dark === '1';
setNavStyle(s.nav_style || 'default');
setCardStyle(s.card_style || 'default');
} catch (e) { showSnackbar(e.message); }
}
function setNavStyle(val) {
document.querySelectorAll('#navStyleGroup .toggle-btn').forEach(el => el.classList.toggle('active', el.dataset.val === val));
window._navStyle = val;
}
function setCardStyle(val) {
document.querySelectorAll('#cardStyleGroup .toggle-btn').forEach(el => el.classList.toggle('active', el.dataset.val === val));
window._cardStyle = val;
}
function previewWallpaperUrl(url) {
const preview = document.getElementById('wallpaperPreview');
if (url) {
preview.style.display = 'block';
document.getElementById('wallpaperPreviewImg').src = url;
document.getElementById('wallpaperFilename').textContent = 'URL: ' + url;
} else { preview.style.display = 'none'; }
}
function removeWallpaper() {
document.getElementById('setWallpaper').value = '';
document.getElementById('wallpaperPreview').style.display = 'none';
}
async function loadWallpaperList() {
try {
const list = await API.request('GET', '/upload/wallpapers');
const container = document.getElementById('uploadedWallpapers');
if (list.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = list.map(f =>
`<div style="position:relative;cursor:pointer" onclick="selectUploadedWallpaper('${f.url}')">
<img src="${f.url}" style="width:100%;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--md-ref-outline-variant)" title="${f.filename}">
<div style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px;color:var(--md-ref-on-surface-variant)">${f.filename}</div>
</div>`).join('');
} catch {}
}
function selectUploadedWallpaper(url) {
document.getElementById('setWallpaper').value = url;
previewWallpaperUrl(url);
}
async function uploadWallpaper() {
const fileInput = document.getElementById('wallpaperFile');
if (!fileInput.files || !fileInput.files[0]) { showSnackbar('请选择图片文件'); return; }
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/wallpaper', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
document.getElementById('setWallpaper').value = data.url;
previewWallpaperUrl(data.url);
loadWallpaperList();
showSnackbar('上传成功');
} catch (e) { showSnackbar(e.message); }
}
async function saveThemeSettings() {
try {
const data = {
primary_color: document.getElementById('setPrimaryColor').value,
theme_wallpaper: document.getElementById('setWallpaper').value.trim(),
theme_wallpaper_scale: document.getElementById('setWallpaperScale').value,
theme_force_dark: document.getElementById('setForceDark').checked ? '1' : '0',
nav_style: window._navStyle || 'default',
card_style: window._cardStyle || 'default',
glass_blur: document.getElementById('setGlassBlur').value,
glass_opacity: document.getElementById('setGlassOpacity').value,
};
await API.saveSettings(data);
showSnackbar('已保存');
if (window.NAV) {
window.NAV.siteSettings = await API.getSettings();
window.NAV.applyTheme();
}
} catch (e) { showSnackbar(e.message); }
}
function setNavStyle(val) {
document.querySelectorAll('#navStyleGroup .toggle-btn').forEach(el => {
el.classList.toggle('active', el.dataset.val === val);
});
window._navStyle = val;
}
function setGlassComponents(val) {
document.querySelectorAll('#glassComponentsGroup .toggle-btn').forEach(el => {
el.classList.toggle('active', el.dataset.val === val);
});
window._glassComponents = val;
}
function setCardStyle(val) {
document.querySelectorAll('#cardStyleGroup .toggle-btn').forEach(el => {
el.classList.toggle('active', el.dataset.val === val);
});
window._cardStyle = val;
}
function previewWallpaperUrl(url) {
const preview = document.getElementById('wallpaperPreview');
if (url) {
preview.style.display = 'block';
document.getElementById('wallpaperPreviewImg').src = url;
document.getElementById('wallpaperFilename').textContent = 'URL: ' + url;
} else {
preview.style.display = 'none';
}
}
function removeWallpaper() {
document.getElementById('setWallpaper').value = '';
document.getElementById('wallpaperPreview').style.display = 'none';
}
async function loadWallpaperList() {
try {
const list = await API.request('GET', '/upload/wallpapers');
const container = document.getElementById('uploadedWallpapers');
if (list.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = list.map(f => `
<div style="position:relative;cursor:pointer" onclick="selectUploadedWallpaper('${f.url}')">
<img src="${f.url}" style="width:100%;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--md-ref-outline-variant)" title="${f.filename}">
<div style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px;color:var(--md-ref-on-surface-variant)">${f.filename}</div>
</div>
`).join('');
} catch {}
}
function selectUploadedWallpaper(url) {
document.getElementById('setWallpaper').value = url;
previewWallpaperUrl(url);
}
async function uploadWallpaper() {
const fileInput = document.getElementById('wallpaperFile');
if (!fileInput.files || !fileInput.files[0]) { showSnackbar('请选择图片文件'); return; }
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/wallpaper', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
document.getElementById('setWallpaper').value = data.url;
previewWallpaperUrl(data.url);
loadWallpaperList();
showSnackbar('上传成功');
} catch (e) { showSnackbar(e.message); }
}
// === Background Color ===
function applyBgColor(color) {
document.body.style.setProperty('--md-ref-background', color);
document.body.style.background = color;
}
// === Captcha Config Toggle ===
function toggleCaptchaConfig() {
const type = document.getElementById('captchaType').value;
document.getElementById('captchaScopeConfig').style.display = type === 'none' ? 'none' : 'block';
document.getElementById('recaptchaConfig').style.display = type === 'recaptcha' ? 'block' : 'none';
document.getElementById('turnstileConfig').style.display = type === 'turnstile' ? 'block' : 'none';
}
// === Email Settings ===
async function loadEmailSettings() {
try {
const s = await API.getSettings();
document.getElementById('setSmtpHost').value = s.smtp_host || '';
document.getElementById('setSmtpPort').value = s.smtp_port || '587';
document.getElementById('setSmtpUser').value = s.smtp_user || '';
document.getElementById('setSmtpFrom').value = s.smtp_from_email || '';
document.getElementById('setSmtpFromName').value = s.smtp_from_name || 'RainWeb';
} catch (e) { showSnackbar(e.message); }
}
async function saveSmtpSettings() {
try {
await API.saveSettings({ smtp_host: document.getElementById('setSmtpHost').value.trim(),
smtp_port: document.getElementById('setSmtpPort').value,
smtp_user: document.getElementById('setSmtpUser').value.trim(),
smtp_pass: document.getElementById('setSmtpPass').value,
smtp_from_email: document.getElementById('setSmtpFrom').value.trim(),
smtp_from_name: document.getElementById('setSmtpFromName').value.trim() });
showSnackbar('邮件配置已保存');
} catch (e) { showSnackbar(e.message); }
}
async function testSmtp() {
const email = prompt('请输入接收测试邮件的邮箱地址:', localStorage.getItem('username') + '@example.com');
if (!email) return;
try {
await API.request('POST', '/email/test', { email });
showSnackbar('测试邮件已发送至 ' + email);
} catch (e) { showSnackbar(e.message); }
}
// === Homepage ===
function buildContactsEditor(links) {
const container = document.getElementById('contactsEditor');
container.innerHTML = '';
const list = links || [];
for (let i = 0; i < Math.max(list.length, 1); i++) {
addContactRow(list[i] || { icon: 'link', url: '', title: '' });
}
}
function addContactRow(data) {
const container = document.getElementById('contactsEditor');
const rows = container.querySelectorAll('.contact-row');
if (rows.length >= 5) return;
const row = document.createElement('div');
row.className = 'contact-row';
row.style.cssText = 'display:flex;gap:6px;align-items:center';
row.innerHTML = `
<input type="text" class="ci-icon" placeholder="图标名" value="${escapeHtml(data.icon || 'link')}" style="width:80px;flex-shrink:0;font-size:13px;padding:8px 10px" title="Material 图标名称,如 github, send, mail">
<input type="url" class="ci-url" placeholder="https://..." value="${escapeHtml(data.url || '')}" style="flex:1;font-size:13px;padding:8px 10px">
<input type="text" class="ci-title" placeholder="标题(选填)" value="${escapeHtml(data.title || '')}" style="width:100px;flex-shrink:0;font-size:13px;padding:8px 10px">
<button class="btn btn-text btn-sm" style="flex-shrink:0;min-width:32px;padding:0;color:var(--md-ref-error)" onclick="this.parentElement.remove()" title="删除"></button>
`;
container.appendChild(row);
}
function collectContacts() {
const rows = document.querySelectorAll('#contactsEditor .contact-row');
const links = [];
rows.forEach(row => {
const icon = row.querySelector('.ci-icon').value.trim();
const url = row.querySelector('.ci-url').value.trim();
const title = row.querySelector('.ci-title').value.trim();
if (url) links.push({ icon: icon || 'link', url, title });
});
return links;
}
async function loadHomepage() {
try {
const s = await API.getSettings();
document.getElementById('hpAvatar').value = s.homepage_avatar || '';
document.getElementById('hpBio').value = s.homepage_bio || '';
document.getElementById('hpContent').value = s.homepage_content || '';
let contacts = [];
try { contacts = JSON.parse(s.homepage_contacts || '[]'); } catch {}
buildContactsEditor(contacts);
// Music embed
document.getElementById('musicEmbedEnabled').checked = s.music_embed_enabled === '1';
document.getElementById('musicEmbedCode').value = s.music_embed_code || '';
document.getElementById('musicEmbedPosition').value = s.music_embed_position || 'right';
document.getElementById('musicEmbedAutohide').checked = s.music_embed_autohide === '1';
document.getElementById('musicEmbedIdleTimeout').value = s.music_embed_idle_timeout || '10';
} catch (e) { showSnackbar(e.message); }
}
async function saveHomepage() {
try {
const contacts = collectContacts();
await API.saveSettings({
homepage_avatar: document.getElementById('hpAvatar').value.trim(),
homepage_bio: document.getElementById('hpBio').value.trim(),
homepage_content: document.getElementById('hpContent').value,
homepage_contacts: JSON.stringify(contacts),
music_embed_enabled: document.getElementById('musicEmbedEnabled').checked ? '1' : '0',
music_embed_code: document.getElementById('musicEmbedCode').value.trim(),
music_embed_position: document.getElementById('musicEmbedPosition').value,
music_embed_autohide: document.getElementById('musicEmbedAutohide').checked ? '1' : '0',
music_embed_idle_timeout: document.getElementById('musicEmbedIdleTimeout').value,
});
showSnackbar('已保存');
} catch (e) { showSnackbar(e.message); }
}
async function uploadHomepageFile() {
const input = document.createElement('input');
input.type = 'file';
input.onchange = async () => {
if (!input.files[0]) return;
const formData = new FormData();
formData.append('file', input.files[0]);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/file', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
const ta = document.getElementById('hpContent');
ta.value = ta.value + '\n' + data.tag + '\n';
ta.focus();
document.getElementById('hpUploadStatus').textContent = '已插入: ' + data.tag;
} catch (e) { showSnackbar(e.message); }
};
input.click();
}
// === Forum ===
async function loadForumCards() {
const container = document.getElementById('forumCards');
container.innerHTML = '<div class="loading" style="grid-column:1/-1"><div class="spinner"></div></div>';
try {
const cats = await API.getForumCategories();
const posts = await API.getForumPosts();
const replyCounts = {};
posts.forEach(p => { replyCounts[p.category_id] = (replyCounts[p.category_id] || 0) + (p.reply_count || 0); });
if (cats.length === 0) {
container.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="empty-icon">📋</div><p>暂无板块,点击"添加板块"创建</p></div>';
return;
}
container.innerHTML = cats.map(c => {
const postCount = posts.filter(p => p.category_id === c.id).length;
return `<div class="card" style="padding:20px;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:16px;font-weight:600">${escapeHtml(c.name)}</span>
<span class="chip" style="cursor:default;font-size:11px;padding:1px 8px">排序 ${c.sort_order}</span>
</div>
<p class="text-muted" style="font-size:13px;margin-bottom:8px;flex:1">${escapeHtml(c.description || '无描述')}</p>
${c.announcement ? `<div class="text-muted" style="font-size:12px;margin-bottom:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(c.announcement)}">📢 ${escapeHtml(c.announcement)}</div>` : ''}
<div class="text-muted" style="font-size:12px;margin-bottom:12px">${postCount} 个帖子</div>
<div style="display:flex;gap:8px;margin-top:auto">
<a href="/forum/manage/${c.id}" class="btn btn-filled btn-sm" style="flex:1"><span class="material-icons" style="font-size:16px">settings</span> </a>
<button class="btn btn-text btn-sm" onclick="editForumCat(${c.id})"><span class="material-icons" style="font-size:16px">edit</span></button>
<button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="confirmDelete('forumcat',${c.id},'${escapeHtml(c.name)}')"><span class="material-icons" style="font-size:16px">delete</span></button>
</div>
</div>`;
}).join('');
} catch (e) { container.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="empty-icon">⚠️</div><p>加载失败</p></div>'; }
}
function openForumCatDialog(data) {
document.getElementById('forumCatId').value = data ? data.id : '';
document.getElementById('forumCatName').value = data ? data.name : '';
document.getElementById('forumCatDesc').value = data ? (data.description || '') : '';
document.getElementById('forumCatAnnounce').value = data ? (data.announcement || '') : '';
document.getElementById('forumCatSubCats').value = data ? (data.sub_categories || '') : '';
document.getElementById('forumCatSort').value = data ? data.sort_order : 0;
document.getElementById('forumCatDialogTitle').textContent = data ? '编辑板块' : '添加板块';
openDialog('forumCatDialog');
}
async function saveForumCat() {
const id = document.getElementById('forumCatId').value;
const data = {
name: document.getElementById('forumCatName').value.trim(),
description: document.getElementById('forumCatDesc').value.trim(),
announcement: document.getElementById('forumCatAnnounce').value.trim(),
sub_categories: document.getElementById('forumCatSubCats').value.trim(),
sort_order: parseInt(document.getElementById('forumCatSort').value) || 0
};
if (!data.name) { showSnackbar('名称不能为空'); return; }
try { if (id) await API.updateForumCategory(id, data); else await API.createForumCategory(data); showSnackbar('保存成功'); closeDialog('forumCatDialog'); loadForumCards(); } catch (e) { showSnackbar(e.message); }
}
function editForumCat(id) { API.getForumCategories().then(cats => { const c = cats.find(x => x.id === id); if (c) openForumCatDialog(c); }); }
// === Blog ===
function loadBlogSidebar() {
const el = document.getElementById('setBlogSidebar');
if (!el) return;
API.getSettings().then(s => { el.checked = s.blog_show_sidebar !== '0'; }).catch(() => {});
}
function saveBlogSidebar() {
const el = document.getElementById('setBlogSidebar');
if (!el) return;
API.saveSettings({ blog_show_sidebar: el.checked ? '1' : '0' }).then(() => showSnackbar('已保存')).catch(e => showSnackbar(e.message));
}
async function loadBlogPosts() {
const tbody = document.getElementById('blogBody');
loadBlogSidebar();
try {
const posts = await API.getBlogPosts(true);
tbody.innerHTML = posts.length === 0 ? '<tr><td colspan="5" class="text-center text-muted">暂无文章</td></tr>' :
posts.map(p => `<tr><td><strong>${escapeHtml(p.title)}</strong></td>
<td><span class="chip" style="cursor:default;font-size:12px;background:${p.published?'var(--md-ref-primary-container)':'var(--md-ref-surface-variant)'}">${p.published?'已发布':'草稿'}</span></td>
<td><span class="chip" style="cursor:default;font-size:12px">${p.use_markdown?'Markdown':'纯文本'}</span></td>
<td class="text-muted" style="font-size:13px">${p.created_at}</td>
<td><button class="btn btn-text btn-sm" onclick="editBlogPost(${p.id})">编辑</button><button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="confirmDelete('blog',${p.id},'${escapeHtml(p.title)}')"></button></td></tr>`).join('');
} catch (e) { tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">加载失败</td></tr>'; }
}
function openBlogDialog(data) {
document.getElementById('blogId').value = data ? data.id : '';
document.getElementById('blogTitle').value = data ? data.title : '';
document.getElementById('blogExcerpt').value = data ? (data.excerpt || '') : '';
document.getElementById('blogContent').value = data ? data.content : '';
document.getElementById('blogPublished').checked = data ? !!data.published : true;
document.getElementById('blogPreview').style.display = 'none';
document.getElementById('blogDialogTitle').textContent = data ? '编辑文章' : '写文章';
openDialog('blogDialog');
}
async function saveBlogPost() {
const id = document.getElementById('blogId').value;
const data = { title: document.getElementById('blogTitle').value.trim(), content: document.getElementById('blogContent').value.trim(),
excerpt: document.getElementById('blogExcerpt').value.trim(), published: document.getElementById('blogPublished').checked, use_markdown: 1 };
if (!data.title || !data.content) { showSnackbar('标题和内容不能为空'); return; }
try {
if (id) await API.updateBlogPost(id, data); else await API.createBlogPost(data);
showSnackbar('保存成功'); closeDialog('blogDialog'); loadBlogPosts();
} catch (e) { showSnackbar(e.message); }
}
function editBlogPost(id) { API.getBlogPosts(true).then(list => { const p = list.find(x => x.id === id); if (p) openBlogDialog(p); }); }
// === Users ===
async function loadUsers() {
const tbody = document.getElementById('usersBody');
try {
const users = await API.getUsers();
tbody.innerHTML = users.map(u => `<tr><td>${u.id}</td><td><strong>${escapeHtml(u.username)}</strong></td>
<td class="text-muted">${escapeHtml(u.email||'-')}</td>
<td>${u.email_verified ? '<span class="chip" style="cursor:default;font-size:12px;background:var(--md-ref-primary-container)">已验证</span>' : '<span class="chip" style="cursor:default;font-size:12px;background:var(--md-ref-surface-variant)">未验证</span>'}</td>
<td><span class="chip" style="cursor:default;font-size:12px;background:${u.role==='admin'?'var(--md-ref-primary-container)':'var(--md-ref-surface-variant)'}">${u.role==='admin'?'管理员':'用户'}</span></td>
<td class="text-muted" style="font-size:13px">${u.created_at}</td>
<td style="white-space:nowrap">
<button class="btn btn-text btn-sm" onclick="openResetPw(${u.id},'${escapeHtml(u.username)}')">改密</button>
<button class="btn btn-text btn-sm" onclick="openRoleChange(${u.id},'${escapeHtml(u.username)}','${u.role}')">改权</button>
<button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="confirmDelete('user',${u.id},'${escapeHtml(u.username)}')">删除</button>
</td></tr>`).join('');
} catch (e) { tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted">加载失败</td></tr>'; }
}
async function saveUser() {
const username = document.getElementById('newUsername').value.trim();
const password = document.getElementById('newPassword').value;
const role = document.getElementById('newRole').value;
if (!username || !password) { showSnackbar('用户名和密码不能为空'); return; }
try { await API.registerByAdmin(username, password, role); showSnackbar('用户已创建'); closeDialog('userDialog'); document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; loadUsers(); } catch (e) { showSnackbar(e.message); }
}
function openUserDialog() { document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; document.getElementById('newRole').value = 'user'; openDialog('userDialog'); }
// === User Management Actions ===
let targetUserId = null;
function openResetPw(id, username) {
targetUserId = id;
document.getElementById('resetPwUser').textContent = '重置用户 ' + username + ' 的密码';
document.getElementById('resetPwInput').value = '';
document.getElementById('resetPwConfirm').value = '';
openDialog('resetPwDialog');
}
async function confirmResetPw() {
const newPw = document.getElementById('resetPwInput').value;
const confirm = document.getElementById('resetPwConfirm').value;
if (!newPw || newPw.length < 6) { showSnackbar('密码至少6位'); return; }
if (newPw !== confirm) { showSnackbar('两次密码不一致'); return; }
try {
await API.resetUserPassword(targetUserId, newPw);
showSnackbar('密码已重置');
closeDialog('resetPwDialog');
} catch (e) { showSnackbar(e.message); }
}
function openRoleChange(id, username, currentRole) {
targetUserId = id;
document.getElementById('roleUser').textContent = '修改用户 ' + username + ' 的角色';
document.getElementById('roleSelect').value = currentRole;
openDialog('roleDialog');
}
async function confirmRoleChange() {
const role = document.getElementById('roleSelect').value;
try {
await API.setUserRole(targetUserId, role);
showSnackbar('角色已更新');
closeDialog('roleDialog');
loadUsers();
} catch (e) { showSnackbar(e.message); }
}
// === Attachments ===
async function loadAttachments() {
const tbody = document.getElementById('attachmentsBody');
try {
const list = await API.request('GET', '/upload/list');
if (list.length === 0) { tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted">暂无附件</td></tr>'; return; }
tbody.innerHTML = list.map(f => `
<tr>
<td>${f.id}</td>
<td class="truncate" style="max-width:150px"><a href="/uploads/${f.filename}" target="_blank" style="color:var(--md-ref-primary)">${escapeHtml(f.filename)}</a></td>
<td class="truncate" style="max-width:150px" title="${escapeHtml(f.original_name)}">${escapeHtml(f.original_name)}</td>
<td>${(f.size / 1024).toFixed(0)} KB</td>
<td class="text-muted" style="font-size:13px">${f.mime_type || '-'}</td>
<td class="text-muted">UID ${f.user_id}</td>
<td class="text-muted" style="font-size:13px">${f.created_at}</td>
<td><button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="confirmDelete('attachment',${f.id},'${escapeHtml(f.filename)}')">删除</button></td>
</tr>`).join('');
} catch (e) { tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted">加载失败</td></tr>'; }
}
// === Image/File Upload ===
async function uploadBlogFile() {
const input = document.createElement('input');
input.type = 'file';
input.onchange = async () => {
if (!input.files[0]) return;
const formData = new FormData();
formData.append('file', input.files[0]);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/file', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
const ta = document.getElementById('blogContent');
ta.value = ta.value + '\n' + data.tag + '\n';
ta.focus();
document.getElementById('blogUploadStatus').textContent = '已插入: ' + data.tag;
} catch (e) { showSnackbar(e.message); }
};
input.click();
}
// === Check for Updates ===
async function checkUpdate() {
const statusEl = document.getElementById('updateStatus');
statusEl.style.display = 'block';
statusEl.innerHTML = '<div class="loading" style="padding:8px"><div class="spinner" style="width:16px;height:16px"></div> 检查中...</div>';
try {
const r = await API.request('GET', '/update/check');
document.getElementById('localVersion').textContent = r.local || '?';
document.getElementById('remoteVersion').textContent = r.remote || '连接失败';
if (r.hasUpdate) {
statusEl.innerHTML = '<div style="color:var(--md-ref-primary);font-weight:500">📦 发现新版本 ' + r.remote + ',点击下方按钮更新</div>';
document.getElementById('updateBtn').style.display = 'inline-flex';
} else if (r.remote) {
statusEl.innerHTML = '<div style="color:var(--md-ref-on-surface-variant)">✅ 已是最新版本</div>';
document.getElementById('updateBtn').style.display = 'none';
} else {
statusEl.innerHTML = '<div style="color:var(--md-ref-error)">❌ ' + (r.error || '检查失败') + '</div>';
}
} catch (e) {
statusEl.innerHTML = '<div style="color:var(--md-ref-error)">❌ ' + e.message + '</div>';
}
}
async function runUpdate() {
if (!confirm('确定要更新吗?更新完成后需要手动重启服务。')) return;
const statusEl = document.getElementById('updateStatus');
const btn = document.getElementById('updateBtn');
btn.disabled = true;
btn.textContent = '更新中...';
statusEl.innerHTML = '<div class="loading" style="padding:8px"><div class="spinner" style="width:16px;height:16px"></div> 下载并安装更新...</div>';
try {
const r = await API.request('POST', '/update/run');
statusEl.innerHTML = '<div style="color:var(--md-ref-primary);font-weight:500">✅ ' + r.message + '</div>';
btn.style.display = 'none';
} catch (e) {
statusEl.innerHTML = '<div style="color:var(--md-ref-error)">❌ ' + e.message + '</div>';
btn.disabled = false;
btn.textContent = '立即更新';
}
}
// === Data Import ===
async function importDatabase() {
const input = document.getElementById('importDbFile');
if (!input.files || !input.files[0]) { showSnackbar('请选择 data.db 文件'); return; }
const resultDiv = document.getElementById('importResult');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="loading" style="padding:16px"><div class="spinner" style="width:20px;height:20px"></div>导入中...</div>';
const formData = new FormData();
formData.append('file', input.files[0]);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/import/database', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '导入失败');
let html = '<div class="card" style="padding:16px;font-size:14px">';
html += '<div style="font-weight:600;margin-bottom:8px">✅ 导入完成,共 ' + data.total + ' 条记录</div>';
for (const [table, count] of Object.entries(data.details)) {
html += '<div style="margin:2px 0;color:var(--md-ref-on-surface-variant)">' + table + ': ' + count + ' 条</div>';
}
if (data.errors && data.errors.length > 0) {
html += '<div style="margin-top:8px;color:var(--md-ref-error);font-size:13px">警告:<br>' + data.errors.slice(0,5).join('<br>') + '</div>';
}
html += '</div>';
resultDiv.innerHTML = html;
} catch (e) {
resultDiv.innerHTML = '<div style="color:var(--md-ref-error);padding:12px">导入失败: ' + e.message + '</div>';
}
}
// === Confirm Delete ===
let pendingDelete = null;
function confirmDelete(type, id, label) {
pendingDelete = { type, id };
document.getElementById('confirmMsg').textContent = '确定要删除 "' + label + '" 吗?';
document.getElementById('confirmBtn').onclick = executeDelete;
openDialog('confirmDialog');
}
async function executeDelete() {
if (!pendingDelete) return;
const { type, id } = pendingDelete;
try {
if (type === 'link') await API.deleteAdminLink(id);
else if (type === 'forumcat') await API.deleteForumCategory(id);
else if (type === 'forumpost') await API.deleteForumPost(id);
else if (type === 'blog') await API.deleteBlogPost(id);
else if (type === 'user') await API.deleteUser(id);
else if (type === 'attachment') await API.request('DELETE', '/upload/' + id);
showSnackbar('删除成功'); closeDialog('confirmDialog'); pendingDelete = null;
if (type === 'link') loadLinks();
else if (type === 'forumcat' || type === 'forumpost') { loadForumCards(); }
else if (type === 'blog') loadBlogPosts();
else if (type === 'user') loadUsers();
} catch (e) { showSnackbar(e.message); }
}
// === Init ===
var ADMIN = {
init: async function () {
var user = await checkAuth();
if (user) {
var tabMatch = location.search.match(/tab=(\w+)/);
switchTab(tabMatch ? tabMatch[1] : 'panels');
}
}
};
-90
View File
@@ -1,90 +0,0 @@
const API = {
base: '/api',
async request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
const token = localStorage.getItem('token');
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(this.base + path, opts);
const data = await res.json();
if (!res.ok) {
const err = new Error(data.error || '请求失败');
// Preserve extra fields from response (e.g. needs_captcha)
for (const k of Object.keys(data)) if (k !== 'error') err[k] = data[k];
throw err;
}
return data;
},
// Auth
login(username, password, captcha_token) { const d = { username, password }; if (captcha_token) d.captcha_token = captcha_token; return this.request('POST', '/auth/login', d); },
register(username, password, email, captcha_token) { return this.request('POST', '/auth/register', { username, password, email, captcha_token }); },
getMe() { return this.request('GET', '/auth/me'); },
registerByAdmin(username, password, role) { return this.request('POST', '/auth/register-by-admin', { username, password, role }); },
getUsers() { return this.request('GET', '/auth/users'); },
deleteUser(id) { return this.request('DELETE', '/auth/users/' + id); },
resetUserPassword(id, newPassword) { return this.request('PUT', '/auth/users/' + id + '/password', { newPassword }); },
setUserRole(id, role) { return this.request('PUT', '/auth/users/' + id + '/role', { role }); },
// Settings
getPublicSettings() { return this.request('GET', '/settings/public'); },
getSettings() { return this.request('GET', '/settings'); },
saveSettings(data) { return this.request('PUT', '/settings', data); },
// Admin Links
getAdminLinks() { return this.request('GET', '/admin-links'); },
createAdminLink(data) { return this.request('POST', '/admin-links', data); },
updateAdminLink(id, data) { return this.request('PUT', '/admin-links/' + id, data); },
deleteAdminLink(id) { return this.request('DELETE', '/admin-links/' + id); },
// Forum
getForumCategories() { return this.request('GET', '/forum/categories'); },
createForumCategory(data) { return this.request('POST', '/forum/categories', data); },
updateForumCategory(id, data) { return this.request('PUT', '/forum/categories/' + id, data); },
deleteForumCategory(id) { return this.request('DELETE', '/forum/categories/' + id); },
getForumPosts(catId) { return this.request('GET', '/forum/posts' + (catId ? '?category_id=' + catId : '')); },
getForumPost(id) { return this.request('GET', '/forum/posts/' + id); },
createForumPost(data) { return this.request('POST', '/forum/posts', data); },
createForumReply(postId, content) { return this.request('POST', '/forum/posts/' + postId + '/replies', { content }); },
deleteForumPost(id) { return this.request('DELETE', '/forum/posts/' + id); },
deleteForumReply(id) { return this.request('DELETE', '/forum/replies/' + id); },
// Blog
getBlogPosts(all) { return this.request('GET', '/blog/posts' + (all ? '?all=1' : '')); },
getBlogPost(id) { return this.request('GET', '/blog/posts/' + id); },
createBlogPost(data) { return this.request('POST', '/blog/posts', data); },
updateBlogPost(id, data) { return this.request('PUT', '/blog/posts/' + id, data); },
deleteBlogPost(id) { return this.request('DELETE', '/blog/posts/' + id); },
// Passwords
getPinStatus() { return this.request('GET', '/passwords/pin-status'); },
setPin(pin) { return this.request('POST', '/passwords/set-pin', { pin }); },
unlock(pin) { return this.request('POST', '/passwords/unlock', { pin }); },
lock() { return this.request('POST', '/passwords/lock'); },
getPasswords() { return this.request('GET', '/passwords'); },
createPassword(data) { return this.request('POST', '/passwords', data); },
updatePassword(id, data) { return this.request('PUT', '/passwords/' + id, data); },
deletePassword(id) { return this.request('DELETE', '/passwords/' + id); },
// Email / SMTP
testSmtp(email) { return this.request('POST', '/email/test', { email }); },
sendVerify(email, username) { return this.request('POST', '/email/send-verify', { email, username }); },
completeRegister(code, username, password) { return this.request('POST', '/email/complete-register', { code, username, password }); },
};
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);
}
-69
View File
@@ -1,69 +0,0 @@
async function loadSidebar() {
try {
const s = await API.getPublicSettings();
const sidebar = document.getElementById('blogSidebar');
if (s.blog_show_sidebar === '0') {
if (sidebar) sidebar.style.display = 'none';
return;
}
if (sidebar) sidebar.style.display = '';
if (s.homepage_avatar) {
document.getElementById('hpAvatar').src = s.homepage_avatar;
document.getElementById('hpAvatar').style.display = 'block';
document.getElementById('hpAvatarPlaceholder').style.display = 'none';
}
document.getElementById('hpBio').textContent = s.homepage_bio || '';
} catch {}
}
async function loadPosts() {
const container = document.getElementById('blogList');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
document.getElementById('blogDetail').style.display = 'none';
document.getElementById('blogList').style.display = 'block';
try {
const posts = await API.getBlogPosts(false);
if (posts.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-icon">📖</div><p>暂无文章</p></div>';
return;
}
container.innerHTML = '<div class="blog-grid">' + posts.map(p => `
<div class="card blog-card" onclick="viewPost(${p.id})">
<div class="blog-title">${escapeHtml(p.title)}</div>
<div class="blog-excerpt">${escapeHtml(p.excerpt || p.content.replace(/[#*`\[\]()>|~_]/g,'').slice(0, 200))}</div>
<div class="blog-meta">${escapeHtml(p.author_name || '管理员')} · ${p.created_at}</div>
</div>
`).join('') + '</div>';
} catch (e) {
container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></div>';
}
}
async function viewPost(id) {
const list = document.getElementById('blogList');
const detail = document.getElementById('blogDetail');
list.style.display = 'none';
detail.style.display = 'block';
detail.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const post = await API.getBlogPost(id);
detail.innerHTML = `
<div class="blog-article">
<button class="btn btn-text btn-sm" onclick="loadPosts()" style="margin-bottom:16px">
<span class="material-icons" style="font-size:16px">arrow_back</span>
</button>
<h1 class="article-title">${escapeHtml(post.title)}</h1>
<div class="article-meta">${escapeHtml(post.author_name || '管理员')} · ${post.created_at}</div>
<div class="article-body">${renderContent(post.content, post.use_markdown)}</div>
</div>`;
} catch (e) {
detail.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></div>';
}
}
var BLOG = {
init: async function () {
await loadSidebar();
loadPosts();
}
};
-179
View File
@@ -1,179 +0,0 @@
const CAPTCHA = {
currentToken: null, verified: false, modalOverlay: null,
async checkRequired(action) {
try { return await API.request('POST', '/captcha/required', { action }); } catch { return { required: false }; }
},
async loadImage() {
try { const r = await API.request('GET', '/captcha/image'); this.currentToken = r.token; return r; } catch { return null; }
},
async verify(answer) {
if (!this.currentToken || !answer) return { success: false };
try { const r = await API.request('POST', '/captcha/verify', { token: this.currentToken, answer }); if (r.success) this.currentToken = null; return r; } catch { return { success: false }; }
},
showModal(action) {
return new Promise(async (resolve) => {
const r = await this.checkRequired(action);
if (!r.required) { resolve(true); return; }
this._removeModal();
if (r.type === 'builtin') this._showBuiltinModal(resolve);
else if (r.type === 'recaptcha') this._showRecaptchaModal(resolve);
else if (r.type === 'turnstile') this._showTurnstileModal(resolve);
else resolve(true);
});
},
async _showBuiltinModal(resolve) {
const overlay = this._createOverlay(`
<div class="dialog" style="max-width:380px;text-align:center">
<h3 style="margin-bottom:12px">验证码</h3>
<div id="capImg" style="margin:0 auto 12px;max-width:280px"><div class="spinner" style="width:24px;height:24px;margin:16px auto"></div></div>
<div style="display:flex;gap:8px;align-items:center;justify-content:center">
<input type="text" id="capInput" placeholder="输入验证码" maxlength="6" style="flex:1;text-align:center;font-size:20px;letter-spacing:6px;text-transform:uppercase" autocomplete="off">
<button class="btn btn-icon" id="capRefresh" title="刷新" style="flex-shrink:0"><span class="material-icons">refresh</span></button>
</div>
<div id="capError" style="color:var(--md-ref-error);font-size:13px;margin-top:8px;display:none"></div>
<div id="powStatus" style="display:flex;align-items:center;justify-content:center;gap:6px;margin-top:8px;font-size:13px;color:var(--md-ref-on-surface-variant)">
<span class="material-icons" id="powIcon" style="font-size:16px">smart_toy</span> <span id="powText">...</span>
</div>
<div class="actions" style="justify-content:center;margin-top:16px">
<button class="btn btn-text" id="capCancel">取消</button>
<button class="btn btn-filled" id="capConfirm">确认</button>
</div>
</div>`);
const loadImg = async () => {
const data = await this.loadImage();
if (!data) return;
const d = document.getElementById('capImg');
if (d) { d.innerHTML = data.svg; const s = d.querySelector('svg'); if (s) s.style.cssText = 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block'; }
};
await loadImg();
let powDone = false;
this._runPowWithUI().then(() => { powDone = true; const el = document.getElementById('powStatus'); if (el) { el.innerHTML = '<span class=material-icons style=font-size:16px;color:#4caf50>check_circle</span> <span style=color:#4caf50>智能验证通过</span>'; } }).catch(() => {});
document.getElementById('capRefresh').onclick = () => { document.getElementById('capInput').value = ''; document.getElementById('capError').style.display = 'none'; loadImg(); };
document.getElementById('capCancel').onclick = () => { this._removeModal(); this.modalOverlay = null; resolve(false); };
document.getElementById('capConfirm').onclick = async () => {
if (!powDone) { document.getElementById('capError').textContent = '智能验证尚未完成,请稍候...'; document.getElementById('capError').style.display = 'block'; return; }
const val = document.getElementById('capInput').value.trim();
if (!val || !this.currentToken) { document.getElementById('capError').textContent = '请输入验证码'; document.getElementById('capError').style.display = 'block'; return; }
const v = await this.verify(val);
if (v.success) { this._removeModal(); this.modalOverlay = null; this.verified = true; resolve(true); }
else {
document.getElementById('capError').textContent = v.error || '验证码错误';
document.getElementById('capError').style.display = 'block';
this.currentToken = null;
document.getElementById('capInput').value = '';
loadImg();
}
};
const inp = document.getElementById('capInput');
inp.onkeydown = (e) => { if (e.key === 'Enter') document.getElementById('capConfirm').click(); };
setTimeout(() => inp.focus(), 100);
},
_showRecaptchaModal(resolve) {
const siteKey = window._recaptchaSiteKey || '';
if (!siteKey) { resolve(false); return; }
const overlay = this._createOverlay(`
<div class="dialog" style="max-width:400px;text-align:center">
<h3 style="margin-bottom:16px">Google reCAPTCHA</h3>
<div id="capWidget" style="display:flex;justify-content:center;margin:16px 0"></div>
<p id="capStatus" class="text-muted" style="font-size:13px">正在加载...</p>
<div class="actions" style="justify-content:center"><button class="btn btn-text" id="capCancel">取消</button></div>
</div>`);
const wd = document.getElementById('capWidget'), st = document.getElementById('capStatus');
let done = false;
const finish = (ok) => { if (!done) { done = true; this._removeModal(); this.modalOverlay = null; if (ok) this.verified = true; resolve(ok); } };
document.getElementById('capCancel').onclick = () => finish(false);
const render = () => {
try {
grecaptcha.render(wd, { sitekey: siteKey, callback: () => { st.textContent = '验证通过'; setTimeout(() => finish(true), 300); }, 'expired-callback': () => { st.textContent = '验证已过期'; } });
st.textContent = '请完成验证';
} catch { st.textContent = '加载失败'; setTimeout(() => finish(false), 2000); }
};
if (typeof grecaptcha !== 'undefined') render();
else {
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
window.recaptchaCallbacks.push(render);
if (!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);
}
}
},
_showTurnstileModal(resolve) {
const siteKey = window._turnstileSiteKey || '';
if (!siteKey) { console.warn('Turnstile: site key not configured'); resolve(false); return; }
const overlay = this._createOverlay(`
<div class="dialog" style="max-width:400px;text-align:center">
<h3 style="margin-bottom:16px">Cloudflare Turnstile</h3>
<div id="capWidget" style="display:flex;justify-content:center;margin:16px 0"></div>
<p id="capStatus" class="text-muted" style="font-size:13px">正在加载...</p>
<div class="actions" style="justify-content:center"><button class="btn btn-text" id="capCancel">取消</button></div>
</div>`);
const wd = document.getElementById('capWidget'), st = document.getElementById('capStatus');
let done = false;
const finish = (ok) => { if (!done) { done = true; this._removeModal(); this.modalOverlay = null; if (ok) this.verified = true; resolve(ok); } };
document.getElementById('capCancel').onclick = () => finish(false);
const render = () => {
try {
turnstile.render(wd, { sitekey: siteKey, callback: () => { st.textContent = '验证通过'; setTimeout(() => finish(true), 300); }, 'expired-callback': () => { st.textContent = '验证已过期'; } });
st.textContent = '请完成验证';
} catch { st.textContent = '加载失败'; setTimeout(() => finish(false), 2000); }
};
if (typeof turnstile !== 'undefined') render();
else {
window.turnstileCallbacks = window.turnstileCallbacks || [];
window.turnstileCallbacks.push(render);
if (!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);
}
}
},
_createOverlay(html) {
this._removeModal();
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay active';
overlay.style.cssText = 'display:flex;z-index:9999';
overlay.innerHTML = html;
document.body.appendChild(overlay);
this.modalOverlay = overlay;
return overlay;
},
_removeModal() { if (this.modalOverlay) { this.modalOverlay.remove(); this.modalOverlay = null; } },
async _runPowWithUI() {
try {
const chal = await API.request('GET', '/captcha/pow-challenge');
if (!chal.token) return;
let nonce = 0;
const target = '0'.repeat(chal.difficulty);
const start = Date.now();
while (Date.now() - start < 20000) {
const hash = await this._sha256(chal.prefix + nonce);
if (hash.startsWith(target)) { await API.request('POST', '/captcha/pow-verify', { token: chal.token, nonce: String(nonce) }); return; }
nonce++;
}
} catch {}
},
async _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('');
},
reset() { this.verified = false; }
};
window.onRecaptchaLoad = function() { (window.recaptchaCallbacks || []).forEach(cb => cb()); };
window.onTurnstileLoad = function() { (window.turnstileCallbacks || []).forEach(cb => cb()); };
-272
View File
@@ -1,272 +0,0 @@
let categories = [];
let currentCatId = null;
let currentPostId = null;
let currentUser = null;
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function closeDialog(id) { document.getElementById(id).classList.remove('active'); }
function openDialog(id) { document.querySelectorAll('.dialog-overlay.active').forEach(el => el.classList.remove('active')); document.getElementById(id).classList.add('active'); }
async function checkAuth() {
const token = localStorage.getItem('token');
if (!token) return null;
try { currentUser = await API.getMe(); return currentUser; } catch { return null; }
}
async function loadCategories() {
categories = await API.getForumCategories();
const list = document.getElementById('categoryList');
list.innerHTML = categories.map(c =>
`<div class="forum-cat-item${c.id === currentCatId ? ' active' : ''}" onclick="selectCategory(${c.id})">
<span class="material-icons" style="font-size:18px">forum</span> ${escapeHtml(c.name)}
${c.announcement ? '<span class="material-icons" style="font-size:14px;color:var(--md-ref-primary)">campaign</span>' : ''}
</div>`
).join('');
}
function showAllPosts() {
currentCatId = null;
currentPostId = null;
loadAllPosts();
document.querySelectorAll('.forum-cat-item').forEach(el => el.classList.remove('active'));
document.querySelector('.forum-cat-item:last-child').classList.add('active');
}
function selectCategory(catId) {
currentCatId = catId;
currentPostId = null;
loadCategoryPosts(catId, '');
document.querySelectorAll('.forum-cat-item').forEach(el => el.classList.remove('active'));
const idx = categories.findIndex(c => c.id === catId);
if (idx >= 0) document.querySelectorAll('.forum-cat-item')[idx].classList.add('active');
}
// Load all latest posts (homepage view)
async function loadAllPosts() {
const container = document.getElementById('forumContent');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const posts = await API.getForumPosts();
renderPostList(container, posts);
} catch { container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></div>'; }
}
// Load posts for a specific board
async function loadCategoryPosts(catId, filterSub) {
const container = document.getElementById('forumContent');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const cat = categories.find(c => c.id === catId);
// Board header
let header = `<div style="margin-bottom:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<h3 style="font-weight:600;font-size:20px;margin:0">${escapeHtml(cat ? cat.name : '')}</h3>
<span class="chip" style="cursor:default;font-size:12px;padding:1px 8px;color:var(--md-ref-on-surface-variant);border:1px solid var(--md-ref-outline-variant)">板块</span>
<a href="/forum.html?board=${catId}&full=1" class="btn-icon" title="全屏板块" style="width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;padding:0" onclick="event.stopPropagation()"><span class="material-icons" style="font-size:18px;line-height:1">open_in_full</span></a>
</div>
<p class="text-muted" style="font-size:14px">${escapeHtml(cat ? (cat.description || '') : '')}</p>`;
if (cat && cat.announcement) {
header += `<div class="announcement-bar" style="margin-top:8px"><span class="material-icons" style="font-size:18px">campaign</span> ${escapeHtml(cat.announcement)}</div>`;
}
// Sub-category filter chips
const subCats = cat?.sub_categories ? cat.sub_categories.split(',').filter(Boolean).map(t => t.trim()) : [];
if (subCats.length > 0) {
header += `<div class="chips" style="margin-bottom:12px;margin-top:12px">
<span class="chip${!filterSub ? ' active' : ''}" onclick="loadCategoryPosts(${catId}, '')">全部</span>
${subCats.map(s => `<span class="chip${filterSub === s ? ' active' : ''}" onclick="loadCategoryPosts(${catId}, '${escapeHtml(s)}')">${escapeHtml(s)}</span>`).join('')}
</div>`;
}
header += '</div>';
container.innerHTML = header;
const posts = await API.getForumPosts(catId);
const filtered = filterSub ? posts.filter(p => p.sub_category === filterSub) : posts;
if (filtered.length === 0) {
container.innerHTML += '<div class="empty-state"><div class="empty-icon">📝</div><p>暂无帖子</p></div>';
return;
}
renderPostList(container, filtered, header);
} catch { container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></div>'; }
}
function renderPostList(container, posts, headerHtml) {
const list = posts.map(p => {
const subCat = p.sub_category ? `<span class="chip" style="cursor:default;font-size:11px;padding:1px 8px;background:var(--md-ref-secondary-container);color:var(--md-ref-on-secondary-container)">${escapeHtml(p.sub_category)}</span>` : '';
const catName = categories.find(c => c.id === p.category_id)?.name || '';
return `<div class="card forum-post-card" onclick="viewPost(${p.id})">
<div class="post-title">${escapeHtml(p.title)}</div>
<div class="post-meta" style="margin-bottom:4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>${escapeHtml(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 class="chip" style="cursor:default;font-size:11px;padding:1px 8px;background:transparent;border:1px solid var(--md-ref-outline-variant);color:var(--md-ref-on-surface-variant)">${escapeHtml(catName)}</span>
${subCat}
</div>
</div>`;
}).join('');
container.innerHTML = (headerHtml || '') + '<div class="forum-post-list">' + list + '</div>';
}
async function viewPost(postId) {
currentPostId = postId;
const container = document.getElementById('forumContent');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
history.pushState({ forumPostId: postId }, '', '/forum/' + postId);
try {
const data = await API.getForumPost(postId);
const { post, replies } = data;
const isOwner = currentUser && (currentUser.username === post.author_name || currentUser.role === 'admin');
const subCat = post.sub_category ? `<span class="chip" style="cursor:default;font-size:12px;padding:2px 10px;background:var(--md-ref-secondary-container);color:var(--md-ref-on-secondary-container)">${escapeHtml(post.sub_category)}</span>` : '';
container.innerHTML = `
<div class="post-detail">
<div class="post-header">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<button class="btn btn-text btn-sm" onclick="backToList()">
<span class="material-icons" style="font-size:16px">arrow_back</span>
</button>
${isOwner ? `<button class="btn btn-text btn-sm" style="color:var(--md-ref-error);margin-left:auto" onclick="deletePost(${post.id})">删除</button>` : ''}
</div>
<h3 style="font-size:22px;font-weight:600">${escapeHtml(post.title)}</h3>
<div class="post-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>${escapeHtml(post.author_name || '匿名')}</span>
<span>${post.created_at}</span>
<span class="chip" style="cursor:default;background:var(--md-ref-secondary-container);color:var(--md-ref-on-secondary-container);font-size:12px;padding:2px 10px">${escapeHtml(post.category_name || '')}</span>
${subCat}
</div>
</div>
<div class="post-body">${renderContent(post.content, 1)}</div>
<hr style="border:none;border-top:2px solid var(--md-ref-primary-container);margin:24px 0;border-radius:2px">
<h4 style="font-weight:500;margin-bottom:16px">回复 (${replies.length})</h4>
${currentUser
? `<div style="display:flex;gap:8px;margin-bottom:16px">
<textarea id="forumReplyInput" placeholder="写下你的回复...(支持 Markdown" style="flex:1;min-height:60px;font-size:14px;font-family:monospace"></textarea>
<button class="btn btn-filled btn-sm" style="align-self:flex-end" onclick="submitForumReply(${post.id})">回复</button>
</div>`
: '<p class="text-muted" style="margin-bottom:16px;font-size:14px"><a href="/login.html" style="color:var(--md-ref-primary)">登录</a>后可以回复</p>'}
${replies.length === 0 ? '<div class="text-muted" style="padding:16px">暂无回复</div>' :
replies.map(r => `
<div class="reply-item">
<div class="reply-meta">
<strong>${escapeHtml(r.author_name || '匿名')}</strong> · ${r.created_at}
${(currentUser && (currentUser.username === r.author_name || currentUser.role === 'admin'))
? `<span style="float:right;color:var(--md-ref-error);cursor:pointer;font-size:13px" onclick="deleteReply(${r.id})">删除</span>` : ''}
</div>
<div class="reply-body">${escapeHtml(r.content)}</div>
</div>
`).join('')}
</div>`;
} catch { container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></div>'; }
}
function backToList() {
if (currentCatId) selectCategory(currentCatId);
else showAllPosts();
}
function showNewPost() {
if (!currentUser) { showSnackbar('请先登录'); return; }
const sel = document.getElementById('postCategory');
sel.innerHTML = categories.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
// Update sub-category options when board changes
sel.onchange = () => updateSubCatOptions();
updateSubCatOptions();
document.getElementById('postTitle').value = '';
document.getElementById('postContent').value = '';
document.getElementById('forumUploadStatus').innerHTML = '';
CAPTCHA.verified = false;
openDialog('newPostDialog');
}
function updateSubCatOptions() {
const sel = document.getElementById('postCategory');
const subSel = document.getElementById('postSubCategory');
const catId = parseInt(sel.value);
const cat = categories.find(c => c.id === catId);
const sc = cat?.sub_categories ? cat.sub_categories.split(',').filter(Boolean).map(t => t.trim()) : [];
subSel.innerHTML = '<option value="">无</option>' + sc.map(s => `<option value="${s}">${escapeHtml(s)}</option>`).join('');
}
async function submitPost() {
const data = {
category_id: parseInt(document.getElementById('postCategory').value),
title: document.getElementById('postTitle').value.trim(),
content: document.getElementById('postContent').value.trim(),
sub_category: document.getElementById('postSubCategory').value,
use_markdown: 1
};
if (!data.title || !data.content) { showSnackbar('标题和内容不能为空'); return; }
const capOk = await CAPTCHA.showModal('forum');
if (!capOk) return;
try {
await API.createForumPost(data);
showSnackbar('发布成功');
closeDialog('newPostDialog');
if (currentCatId) loadCategoryPosts(currentCatId);
else loadAllPosts();
} catch (e) { showSnackbar(e.message); }
}
async function uploadForumFile() {
const input = document.createElement('input'); input.type = 'file';
input.onchange = async () => {
if (!input.files[0]) return;
const formData = new FormData(); formData.append('file', input.files[0]);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/file', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
const ta = document.getElementById('postContent');
ta.value = ta.value + '\n' + data.tag + '\n'; ta.focus();
document.getElementById('forumUploadStatus').textContent = '已插入: ' + data.tag;
} catch (e) { showSnackbar(e.message); }
}; input.click();
}
function showReply(postId) { if (!currentUser) { showSnackbar('请先登录'); return; } document.getElementById('forumReplyInput')?.focus(); }
async function submitForumReply(postId) {
const input = document.getElementById('forumReplyInput');
if (!input) return;
const content = input.value.trim();
if (!content) { showSnackbar('回复内容不能为空'); return; }
try { await API.createForumReply(postId, content); showSnackbar('回复成功'); viewPost(postId); } catch (e) { showSnackbar(e.message); }
}
async function deletePost(id) { if (!confirm('确定删除?')) return; try { await API.deleteForumPost(id); if (currentCatId) selectCategory(currentCatId); else showAllPosts(); } catch (e) { showSnackbar(e.message); } }
async function deleteReply(id) { if (!confirm('确定删除?')) return; try { await API.deleteForumReply(id); if (currentPostId) viewPost(currentPostId); } catch (e) { showSnackbar(e.message); } }
var FORUM = {
init: async function () {
await checkAuth();
await loadCategories();
// Check URL for post, board, or full board mode
var postMatch = location.pathname.match(/^\/forum\/(\d+)$/);
var params = new URLSearchParams(location.search);
var boardParam = params.get('board');
var fullMode = params.get('full') === '1';
if (postMatch) {
selectCategory(parseInt(postMatch[1])); // will show post
} else if (boardParam) {
selectCategory(parseInt(boardParam));
if (fullMode) {
var sidebar = document.querySelector('.forum-sidebar');
if (sidebar) sidebar.style.display = 'none';
document.querySelector('.forum-layout').style.gridTemplateColumns = '1fr';
}
} else {
showAllPosts();
}
if (!currentUser) { document.getElementById('newPostBtn').textContent = '登录发帖'; document.getElementById('newPostBtn').onclick = function () { window.location.href = '/login.html'; }; }
}
};
window.addEventListener('popstate', function () {
if (!location.pathname.startsWith('/forum')) return;
var pm = location.pathname.match(/^\/forum\/(\d+)$/);
if (pm) viewPost(parseInt(pm[1]));
else if (currentCatId) selectCategory(currentCatId);
else showAllPosts();
});
-101
View File
@@ -1,101 +0,0 @@
let currentUser = null;
async function loadPosts() {
const container = document.getElementById('blogList');
container.innerHTML = '<div class="loading" style="column-span:all"><div class="spinner"></div></div>';
document.getElementById('blogDetail').style.display = 'none';
document.getElementById('blogList').style.display = 'block';
document.getElementById('blogHeader').style.display = 'block';
try {
const posts = await API.getBlogPosts(false);
if (posts.length === 0) {
container.innerHTML = '<div class="empty-state" style="column-span:all"><div class="empty-icon">📖</div><p>暂无文章</p></div>';
return;
}
container.innerHTML = posts.map(p => {
const excerpt = p.excerpt || p.content.replace(/[#*`\[\]()>|~_]/g,'').slice(0, 150);
return `<div class="card blog-card" onclick="viewPost(${p.id})">
<div class="blog-featured"></div>
<div class="blog-title">${escapeHtml(p.title)}</div>
<div class="blog-excerpt">${escapeHtml(excerpt)}</div>
<div class="blog-meta">${escapeHtml(p.author_name || '管理员')} · ${p.created_at}</div>
</div>`;
}).join('');
} catch (e) {
container.innerHTML = '<div class="empty-state" style="column-span:all"><div class="empty-icon">⚠️</div><p>加载失败: ' + escapeHtml(e.message) + '</p></div>';
}
}
async function viewPost(id) {
const list = document.getElementById('blogList');
const detail = document.getElementById('blogDetail');
const header = document.getElementById('blogHeader');
list.style.display = 'none';
header.style.display = 'none';
detail.style.display = 'block';
history.pushState({ postId: id }, '', '/blog/' + id);
detail.innerHTML = '<div class="loading" style="column-span:all"><div class="spinner"></div></div>';
try {
const post = await API.getBlogPost(id);
const body = renderContent(post.content, post.use_markdown);
const comments = await API.request('GET', '/blog/comments/' + id);
const commentList = comments.map(c =>
`<div class="reply-item">
<div class="reply-meta"><strong>${escapeHtml(c.author_name || '游客')}</strong> · ${c.created_at}</div>
<div class="reply-body">${escapeHtml(c.content)}</div>
</div>`
).join('');
const commentForm = currentUser
? `<div style="display:flex;gap:8px;margin-top:12px">
<textarea id="blogCommentInput" placeholder="写下你的评论..." style="flex:1;min-height:60px;font-size:14px"></textarea>
<button class="btn btn-filled btn-sm" style="align-self:flex-end" onclick="submitComment(${id})">发表评论</button>
</div>`
: `<p class="text-muted" style="margin-top:12px;font-size:14px"><a href="/login.html" style="color:var(--md-ref-primary)">登录</a>后可以评论</p>`;
detail.innerHTML = `
<div class="blog-article">
<button class="btn btn-text btn-sm" onclick="loadPosts()" style="margin-bottom:16px">
<span class="material-icons" style="font-size:16px">arrow_back</span>
</button>
<h1 class="article-title">${escapeHtml(post.title)}</h1>
<div class="article-meta">${escapeHtml(post.author_name || '管理员')} · ${post.created_at}</div>
<div class="md-body">${body}</div>
<hr style="border:none;border-top:1px solid var(--md-ref-outline-variant);margin:32px 0">
<h4 style="font-weight:500;margin-bottom:16px">评论 (${comments.length})</h4>
${commentList || '<p class="text-muted" style="font-size:14px">暂无评论</p>'}
${commentForm}
</div>`;
} catch (e) {
detail.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></div>';
}
}
async function submitComment(postId) {
const input = document.getElementById('blogCommentInput');
const content = input.value.trim();
if (!content) { showSnackbar('评论不能为空'); return; }
try {
await API.request('POST', '/blog/comments/' + postId, { content });
showSnackbar('评论已发表');
viewPost(postId);
} catch (e) { showSnackbar(e.message); }
}
window.addEventListener('popstate', (e) => {
const path = location.pathname;
const blogMatch = path.match(/^\/blog\/(\d+)$/);
if (blogMatch) { viewPost(blogMatch[1]); return; }
loadPosts();
});
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
document.addEventListener('DOMContentLoaded', async () => {
try { currentUser = await API.getMe(); } catch {}
loadPosts();
});
-124
View File
@@ -1,124 +0,0 @@
(function () {
'use strict';
class MusicEmbed {
constructor() {
this.container = null;
this.timer = null;
this.idleTimeout = 10000;
this.autoHide = false;
this.position = 'right';
this.embedCode = '';
}
init() {
var s = NAV.siteSettings;
if (!s || Object.keys(s).length === 0) {
var self = this;
setTimeout(function () { self.init(); }, 50);
return;
}
var enabled = s.music_embed_enabled === '1';
if (!enabled) return;
this.embedCode = s.music_embed_code || '';
if (!this.embedCode) return;
this.position = s.music_embed_position || 'right';
this.autoHide = s.music_embed_autohide === '1';
this.idleTimeout = (parseInt(s.music_embed_idle_timeout) || 10) * 1000;
this._render();
this._bindEvents();
if (this.autoHide) this._startIdleTimer();
}
_render() {
var container = document.getElementById('musicEmbed');
if (!container) return;
container.className = 'music-embed ' + this.position + ' expanded';
container.innerHTML =
'<div class="music-embed-player">' + this.embedCode + '</div>' +
'<div class="music-embed-icon" style="display:none">' +
'<span class="material-icons">music_note</span>' +
'</div>';
this.container = container;
}
_bindEvents() {
var self = this;
this._onEnter = function () {
clearTimeout(self.timer);
self._expand();
};
this._onLeave = function () {
if (!self.autoHide) return;
clearTimeout(self.timer);
self.timer = setTimeout(function () { self._collapse(); }, self.idleTimeout);
};
this.container.addEventListener('mouseenter', this._onEnter);
this.container.addEventListener('mouseleave', this._onLeave);
var icon = this.container.querySelector('.music-embed-icon');
if (icon) {
icon.addEventListener('click', function () {
clearTimeout(self.timer);
self._expand();
// Re-start leave timer after expanding via click
if (self.autoHide) {
self.timer = setTimeout(function () { self._collapse(); }, self.idleTimeout);
}
});
}
// Also re-expand if mouse re-enters after icon click
this.container.addEventListener('mouseenter', function () {
clearTimeout(self.timer);
self._expand();
});
}
_startIdleTimer() {
// Start the initial collapse timer
var self = this;
this.timer = setTimeout(function () { self._collapse(); }, this.idleTimeout);
}
_collapse() {
if (!this.container) return;
this.container.classList.remove('expanded');
this.container.classList.add('collapsed');
var player = this.container.querySelector('.music-embed-player');
var icon = this.container.querySelector('.music-embed-icon');
if (player) player.style.display = 'none';
if (icon) icon.style.display = 'flex';
}
_expand() {
if (!this.container) return;
this.container.classList.remove('collapsed');
this.container.classList.add('expanded');
var player = this.container.querySelector('.music-embed-player');
var icon = this.container.querySelector('.music-embed-icon');
if (player) player.style.display = '';
if (icon) icon.style.display = 'none';
}
destroy() {
clearTimeout(this.timer);
if (this.container) {
this.container.removeEventListener('mouseenter', this._onEnter);
this.container.removeEventListener('mouseleave', this._onLeave);
}
}
}
document.addEventListener('DOMContentLoaded', function () {
var inst = new MusicEmbed();
inst.init();
});
})();
-246
View File
@@ -1,246 +0,0 @@
// ── Global color helpers ──
function hexToHsl(hex) {
let r = parseInt(hex.slice(1,3), 16) / 255;
let g = parseInt(hex.slice(3,5), 16) / 255;
let b = parseInt(hex.slice(5,7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s2 = 0, l2 = (max + min) / 2;
if (max !== min) {
const d = max - min;
s2 = l2 > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return [h * 360, s2 * 100, l2 * 100];
}
function hslToHex(h2, s2, l2) {
h2 /= 360; s2 /= 100; l2 /= 100;
let r, g, b;
if (s2 === 0) { r = g = b = l2; }
else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q2 = l2 < 0.5 ? l2 * (1 + s2) : l2 + s2 - l2 * s2;
const p2 = 2 * l2 - q2;
r = hue2rgb(p2, q2, h2 + 1/3);
g = hue2rgb(p2, q2, h2);
b = hue2rgb(p2, q2, h2 - 1/3);
}
const toHex = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
return '#' + toHex(r) + toHex(g) + toHex(b);
}
function isLight(hex) {
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
return (r * 0.299 + g * 0.587 + b * 0.114) > 160;
}
function injectThemeStyle(settings) {
const color = settings.primary_color || '#6750a4';
const styleId = 'rainweb-theme';
const old = document.getElementById(styleId);
if (old) old.remove();
const [hue, sat] = hexToHsl(color);
const ps = (x) => Math.min(sat * x, 70);
const ss = (x) => Math.min(sat * x, 40);
const su = (x) => Math.min(sat * x, 45);
const light = isLight(color);
const sheet = document.createElement('style');
sheet.id = styleId;
sheet.textContent =
':root{' +
'--md-source:' + color + ';' +
'--md-ref-primary:' + color + ';' +
'--md-ref-on-primary:' + (light ? '#1c1b1f' : '#ffffff') + ';' +
'--md-ref-primary-container:' + hslToHex(hue, ps(0.4), 90) + ';' +
'--md-ref-on-primary-container:' + hslToHex(hue, ps(0.6), 10) + ';' +
'--md-ref-secondary:' + hslToHex(hue, ss(0.2), 42) + ';' +
'--md-ref-on-secondary:#ffffff;' +
'--md-ref-secondary-container:' + hslToHex(hue, ss(0.15), 90) + ';' +
'--md-ref-on-secondary-container:' + hslToHex(hue, ss(0.3), 12) + ';' +
'--md-ref-surface-container:' + hslToHex(hue, su(0.3), 88) + ';' +
'--md-ref-surface-container-low:' + hslToHex(hue, su(0.2), 92) + ';' +
'--md-ref-surface-container-high:' + hslToHex(hue, su(0.4), 84) + ';' +
'--md-ref-surface-variant:' + hslToHex(hue, su(0.5), 82) + ';' +
'--md-ref-on-surface-variant:' + hslToHex(hue, ss(0.15), 28) + ';' +
'--md-ref-outline:' + hslToHex(hue, ss(0.2), 50) + ';' +
'--md-ref-outline-variant:' + hslToHex(hue, su(0.3), 74) + ';' +
'--md-card-bg:' + hslToHex(hue, su(0.15), 96) + ';' +
'--md-ref-primary-rgb:' + parseInt(color.slice(1,3),16) + ',' + parseInt(color.slice(3,5),16) + ',' + parseInt(color.slice(5,7),16) + ';' +
'}' +
'[data-theme="dark"]{' +
'--md-source:' + color + ';' +
'--md-ref-primary:' + hslToHex(hue, ps(0.6), 78) + ';' +
'--md-ref-on-primary:' + hslToHex(hue, ps(0.3), 12) + ';' +
'--md-ref-primary-container:' + hslToHex(hue, ps(0.35), 22) + ';' +
'--md-ref-on-primary-container:' + hslToHex(hue, ps(0.4), 88) + ';' +
'--md-ref-secondary:' + hslToHex(hue, ss(0.15), 74) + ';' +
'--md-ref-on-secondary:' + hslToHex(hue, ss(0.06), 12) + ';' +
'--md-ref-secondary-container:' + hslToHex(hue, ss(0.2), 22) + ';' +
'--md-ref-on-secondary-container:' + hslToHex(hue, ss(0.1), 86) + ';' +
'--md-ref-surface-container:' + hslToHex(hue, su(0.4), 10) + ';' +
'--md-ref-surface-container-low:' + hslToHex(hue, su(0.3), 8) + ';' +
'--md-ref-surface-container-high:' + hslToHex(hue, su(0.5), 13) + ';' +
'--md-ref-surface-variant:' + hslToHex(hue, su(0.6), 18) + ';' +
'--md-ref-on-surface-variant:' + hslToHex(hue, ss(0.1), 76) + ';' +
'--md-ref-outline:' + hslToHex(hue, ss(0.15), 52) + ';' +
'--md-ref-outline-variant:' + hslToHex(hue, su(0.5), 22) + ';' +
'--md-card-bg:' + hslToHex(hue, su(0.3), 10) + ';' +
'}';
document.head.appendChild(sheet);
}
const NAV = {
currentUser: null,
siteSettings: {},
async init() {
// Check if setup is needed
try {
const setup = await API.request('GET', '/setup/status');
if (!setup.setup_complete && location.pathname !== '/setup.html') {
window.location.href = '/setup.html';
return;
}
} catch {}
const token = localStorage.getItem('token');
this.currentUser = null;
if (token) {
try { this.currentUser = await API.getMe(); } catch { localStorage.removeItem('token'); }
}
try {
this.siteSettings = await API.getPublicSettings();
window._recaptchaSiteKey = this.siteSettings.recaptcha_site_key || '';
window._turnstileSiteKey = this.siteSettings.turnstile_site_key || '';
} catch {}
try {
const v = await API.request('GET', '/version');
window._appVersion = v.version;
} catch {}
this.render();
this.applyTheme();
},
render() {
const nav = document.getElementById('mainNav');
if (!nav) return;
const user = this.currentUser;
const isAdmin = user && user.role === 'admin';
const siteName = this.siteSettings.site_name || 'RainWeb';
const path = location.pathname;
let tabs = `<a href="/" class="nav-tab ${path === '/' ? 'active' : ''}">首页</a><a href="/blog.html" class="nav-tab ${path === '/blog.html' ? 'active' : ''}">博客</a>`;
if (user) tabs += `<a href="/forum.html" class="nav-tab ${path === '/forum.html' ? 'active' : ''}">论坛</a>`;
if (isAdmin) tabs += `<a href="/admin.html" class="nav-tab ${path === '/admin.html' ? 'active' : ''}">管理面板</a>`;
if (isAdmin) tabs += `<a href="/passwords.html" class="nav-tab ${path === '/passwords.html' ? 'active' : ''}">密码箱</a>`;
let right = `<button class="btn-icon" onclick="toggleTheme()" title="切换主题"><span class="material-icons">dark_mode</span></button>`;
if (user) {
// Fetch avatar from service (handles QQ auto + uploaded)
let avatarUrl = user.avatar || '';
if (!avatarUrl && user.email && user.email.match(/^(\d+)@qq\.com$/i)) {
avatarUrl = 'https://q1.qlogo.cn/g?b=qq&nk=' + RegExp.$1 + '&s=100';
}
const avatarHtml = avatarUrl
? `<img src="${avatarUrl}" style="width:24px;height:24px;border-radius:50%;object-fit:cover;margin-right:4px">`
: `<span class="material-icons" style="font-size:18px;margin-right:2px">person</span>`;
right += `<a href="/profile.html" class="btn btn-tonal btn-sm" title="个人中心">${avatarHtml} ${escapeHtml(user.username)}</a>`;
} else {
right += `<a href="/login.html" class="btn btn-tonal btn-sm">登录</a><a href="/register.html" class="btn btn-filled btn-sm">注册</a>`;
}
nav.innerHTML = `
<div class="nav-left">
<a href="/" class="nav-brand">${escapeHtml(siteName)}</a>
<span class="nav-version">v${escapeHtml(window._appVersion || '')}</span>
<div class="nav-tabs">${tabs}</div>
</div>
<div class="nav-right">${right}</div>`;
},
applyTheme() {
const s = this.siteSettings;
// Force dark mode
if (s.theme_force_dark === '1') {
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
window._forceDark = true;
} else {
window._forceDark = false;
}
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
injectThemeStyle(s);
const body = document.body;
const wallpaper = s.theme_wallpaper || '';
const scale = s.theme_wallpaper_scale || 'cover';
if (wallpaper) {
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';
} else {
body.classList.remove('has-wallpaper');
body.style.removeProperty('--wallpaper');
body.style.backgroundSize = '';
body.style.backgroundRepeat = '';
}
// Glass blur & opacity
document.documentElement.style.setProperty('--glass-blur', (s.glass_blur || '20') + 'px');
// Nav style
const nav = document.getElementById('mainNav');
if (nav) {
nav.className = 'nav-bar';
const ns = s.nav_style || 'default';
if (ns === 'glass') nav.classList.add('nav-glass');
if (ns === 'capsule') nav.classList.add('nav-capsule');
}
// Card style
const cs = s.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');
});
// Brightness-based text readability for wallpaper backgrounds
// When wallpaper is present, compute luminance and add overlay
if (wallpaper) {
// Try to determine if wallpaper is light or dark by sampling a pixel via canvas
// Simple fallback: check theme mode - dark mode means wallpaper likely dark
const isLikelyDark = isDark;
document.documentElement.style.setProperty('--wallpaper-overlay', isLikelyDark
? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.15)');
document.documentElement.style.setProperty('--wallpaper-text', isLikelyDark
? '#e6e1e5' : '#1c1b1f');
} else {
document.documentElement.style.removeProperty('--wallpaper-overlay');
document.documentElement.style.removeProperty('--wallpaper-text');
}
}
};
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
document.addEventListener('DOMContentLoaded', () => NAV.init());
-227
View File
@@ -1,227 +0,0 @@
let isUnlocked = false;
let currentDetailId = null;
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
function closeDialog(id) {
document.getElementById(id).classList.remove('active');
}
function openDialog(id) {
document.getElementById(id).classList.add('active');
}
// === PIN Screen ===
async function initPinScreen() {
const token = localStorage.getItem('token');
if (!token) { showSnackbar('请先登录'); setTimeout(() => window.location.href = '/login.html', 1000); return; }
try {
const me = await API.getMe();
if (me.role !== 'admin') { showSnackbar('需要管理员权限'); setTimeout(() => window.location.href = '/', 1000); return; }
const status = await API.getPinStatus();
if (!status.hasPin) {
document.getElementById('pinTitle').textContent = '首次使用,请设置 PIN 码';
document.getElementById('pinActionBtn').textContent = '设置';
document.getElementById('pinActionBtn').onclick = showPinSetup;
} else if (status.unlocked) {
isUnlocked = true;
showVault();
} else {
showUnlockScreen();
}
} catch (e) {
showSnackbar(e.message);
}
}
function showPinSetup() {
document.getElementById('newPin').value = '';
document.getElementById('confirmPin').value = '';
openDialog('pinSetupDialog');
}
async function savePin() {
const pin = document.getElementById('newPin').value;
const confirm = document.getElementById('confirmPin').value;
if (!pin || pin.length < 4) { showSnackbar('PIN 码至少4位'); return; }
if (pin !== confirm) { showSnackbar('两次输入的 PIN 不一致'); return; }
try {
await API.setPin(pin);
showSnackbar('PIN 设置成功');
closeDialog('pinSetupDialog');
isUnlocked = true;
showVault();
} catch (e) { showSnackbar(e.message); }
}
async function submitPin() {
const pin = document.getElementById('pinInput').value;
if (!pin) { showSnackbar('请输入 PIN 码'); return; }
try {
await API.unlock(pin);
showSnackbar('已解锁');
isUnlocked = true;
showVault();
} catch (e) {
document.getElementById('pinError').textContent = e.message;
document.getElementById('pinError').style.display = 'block';
document.getElementById('pinInput').value = '';
}
}
function showUnlockScreen() {
document.getElementById('pwContent').style.display = 'none';
document.getElementById('pinScreen').style.display = 'flex';
document.getElementById('pinTitle').textContent = '请输入 PIN 码解锁';
document.getElementById('pinActionBtn').textContent = '解锁';
document.getElementById('pinActionBtn').onclick = submitPin;
document.getElementById('pinInput').value = '';
document.getElementById('pinError').style.display = 'none';
}
async function lockVault() {
try {
await API.lock();
isUnlocked = false;
// Close any open dialogs
document.querySelectorAll('.dialog-overlay.active').forEach(el => el.classList.remove('active'));
showUnlockScreen();
showSnackbar('已锁定');
} catch (e) { showSnackbar(e.message); }
}
function showVault() {
document.getElementById('pinScreen').style.display = 'none';
document.getElementById('pwContent').style.display = 'block';
loadPasswords();
}
// === Passwords ===
async function loadPasswords() {
const grid = document.getElementById('passwordGrid');
grid.innerHTML = '<div class="loading" style="grid-column:1/-1"><div class="spinner"></div></div>';
try {
const list = await API.getPasswords();
if (list.length === 0) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="empty-icon">🔒</div><p>暂无密码记录</p></div>';
return;
}
grid.innerHTML = list.map(p => `
<div class="card password-card" onclick="showDetail(${p.id}, '${escapeHtml(p.title)}', '${escapeHtml(p.username)}', '${escapeHtml(p.password)}', '${escapeHtml(p.url || '')}', '${escapeHtml(p.notes || '')}')">
<div class="pw-title">${escapeHtml(p.title)}</div>
<div class="pw-username">${escapeHtml(p.username || '无用户名')}</div>
<div class="pw-actions">
<button class="btn-icon" style="width:32px;height:32px;font-size:16px" onclick="event.stopPropagation();copyToClipboard('${escapeHtml(p.password)}', '密码')">
<span class="material-icons" style="font-size:16px">content_copy</span>
</button>
</div>
</div>
`).join('');
} catch (e) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="empty-icon">⚠️</div><p>' + escapeHtml(e.message) + '</p></div>';
}
}
function showPasswordDialog(data) {
document.getElementById('pwId').value = data ? data.id : '';
document.getElementById('pwTitle').value = data ? data.title : '';
document.getElementById('pwUsername').value = data ? data.username : '';
document.getElementById('pwPassword').value = data ? data.password : '';
document.getElementById('pwUrl').value = data ? data.url : '';
document.getElementById('pwNotes').value = data ? data.notes : '';
document.getElementById('pwDialogTitle').textContent = data ? '编辑密码' : '添加密码';
openDialog('pwDialog');
}
function genPassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+';
let pwd = '';
for (let i = 0; i < 16; i++) pwd += chars[Math.floor(Math.random() * chars.length)];
document.getElementById('pwPassword').value = pwd;
}
async function savePassword() {
if (!isUnlocked) { showSnackbar('请先解锁'); return; }
const id = document.getElementById('pwId').value;
const data = {
title: document.getElementById('pwTitle').value.trim(),
username: document.getElementById('pwUsername').value.trim(),
password: document.getElementById('pwPassword').value,
url: document.getElementById('pwUrl').value.trim(),
notes: document.getElementById('pwNotes').value.trim()
};
if (!data.title || !data.password) { showSnackbar('标题和密码不能为空'); return; }
try {
if (id) await API.updatePassword(id, data);
else await API.createPassword(data);
showSnackbar('保存成功');
closeDialog('pwDialog');
loadPasswords();
} catch (e) { showSnackbar(e.message); }
}
function showDetail(id, title, username, password, url, notes) {
currentDetailId = id;
document.getElementById('detailTitle').textContent = title;
document.getElementById('detailBody').innerHTML = `
<div class="pw-field"><span class="pw-label">用户名</span><span class="pw-value">${escapeHtml(username || '')}</span><span class="pw-copy" onclick="copyToClipboard('${escapeHtml(username)}', '')"><span class="material-icons" style="font-size:18px">content_copy</span></span></div>
<div class="pw-field"><span class="pw-label">密码</span><span class="pw-value">${escapeHtml(password)}</span><span class="pw-copy" onclick="copyToClipboard('${escapeHtml(password)}', '')"><span class="material-icons" style="font-size:18px">content_copy</span></span></div>
${url ? `<div class="pw-field"><span class="pw-label">网址</span><span class="pw-value"><a href="${escapeHtml(url)}" target="_blank" style="color:var(--md-ref-primary)">${escapeHtml(url)}</a></span></div>` : ''}
${notes ? `<div class="pw-field" style="flex-direction:column;align-items:flex-start;gap:4px"><span class="pw-label">备注</span><span style="font-size:14px">${escapeHtml(notes)}</span></div>` : ''}
`.trim();
openDialog('pwDetailDialog');
}
function editFromDetail() {
if (!isUnlocked) { showSnackbar('请先解锁'); return; }
closeDialog('pwDetailDialog');
API.getPasswords().then(list => {
const entry = list.find(p => p.id === currentDetailId);
if (entry) showPasswordDialog({ id: entry.id, title: entry.title, username: entry.username, password: entry.password, url: entry.url, notes: entry.notes });
});
}
function deleteFromDetail() {
if (!isUnlocked) { showSnackbar('请先解锁'); return; }
closeDialog('pwDetailDialog');
document.getElementById('confirmMsg').textContent = '确定删除此密码记录?';
document.getElementById('confirmBtn').onclick = async () => {
try {
await API.deletePassword(currentDetailId);
showSnackbar('已删除');
closeDialog('confirmDialog');
loadPasswords();
} catch (e) { showSnackbar(e.message); }
};
openDialog('confirmDialog');
}
function deleteFromDetail() {
closeDialog('pwDetailDialog');
document.getElementById('confirmMsg').textContent = '确定删除此密码记录?';
document.getElementById('confirmBtn').onclick = async () => {
try {
await API.deletePassword(currentDetailId);
showSnackbar('已删除');
closeDialog('confirmDialog');
loadPasswords();
} catch (e) { showSnackbar(e.message); }
};
openDialog('confirmDialog');
}
function copyToClipboard(text, label) {
navigator.clipboard.writeText(text).then(() => showSnackbar(label + ' 已复制'));
}
var PASSWORDS = {
init: initPinScreen
};
// Allow Enter key to submit PIN
document.getElementById('pinInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter') submitPin();
});
-50
View File
@@ -1,50 +0,0 @@
// Content renderer: handles Markdown + [image:xxx] / [file:xxx] tags
function renderContent(content, useMarkdown) {
if (!useMarkdown) {
let html = escapeHtml(content);
html = html.replace(/\[image:([^\]]+)\]/g, (m, filename) => {
return `<img src="/uploads/${encodeURIComponent(filename)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
});
html = html.replace(/\[file:([^\]]+)\]/g, (m, filename) => {
const token = localStorage.getItem('token') || '';
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>`;
});
html = html.replace(/\n/g, '<br>');
return html;
}
// Markdown mode: extract custom tags before markdown, restore after
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`; });
if (typeof marked !== 'undefined') {
html = marked.parse(html, { breaks: true, gfm: true });
} else {
html = escapeHtml(html);
html = html.replace(/\n/g, '<br>');
}
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => {
const fn = images[parseInt(i)];
if (!fn) return '';
return `<img src="/uploads/${encodeURIComponent(fn)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
});
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => {
const fn = files[parseInt(i)];
if (!fn) return '';
const token = localStorage.getItem('token') || '';
return `<a href="/api/upload/download/${encodeURIComponent(fn)}?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(fn)}</a>`;
});
return html;
}
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
-118
View File
@@ -1,118 +0,0 @@
(function () {
'use strict';
var PJAX_PATHS = ['/', '/blog.html', '/forum.html', '/admin.html', '/passwords.html', '/login.html', '/register.html', '/profile.html'];
var ROUTER = {
init: function () {
var self = this;
document.addEventListener('click', function (e) {
var link = e.target.closest('a');
if (!link) return;
if (!link.closest('#mainNav')) return;
if (link.hasAttribute('target')) return;
if (link.hostname !== location.hostname) return;
var href = link.getAttribute('href');
if (!href || href === '#' || href.startsWith('#')) return;
if (PJAX_PATHS.indexOf(href) === -1) return;
e.preventDefault();
self.navigate(href);
});
window.addEventListener('popstate', function (e) {
if (!e.state || !e.state.path) return;
if (PJAX_PATHS.indexOf(e.state.path) === -1) {
location.reload();
return;
}
self.loadPage(e.state.path, true);
});
},
navigate: function (path) {
history.pushState({ path: path }, '', path);
this.loadPage(path, false);
},
loadPage: function (path, isPop) {
var self = this;
var main = document.querySelector('main');
if (!main) { if (!isPop) location.href = path; return; }
if (!isPop) {
main.innerHTML = '<div class="loading" style="min-height:200px;display:flex;align-items:center;justify-content:center"><div class="spinner"></div></div>';
}
fetch(path)
.then(function (r) { return r.text(); })
.then(function (html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var newMain = doc.querySelector('main');
if (!newMain) { if (!isPop) location.href = path; return; }
main.outerHTML = newMain.outerHTML;
document.title = doc.title;
self.runPageInit(path);
self.updateNav(path);
})
.catch(function () {
if (!isPop) location.href = path;
});
},
runPageInit: function (path) {
if (path === '/' || path === '/index.html') {
if (window.HOMEPAGE) HOMEPAGE.init();
return;
}
var pageConf = this._pageConfig(path);
if (!pageConf) return;
if (window[pageConf.global]) {
window[pageConf.global].init();
} else {
this._loadScript(pageConf.js, pageConf.global);
}
},
_pageConfig: function (path) {
var map = {
'/blog.html': { global: 'BLOG', js: '/js/blog.js' },
'/forum.html': { global: 'FORUM', js: '/js/forum.js' },
'/admin.html': { global: 'ADMIN', js: '/js/admin.js' },
'/passwords.html': { global: 'PASSWORDS', js: '/js/passwords.js' },
};
return map[path] || null;
},
_loadScript: function (src, globalName) {
var self = this;
var script = document.createElement('script');
script.src = src;
script.onload = function () {
if (window[globalName]) window[globalName].init();
};
document.head.appendChild(script);
},
updateNav: function (path) {
var tabs = document.querySelectorAll('#mainNav .nav-tab');
for (var i = 0; i < tabs.length; i++) {
var a = tabs[i];
if (a.getAttribute('href') === path) {
a.classList.add('active');
} else {
a.classList.remove('active');
}
}
}
};
document.addEventListener('DOMContentLoaded', function () {
ROUTER.init();
});
})();
-25
View File
@@ -1,25 +0,0 @@
function initTheme() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
}
function toggleTheme() {
if (window._forceDark) {
showSnackbar('已强制启用深色模式无法更改');
return;
}
const html = document.documentElement;
const isDark = html.getAttribute('data-theme') === 'dark';
if (isDark) {
html.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
} else {
html.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
}
initTheme();
-120
View File
@@ -1,120 +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">
<style>
.password-wrapper { position: relative; }
.password-wrapper input { width: 100%; padding-right: 44px; }
.password-wrapper .toggle-pw { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--md-ref-on-surface-variant); cursor: pointer; padding: 4px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.password-wrapper .toggle-pw:hover { background: var(--md-ref-surface-variant); }
</style>
</head>
<body>
<div class="login-page">
<div class="card login-card">
<h1>登录</h1>
<p class="subtitle">欢迎回到 RainWeb</p>
<div class="form-group"><label>用户名</label><input type="text" id="username" placeholder="输入用户名" autocomplete="username" autofocus></div>
<div class="form-group"><label>密码</label>
<div class="password-wrapper"><input type="password" id="password" placeholder="输入密码" autocomplete="current-password">
<button class="toggle-pw" type="button" onclick="togglePw(this)" tabindex="-1"><span class="material-icons" style="font-size:20px">visibility_off</span></button></div>
</div>
<div id="loginError" style="color:var(--md-ref-error);font-size:14px;margin-bottom:12px;display:none"></div>
<button class="btn w-full" id="capBtn" onclick="doCaptcha()" style="display:none;margin-bottom:8px;background:#fff;color:#333;border:1px solid #333;justify-content:center;height:44px">
<span class="material-icons" id="capBtnIcon" style="font-size:20px">verified_user</span>
<span id="capBtnText">点击进行人机验证</span>
</button>
<button class="btn btn-filled w-full" onclick="handleLogin()" id="loginBtn">登录</button>
<div style="text-align:center;margin-top:16px">
<span class="text-muted">没有账户?</span>
<a href="/register.html" class="btn-text btn" style="font-size:14px">注册</a>
</div>
<div style="text-align:center;margin-top:8px">
<a href="/" class="btn-text btn" style="font-size:14px">← 返回主页</a>
</div>
</div>
</div>
<div id="snackbar" class="snackbar"></div>
<script src="/js/theme.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/captcha.js"></script>
<script>
const token = localStorage.getItem('token');
if (token) window.location.href = '/';
let captchaNeeded = false;
let settingsLoaded = false;
// First load settings, then check captcha
API.getPublicSettings().then(s => {
window._recaptchaSiteKey = s.recaptcha_site_key || '';
window._turnstileSiteKey = s.turnstile_site_key || '';
settingsLoaded = true;
// Now check captcha
CAPTCHA.checkRequired('login').then(r => {
if (r.required) {
captchaNeeded = true;
document.getElementById('capBtn').style.display = 'inline-flex';
}
});
});
function togglePw(btn) {
const input = btn.parentElement.querySelector('input');
const icon = btn.querySelector('.material-icons');
if (input.type === 'password') { input.type = 'text'; icon.textContent = 'visibility'; }
else { input.type = 'password'; icon.textContent = 'visibility_off'; }
}
async function doCaptcha() {
const ok = await CAPTCHA.showModal('login');
if (ok) {
document.getElementById('capBtn').style.background = '#e8f5e9';
document.getElementById('capBtn').style.borderColor = '#4caf50';
document.getElementById('capBtn').style.color = '#2e7d32';
document.getElementById('capBtnIcon').textContent = 'check_circle';
document.getElementById('capBtnText').textContent = '验证通过';
document.getElementById('capBtn').disabled = true;
}
}
async function handleLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
if (!username || !password) { errEl.textContent = '请输入用户名和密码'; errEl.style.display = 'block'; return; }
if (captchaNeeded && !CAPTCHA.verified) {
errEl.textContent = '请先点击验证按钮完成验证';
errEl.style.display = 'block'; return;
}
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = '登录中...';
try {
const data = await API.login(username, password, 'verified');
localStorage.setItem('token', data.token);
localStorage.setItem('username', data.username);
localStorage.setItem('role', data.role);
window.location.href = '/';
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
btn.disabled = false;
btn.textContent = '登录';
}
}
document.getElementById('username').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('password').focus(); });
document.getElementById('password').addEventListener('keydown', e => { if (e.key === 'Enter') handleLogin(); });
</script>
</body>
</html>
-59
View File
@@ -1,59 +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 id="pwApp">
<div id="pinScreen" class="pin-overlay">
<div class="pin-icon"><span class="material-icons" style="font-size:40px">lock</span></div>
<div id="pinTitle" style="font-size:18px;font-weight:500">请输入 PIN 码解锁</div>
<div id="pinError" style="color:var(--md-ref-error);font-size:14px;display:none"></div>
<input type="password" id="pinInput" class="pin-input" maxlength="6" inputmode="numeric" autocomplete="off">
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center">
<button class="btn btn-filled" onclick="submitPin()" id="pinActionBtn">解锁</button>
</div>
</div>
<div id="pwContent" style="display:none">
<div class="admin-header"><h2>已保存的密码</h2>
<div style="display:flex;gap:8px">
<button class="btn btn-tonal" onclick="lockVault()"><span class="material-icons">lock</span> 锁定</button>
<button class="btn btn-text" onclick="showPinSetup()"><span class="material-icons">edit</span> 修改 PIN</button>
<button class="btn btn-filled" onclick="showPasswordDialog()"><span class="material-icons">add</span> 添加</button>
</div>
</div>
<div id="passwordGrid" class="password-grid"></div>
</div>
</div>
</main>
<div class="dialog-overlay" id="pwDialog"><div class="dialog"><h3 id="pwDialogTitle">添加密码</h3><input type="hidden" id="pwId">
<div class="form-group"><label>标题 *</label><input type="text" id="pwTitle"></div>
<div class="form-group"><label>用户名</label><input type="text" id="pwUsername"></div>
<div class="form-group"><label>密码 *</label><input type="text" id="pwPassword"><div style="margin-top:4px"><button class="btn btn-text btn-sm" onclick="genPassword()">生成随机密码</button></div></div>
<div class="form-group"><label>网址</label><input type="url" id="pwUrl"></div>
<div class="form-group"><label>备注</label><textarea id="pwNotes"></textarea></div>
<div class="actions"><button class="btn btn-text" onclick="closeDialog('pwDialog')">取消</button><button class="btn btn-filled" onclick="savePassword()">保存</button></div></div></div>
<div class="dialog-overlay" id="pwDetailDialog"><div class="dialog" style="max-width:500px"><h3 id="detailTitle"></h3><div class="password-detail" id="detailBody"></div>
<div class="actions" style="margin-top:16px"><button class="btn btn-text" onclick="editFromDetail()">编辑</button><button class="btn btn-text" style="color:var(--md-ref-error)" onclick="deleteFromDetail()">删除</button><button class="btn btn-text" onclick="closeDialog('pwDetailDialog')">关闭</button></div></div></div>
<div class="dialog-overlay" id="pinSetupDialog"><div class="dialog" style="max-width:360px"><h3>设置 PIN 码</h3><p class="text-muted" style="margin-bottom:16px;font-size:14px">PIN 码用于加密保护您的密码数据。</p>
<div class="form-group"><label>PIN 码 *</label><input type="password" id="newPin" maxlength="6" inputmode="numeric" style="text-align:center;font-size:24px;letter-spacing:8px"></div>
<div class="form-group"><label>确认 PIN 码 *</label><input type="password" id="confirmPin" maxlength="6" inputmode="numeric" style="text-align:center;font-size:24px;letter-spacing:8px"></div>
<div class="actions"><button class="btn btn-text" onclick="closeDialog('pinSetupDialog')">取消</button><button class="btn btn-filled" onclick="savePin()">确认</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="/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/passwords.js"></script>
<script>document.addEventListener('DOMContentLoaded',function(){if(window.PASSWORDS)PASSWORDS.init();});</script>
</body>
</html>
-182
View File
@@ -1,182 +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" style="max-width:600px">
<h1 class="page-title">个人中心</h1>
<div id="profileContent"><div class="loading"><div class="spinner"></div></div></div>
</main>
<!-- Password Dialog (Step 1: send code) -->
<div class="dialog-overlay" id="pwChangeDialog">
<div class="dialog" id="pwDialogContent">
<h3>修改密码</h3>
<div id="pwStep1">
<p class="text-muted" style="margin-bottom:16px;font-size:14px">验证码将发送到您的注册邮箱</p>
<button class="btn btn-filled w-full" onclick="sendPwCode()">发送验证码</button>
</div>
<div id="pwStep2" style="display:none">
<div class="form-group"><label>验证码</label><input type="text" id="pwCode" placeholder="8 位验证码" maxlength="8" style="text-align:center;font-size:24px;letter-spacing:8px"></div>
<div class="form-group"><label>原密码</label><input type="password" id="oldPw"></div>
<div class="form-group"><label>新密码(至少6位)</label><input type="password" id="newPw"></div>
<div class="form-group"><label>确认新密码</label><input type="password" id="confirmPw"></div>
<div class="actions">
<button class="btn btn-text" onclick="closeDialog('pwChangeDialog')">取消</button>
<button class="btn btn-filled" onclick="changePassword()">确认修改</button>
</div>
</div>
</div>
</div>
<div id="snackbar" class="snackbar"></div>
<script src="/js/theme.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t; return d.innerHTML;
}
function closeDialog(id) { document.getElementById(id).classList.remove('active'); }
function openDialog(id) { document.getElementById(id).classList.add('active'); }
async function loadProfile() {
const container = document.getElementById('profileContent');
try {
const user = await API.getMe();
// Fetch avatar URL (QQ auto or uploaded)
const av = await API.request('GET', '/upload/avatar-url?uid=' + user.id);
const avatarUrl = av.url || '';
const avatarHtml = avatarUrl
? `<img src="${avatarUrl}" style="width:64px;height:64px;border-radius:50%;object-fit:cover">`
: `<div style="width:64px;height:64px;border-radius:50%;background:var(--md-ref-primary-container);color:var(--md-ref-on-primary-container);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:600">${escapeHtml(user.username.charAt(0).toUpperCase())}</div>`;
container.innerHTML = `
<div class="card" style="margin-bottom:16px">
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
<div style="position:relative">
${avatarHtml}
<button class="btn-icon" style="position:absolute;bottom:-4px;right:-4px;width:28px;height:28px;background:var(--md-ref-surface-container);font-size:14px;box-shadow:0 2px 8px var(--md-shadow)" onclick="uploadAvatar()" title="更换头像">
<span class="material-icons" style="font-size:16px">camera_alt</span>
</button>
</div>
<div>
<h3 style="font-size:20px;font-weight:600">${escapeHtml(user.username)}</h3>
<span class="chip" style="cursor:default;font-size:12px;background:var(--md-ref-secondary-container);color:var(--md-ref-on-secondary-container)">${user.role === 'admin' ? '管理员' : '用户'}</span>
</div>
</div>
<div class="form-group"><label>邮箱(注册后不可修改)</label>
<div style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;background:var(--md-ref-surface-container);font-size:15px">
<span>${escapeHtml(user.email || '未设置')}</span>
${user.email_verified
? '<span class="chip" style="cursor:default;font-size:12px;background:var(--md-ref-primary-container);color:var(--md-ref-on-primary-container)">已验证</span>'
: '<span class="chip" style="cursor:default;font-size:12px;background:var(--md-ref-error);color:var(--md-ref-on-error)">未验证</span>'}
</div>
</div>
<hr style="border:none;border-top:1px solid var(--md-ref-outline-variant);margin:24px 0">
<h4 style="font-weight:500;margin-bottom:12px">安全设置</h4>
<button class="btn btn-outline" onclick="openPwDialog()">修改密码</button>
<button class="btn btn-text btn-sm" style="color:var(--md-ref-error);margin-left:8px" onclick="handleLogout()">退出登录</button>
</div>
<div class="card" style="text-align:center;padding:16px;color:var(--md-ref-on-surface-variant);font-size:13px">
UID: ${user.id} · 注册时间: ${user.created_at}
</div>`;
} catch (e) {
container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>' + escapeHtml(e.message) + '</p></div>';
}
}
function openPwDialog() {
document.getElementById('pwStep1').style.display = 'block';
document.getElementById('pwStep2').style.display = 'none';
document.getElementById('pwCode').value = '';
document.getElementById('oldPw').value = '';
document.getElementById('newPw').value = '';
document.getElementById('confirmPw').value = '';
openDialog('pwChangeDialog');
}
async function uploadAvatar() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
if (!input.files || !input.files[0]) return;
// Client-side resize to 512x512
const file = input.files[0];
const img = await 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);
});
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, 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));
const formData = new FormData();
formData.append('file', blob, 'avatar.jpg');
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/avatar', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
showSnackbar('头像已更新');
loadProfile();
} catch (e) { showSnackbar(e.message); }
};
input.click();
}
async function sendPwCode() {
try {
await API.request('POST', '/profile/send-pw-code');
showSnackbar('验证码已发送');
document.getElementById('pwStep1').style.display = 'none';
document.getElementById('pwStep2').style.display = 'block';
} catch (e) { showSnackbar(e.message); }
}
async function changePassword() {
const code = document.getElementById('pwCode').value.trim();
const oldPw = document.getElementById('oldPw').value;
const newPw = document.getElementById('newPw').value;
const confirm = document.getElementById('confirmPw').value;
if (!code || code.length !== 8) { showSnackbar('请输入完整的 8 位验证码'); return; }
if (!oldPw || !newPw) { showSnackbar('请填写完整'); return; }
if (newPw.length < 6) { showSnackbar('新密码至少6位'); return; }
if (newPw !== confirm) { showSnackbar('两次密码不一致'); return; }
try {
await API.request('PUT', '/profile/password', { code, oldPassword: oldPw, newPassword: newPw });
showSnackbar('密码已修改');
closeDialog('pwChangeDialog');
} catch (e) { showSnackbar(e.message); }
}
function handleLogout() {
localStorage.removeItem('token'); localStorage.removeItem('username'); localStorage.removeItem('role');
window.location.href = '/';
}
document.addEventListener('DOMContentLoaded', async () => {
const token = localStorage.getItem('token');
if (!token) window.location.href = '/login.html';
else loadProfile();
});
</script>
</body>
</html>
-194
View File
@@ -1,194 +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">
<style>
.register-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.register-card { width: 100%; max-width: 420px; padding: 40px 32px; }
.register-card h1 { font-size: 28px; font-weight: 500; text-align: center; margin-bottom: 8px; }
.register-card .subtitle { text-align: center; color: var(--md-ref-on-surface-variant); margin-bottom: 28px; font-size: 14px; }
</style>
</head>
<body>
<div class="register-page">
<div class="card register-card">
<h1>创建账户</h1>
<p class="subtitle">加入 RainWeb</p>
<div class="form-group">
<label>用户名 *</label>
<input type="text" id="regUsername" placeholder="用户名" autocomplete="username">
</div>
<div class="form-group">
<label>邮箱 *</label>
<input type="email" id="regEmail" placeholder="your@email.com" autocomplete="email">
</div>
<div class="form-group">
<label>密码 *</label>
<input type="password" id="regPassword" placeholder="至少6位" autocomplete="new-password">
</div>
<div class="form-group">
<label>确认密码 *</label>
<input type="password" id="regConfirm" placeholder="再次输入密码" autocomplete="new-password">
</div>
<!-- Verification code input (shown after registration) -->
<div id="verifySection" style="display:none">
<div class="card" style="padding:20px;margin-bottom:16px;text-align:center">
<div class="form-group">
<label style="font-size:16px;font-weight:600;margin-bottom:8px">邮箱验证码</label>
<p class="text-muted" style="font-size:14px;margin-bottom:12px">验证码已发送到您的邮箱,请输入 8 位数字验证码</p>
<input type="text" id="verifyCode" placeholder="输入 8 位验证码" maxlength="8" style="text-align:center;font-size:24px;letter-spacing:8px" inputmode="numeric">
</div>
<button class="btn btn-filled w-full" onclick="handleVerify()">验证并完成注册</button>
<button class="btn btn-text btn-sm mt-16" onclick="resendCode()" style="margin-top:8px">重新发送验证码</button>
</div>
</div>
<div id="regError" style="color:var(--md-ref-error);font-size:14px;margin-bottom:12px;display:none"></div>
<button class="btn w-full" id="capBtn" onclick="doCaptcha()" style="display:none;margin-bottom:8px;background:#fff;color:#333;border:1px solid #333;justify-content:center;height:44px">
<span class="material-icons" id="capBtnIcon" style="font-size:20px">verified_user</span>
<span id="capBtnText">点击进行人机验证</span>
</button>
<button class="btn btn-filled w-full" onclick="handleRegister()" id="regBtn">注册</button>
<div style="text-align:center;margin-top:16px">
<span class="text-muted">已有账户?</span>
<a href="/login.html" class="btn-text btn" style="font-size:14px">登录</a>
</div>
<div style="text-align:center;margin-top:8px">
<a href="/" class="btn-text btn" style="font-size:14px">← 返回主页</a>
</div>
</div>
</div>
<div id="snackbar" class="snackbar"></div>
<script src="/js/theme.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/captcha.js"></script>
<script>
let captchaNeeded = false;
const token = localStorage.getItem('token');
if (token) window.location.href = '/';
// First load settings, then check captcha
API.getPublicSettings().then(s => {
window._recaptchaSiteKey = s.recaptcha_site_key || '';
window._turnstileSiteKey = s.turnstile_site_key || '';
CAPTCHA.checkRequired('register').then(r => {
if (r.required) {
captchaNeeded = true;
document.getElementById('capBtn').style.display = 'inline-flex';
}
});
});
CAPTCHA.checkRequired('register').then(r => {
if (r.required) {
captchaNeeded = true;
document.getElementById('capBtn').style.display = 'inline-flex';
}
});
async function doCaptcha() {
const ok = await CAPTCHA.showModal('register');
if (ok) {
document.getElementById('capBtn').style.background = '#e8f5e9';
document.getElementById('capBtn').style.borderColor = '#4caf50';
document.getElementById('capBtn').style.color = '#2e7d32';
document.getElementById('capBtnIcon').textContent = 'check_circle';
document.getElementById('capBtnText').textContent = '验证通过';
document.getElementById('capBtn').disabled = true;
}
}
async function handleRegister() {
const username = document.getElementById('regUsername').value.trim();
const email = document.getElementById('regEmail').value.trim();
const password = document.getElementById('regPassword').value;
const confirm = document.getElementById('regConfirm').value;
const errEl = document.getElementById('regError');
const btn = document.getElementById('regBtn');
if (!username || !email || !password) {
errEl.textContent = '请填写所有必填项';
errEl.style.display = 'block'; return;
}
if (password.length < 6) {
errEl.textContent = '密码至少6位';
errEl.style.display = 'block'; return;
}
if (password !== confirm) {
errEl.textContent = '两次密码不一致';
errEl.style.display = 'block'; return;
}
let captcha_token = '';
if (captchaNeeded && !CAPTCHA.verified) {
errEl.textContent = '请先点击验证按钮完成验证';
errEl.style.display = 'block'; return;
}
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = '注册中...';
try {
const data = await API.register(username, password, email, captcha_token);
if (data.requires_verification) {
// Show verification code input
document.getElementById('verifySection').style.display = 'block';
document.getElementById('regBtn').style.display = 'none';
document.querySelectorAll('.form-group:not(#verifySection .form-group)').forEach(el => el.style.display = 'none');
document.querySelector('.subtitle').textContent = '请输入邮箱中的验证码';
errEl.style.display = 'none';
} else {
showSnackbar('注册成功,请登录');
setTimeout(() => window.location.href = '/login.html', 1000);
}
} catch (e) {
errEl.style.color = 'var(--md-ref-error)';
errEl.textContent = e.message;
errEl.style.display = 'block';
btn.disabled = false;
btn.textContent = '注册';
}
}
async function handleVerify() {
const code = document.getElementById('verifyCode').value.trim();
const username = document.getElementById('regUsername').value.trim();
const password = document.getElementById('regPassword').value;
const errEl = document.getElementById('regError');
if (!code || code.length !== 8) {
errEl.textContent = '请输入完整的 8 位验证码';
errEl.style.display = 'block'; return;
}
errEl.style.display = 'none';
try {
const data = await API.completeRegister(code, username, password);
showSnackbar('注册成功,请登录');
setTimeout(() => window.location.href = '/login.html', 1000);
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
async function resendCode() {
const email = document.getElementById('regEmail').value.trim();
const username = document.getElementById('regUsername').value.trim();
try {
await API.sendVerify(email, username);
showSnackbar('验证码已重新发送');
} catch (e) { showSnackbar(e.message); }
}
</script>
</body>
</html>
-134
View File
@@ -1,134 +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">
<style>
.setup-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--md-ref-background); }
.setup-card { width: 100%; max-width: 480px; padding: 40px 32px; }
.setup-card h1 { font-size: 28px; font-weight: 500; text-align: center; margin-bottom: 4px; }
.setup-card .subtitle { text-align: center; color: var(--md-ref-on-surface-variant); margin-bottom: 28px; font-size: 14px; }
.step-indicator { display: flex; gap: 8px; justify-content: center; margin-bottom: 24px; }
.step-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--md-ref-outline-variant); transition: all 0.3s; }
.step-dot.active { background: var(--md-ref-primary); width: 28px; border-radius: 5px; }
.step-dot.done { background: var(--md-ref-primary-container); }
.theme-options { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.theme-option { padding: 16px; border-radius: 12px; border: 2px solid var(--md-ref-outline-variant); cursor: pointer; text-align: center; transition: 0.2s; }
.theme-option:hover { border-color: var(--md-ref-primary); }
.theme-option.active { border-color: var(--md-ref-primary); background: var(--md-ref-primary-container); }
.theme-option .color-dot { width: 32px; height: 32px; border-radius: 50%; margin: 0 auto 8px; }
.setup-step { display: none; }
.setup-step.active { display: block; animation: fadeIn 0.3s; }
</style>
</head>
<body>
<div class="setup-page">
<div class="card setup-card">
<h1>🌧️ RainWeb</h1>
<p class="subtitle">欢迎使用!请完成以下初始化设置</p>
<div class="step-indicator" id="stepDots">
<div class="step-dot active"></div>
<div class="step-dot"></div>
<div class="step-dot"></div>
</div>
<!-- Step 1: Admin Password -->
<div class="setup-step active" id="step1">
<h3 style="font-weight:500;margin-bottom:16px">设置管理员密码</h3>
<div class="form-group"><label>新密码 *</label><input type="password" id="adminPw" placeholder="至少6位" autocomplete="new-password"></div>
<div class="form-group"><label>确认密码 *</label><input type="password" id="adminPwConfirm" placeholder="再次输入" autocomplete="new-password"></div>
<button class="btn btn-filled w-full" onclick="nextStep(2)">下一步</button>
</div>
<!-- Step 2: Site Name -->
<div class="setup-step" id="step2">
<h3 style="font-weight:500;margin-bottom:16px">网站设置</h3>
<div class="form-group"><label>网站名称</label><input type="text" id="siteName" value="RainWeb" placeholder="我的网站"></div>
<div class="form-group"><label>选择主题色</label>
<div class="theme-options" id="themeOptions">
<div class="theme-option active" data-color="#6750a4" data-name="default" onclick="selectTheme(this)"><div class="color-dot" style="background:#6750a4"></div><div>默认</div></div>
<div class="theme-option" data-color="#0288d1" data-name="ocean" onclick="selectTheme(this)"><div class="color-dot" style="background:#0288d1"></div><div>海洋</div></div>
<div class="theme-option" data-color="#2e7d32" data-name="nature" onclick="selectTheme(this)"><div class="color-dot" style="background:#2e7d32"></div><div>自然</div></div>
<div class="theme-option" data-color="#e65100" data-name="sunset" onclick="selectTheme(this)"><div class="color-dot" style="background:#e65100"></div><div>日落</div></div>
</div>
</div>
<button class="btn btn-filled w-full" onclick="nextStep(3)">下一步</button>
</div>
<!-- Step 3: Confirm -->
<div class="setup-step" id="step3">
<h3 style="font-weight:500;margin-bottom:16px">确认并完成</h3>
<div class="card" style="padding:16px;margin-bottom:16px;font-size:14px">
<div style="margin-bottom:8px"><strong>管理员密码</strong>: 已设置</div>
<div style="margin-bottom:8px"><strong>网站名称</strong>: <span id="confirmName">RainWeb</span></div>
<div><strong>主题色</strong>: <span id="confirmColor">默认</span></div>
</div>
<button class="btn btn-filled w-full" onclick="completeSetup()">完成初始化</button>
</div>
<div id="setupError" style="color:var(--md-ref-error);font-size:14px;margin-top:12px;display:none"></div>
</div>
</div>
<div id="snackbar" class="snackbar"></div>
<script src="/js/theme.js"></script>
<script src="/js/api.js"></script>
<script>
let selectedTheme = { color: '#6750a4', name: 'default' };
function nextStep(n) {
const err = document.getElementById('setupError');
err.style.display = 'none';
if (n === 2) {
const pw = document.getElementById('adminPw').value;
const confirm = document.getElementById('adminPwConfirm').value;
if (pw.length < 6) { err.textContent = '密码至少6位'; err.style.display = 'block'; return; }
if (pw !== confirm) { err.textContent = '两次密码不一致'; err.style.display = 'block'; return; }
}
document.querySelectorAll('.setup-step').forEach(el => el.classList.remove('active'));
document.getElementById('step' + n).classList.add('active');
document.querySelectorAll('.step-dot').forEach((el, i) => {
el.classList.toggle('active', i === n - 1);
el.classList.toggle('done', i < n - 1);
});
if (n === 3) {
document.getElementById('confirmName').textContent = document.getElementById('siteName').value || 'RainWeb';
document.getElementById('confirmColor').textContent = selectedTheme.name;
}
}
function selectTheme(el) {
document.querySelectorAll('.theme-option').forEach(e => e.classList.remove('active'));
el.classList.add('active');
selectedTheme = { color: el.dataset.color, name: el.querySelector('div:last-child').textContent };
}
async function completeSetup() {
const btn = document.querySelector('#step3 .btn');
btn.disabled = true;
btn.textContent = '处理中...';
try {
await API.request('POST', '/setup/complete', {
password: document.getElementById('adminPw').value,
site_name: document.getElementById('siteName').value || 'RainWeb',
primary_color: selectedTheme.color,
theme_preset: selectedTheme.name
});
showSnackbar('初始化完成!');
setTimeout(() => window.location.href = '/', 1000);
} catch (e) {
document.getElementById('setupError').textContent = e.message;
document.getElementById('setupError').style.display = 'block';
btn.disabled = false;
btn.textContent = '完成初始化';
}
}
</script>
</body>
</html>
-112
View File
@@ -1,112 +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">
<style>
.write-page { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
.write-header { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
.write-header h1 { font-size: 24px; font-weight: 500; flex: 1; margin: 0; }
</style>
</head>
<body>
<nav class="nav-bar" id="mainNav"></nav>
<main class="write-page">
<div class="write-header">
<h1>写文章</h1>
<a href="/admin.html" class="btn btn-text btn-sm"><span class="material-icons">arrow_back</span> 返回管理</a>
</div>
<div class="card" style="padding:24px">
<div class="form-group"><label>标题 *</label><input type="text" id="wTitle" placeholder="文章标题"></div>
<div class="form-group"><label>摘要</label><input type="text" id="wExcerpt" placeholder="简短摘要"></div>
<div class="form-group"><label>内容 *</label><textarea id="wContent" style="min-height:350px;font-family:monospace;font-size:14px" placeholder="支持 Markdown 语法,拖入图片自动上传"></textarea></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
<button class="btn btn-tonal btn-sm" onclick="uploadWFile()"><span class="material-icons">upload</span> 上传附件/图片</button>
<button class="btn btn-text btn-sm" onclick="previewW()"><span class="material-icons">visibility</span> 预览</button>
<label style="display:flex;align-items:center;gap:6px;margin-left:auto;font-size:14px;font-weight:500;color:var(--md-ref-on-surface-variant)">
<input type="checkbox" id="wPublished" checked> 发布
</label>
</div>
<div id="wUploadStatus" class="text-muted" style="font-size:13px;margin-bottom:12px"></div>
<div id="wPreview" class="md-body" style="display:none;padding:16px;background:var(--md-ref-surface-container-low);border-radius:12px;margin-bottom:16px;max-height:400px;overflow-y:auto"></div>
<button class="btn btn-filled" onclick="submitWPost()" id="wSubmitBtn">发布文章</button>
</div>
</main>
<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/render.js"></script>
<script>
let editingId = null;
// Check if editing existing post
const editMatch = location.search.match(/edit=(\d+)/);
if (editMatch) {
editingId = parseInt(editMatch[1]);
API.getBlogPosts(true).then(list => {
const p = list.find(x => x.id === editingId);
if (p) { document.getElementById('wTitle').value = p.title; document.getElementById('wExcerpt').value = p.excerpt || ''; document.getElementById('wContent').value = p.content; document.getElementById('wPublished').checked = !!p.published; document.querySelector('h1').textContent = '编辑文章'; }
});
}
function previewW() {
document.getElementById('wPreview').innerHTML = renderContent(document.getElementById('wContent').value, 1);
document.getElementById('wPreview').style.display = 'block';
}
// Drag-and-drop image upload
const wContent = document.getElementById('wContent');
wContent.addEventListener('dragover', e => { e.preventDefault(); wContent.style.borderColor = 'var(--md-ref-primary)'; });
wContent.addEventListener('dragleave', () => { wContent.style.borderColor = ''; });
wContent.addEventListener('drop', async e => {
e.preventDefault(); wContent.style.borderColor = '';
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
if (files.length === 0) { showSnackbar('请拖入图片文件'); return; }
for (const file of files) {
const formData = new FormData(); formData.append('file', file);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/file', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
wContent.value = wContent.value + '\n' + data.tag + '\n';
document.getElementById('wUploadStatus').textContent = '已插入: ' + data.tag;
} catch (e) { showSnackbar(e.message); }
}
wContent.focus();
});
async function uploadWFile() {
const input = document.createElement('input'); input.type = 'file';
input.onchange = async () => {
if (!input.files[0]) return;
const formData = new FormData(); formData.append('file', input.files[0]);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/upload/file', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error || '上传失败');
const ta = document.getElementById('wContent');
ta.value = ta.value + '\n' + data.tag + '\n'; ta.focus();
document.getElementById('wUploadStatus').textContent = '已插入: ' + data.tag;
} catch (e) { showSnackbar(e.message); }
}; input.click();
}
async function submitWPost() {
const title = document.getElementById('wTitle').value.trim();
const content = document.getElementById('wContent').value.trim();
if (!title || !content) { showSnackbar('标题和内容不能为空'); return; }
try {
const data = { title, content, excerpt: document.getElementById('wExcerpt').value.trim(), published: document.getElementById('wPublished').checked, use_markdown: 1 };
if (editingId) { await API.updateBlogPost(editingId, data); showSnackbar('已更新'); }
else { await API.createBlogPost(data); showSnackbar('已发布'); }
setTimeout(() => window.location.href = '/admin.html?tab=blog', 800);
} catch (e) { showSnackbar(e.message); }
}
</script>
</body>
</html>
+73 -41
View File
@@ -1,60 +1,87 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const db = require('../db');
const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth');
const { consumeProof } = require('./captcha');
const router = express.Router();
function checkCaptcha(action) {
const type = db.getSetting('captcha_type') || 'none';
if (type === 'none') return false;
const setting = db.getSetting('captcha_' + action);
return setting === '1';
// 登录限流:15 分钟窗口内最多 10 次尝试
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: '尝试次数过多,请 15 分钟后再试' },
});
// 校验并一次性消费验证码证明令牌:不存在、无效或已使用返回 false
function validateCaptchaProof(proof) {
return consumeProof(proof).ok;
}
// Verify reCAPTCHA V2 token
async function verifyCaptcha(token) {
const secret = db.getSetting('recaptcha_secret_key');
if (!secret) return true; // captcha not configured, skip
// 第三方验证码服务端校验(reCAPTCHA / Cloudflare Turnstile siteverify
async function verifyThirdParty(token, type) {
if (!token) return false;
try {
const https = require('https');
const data = await new Promise((resolve, reject) => {
const qs = `secret=${encodeURIComponent(secret)}&response=${encodeURIComponent(token)}`;
const req = https.request({ hostname: 'www.google.com', path: '/recaptcha/api/siteverify', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }}, res => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => resolve(JSON.parse(body)));
});
req.on('error', reject);
req.write(qs);
req.end();
let url, secret;
if (type === 'recaptcha') {
url = 'https://www.google.com/recaptcha/api/siteverify';
secret = db.getSetting('recaptcha_secret_key');
} else if (type === 'turnstile') {
url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
secret = db.getSetting('turnstile_secret_key');
} else {
return false;
}
if (!secret) return false;
const params = new URLSearchParams({ secret, response: token });
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
return data.success;
} catch { return false; }
}
// Verify captcha helper
function verifyCaptchaToken(captcha_token, captcha_answer) {
if (!captcha_token || !captcha_answer) return false;
const crypto = require('crypto');
const https = require('https');
// Check reCAPTCHA
const recaptchaSecret = db.getSetting('recaptcha_secret_key');
if (recaptchaSecret) {
// Async verification for reCAPTCHA is handled differently
// For built-in captcha, we verify against our store
const data = await res.json().catch(() => null);
return !!(data && data.success);
} catch (e) {
console.error('Third-party captcha verify error:', e.message);
return false;
}
// Built-in captcha: verify against stored entries
// This is simplified - in production use a verified token approach
return true; // Will be verified by the verify endpoint flow
}
router.post('/login', (req, res) => {
// 统一验证码校验(供 login/register/forum 共用):
// 按 captcha_type 与 captcha_<action> 设置判断;builtin/both 校验 captcha_proof
// recaptcha/turnstile/both 校验 recaptcha_token/turnstile_tokensiteverify 直验);
// both 模式:任一通过即可。
async function resolveCaptcha(req, action) {
const type = db.getSetting('captcha_type') || 'none';
if (type === 'none') return true;
if (db.getSetting('captcha_' + action) !== '1') return true;
if (type === 'builtin' || type === 'both') {
if (validateCaptchaProof(req.body.captcha_proof)) return true;
}
if (type === 'recaptcha' || type === 'both') {
if (await verifyThirdParty(req.body.recaptcha_token, 'recaptcha')) return true;
}
if (type === 'turnstile' || type === 'both') {
if (await verifyThirdParty(req.body.turnstile_token, 'turnstile')) return true;
}
return false;
}
router.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' });
// 服务端强制验证码校验:开启后必须通过内置 proof 或第三方 token 其一
if (!(await resolveCaptcha(req, 'login'))) {
return res.status(400).json({ error: '请先完成验证码验证' });
}
const user = db.get('SELECT * FROM users WHERE username = ?', [username]);
if (!user || !bcrypt.compareSync(password, user.password)) {
return res.status(401).json({ error: '用户名或密码错误' });
@@ -65,10 +92,14 @@ router.post('/login', (req, res) => {
});
router.post('/register', async (req, res) => {
const { username, password, email, captcha_token } = req.body;
const { username, password, email } = req.body;
if (!username || !password || !email)
return res.status(400).json({ error: '请填写所有必填项' });
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
// 服务端强制验证码校验
if (!(await resolveCaptcha(req, 'register'))) {
return res.status(400).json({ error: '请先完成验证码验证' });
}
if (db.get('SELECT id FROM users WHERE username = ?', [username]))
return res.status(400).json({ error: '用户名已存在' });
if (db.get('SELECT id FROM users WHERE email = ?', [email]))
@@ -77,7 +108,7 @@ router.post('/register', async (req, res) => {
const smtpHost = db.getSetting('smtp_host');
if (smtpHost) {
// Email verification flow - 8-digit code
const code = Math.floor(10000000 + Math.random() * 90000000).toString();
const code = crypto.randomInt(10000000, 100000000).toString();
const hash = bcrypt.hashSync(password, 10);
db.run('DELETE FROM pending_users WHERE email = ?', [email]);
db.run('INSERT INTO pending_users (username, password, email, token) VALUES (?, ?, ?, ?)',
@@ -175,3 +206,4 @@ router.put('/users/:id/role', authMiddleware, adminOnly, (req, res) => {
});
module.exports = router;
module.exports.resolveCaptcha = resolveCaptcha;
+31 -8
View File
@@ -1,22 +1,45 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth');
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router();
router.get('/posts', (req, res) => {
const publishedOnly = req.query.all !== '1';
let sql = 'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id';
if (publishedOnly) sql += ' WHERE bp.published = 1';
sql += ' ORDER BY bp.created_at DESC';
res.json(db.all(sql));
// 可选鉴权:有效 token 时解析出 req.user,匿名请求直接放行(用于草稿的"作者可见"判断)
function optionalAuth(req, res, next) {
const header = req.headers.authorization;
if (header && header.startsWith('Bearer ')) {
try {
req.user = jwt.verify(header.slice(7), SECRET);
} catch { /* 无效 token 按匿名处理 */ }
}
next();
}
// 公开列表:仅返回已发布文章
router.get('/posts', (req, res, next) => {
if (req.query.all !== '1') {
return res.json(db.all('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.published = 1 ORDER BY bp.created_at DESC'));
}
// ?all=1:仅管理员可见,交给下方鉴权路由处理
next();
});
router.get('/posts/:id', (req, res) => {
// ?all=1:返回全部文章(含草稿),仅管理员可见
router.get('/posts', authMiddleware, adminOnly, (req, res) => {
res.json(db.all('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id ORDER BY bp.created_at DESC'));
});
router.get('/posts/:id', optionalAuth, (req, res) => {
const post = db.get(
'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?',
[req.params.id]);
if (!post) return res.status(404).json({ error: '文章不存在' });
// 未发布文章(草稿):仅管理员或作者本人可见,其余按 404 处理避免泄露存在性
if (post.published !== 1) {
if (!req.user || !(req.user.role === 'admin' || req.user.id === post.author_id))
return res.status(404).json({ error: '文章不存在' });
}
res.json(post);
});
+29 -1
View File
@@ -1,15 +1,23 @@
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const db = require('../db');
const { SECRET } = require('../middleware/auth');
const router = express.Router();
// In-memory captcha store
const captchaStore = new Map();
// 一次性消费的验证码证明令牌(防重放):proof -> 消费时间戳
const usedProofs = new Map();
setInterval(() => {
const now = Date.now();
for (const [key, val] of captchaStore) {
if (val.expires < now) captchaStore.delete(key);
}
// proof 5 分钟过期,超过 300s 的已消费记录直接清理
for (const [key, t] of usedProofs) {
if (now - t > 300000) usedProofs.delete(key);
}
}, 300000);
// Harder captcha: longer answer, more noise
@@ -79,7 +87,10 @@ router.post('/verify', (req, res) => {
if (!entry) return res.json({ success: false, error: '验证码已过期' });
captchaStore.delete(token);
if (entry.answer.toLowerCase() === String(answer).toLowerCase().trim()) {
res.json({ success: true });
// 验证成功,签发短期证明令牌,供登录/发帖时服务端校验
// jti 保证每次签发唯一(iat 为秒级、payload 恒定会导致同秒内 proof 完全相同,撞上防重放)
const proof = jwt.sign({ captcha: true, action: req.body.action || '', jti: crypto.randomBytes(16).toString('hex') }, SECRET, { expiresIn: '5m' });
res.json({ success: true, proof });
} else {
res.json({ success: false, error: '验证码错误' });
}
@@ -89,6 +100,22 @@ router.post('/verify', (req, res) => {
}
});
// 校验并一次性消费验证码证明令牌(防重放):login/register/forum 校验时调用
function consumeProof(proof) {
if (!proof) return { ok: false, error: '验证码验证已过期' };
try {
const payload = jwt.verify(proof, SECRET);
if (!payload || payload.captcha !== true) {
return { ok: false, error: '验证码验证已过期' };
}
} catch {
return { ok: false, error: '验证码验证已过期' };
}
if (usedProofs.has(proof)) return { ok: false, error: '验证码已使用' };
usedProofs.set(proof, Date.now());
return { ok: true };
}
// Proof-of-Work challenge
router.get('/pow-challenge', (req, res) => {
const prefix = crypto.randomBytes(8).toString('hex');
@@ -135,3 +162,4 @@ router.post('/required', (req, res) => {
});
module.exports = router;
module.exports.consumeProof = consumeProof;
+15 -5
View File
@@ -51,7 +51,8 @@ router.post('/test', authMiddleware, adminOnly, async (req, res) => {
});
res.json({ message: '测试邮件已发送至 ' + to });
} catch (e) {
res.status(500).json({ error: '发送失败: ' + e.message });
console.error('邮件发送失败:', e.message);
res.status(500).json({ error: '邮件发送失败' });
}
});
@@ -60,12 +61,20 @@ router.post('/send-verify', async (req, res) => {
const { email, username } = req.body;
if (!email || !username) return res.status(400).json({ error: '参数不完整' });
const code = Math.floor(10000000 + Math.random() * 90000000).toString();
db.run('DELETE FROM pending_users WHERE email = ?', [email]);
// 先取出旧 pending 记录(其中存有注册时加密的密码),避免删除后无法重建
const pending = db.get('SELECT * FROM pending_users WHERE email = ?', [email]);
if (!pending) return res.status(400).json({ error: '未找到待验证的注册信息,请重新注册' });
// 先确认 SMTP 已配置,避免删除旧记录后无法发送新验证码
const transporter = getTransporter();
if (!transporter) return res.status(400).json({ error: 'SMTP 未配置,请联系管理员' });
// 生成新的验证码,删除旧记录后重新插入新的 pending 记录
const code = crypto.randomInt(10000000, 100000000).toString();
db.run('DELETE FROM pending_users WHERE email = ?', [email]);
db.run('INSERT INTO pending_users (username, password, email, token) VALUES (?, ?, ?, ?)',
[pending.username, pending.password, email, code]);
try {
const siteName = db.getSetting('site_name') || 'RainWeb';
const color = db.getSetting('primary_color') || '#6750a4';
@@ -88,9 +97,10 @@ router.post('/send-verify', async (req, res) => {
<div class="footer">${siteName} &middot; 自动发送请勿回复</div>
</div></body></html>`,
});
res.json({ message: '验证码已发送', code }); // code for dev convenience
res.json({ message: '验证码已发送' });
} catch (e) {
res.status(500).json({ error: '邮件发送失败: ' + e.message });
console.error('邮件发送失败:', e.message);
res.status(500).json({ error: '邮件发送失败' });
}
});
+8 -3
View File
@@ -1,6 +1,7 @@
const express = require('express');
const db = require('../db');
const { authMiddleware } = require('../middleware/auth');
const { resolveCaptcha } = require('./auth');
const router = express.Router();
@@ -18,7 +19,7 @@ router.post('/categories', authMiddleware, (req, res) => {
const id = db.run('INSERT INTO forum_categories (name, description, sort_order, announcement, sub_categories) VALUES (?, ?, ?, ?, ?)',
[name, description || '', sort_order || 0, announcement || '', sc]);
res.json(db.get('SELECT * FROM forum_categories WHERE id = ?', [id]));
} catch (e) { console.error('Create category error:', e.message); res.status(500).json({ error: e.message }); }
} catch (e) { console.error('Create category error:', e.message); res.status(500).json({ error: '操作失败' }); }
});
router.put('/categories/:id', authMiddleware, (req, res) => {
@@ -32,7 +33,7 @@ router.put('/categories/:id', authMiddleware, (req, res) => {
const updated = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]);
if (!updated) return res.status(500).json({ error: '更新后读取失败' });
res.json(updated);
} catch (e) { console.error('Update category error:', e.message); res.status(500).json({ error: e.message }); }
} catch (e) { console.error('Update category error:', e.message); res.status(500).json({ error: '操作失败' }); }
});
router.delete('/categories/:id', authMiddleware, (req, res) => {
@@ -75,9 +76,13 @@ router.get('/posts/:id', (req, res) => {
res.json({ post, replies });
});
router.post('/posts', authMiddleware, (req, res) => {
router.post('/posts', authMiddleware, async (req, res) => {
const { category_id, title, content, use_markdown, sub_category } = req.body;
if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' });
// 服务端强制验证码校验:内置 proof 或第三方 token 任一通过即可
if (!(await resolveCaptcha(req, 'forum'))) {
return res.status(400).json({ error: '请先完成验证码验证' });
}
const id = db.run(
'INSERT INTO forum_posts (category_id, title, content, author_id, use_markdown, sub_category) VALUES (?, ?, ?, ?, ?, ?)',
[category_id, title, content, req.user.id, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, sub_category || '']);
+19 -17
View File
@@ -3,7 +3,7 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs');
const os = require('os');
const initSqlJs = require('sql.js');
const Database = require('better-sqlite3');
const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth');
@@ -15,41 +15,43 @@ const IMPORT_TABLES = [
'attachments', 'site_settings', 'user_settings'
];
router.post('/database', authMiddleware, adminOnly, async (req, res) => {
router.post('/database', authMiddleware, adminOnly, (req, res) => {
const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 50 * 1024 * 1024 } }).single('file');
upload(req, res, async (err) => {
upload(req, res, (err) => {
if (err) return res.status(400).json({ error: '上传失败: ' + err.message });
if (!req.file) return res.status(400).json({ error: '请选择数据库文件' });
// 以只读方式打开上传的 SQLite 文件(better-sqlite3 直接读磁盘路径)
let oldDb = null;
try {
const SQL = await initSqlJs();
const buffer = fs.readFileSync(req.file.path);
const oldDb = new SQL.Database(buffer);
oldDb = new Database(req.file.path, { readonly: true });
const report = { imported: {}, errors: [], total: 0 };
for (const table of IMPORT_TABLES) {
try {
// Check if table exists in old db
const check = oldDb.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='" + table + "'");
if (!check || check.length === 0 || check[0].values.length === 0) {
const check = oldDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(table);
if (!check) {
report.errors.push(table + ': 表不存在,跳过');
continue;
}
// Get columns from old table
const colInfo = oldDb.exec('PRAGMA table_info(' + table + ')');
const columns = colInfo[0].values.map(v => v[1]); // column names
const rows = oldDb.exec('SELECT * FROM ' + table);
if (!rows || rows.length === 0 || rows[0].values.length === 0) {
const colInfo = oldDb.pragma('table_info(' + table + ')');
const columns = colInfo.map(c => c.name); // column names
const rows = oldDb.prepare('SELECT * FROM ' + table).all();
if (rows.length === 0) {
report.imported[table] = 0;
continue;
}
const colNames = columns.join(',');
const placeholders = columns.map(() => '?').join(',');
let count = 0;
for (const row of rows[0].values) {
for (const row of rows) {
try {
db.run('INSERT OR IGNORE INTO ' + table + ' (' + colNames + ') VALUES (' + placeholders + ')', row);
// all() 返回对象数组,按列顺序还原为值数组后插入目标库
const values = columns.map(c => row[c]);
db.run('INSERT OR IGNORE INTO ' + table + ' (' + colNames + ') VALUES (' + placeholders + ')', values);
count++;
} catch (e) {
report.errors.push(table + ': 行跳过 (' + e.message.substring(0, 50) + ')');
@@ -62,9 +64,6 @@ router.post('/database', authMiddleware, adminOnly, async (req, res) => {
}
}
oldDb.close();
try { fs.unlinkSync(req.file.path); } catch {}
res.json({
message: '导入完成',
total: report.total,
@@ -73,6 +72,9 @@ router.post('/database', authMiddleware, adminOnly, async (req, res) => {
});
} catch (e) {
res.status(500).json({ error: '导入失败: ' + e.message });
} finally {
if (oldDb) oldDb.close();
try { fs.unlinkSync(req.file.path); } catch {}
}
});
});
+84 -18
View File
@@ -6,14 +6,68 @@ const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
// In-memory session store for unlocked encryption keys
// PBKDF2 迭代数:新 PIN 使用 OWASP 2023 建议的 600000;老用户兼容旧值 100000
const PBKDF2_ITERATIONS = 600000;
const LEGACY_ITERATIONS = 100000;
// In-memory session store for unlocked encryption keys(滑动过期)
const unlockedSessions = new Map();
const SESSION_TTL = 3600000; // 1 小时滑动过期
// 解锁失败限流:userId -> { count, firstTs, lockedUntil }
const unlockFails = new Map();
const FAIL_LIMIT = 3; // 5 分钟内最多失败次数
const FAIL_WINDOW = 5 * 60 * 1000; // 统计窗口
const LOCK_DURATION = 5 * 60 * 1000; // 锁定时长
function getEncryptionKey(userId, pin) {
const setting = db.get('SELECT kdf_salt FROM user_settings WHERE user_id = ?', [userId]);
const setting = db.get('SELECT kdf_salt, pin_iter FROM user_settings WHERE user_id = ?', [userId]);
if (!setting || !setting.kdf_salt) return null;
const salt = Buffer.from(setting.kdf_salt, 'hex');
return crypto.pbkdf2Sync(pin, salt, 100000, 32, 'sha256');
// 按用户存储的迭代数派生:老用户 100000 继续可用,新 set-pin 后为 600000
const iterations = setting.pin_iter || LEGACY_ITERATIONS;
return crypto.pbkdf2Sync(pin, salt, iterations, 32, 'sha256');
}
// 会话辅助:惰性过期检查 + 滑动续期
function getSession(userId) {
const session = unlockedSessions.get(userId);
if (!session) return null;
if (Date.now() > session.expires) { // 已过期,惰性清除
unlockedSessions.delete(userId);
return null;
}
return session;
}
function refreshSession(userId) {
const session = getSession(userId);
if (!session) return false;
session.expires = Date.now() + SESSION_TTL; // 每次操作刷新过期时间
return true;
}
// 限流辅助
function isLocked(userId) {
const rec = unlockFails.get(userId);
if (!rec || !rec.lockedUntil) return false;
if (Date.now() >= rec.lockedUntil) { // 锁定期结束,清除
unlockFails.delete(userId);
return false;
}
return true;
}
function recordFail(userId) {
const now = Date.now();
let rec = unlockFails.get(userId);
if (!rec || (now - rec.firstTs) > FAIL_WINDOW) rec = { count: 0, firstTs: now, lockedUntil: 0 };
rec.count += 1;
if (rec.count >= FAIL_LIMIT) {
rec.lockedUntil = now + LOCK_DURATION;
rec.count = 0;
}
unlockFails.set(userId, rec);
}
function encrypt(text, key) {
@@ -42,39 +96,50 @@ function decrypt(encoded, key) {
// Set or change PIN
router.post('/set-pin', authMiddleware, (req, res) => {
const { pin } = req.body;
if (!pin || pin.length < 4) return res.status(400).json({ error: 'PIN 至少4位' });
// PIN 强度:仅长度 < 6 拒绝(纯数字不强制拒绝,但提示建议包含字母)
if (!pin || typeof pin !== 'string' || pin.length < 6) {
return res.status(400).json({ error: 'PIN 至少 6 位,建议包含字母' });
}
const existing = db.get('SELECT id FROM user_settings WHERE user_id = ?', [req.user.id]);
const pinHash = bcrypt.hashSync(pin, 10);
const kdfSalt = crypto.randomBytes(16).toString('hex');
if (existing) {
db.run('UPDATE user_settings SET pin_hash=?, kdf_salt=? WHERE user_id=?', [pinHash, kdfSalt, req.user.id]);
db.run('UPDATE user_settings SET pin_hash=?, kdf_salt=?, pin_iter=? WHERE user_id=?', [pinHash, kdfSalt, PBKDF2_ITERATIONS, req.user.id]);
} else {
db.run('INSERT INTO user_settings (user_id, pin_hash, kdf_salt) VALUES (?, ?, ?)', [req.user.id, pinHash, kdfSalt]);
db.run('INSERT INTO user_settings (user_id, pin_hash, kdf_salt, pin_iter) VALUES (?, ?, ?, ?)', [req.user.id, pinHash, kdfSalt, PBKDF2_ITERATIONS]);
}
// Auto-unlock after setting PIN
// Auto-unlock after setting PIN(滑动过期)
const key = getEncryptionKey(req.user.id, pin);
if (key) unlockedSessions.set(req.user.id, { key, time: Date.now() });
if (key) unlockedSessions.set(req.user.id, { key, expires: Date.now() + SESSION_TTL });
res.json({ message: 'PIN 设置成功' });
});
// Check if PIN is set
router.get('/pin-status', authMiddleware, (req, res) => {
const setting = db.get('SELECT pin_hash FROM user_settings WHERE user_id = ?', [req.user.id]);
res.json({ hasPin: !!setting && !!setting.pin_hash, unlocked: unlockedSessions.has(req.user.id) });
res.json({ hasPin: !!setting && !!setting.pin_hash, unlocked: !!getSession(req.user.id) });
});
// Unlock with PIN
router.post('/unlock', authMiddleware, (req, res) => {
const { pin } = req.body;
if (!pin) return res.status(400).json({ error: '请输入 PIN' });
// 失败限流:锁定期内直接拒绝
if (isLocked(req.user.id)) {
return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
}
const setting = db.get('SELECT pin_hash FROM user_settings WHERE user_id = ?', [req.user.id]);
if (!setting || !setting.pin_hash) return res.status(400).json({ error: '请先设置 PIN' });
if (!bcrypt.compareSync(pin, setting.pin_hash)) return res.status(401).json({ error: 'PIN 错误' });
if (!bcrypt.compareSync(pin, setting.pin_hash)) {
recordFail(req.user.id);
// 达到失败上限后本次直接返回 429
if (isLocked(req.user.id)) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
return res.status(401).json({ error: 'PIN 错误' });
}
const key = getEncryptionKey(req.user.id, pin);
if (!key) return res.status(500).json({ error: '解密失败' });
unlockedSessions.set(req.user.id, { key, time: Date.now() });
// Auto-lock after 1 hour
setTimeout(() => { unlockedSessions.delete(req.user.id); }, 3600000);
unlockFails.delete(req.user.id); // 成功解锁清零失败计数
unlockedSessions.set(req.user.id, { key, expires: Date.now() + SESSION_TTL }); // 滑动过期,不再用一次性 setTimeout
res.json({ message: '已解锁' });
});
@@ -84,9 +149,9 @@ router.post('/lock', authMiddleware, (req, res) => {
res.json({ message: '已锁定' });
});
// Ensure unlocked middleware
// Ensure unlocked middleware(滑动过期:每次操作刷新过期时间)
function requireUnlock(req, res, next) {
if (!unlockedSessions.has(req.user.id)) {
if (!refreshSession(req.user.id)) {
return res.status(401).json({ error: '请先解锁密码管理器' });
}
next();
@@ -95,7 +160,8 @@ function requireUnlock(req, res, next) {
// List all password entries (decrypted)
router.get('/', authMiddleware, requireUnlock, (req, res) => {
const entries = db.all('SELECT * FROM password_entries WHERE user_id = ? ORDER BY created_at DESC', [req.user.id]);
const session = unlockedSessions.get(req.user.id);
const session = getSession(req.user.id);
if (!session) return res.status(401).json({ error: '请先解锁密码管理器' });
const decrypted = entries.map(e => {
const pwd = decrypt(e.encrypted_password, session.key);
return { id: e.id, title: e.title, username: e.username, password: pwd || '', url: e.url, notes: e.notes, created_at: e.created_at };
@@ -107,7 +173,7 @@ router.get('/', authMiddleware, requireUnlock, (req, res) => {
router.post('/', authMiddleware, requireUnlock, (req, res) => {
const { title, username, password, url, notes } = req.body;
if (!title || !password) return res.status(400).json({ error: '标题和密码不能为空' });
const session = unlockedSessions.get(req.user.id);
const session = getSession(req.user.id);
const encrypted = encrypt(password, session.key);
const id = db.run(
'INSERT INTO password_entries (user_id, title, username, encrypted_password, url, notes) VALUES (?, ?, ?, ?, ?, ?)',
@@ -120,7 +186,7 @@ router.put('/:id', authMiddleware, requireUnlock, (req, res) => {
const { title, username, password, url, notes } = req.body;
const existing = db.get('SELECT id FROM password_entries WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!existing) return res.status(404).json({ error: '记录不存在' });
const session = unlockedSessions.get(req.user.id);
const session = getSession(req.user.id);
const encrypted = encrypt(password || '', session.key);
db.run(
"UPDATE password_entries SET title=?, username=?, encrypted_password=?, url=?, notes=?, updated_at=datetime('now') WHERE id=?",

Some files were not shown because too many files have changed in this diff Show More