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 .env.json
server.pid server.pid
releases/ 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 - 个人云管理平台 # 🌧️ 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) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
一体化个人云平台,集成博客、论坛、密码管理器、管理后台聚合等功能,Material Design 3 风格,支持深色/浅色切换。 一体化个人云平台,集成博客、论坛、密码管理器、管理后台聚合等功能,Material Design 3 风格,支持深色/浅色切换。
@@ -25,7 +24,7 @@
### 环境要求 ### 环境要求
- **Node.js** 16.x 或更高版本(推荐 20.x LTS - **Node.js** 22 或更高版本(推荐 22 LTS
- **npm** 随 Node.js 安装 - **npm** 随 Node.js 安装
### 下载安装 ### 下载安装
@@ -45,14 +44,7 @@
unzip rainweb-source.zip -d rainweb unzip rainweb-source.zip -d rainweb
cd rainweb cd rainweb
npm install npm install
npm start npm run build
```
**从 GitHub 安装:**
```bash
git clone https://github.com/Xianyunah/rainwebblog.git
cd rainwebblog
npm install
npm start npm start
``` ```
@@ -65,7 +57,8 @@ npm start
- **启动文件**: `server.js` - **启动文件**: `server.js`
- **端口**: `3001` - **端口**: `3001`
3. 提交后宝塔自动 `npm install` 并启动 3. 提交后宝塔自动 `npm install` 并启动
4. 如需域名访问,配置 Nginx 反向代理: 4. 若未自动构建,手动执行 `npm run build`(构建 React 前端到 `public/dist/`
5. 如需域名访问,配置 Nginx 反向代理:
```nginx ```nginx
server { server {
@@ -90,7 +83,7 @@ server {
```bash ```bash
# 使用 Node.js 官方镜像 # 使用 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 cd /app && npm install && node server.js
" "
``` ```
@@ -126,8 +119,8 @@ npm run cli -- <command>
## 技术栈 ## 技术栈
- **后端**: Node.js + Express - **后端**: Node.js + Express
- **数据库**: SQLite (sql.js) - **数据库**: SQLite (better-sqlite3)
- **前端**: 原生 HTML/CSS/JS + Material Design 3 - **前端**: React + Vite + React Router(前台原生样式 / 后台 MUI)
- **加密**: AES-256-GCM, PBKDF2, bcrypt - **加密**: AES-256-GCM, PBKDF2, bcrypt
- **验证码**: SVG 扭曲文字 / Google reCAPTCHA V2 - **验证码**: SVG 扭曲文字 / Google reCAPTCHA V2
- **文件上传**: multer - **文件上传**: multer
@@ -139,6 +132,7 @@ npm run cli -- <command>
``` ```
rainweb/ rainweb/
├── server.js # 主入口 ├── server.js # 主入口
├── vite.config.js # Vite 构建配置
├── cli.js # CLI 工具 ├── cli.js # CLI 工具
├── db.js # 数据库层 ├── db.js # 数据库层
├── middleware/ ├── middleware/
@@ -156,29 +150,20 @@ rainweb/
│ ├── setup.js # 初始化向导 │ ├── setup.js # 初始化向导
│ ├── announcements.js # 公告 │ ├── announcements.js # 公告
│ └── profile.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/ ├── public/
│ ├── index.html # 博客首页 │ ├── dist/ # Vite 构建产物(npm run build 生成,serveIndex 读取)
│ ├── login.html # 登录
│ ├── register.html # 注册
│ ├── admin.html # 管理后台
│ ├── forum.html # 论坛
│ ├── blog.html # 博客页
│ ├── passwords.html # 密码管理器
│ ├── embed.html # 网页嵌入
│ ├── profile.html # 个人中心
│ ├── setup.html # 初始化向导
│ ├── css/style.css # 全局样式 + 主题 │ ├── css/style.css # 全局样式 + 主题
│ ├── js/
│ │ ├── api.js # API 封装
│ │ ├── nav.js # 导航栏 + 主题
│ │ ├── captcha.js # 验证码前端
│ │ ├── theme.js # 深浅色切换
│ │ ├── main.js # 博客首页
│ │ ├── admin.js # 管理后台
│ │ ├── forum.js # 论坛
│ │ ├── blog.js # 博客
│ │ ├── passwords.js # 密码管理器
│ │ └── ... # 其他页面逻辑
│ └── wallpaper/ # 上传的壁纸 │ └── wallpaper/ # 上传的壁纸
└── package.json └── package.json
``` ```
@@ -211,11 +196,20 @@ rainweb/
## 开发 ## 开发
```bash ```bash
# 开发模式启动 # 开发模式Vite dev server,端口 5173/api 与 /uploads 代理到本地后端 3101
node server.js 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: 手动 # 方法2: 手动
git pull git pull
npm install npm install
npm run build
node cli.js restart 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 bcrypt = require('bcryptjs');
const http = require('http'); const http = require('http');
const DB_PATH = path.join(__dirname, 'data.db');
const CONFIG_PATH = path.join(__dirname, '.env.json'); const CONFIG_PATH = path.join(__dirname, '.env.json');
const PKG = require('./package.json'); const PKG = require('./package.json');
const db = require('./db');
// 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 HELP = ` const HELP = `
RainWeb CLI v${PKG.version} RainWeb CLI v${PKG.version}
@@ -98,7 +58,7 @@ async function cmdStatus() {
console.log(`RainWeb v${PKG.version}`); console.log(`RainWeb v${PKG.version}`);
console.log(`Node.js: ${process.version}`); console.log(`Node.js: ${process.version}`);
console.log(`Platform: ${process.platform}`); 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 // Check if server is running
try { try {
@@ -110,8 +70,8 @@ async function cmdStatus() {
// Show admin info // Show admin info
try { try {
const db = await getDb(); await db.getDb();
const admin = dbGet("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'"); const admin = db.get("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
if (admin) { if (admin) {
console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`); console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`);
} }
@@ -150,6 +110,7 @@ function cmdPort() {
// === Password === // === Password ===
async function cmdPassword() { async function cmdPassword() {
await warnIfServerRunning();
let newPass = process.argv[3]; let newPass = process.argv[3];
if (!newPass) { if (!newPass) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -163,44 +124,45 @@ async function cmdPassword() {
process.exit(1); process.exit(1);
} }
await getDb(); await db.getDb();
const admin = dbGet("SELECT id FROM users WHERE role = 'admin'"); const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
if (!admin) { console.error('No admin user found.'); process.exit(1); } if (!admin) { console.error('No admin user found.'); process.exit(1); }
const hash = bcrypt.hashSync(newPass, 10); 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.'); console.log('Admin password updated successfully.');
} }
// === Captcha === // === Captcha ===
async function cmdCaptcha() { async function cmdCaptcha() {
await getDb(); await warnIfServerRunning();
await db.getDb();
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const q = (q) => new Promise(resolve => rl.question(q, resolve)); const q = (q) => new Promise(resolve => rl.question(q, resolve));
console.log('=== Captcha Rule Configuration ===\n'); console.log('=== Captcha Rule Configuration ===\n');
console.log('Current settings:'); console.log('Current settings:');
['login','register','forum','failed'].forEach(k => { ['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'}`); 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`); console.log(` type: ${type ? type.value : 'builtin'}\n`);
const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: '); 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']) { 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'}]: `); 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): '); 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') { if (failAns.toLowerCase() === 'y') {
const threshold = await q('Failed attempts threshold (default 5): '); 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(); rl.close();
@@ -209,8 +171,8 @@ async function cmdCaptcha() {
// === Config === // === Config ===
async function cmdConfig() { async function cmdConfig() {
await getDb(); await db.getDb();
const rows = dbAll("SELECT key, value FROM site_settings ORDER BY key"); const rows = db.all("SELECT key, value FROM site_settings ORDER BY key");
console.log('=== Site Configuration ===\n'); console.log('=== Site Configuration ===\n');
const secrets = ['smtp_pass', 'recaptcha_secret_key']; const secrets = ['smtp_pass', 'recaptcha_secret_key'];
rows.forEach(r => { rows.forEach(r => {
@@ -262,87 +224,20 @@ async function cmdRestart() {
} }
// === Upgrade === // === 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() { async function cmdUpgrade() {
console.log('=== RainWeb Upgrade ===\n'); console.log('=== RainWeb Upgrade ===\n');
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
if (isGitRepo) { if (!fs.existsSync(path.join(__dirname, '.git'))) {
console.log('1. Pulling latest code via git...'); console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。');
try { process.exit(1);
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 });
// Download zip using fetch or curl console.log('1. Pulling latest code via git...');
const zipPath = path.join(tmpDir, 'rainweb.zip'); try {
try { execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
execSync(`curl -L "${REPO_ZIP}" -o "${zipPath}"`, { stdio: 'pipe' }); } catch {
} catch { console.error('Git pull failed. Check for conflicts.');
// Fallback to wget or node https process.exit(1);
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('\n2. Installing dependencies...'); console.log('\n2. Installing dependencies...');
@@ -359,6 +254,14 @@ async function cmdUpgrade() {
} }
// === Helper === // === Helper ===
// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行
async function warnIfServerRunning() {
try {
await httpGet('http://localhost:' + (getConfigPort()));
console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行');
} catch {}
}
function httpGet(url) { function httpGet(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); }) 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 fs = require('fs');
const path = require('path'); const path = require('path');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
@@ -7,74 +7,57 @@ const DB_PATH = path.join(__dirname, 'data', 'rainweb.db');
const DATA_DIR = path.dirname(DB_PATH); const DATA_DIR = path.dirname(DB_PATH);
let db = null; let db = null;
async function getDb() { function getDb() {
if (db) return db; if (db) return db;
// Ensure data directory exists // Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) { if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
} }
const SQL = await initSqlJs(); db = new Database(DB_PATH);
if (fs.existsSync(DB_PATH)) {
const buffer = fs.readFileSync(DB_PATH);
db = new SQL.Database(buffer);
} else {
db = new SQL.Database();
}
initTables(); initTables();
migrateSchema();
seedAdmin(); seedAdmin();
seedDefaults(); seedDefaults();
seedSampleData(); seedSampleData();
saveDb();
return db; 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() { 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, id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0, password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '', role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`); 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, id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL, password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT (datetime('now')))`); 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, id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '', url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0, icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '', use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`); created_at DATETIME DEFAULT (datetime('now')))`);
// Migration: add columns if missing // Migration: add columns if missing
try { db.run('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {} try { db.exec('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 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 '', id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '',
content TEXT NOT NULL, active INTEGER DEFAULT 1, content TEXT NOT NULL, active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
updated_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, id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0,
announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`); announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`);
try { db.run('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {} try { db.exec('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 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, id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL,
title TEXT NOT NULL, content TEXT NOT NULL, author_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 '', use_markdown INTEGER DEFAULT 1, sub_category TEXT DEFAULT '',
@@ -82,24 +65,24 @@ function initTables() {
updated_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (category_id) REFERENCES forum_categories(id), FOREIGN KEY (category_id) REFERENCES forum_categories(id),
FOREIGN KEY (author_id) REFERENCES users(id))`); FOREIGN KEY (author_id) REFERENCES users(id))`);
try { db.run('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {} try { db.exec('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
try { db.run('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {} try { db.exec('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 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, id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
content TEXT NOT NULL, author_id INTEGER, content TEXT NOT NULL, author_id INTEGER,
author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')), author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (post_id) REFERENCES blog_posts(id))`); 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, id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
content TEXT NOT NULL, author_id INTEGER NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL,
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (post_id) REFERENCES forum_posts(id), FOREIGN KEY (post_id) REFERENCES forum_posts(id),
FOREIGN KEY (author_id) REFERENCES users(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, id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL, content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL,
published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1, published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1,
@@ -107,12 +90,12 @@ function initTables() {
updated_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (author_id) REFERENCES users(id))`); 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, id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL,
pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '', pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '',
FOREIGN KEY (user_id) REFERENCES users(id))`); 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, id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
title TEXT NOT NULL, username TEXT DEFAULT '', title TEXT NOT NULL, username TEXT DEFAULT '',
encrypted_password TEXT NOT NULL, url TEXT DEFAULT '', notes 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')), updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id))`); 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, id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL,
original_name TEXT NOT NULL, size INTEGER NOT NULL, original_name TEXT NOT NULL, size INTEGER NOT NULL,
mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL, mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL,
@@ -128,21 +111,36 @@ function initTables() {
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id))`); 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 '')`); key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
// Indexes for performance // Indexes for performance
db.run('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)'); db.exec('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.exec('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.exec('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.exec('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_attachments_ref ON attachments(ref_type, ref_id)');
// Legacy migrations for old database compatibility // Legacy migrations for old database compatibility
try { db.run("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {} try { db.exec("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.exec('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.exec("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 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() { function seedAdmin() {
@@ -195,11 +193,8 @@ function seedDefaults() {
function run(sql, params = []) { function run(sql, params = []) {
try { try {
db.run(sql, params); const info = db.prepare(sql).run(...params);
const r = db.exec("SELECT last_insert_rowid()"); return Number(info.lastInsertRowid) || 0;
const rowid = r && r[0] && r[0].values ? r[0].values[0][0] : 0;
saveDb();
return rowid;
} catch (e) { } catch (e) {
console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80)); console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80));
return 0; return 0;
@@ -208,9 +203,8 @@ function run(sql, params = []) {
function get(sql, params = []) { function get(sql, params = []) {
try { try {
const stmt = db.prepare(sql); stmt.bind(params); const row = db.prepare(sql).get(...params);
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; } return row || null;
stmt.free(); return null;
} catch (e) { } catch (e) {
console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80)); console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80));
return null; return null;
@@ -219,10 +213,7 @@ function get(sql, params = []) {
function all(sql, params = []) { function all(sql, params = []) {
try { try {
const stmt = db.prepare(sql); stmt.bind(params); return db.prepare(sql).all(...params);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); return rows;
} catch (e) { } catch (e) {
console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80)); console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80));
return []; 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 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) { function authMiddleware(req, res, next) {
const header = req.headers.authorization; const header = req.headers.authorization;
@@ -17,7 +18,9 @@ function authMiddleware(req, res, next) {
} }
function adminOnly(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: '需要管理员权限' }); return res.status(403).json({ error: '需要管理员权限' });
} }
next(); 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", "name": "rainweb-links",
"version": "1.4.2", "version": "2.0.0",
"description": "链接聚合管理平台", "description": "链接聚合管理平台",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "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" "cli": "node cli.js"
}, },
"dependencies": { "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", "bcryptjs": "^2.4.3",
"better-sqlite3": "^13.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dompurify": "^3.4.13",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.6.2",
"jsdom": "^30.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"marked": "^18.0.5", "marked": "^18.0.5",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.1", "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 express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const db = require('../db'); const db = require('../db');
const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth'); const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth');
const { consumeProof } = require('./captcha');
const router = express.Router(); const router = express.Router();
function checkCaptcha(action) { // 登录限流:15 分钟窗口内最多 10 次尝试
const type = db.getSetting('captcha_type') || 'none'; const loginLimiter = rateLimit({
if (type === 'none') return false; windowMs: 15 * 60 * 1000,
const setting = db.getSetting('captcha_' + action); max: 10,
return setting === '1'; standardHeaders: true,
legacyHeaders: false,
message: { error: '尝试次数过多,请 15 分钟后再试' },
});
// 校验并一次性消费验证码证明令牌:不存在、无效或已使用返回 false
function validateCaptchaProof(proof) {
return consumeProof(proof).ok;
} }
// Verify reCAPTCHA V2 token // 第三方验证码服务端校验(reCAPTCHA / Cloudflare Turnstile siteverify
async function verifyCaptcha(token) { async function verifyThirdParty(token, type) {
const secret = db.getSetting('recaptcha_secret_key'); if (!token) return false;
if (!secret) return true; // captcha not configured, skip
try { try {
const https = require('https'); let url, secret;
const data = await new Promise((resolve, reject) => { if (type === 'recaptcha') {
const qs = `secret=${encodeURIComponent(secret)}&response=${encodeURIComponent(token)}`; url = 'https://www.google.com/recaptcha/api/siteverify';
const req = https.request({ hostname: 'www.google.com', path: '/recaptcha/api/siteverify', method: 'POST', secret = db.getSetting('recaptcha_secret_key');
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }}, res => { } else if (type === 'turnstile') {
let body = ''; url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
res.on('data', c => body += c); secret = db.getSetting('turnstile_secret_key');
res.on('end', () => resolve(JSON.parse(body))); } else {
}); return false;
req.on('error', reject); }
req.write(qs); if (!secret) return false;
req.end(); 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; const data = await res.json().catch(() => null);
} catch { return false; } return !!(data && data.success);
} } catch (e) {
console.error('Third-party captcha verify error:', e.message);
// Verify captcha helper return false;
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
} }
// 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; const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' }); 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]); const user = db.get('SELECT * FROM users WHERE username = ?', [username]);
if (!user || !bcrypt.compareSync(password, user.password)) { if (!user || !bcrypt.compareSync(password, user.password)) {
return res.status(401).json({ error: '用户名或密码错误' }); return res.status(401).json({ error: '用户名或密码错误' });
@@ -65,10 +92,14 @@ router.post('/login', (req, res) => {
}); });
router.post('/register', async (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) if (!username || !password || !email)
return res.status(400).json({ error: '请填写所有必填项' }); return res.status(400).json({ error: '请填写所有必填项' });
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' }); 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])) if (db.get('SELECT id FROM users WHERE username = ?', [username]))
return res.status(400).json({ error: '用户名已存在' }); return res.status(400).json({ error: '用户名已存在' });
if (db.get('SELECT id FROM users WHERE email = ?', [email])) 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'); const smtpHost = db.getSetting('smtp_host');
if (smtpHost) { if (smtpHost) {
// Email verification flow - 8-digit code // 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); const hash = bcrypt.hashSync(password, 10);
db.run('DELETE FROM pending_users WHERE email = ?', [email]); db.run('DELETE FROM pending_users WHERE email = ?', [email]);
db.run('INSERT INTO pending_users (username, password, email, token) VALUES (?, ?, ?, ?)', 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 = router;
module.exports.resolveCaptcha = resolveCaptcha;
+31 -8
View File
@@ -1,22 +1,45 @@
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken');
const db = require('../db'); const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth'); const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
router.get('/posts', (req, res) => { // 可选鉴权:有效 token 时解析出 req.user,匿名请求直接放行(用于草稿的"作者可见"判断)
const publishedOnly = req.query.all !== '1'; function optionalAuth(req, res, next) {
let sql = 'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id'; const header = req.headers.authorization;
if (publishedOnly) sql += ' WHERE bp.published = 1'; if (header && header.startsWith('Bearer ')) {
sql += ' ORDER BY bp.created_at DESC'; try {
res.json(db.all(sql)); 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( const post = db.get(
'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?', 'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?',
[req.params.id]); [req.params.id]);
if (!post) return res.status(404).json({ error: '文章不存在' }); 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); res.json(post);
}); });
+29 -1
View File
@@ -1,15 +1,23 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const db = require('../db'); const db = require('../db');
const { SECRET } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
// In-memory captcha store // In-memory captcha store
const captchaStore = new Map(); const captchaStore = new Map();
// 一次性消费的验证码证明令牌(防重放):proof -> 消费时间戳
const usedProofs = new Map();
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [key, val] of captchaStore) { for (const [key, val] of captchaStore) {
if (val.expires < now) captchaStore.delete(key); if (val.expires < now) captchaStore.delete(key);
} }
// proof 5 分钟过期,超过 300s 的已消费记录直接清理
for (const [key, t] of usedProofs) {
if (now - t > 300000) usedProofs.delete(key);
}
}, 300000); }, 300000);
// Harder captcha: longer answer, more noise // Harder captcha: longer answer, more noise
@@ -79,7 +87,10 @@ router.post('/verify', (req, res) => {
if (!entry) return res.json({ success: false, error: '验证码已过期' }); if (!entry) return res.json({ success: false, error: '验证码已过期' });
captchaStore.delete(token); captchaStore.delete(token);
if (entry.answer.toLowerCase() === String(answer).toLowerCase().trim()) { 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 { } else {
res.json({ success: false, error: '验证码错误' }); 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 // Proof-of-Work challenge
router.get('/pow-challenge', (req, res) => { router.get('/pow-challenge', (req, res) => {
const prefix = crypto.randomBytes(8).toString('hex'); const prefix = crypto.randomBytes(8).toString('hex');
@@ -135,3 +162,4 @@ router.post('/required', (req, res) => {
}); });
module.exports = router; 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 }); res.json({ message: '测试邮件已发送至 ' + to });
} catch (e) { } 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; const { email, username } = req.body;
if (!email || !username) return res.status(400).json({ error: '参数不完整' }); if (!email || !username) return res.status(400).json({ error: '参数不完整' });
const code = Math.floor(10000000 + Math.random() * 90000000).toString(); // 先取出旧 pending 记录(其中存有注册时加密的密码),避免删除后无法重建
db.run('DELETE FROM pending_users WHERE email = ?', [email]); const pending = db.get('SELECT * FROM pending_users WHERE email = ?', [email]);
if (!pending) return res.status(400).json({ error: '未找到待验证的注册信息,请重新注册' });
// 先确认 SMTP 已配置,避免删除旧记录后无法发送新验证码
const transporter = getTransporter(); const transporter = getTransporter();
if (!transporter) return res.status(400).json({ error: 'SMTP 未配置,请联系管理员' }); 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 { try {
const siteName = db.getSetting('site_name') || 'RainWeb'; const siteName = db.getSetting('site_name') || 'RainWeb';
const color = db.getSetting('primary_color') || '#6750a4'; 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 class="footer">${siteName} &middot; 自动发送请勿回复</div>
</div></body></html>`, </div></body></html>`,
}); });
res.json({ message: '验证码已发送', code }); // code for dev convenience res.json({ message: '验证码已发送' });
} catch (e) { } 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 express = require('express');
const db = require('../db'); const db = require('../db');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
const { resolveCaptcha } = require('./auth');
const router = express.Router(); 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 (?, ?, ?, ?, ?)', const id = db.run('INSERT INTO forum_categories (name, description, sort_order, announcement, sub_categories) VALUES (?, ?, ?, ?, ?)',
[name, description || '', sort_order || 0, announcement || '', sc]); [name, description || '', sort_order || 0, announcement || '', sc]);
res.json(db.get('SELECT * FROM forum_categories WHERE id = ?', [id])); 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) => { 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]); const updated = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]);
if (!updated) return res.status(500).json({ error: '更新后读取失败' }); if (!updated) return res.status(500).json({ error: '更新后读取失败' });
res.json(updated); 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) => { router.delete('/categories/:id', authMiddleware, (req, res) => {
@@ -75,9 +76,13 @@ router.get('/posts/:id', (req, res) => {
res.json({ post, replies }); 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; const { category_id, title, content, use_markdown, sub_category } = req.body;
if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' }); 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( const id = db.run(
'INSERT INTO forum_posts (category_id, title, content, author_id, use_markdown, sub_category) VALUES (?, ?, ?, ?, ?, ?)', '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 || '']); [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 path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const initSqlJs = require('sql.js'); const Database = require('better-sqlite3');
const db = require('../db'); const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth'); const { authMiddleware, adminOnly } = require('../middleware/auth');
@@ -15,41 +15,43 @@ const IMPORT_TABLES = [
'attachments', 'site_settings', 'user_settings' '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'); 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 (err) return res.status(400).json({ error: '上传失败: ' + err.message });
if (!req.file) return res.status(400).json({ error: '请选择数据库文件' }); if (!req.file) return res.status(400).json({ error: '请选择数据库文件' });
// 以只读方式打开上传的 SQLite 文件(better-sqlite3 直接读磁盘路径)
let oldDb = null;
try { try {
const SQL = await initSqlJs(); oldDb = new Database(req.file.path, { readonly: true });
const buffer = fs.readFileSync(req.file.path);
const oldDb = new SQL.Database(buffer);
const report = { imported: {}, errors: [], total: 0 }; const report = { imported: {}, errors: [], total: 0 };
for (const table of IMPORT_TABLES) { for (const table of IMPORT_TABLES) {
try { try {
// Check if table exists in old db // Check if table exists in old db
const check = oldDb.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='" + table + "'"); const check = oldDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(table);
if (!check || check.length === 0 || check[0].values.length === 0) { if (!check) {
report.errors.push(table + ': 表不存在,跳过'); report.errors.push(table + ': 表不存在,跳过');
continue; continue;
} }
// Get columns from old table // Get columns from old table
const colInfo = oldDb.exec('PRAGMA table_info(' + table + ')'); const colInfo = oldDb.pragma('table_info(' + table + ')');
const columns = colInfo[0].values.map(v => v[1]); // column names const columns = colInfo.map(c => c.name); // column names
const rows = oldDb.exec('SELECT * FROM ' + table); const rows = oldDb.prepare('SELECT * FROM ' + table).all();
if (!rows || rows.length === 0 || rows[0].values.length === 0) { if (rows.length === 0) {
report.imported[table] = 0; report.imported[table] = 0;
continue; continue;
} }
const colNames = columns.join(','); const colNames = columns.join(',');
const placeholders = columns.map(() => '?').join(','); const placeholders = columns.map(() => '?').join(',');
let count = 0; let count = 0;
for (const row of rows[0].values) { for (const row of rows) {
try { 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++; count++;
} catch (e) { } catch (e) {
report.errors.push(table + ': 行跳过 (' + e.message.substring(0, 50) + ')'); 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({ res.json({
message: '导入完成', message: '导入完成',
total: report.total, total: report.total,
@@ -73,6 +72,9 @@ router.post('/database', authMiddleware, adminOnly, async (req, res) => {
}); });
} catch (e) { } catch (e) {
res.status(500).json({ error: '导入失败: ' + e.message }); 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(); 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 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) { 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; if (!setting || !setting.kdf_salt) return null;
const salt = Buffer.from(setting.kdf_salt, 'hex'); 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) { function encrypt(text, key) {
@@ -42,39 +96,50 @@ function decrypt(encoded, key) {
// Set or change PIN // Set or change PIN
router.post('/set-pin', authMiddleware, (req, res) => { router.post('/set-pin', authMiddleware, (req, res) => {
const { pin } = req.body; 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 existing = db.get('SELECT id FROM user_settings WHERE user_id = ?', [req.user.id]);
const pinHash = bcrypt.hashSync(pin, 10); const pinHash = bcrypt.hashSync(pin, 10);
const kdfSalt = crypto.randomBytes(16).toString('hex'); const kdfSalt = crypto.randomBytes(16).toString('hex');
if (existing) { 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 { } 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); 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 设置成功' }); res.json({ message: 'PIN 设置成功' });
}); });
// Check if PIN is set // Check if PIN is set
router.get('/pin-status', authMiddleware, (req, res) => { router.get('/pin-status', authMiddleware, (req, res) => {
const setting = db.get('SELECT pin_hash FROM user_settings WHERE user_id = ?', [req.user.id]); 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 // Unlock with PIN
router.post('/unlock', authMiddleware, (req, res) => { router.post('/unlock', authMiddleware, (req, res) => {
const { pin } = req.body; const { pin } = req.body;
if (!pin) return res.status(400).json({ error: '请输入 PIN' }); 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]); 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 (!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); const key = getEncryptionKey(req.user.id, pin);
if (!key) return res.status(500).json({ error: '解密失败' }); if (!key) return res.status(500).json({ error: '解密失败' });
unlockedSessions.set(req.user.id, { key, time: Date.now() }); unlockFails.delete(req.user.id); // 成功解锁清零失败计数
// Auto-lock after 1 hour unlockedSessions.set(req.user.id, { key, expires: Date.now() + SESSION_TTL }); // 滑动过期,不再用一次性 setTimeout
setTimeout(() => { unlockedSessions.delete(req.user.id); }, 3600000);
res.json({ message: '已解锁' }); res.json({ message: '已解锁' });
}); });
@@ -84,9 +149,9 @@ router.post('/lock', authMiddleware, (req, res) => {
res.json({ message: '已锁定' }); res.json({ message: '已锁定' });
}); });
// Ensure unlocked middleware // Ensure unlocked middleware(滑动过期:每次操作刷新过期时间)
function requireUnlock(req, res, next) { function requireUnlock(req, res, next) {
if (!unlockedSessions.has(req.user.id)) { if (!refreshSession(req.user.id)) {
return res.status(401).json({ error: '请先解锁密码管理器' }); return res.status(401).json({ error: '请先解锁密码管理器' });
} }
next(); next();
@@ -95,7 +160,8 @@ function requireUnlock(req, res, next) {
// List all password entries (decrypted) // List all password entries (decrypted)
router.get('/', authMiddleware, requireUnlock, (req, res) => { 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 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 decrypted = entries.map(e => {
const pwd = decrypt(e.encrypted_password, session.key); 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 }; 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) => { router.post('/', authMiddleware, requireUnlock, (req, res) => {
const { title, username, password, url, notes } = req.body; const { title, username, password, url, notes } = req.body;
if (!title || !password) return res.status(400).json({ error: '标题和密码不能为空' }); 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 encrypted = encrypt(password, session.key);
const id = db.run( const id = db.run(
'INSERT INTO password_entries (user_id, title, username, encrypted_password, url, notes) VALUES (?, ?, ?, ?, ?, ?)', '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 { 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]); 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: '记录不存在' }); 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); const encrypted = encrypt(password || '', session.key);
db.run( db.run(
"UPDATE password_entries SET title=?, username=?, encrypted_password=?, url=?, notes=?, updated_at=datetime('now') WHERE id=?", "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