Compare commits
59
Commits
e7f8b3f173
..
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb9592d56 | ||
|
|
8a08932d78 | ||
|
|
b152a2a9c9 | ||
|
|
4af71185d5 | ||
|
|
6d60b10c14 | ||
|
|
cf4c7d4ca4 | ||
|
|
5acc461e88 | ||
|
|
391cd1bdae | ||
|
|
5b166ef0e2 | ||
|
|
4f89c63b28 | ||
|
|
7bfeffaaa0 | ||
|
|
aff5764b9b | ||
|
|
bb830f779c | ||
|
|
63d329db5f | ||
|
|
e83a4e6ee1 | ||
|
|
0153f770f6 | ||
|
|
789a2d9b33 | ||
|
|
5a878c1aab | ||
|
|
5e4cdf26b9 | ||
|
|
2faace4810 | ||
|
|
45002b809a | ||
|
|
1b62bf99ce | ||
|
|
6fafa6eb00 | ||
|
|
6649eba777 | ||
|
|
6aedc5f632 | ||
|
|
ed82908a0d | ||
|
|
274291f8fe | ||
|
|
588df32306 | ||
|
|
3c98084909 | ||
|
|
1c0339bae1 | ||
|
|
651bbbb22e | ||
|
|
36be795af5 | ||
|
|
632d19f49e | ||
|
|
3cf560f932 | ||
|
|
ad8883cc61 | ||
|
|
f8d495b128 | ||
|
|
c2ca0f2327 | ||
|
|
249ed8cd2c | ||
|
|
a858439e19 | ||
|
|
535e4b8fcd | ||
|
|
75d102d6ee | ||
|
|
f0b642362a | ||
|
|
45f0dc9beb | ||
|
|
b29fbcdcfc | ||
|
|
20deb09dbd | ||
|
|
c71f4f0ed6 | ||
|
|
f7dd4163b2 | ||
|
|
72295ded4d | ||
|
|
f4a5276fb8 | ||
|
|
be6f644154 | ||
|
|
bdd3a068af | ||
|
|
990f6f40d0 | ||
|
|
1bed2dda5f | ||
|
|
b319584012 | ||
|
|
c4c0115ba6 | ||
|
|
f6407a84f7 | ||
|
|
2ac286a4f8 | ||
|
|
91cd009899 | ||
|
|
4d0ad9da68 |
@@ -6,3 +6,5 @@ uploads/*
|
||||
.env.json
|
||||
server.pid
|
||||
releases/
|
||||
public/dist/
|
||||
backups/
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# AGENTS.md
|
||||
|
||||
RainWeb 个人云平台(博客 + 论坛 + 密码管理器 + 管理面板聚合)。Node.js 22+(推荐 22 LTS)、CommonJS、Express 4、better-sqlite3(原生 SQLite)。前端 **React + Vite + React Router**(前台原生样式 Material Design 3,后台 MUI 独立应用)。**无测试、无 lint**——前端改动进 `frontend/src/`,构建产物 `public/dist/`(gitignored)由 server 静态服务,部署必须 `npm run build`。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm start # 生产:node server.js(serve 构建产物 + /api)
|
||||
npm run dev # 开发:vite dev server(5173,/api 与 /uploads 代理到 3101)
|
||||
npm run build # 构建 React 前端 → public/dist/(index.html + admin.html + assets)
|
||||
npm run preview # 预览构建产物
|
||||
node cli.js <cmd> # status | start | stop | restart | port [N] | password [pwd] | config | captcha | upgrade
|
||||
```
|
||||
|
||||
- 端口:`.env.json`(gitignored)`{"port": N}` 或环境变量 `PORT`,默认 3001;vite 代理目标硬编码 3101(vite.config.js),后端端口改了就同步改。
|
||||
- 验证方式:后端改动启动后 `curl` 接口;前端改动 `npm run dev` 热更新,或 `npm run build` 后刷新验证(需强刷一次,见下)。
|
||||
- **部署后浏览器必须拿新 HTML**:`serveIndex` 响应带 `Cache-Control: no-store`;构建产物 assets 文件名带 hash,经 `/assets` 挂载长期缓存(immutable)。若用户白屏且 console 报 "MIME type text/html",先查是否 `npm run build` 缺失/旧产物。
|
||||
- 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete` 用 `setup_complete` 门禁,非 adminOnly——新装无 token)。
|
||||
|
||||
## 数据层(db.js)
|
||||
|
||||
- better-sqlite3 是原生 SQLite:`data/rainweb.db` 单文件持久化,写操作经事务落盘。CLI 与 server 并发写库会等待(busy_timeout 5s)而非互相覆盖,但建议不要同时执行写操作。
|
||||
- 建表在 `initTables()`;列迁移用 try/catch 包裹 `ALTER TABLE ... ADD COLUMN`(幂等、静默失败)确保列存在。**新增列必须沿用此模式**,否则旧库会崩。版本化迁移在 `migrateSchema()`:`PRAGMA user_version` 记录 schema 版本,migrations 数组按 version 升序执行(`current < m.version` 才运行并推进版本号)——**新迁移写进 migrations 数组,不要散落 try/catch**。
|
||||
- 站点设置存 `site_settings`(key/value),通过 `db.getSetting` / `setSetting` 读写。
|
||||
- **cli.js 与 server.js 统一使用 db.js 的 `data/rainweb.db`**;旧版 `data.db` 已由 server.js 首次启动时迁移为 `data/rainweb.db`(旧文件改名 `data.db.bak`)。
|
||||
|
||||
## 路由(server.js)
|
||||
|
||||
- 集中挂载所有 `/api/*`(auth、admin-links、announcements、forum、blog、passwords、settings、email、profile、captcha、upload、setup、proxy、import)。**新路由必须在此挂载**——后面有 SPA catch-all:非 `/api/` 一律回 dist/index.html。
|
||||
- **挂载顺序关键**:`serveIndex('/')` → `/assets`(dist/assets,immutable)→ static(public) → `/uploads` → `/api/*` → `/admin*`(dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。
|
||||
- `middleware/auth.js`:Bearer JWT;`SECRET` = `process.env.JWT_SECRET`(**无硬编码回退**——server.js 启动时若 `.env.json` 无 `jwt_secret` 则随机生成写入并设置环境变量);`adminOnly` 会查库复查角色(用户被删/降权立即失效)。
|
||||
- `routes/setup.js`:`/complete` 用 `setup_complete` 门禁('1' 后 403),新密码禁止等于默认 `admin123`。
|
||||
- `routes/proxy.js`:面板嵌入代理——支持 `Authorization` header 或 `?token=` query(iframe 无法带 header);有内网地址拦截(SSRF)。
|
||||
- 验证码:内置 SVG + reCAPTCHA/Turnstile 第三方,服务端统一 `resolveCaptcha` 校验(builtin 走 proof JWT,第三方走 siteverify)。
|
||||
|
||||
## 前端(React + Vite)
|
||||
|
||||
- 源码在 `frontend/`:`src/main.jsx`(前台入口)、`src/App.jsx`(前台布局 Layout + 路由表)、`src/pages/`(前台页面)、`src/components/`(Layout/MarkdownRenderer/CaptchaModal/MusicEmbed/BlogSidebar)、`src/admin/`(**后台独立应用** MUI:main.jsx + AdminLayout + pages/)、`src/api/`(按模块封装的 fetch 层,`client.js` 管 token)、`src/lib/utils.js`、`src/theme.jsx`。
|
||||
- 前后台是**两个独立入口**(Vite 多入口),仅通过整页跳转连接:前台导航「管理后台」=`<a href="/admin">`(后台路由 basename `/admin`)。
|
||||
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html`、`/forum.html` 等),SPA fallback 已支持,链接不用改。
|
||||
- `frontend/index.html` 含 `${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。
|
||||
- 主题:`data-theme="dark"` 属性在 `<html>` 上(`public/css/style.css` 的 `[data-theme="dark"]` 选择器),非 `.dark` 类,非 prefers-color-scheme。切换逻辑在 `src/theme.jsx`。
|
||||
- 构建:`npm run build` → `public/dist/`(gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。
|
||||
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`(marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
|
||||
- 登录态:token 存 localStorage(key `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js` 的 `notifyAuthChange`)。
|
||||
|
||||
## SSR 与 SEO
|
||||
|
||||
- `ssr.js`:`/blog/:id`、`/forum/:id`、`/sitemap.xml`(robots.txt 的域名取自 `site_url` 设置)。SSR 页引用 `/css/style.css`(public/css 保留,勿删)。
|
||||
- 已发布博文(`published=1`)才会 SSR 渲染,未发布返回 404。
|
||||
|
||||
## 版本与更新
|
||||
|
||||
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
|
||||
- **Web 更新接口已移除**(P0 删除 `/api/update/check`、`/api/update/run`,无 RCE 面)。升级只走 `cli.js upgrade`:git pull(本地 origin:git.rainnya.asia 镜像)+ npm install + 重启。
|
||||
- 上传附件在 `uploads/`(gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。
|
||||
|
||||
## 约定
|
||||
|
||||
- UI 文案、代码注释、commit message 均为中文,保持一致。
|
||||
- 不要提交:`node_modules/`、`data/*`、`uploads/*`、`.env.json`、`server.pid`、`releases/`、`public/dist/`。
|
||||
@@ -1,6 +1,5 @@
|
||||
# 🌧️ RainWeb - 个人云管理平台
|
||||
|
||||
[](https://github.com/Xianyunah/rainwebblog)
|
||||
[](LICENSE)
|
||||
|
||||
一体化个人云平台,集成博客、论坛、密码管理器、管理后台聚合等功能,Material Design 3 风格,支持深色/浅色切换。
|
||||
@@ -25,7 +24,7 @@
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Node.js** 16.x 或更高版本(推荐 20.x LTS)
|
||||
- **Node.js** 22 或更高版本(推荐 22 LTS)
|
||||
- **npm** 随 Node.js 安装
|
||||
|
||||
### 下载安装
|
||||
@@ -45,14 +44,7 @@
|
||||
unzip rainweb-source.zip -d rainweb
|
||||
cd rainweb
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
**从 GitHub 安装:**
|
||||
```bash
|
||||
git clone https://github.com/Xianyunah/rainwebblog.git
|
||||
cd rainwebblog
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
@@ -65,7 +57,8 @@ npm start
|
||||
- **启动文件**: `server.js`
|
||||
- **端口**: `3001`
|
||||
3. 提交后宝塔自动 `npm install` 并启动
|
||||
4. 如需域名访问,配置 Nginx 反向代理:
|
||||
4. 若未自动构建,手动执行 `npm run build`(构建 React 前端到 `public/dist/`)
|
||||
5. 如需域名访问,配置 Nginx 反向代理:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -90,7 +83,7 @@ server {
|
||||
|
||||
```bash
|
||||
# 使用 Node.js 官方镜像
|
||||
docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:20 bash -c "
|
||||
docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:22 bash -c "
|
||||
cd /app && npm install && node server.js
|
||||
"
|
||||
```
|
||||
@@ -126,8 +119,8 @@ npm run cli -- <command>
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Node.js + Express
|
||||
- **数据库**: SQLite (sql.js)
|
||||
- **前端**: 原生 HTML/CSS/JS + Material Design 3
|
||||
- **数据库**: SQLite (better-sqlite3)
|
||||
- **前端**: React + Vite + React Router(前台原生样式 / 后台 MUI)
|
||||
- **加密**: AES-256-GCM, PBKDF2, bcrypt
|
||||
- **验证码**: SVG 扭曲文字 / Google reCAPTCHA V2
|
||||
- **文件上传**: multer
|
||||
@@ -139,6 +132,7 @@ npm run cli -- <command>
|
||||
```
|
||||
rainweb/
|
||||
├── server.js # 主入口
|
||||
├── vite.config.js # Vite 构建配置
|
||||
├── cli.js # CLI 工具
|
||||
├── db.js # 数据库层
|
||||
├── middleware/
|
||||
@@ -156,29 +150,20 @@ rainweb/
|
||||
│ ├── setup.js # 初始化向导
|
||||
│ ├── announcements.js # 公告
|
||||
│ └── profile.js # 个人资料
|
||||
├── frontend/ # React 前端源码(Vite 根目录)
|
||||
│ ├── index.html # 前台入口(含 ${site_name} 占位符)
|
||||
│ ├── admin.html # 后台入口
|
||||
│ └── src/
|
||||
│ ├── main.jsx # 前台入口
|
||||
│ ├── App.jsx # 前台布局 + 路由表
|
||||
│ ├── admin/ # 后台入口(MUI)
|
||||
│ ├── api/ # fetch 封装
|
||||
│ ├── lib/ # 工具函数
|
||||
│ ├── theme.jsx # 主题切换(data-theme)
|
||||
│ └── pages/ # 前台页面
|
||||
├── public/
|
||||
│ ├── index.html # 博客首页
|
||||
│ ├── login.html # 登录
|
||||
│ ├── register.html # 注册
|
||||
│ ├── admin.html # 管理后台
|
||||
│ ├── forum.html # 论坛
|
||||
│ ├── blog.html # 博客页
|
||||
│ ├── passwords.html # 密码管理器
|
||||
│ ├── embed.html # 网页嵌入
|
||||
│ ├── profile.html # 个人中心
|
||||
│ ├── setup.html # 初始化向导
|
||||
│ ├── dist/ # Vite 构建产物(npm run build 生成,serveIndex 读取)
|
||||
│ ├── css/style.css # 全局样式 + 主题
|
||||
│ ├── js/
|
||||
│ │ ├── api.js # API 封装
|
||||
│ │ ├── nav.js # 导航栏 + 主题
|
||||
│ │ ├── captcha.js # 验证码前端
|
||||
│ │ ├── theme.js # 深浅色切换
|
||||
│ │ ├── main.js # 博客首页
|
||||
│ │ ├── admin.js # 管理后台
|
||||
│ │ ├── forum.js # 论坛
|
||||
│ │ ├── blog.js # 博客
|
||||
│ │ ├── passwords.js # 密码管理器
|
||||
│ │ └── ... # 其他页面逻辑
|
||||
│ └── wallpaper/ # 上传的壁纸
|
||||
└── package.json
|
||||
```
|
||||
@@ -211,11 +196,20 @@ rainweb/
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 开发模式启动
|
||||
node server.js
|
||||
# 开发模式(Vite dev server,端口 5173,/api 与 /uploads 代理到本地后端 3101)
|
||||
npm run dev
|
||||
|
||||
# 构建生产产物到 public/dist/
|
||||
npm run build
|
||||
|
||||
# 预览构建产物
|
||||
npm run preview
|
||||
|
||||
# 生产运行(Express 服务 public/ 与构建产物)
|
||||
npm start
|
||||
```
|
||||
|
||||
项目不依赖构建工具,直接修改 `public/` 目录下的文件即可。
|
||||
前端为 React + Vite,开发时前端改动由 Vite 热更新,无需刷新;生产部署必须先在服务器执行 `npm run build` 生成 `public/dist/`。
|
||||
|
||||
## 升级
|
||||
|
||||
@@ -226,6 +220,7 @@ node cli.js upgrade
|
||||
# 方法2: 手动
|
||||
git pull
|
||||
npm install
|
||||
npm run build
|
||||
node cli.js restart
|
||||
```
|
||||
|
||||
|
||||
@@ -6,49 +6,9 @@ const readline = require('readline');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const http = require('http');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data.db');
|
||||
const CONFIG_PATH = path.join(__dirname, '.env.json');
|
||||
const PKG = require('./package.json');
|
||||
|
||||
// Lazy-load db
|
||||
let _db = null;
|
||||
async function getDb() {
|
||||
if (_db) return _db;
|
||||
const initSqlJs = require('sql.js');
|
||||
const SQL = await initSqlJs();
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
_db = new SQL.Database(buffer);
|
||||
} else {
|
||||
console.error('data.db not found. Run the server first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
function dbRun(sql, params = []) {
|
||||
if (!_db) return;
|
||||
_db.run(sql, params);
|
||||
const r = _db.exec("SELECT last_insert_rowid()");
|
||||
const data = _db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
return r && r[0] && r[0].values ? r[0].values[0][0] : 0;
|
||||
}
|
||||
|
||||
function dbGet(sql, params = []) {
|
||||
if (!_db) return null;
|
||||
const stmt = _db.prepare(sql); stmt.bind(params);
|
||||
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
|
||||
stmt.free(); return null;
|
||||
}
|
||||
|
||||
function dbAll(sql, params = []) {
|
||||
if (!_db) return [];
|
||||
const stmt = _db.prepare(sql); stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free(); return rows;
|
||||
}
|
||||
const db = require('./db');
|
||||
|
||||
const HELP = `
|
||||
RainWeb CLI v${PKG.version}
|
||||
@@ -63,6 +23,7 @@ Commands:
|
||||
password [new-pass] Change admin password (leave empty for prompt)
|
||||
captcha Interactive captcha rule configuration
|
||||
config Show all current settings
|
||||
backup Backup database to backups/ (readonly, safe to run while running)
|
||||
upgrade Git pull + npm install + restart (one-click upgrade)
|
||||
help Show this help
|
||||
|
||||
@@ -71,6 +32,7 @@ Examples:
|
||||
node cli.js port 8080
|
||||
node cli.js password MyNewP@ss123
|
||||
node cli.js captcha
|
||||
node cli.js backup
|
||||
node cli.js upgrade
|
||||
`;
|
||||
|
||||
@@ -86,6 +48,7 @@ async function main() {
|
||||
case 'password': return cmdPassword();
|
||||
case 'captcha': return cmdCaptcha();
|
||||
case 'config': return cmdConfig();
|
||||
case 'backup': return cmdBackup();
|
||||
case 'upgrade': return cmdUpgrade();
|
||||
case 'help':
|
||||
default:
|
||||
@@ -98,7 +61,11 @@ async function cmdStatus() {
|
||||
console.log(`RainWeb v${PKG.version}`);
|
||||
console.log(`Node.js: ${process.version}`);
|
||||
console.log(`Platform: ${process.platform}`);
|
||||
console.log(`Data DB: ${fs.existsSync(DB_PATH) ? fs.statSync(DB_PATH).size + ' bytes' : 'NOT FOUND'}`);
|
||||
console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`);
|
||||
|
||||
// 最近一次备份时间
|
||||
const latestBackup = getLatestBackup();
|
||||
console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份'));
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
@@ -110,8 +77,8 @@ async function cmdStatus() {
|
||||
|
||||
// Show admin info
|
||||
try {
|
||||
const db = await getDb();
|
||||
const admin = dbGet("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
|
||||
await db.getDb();
|
||||
const admin = db.get("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
|
||||
if (admin) {
|
||||
console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`);
|
||||
}
|
||||
@@ -150,6 +117,7 @@ function cmdPort() {
|
||||
|
||||
// === Password ===
|
||||
async function cmdPassword() {
|
||||
await warnIfServerRunning();
|
||||
let newPass = process.argv[3];
|
||||
if (!newPass) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -163,44 +131,45 @@ async function cmdPassword() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await getDb();
|
||||
const admin = dbGet("SELECT id FROM users WHERE role = 'admin'");
|
||||
await db.getDb();
|
||||
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
|
||||
if (!admin) { console.error('No admin user found.'); process.exit(1); }
|
||||
|
||||
const hash = bcrypt.hashSync(newPass, 10);
|
||||
dbRun('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
||||
db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
||||
console.log('Admin password updated successfully.');
|
||||
}
|
||||
|
||||
// === Captcha ===
|
||||
async function cmdCaptcha() {
|
||||
await getDb();
|
||||
await warnIfServerRunning();
|
||||
await db.getDb();
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const q = (q) => new Promise(resolve => rl.question(q, resolve));
|
||||
|
||||
console.log('=== Captcha Rule Configuration ===\n');
|
||||
console.log('Current settings:');
|
||||
['login','register','forum','failed'].forEach(k => {
|
||||
const v = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
|
||||
const v = db.get("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
|
||||
console.log(` ${k}: ${v ? v.value : '0'}`);
|
||||
});
|
||||
const type = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_type'");
|
||||
const type = db.get("SELECT value FROM site_settings WHERE key = 'captcha_type'");
|
||||
console.log(` type: ${type ? type.value : 'builtin'}\n`);
|
||||
|
||||
const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: ');
|
||||
if (typeAns) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
|
||||
if (typeAns) db.run("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
|
||||
|
||||
for (const scope of ['login', 'register', 'forum']) {
|
||||
const current = dbGet("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'");
|
||||
const current = db.get("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'");
|
||||
const ans = await q(`Enable captcha for ${scope}? (y/n) [${current && current.value === '1' ? 'y' : 'n'}]: `);
|
||||
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']);
|
||||
db.run("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']);
|
||||
}
|
||||
|
||||
const failAns = await q('Enable captcha after failed attempts? (y/n): ');
|
||||
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
|
||||
db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
|
||||
if (failAns.toLowerCase() === 'y') {
|
||||
const threshold = await q('Failed attempts threshold (default 5): ');
|
||||
if (threshold) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
||||
if (threshold) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
@@ -209,8 +178,8 @@ async function cmdCaptcha() {
|
||||
|
||||
// === Config ===
|
||||
async function cmdConfig() {
|
||||
await getDb();
|
||||
const rows = dbAll("SELECT key, value FROM site_settings ORDER BY key");
|
||||
await db.getDb();
|
||||
const rows = db.all("SELECT key, value FROM site_settings ORDER BY key");
|
||||
console.log('=== Site Configuration ===\n');
|
||||
const secrets = ['smtp_pass', 'recaptcha_secret_key'];
|
||||
rows.forEach(r => {
|
||||
@@ -261,88 +230,70 @@ async function cmdRestart() {
|
||||
await cmdStart();
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
const REPO_URL = 'https://github.com/Xianyunah/rainwebblog.git';
|
||||
const REPO_ZIP = 'https://codeload.github.com/Xianyunah/rainwebblog/zip/refs/heads/master';
|
||||
// === Backup ===
|
||||
async function cmdBackup() {
|
||||
const srcPath = path.join(__dirname, 'data', 'rainweb.db');
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.error('数据库不存在:' + srcPath + '(首次启动 server 后才会创建)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backupDir = path.join(__dirname, 'backups');
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const destPath = path.join(backupDir,
|
||||
`rainweb-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.db`);
|
||||
|
||||
// 只读打开源库,不写数据,安全(server 运行中也可执行)
|
||||
// better-sqlite3 v13:src.backup(destPath) 接受目标文件路径(string),返回 Promise
|
||||
const Database = require('better-sqlite3');
|
||||
const src = new Database(srcPath, { readonly: true });
|
||||
try {
|
||||
await src.backup(destPath);
|
||||
} finally {
|
||||
src.close();
|
||||
}
|
||||
|
||||
const size = fs.statSync(destPath).size;
|
||||
console.log(`备份成功:${destPath}(${size} bytes)`);
|
||||
}
|
||||
|
||||
// 读取 backups/ 目录下最新的备份文件(按 mtime),无备份返回 null
|
||||
function getLatestBackup() {
|
||||
const backupDir = path.join(__dirname, 'backups');
|
||||
if (!fs.existsSync(backupDir)) return null;
|
||||
let latest = null;
|
||||
for (const f of fs.readdirSync(backupDir)) {
|
||||
if (!f.startsWith('rainweb-') || !f.endsWith('.db')) continue;
|
||||
const filePath = path.join(backupDir, f);
|
||||
const mtime = fs.statSync(filePath).mtime;
|
||||
if (!latest || mtime > latest.mtime) latest = { file: f, mtime };
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function formatMtime(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
async function cmdUpgrade() {
|
||||
console.log('=== RainWeb Upgrade ===\n');
|
||||
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
||||
|
||||
if (isGitRepo) {
|
||||
console.log('1. Pulling latest code via git...');
|
||||
try {
|
||||
execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('Git pull failed. Check for conflicts.');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log('1. Downloading latest release...');
|
||||
try {
|
||||
const tmpDir = path.join(__dirname, '.upgrade-tmp');
|
||||
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
if (!fs.existsSync(path.join(__dirname, '.git'))) {
|
||||
console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Download zip using fetch or curl
|
||||
const zipPath = path.join(tmpDir, 'rainweb.zip');
|
||||
try {
|
||||
execSync(`curl -L "${REPO_ZIP}" -o "${zipPath}"`, { stdio: 'pipe' });
|
||||
} catch {
|
||||
// Fallback to wget or node https
|
||||
execSync(`node -e "
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const f = fs.createWriteStream('${zipPath.replace(/\\/g, '/')}');
|
||||
const url = '${REPO_ZIP}';
|
||||
https.get(url, r => {
|
||||
let i = 0, u = url;
|
||||
const fol = res => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && i < 5) {
|
||||
i++; u = new URL(res.headers.location, u).href;
|
||||
https.get(u, fol).on('error', () => {});
|
||||
return;
|
||||
}
|
||||
res.pipe(f);
|
||||
};
|
||||
fol(r);
|
||||
});
|
||||
"`, { stdio: 'pipe', timeout: 60000 });
|
||||
}
|
||||
|
||||
// Extract (requires unzip or 7z)
|
||||
const extractDir = path.join(tmpDir, 'extracted');
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
execSync(`tar -xf "${zipPath}" -C "${extractDir}"`, { stdio: 'pipe' });
|
||||
|
||||
// Find the inner directory (github adds a prefix dir)
|
||||
const inner = fs.readdirSync(extractDir).filter(f => fs.statSync(path.join(extractDir, f)).isDirectory())[0];
|
||||
const srcDir = inner ? path.join(extractDir, inner) : extractDir;
|
||||
|
||||
// Copy files, excluding data.db and node_modules
|
||||
const exclude = ['data.db', 'node_modules', '.env.json', 'server.pid', 'releases'];
|
||||
const cpDir = (src, dest) => {
|
||||
fs.readdirSync(src).forEach(f => {
|
||||
if (exclude.includes(f)) return;
|
||||
const s = path.join(src, f), d = path.join(dest, f);
|
||||
if (fs.statSync(s).isDirectory()) {
|
||||
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
||||
cpDir(s, d);
|
||||
} else {
|
||||
fs.copyFileSync(s, d);
|
||||
}
|
||||
});
|
||||
};
|
||||
cpDir(srcDir, __dirname);
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
console.log(' Download & extract complete.');
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e.message);
|
||||
console.log(' Fallback: manually download from ' + REPO_URL);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('1. Pulling latest code via git...');
|
||||
try {
|
||||
execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('Git pull failed. Check for conflicts.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n2. Installing dependencies...');
|
||||
@@ -359,6 +310,14 @@ async function cmdUpgrade() {
|
||||
}
|
||||
|
||||
// === Helper ===
|
||||
// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行
|
||||
async function warnIfServerRunning() {
|
||||
try {
|
||||
await httpGet('http://localhost:' + (getConfigPort()));
|
||||
console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const initSqlJs = require('sql.js');
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
@@ -7,74 +7,57 @@ const DB_PATH = path.join(__dirname, 'data', 'rainweb.db');
|
||||
const DATA_DIR = path.dirname(DB_PATH);
|
||||
let db = null;
|
||||
|
||||
async function getDb() {
|
||||
function getDb() {
|
||||
if (db) return db;
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
const SQL = await initSqlJs();
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
db = new SQL.Database(buffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
db = new Database(DB_PATH);
|
||||
initTables();
|
||||
migrateSchema();
|
||||
seedAdmin();
|
||||
seedDefaults();
|
||||
seedSampleData();
|
||||
saveDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
function saveDb() {
|
||||
if (!db) return;
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const data = db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
} catch (e) {
|
||||
console.error('Save DB failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function initTables() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
|
||||
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS pending_users (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS pending_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS admin_links (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS admin_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
|
||||
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||
// Migration: add columns if missing
|
||||
try { db.run('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.run('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS announcements (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS announcements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '',
|
||||
content TEXT NOT NULL, active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL,
|
||||
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0,
|
||||
announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`);
|
||||
try { db.run('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {}
|
||||
try { db.run('ALTER TABLE forum_categories ADD COLUMN sub_categories TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_categories ADD COLUMN sub_categories TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_posts (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL,
|
||||
use_markdown INTEGER DEFAULT 1, sub_category TEXT DEFAULT '',
|
||||
@@ -82,37 +65,45 @@ function initTables() {
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (category_id) REFERENCES forum_categories(id),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
try { db.run('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.run('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {}
|
||||
try { db.run('ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {}
|
||||
try { db.exec('ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT ""'); } catch {}
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blog_comments (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS blog_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL, author_id INTEGER,
|
||||
author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')),
|
||||
author_name TEXT DEFAULT '', parent_id INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'approved',
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (post_id) REFERENCES blog_posts(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL, author_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (post_id) REFERENCES forum_posts(id),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
|
||||
content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL,
|
||||
published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1,
|
||||
tags TEXT DEFAULT '', views INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (author_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS post_likes (
|
||||
post_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (post_id, user_id))`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL,
|
||||
pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS password_entries (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS password_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, username TEXT DEFAULT '',
|
||||
encrypted_password TEXT NOT NULL, url TEXT DEFAULT '', notes TEXT DEFAULT '',
|
||||
@@ -120,7 +111,7 @@ function initTables() {
|
||||
updated_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS attachments (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL, size INTEGER NOT NULL,
|
||||
mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL,
|
||||
@@ -128,21 +119,45 @@ function initTables() {
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id))`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
|
||||
|
||||
// Indexes for performance
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)');
|
||||
|
||||
// Legacy migrations for old database compatibility
|
||||
try { db.run("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {}
|
||||
try { db.run('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.run("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''"); } catch {}
|
||||
try { db.run('ALTER TABLE blog_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec('ALTER TABLE blog_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
|
||||
}
|
||||
|
||||
// 版本化迁移框架:PRAGMA user_version 记录当前 schema 版本,
|
||||
// migrations 数组按 version 升序执行,仅当 current < m.version 时运行并推进版本号。
|
||||
// 现有 try/catch ALTER 保留作为"确保列存在"的幂等层,未来新列迁移写入 migrations。
|
||||
function migrateSchema() {
|
||||
const current = db.pragma('user_version', { simple: true });
|
||||
const migrations = [
|
||||
// v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑,
|
||||
// 默认 100000:存量用户保持旧迭代数,旧密文仍可解密)
|
||||
{ version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } },
|
||||
// v2: 博客增强——博文标签/阅读量、评论嵌套与审核、点赞表
|
||||
{ version: 2, up: () => {
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN tags TEXT DEFAULT ''"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_posts ADD COLUMN views INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
|
||||
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
|
||||
} },
|
||||
];
|
||||
for (const m of migrations) {
|
||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin() {
|
||||
@@ -171,6 +186,7 @@ function seedDefaults() {
|
||||
smtp_from_name: 'RainWeb',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: '1',
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
@@ -185,6 +201,12 @@ function seedDefaults() {
|
||||
music_embed_position: 'right',
|
||||
music_embed_autohide: '0',
|
||||
music_embed_idle_timeout: '10',
|
||||
footer_style: 'classic',
|
||||
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
|
||||
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
|
||||
footer_desc: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
};
|
||||
for (const [k, v] of Object.entries(defaults)) {
|
||||
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
|
||||
@@ -195,11 +217,8 @@ function seedDefaults() {
|
||||
|
||||
function run(sql, params = []) {
|
||||
try {
|
||||
db.run(sql, params);
|
||||
const r = db.exec("SELECT last_insert_rowid()");
|
||||
const rowid = r && r[0] && r[0].values ? r[0].values[0][0] : 0;
|
||||
saveDb();
|
||||
return rowid;
|
||||
const info = db.prepare(sql).run(...params);
|
||||
return Number(info.lastInsertRowid) || 0;
|
||||
} catch (e) {
|
||||
console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return 0;
|
||||
@@ -208,9 +227,8 @@ function run(sql, params = []) {
|
||||
|
||||
function get(sql, params = []) {
|
||||
try {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
|
||||
stmt.free(); return null;
|
||||
const row = db.prepare(sql).get(...params);
|
||||
return row || null;
|
||||
} catch (e) {
|
||||
console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return null;
|
||||
@@ -219,10 +237,7 @@ function get(sql, params = []) {
|
||||
|
||||
function all(sql, params = []) {
|
||||
try {
|
||||
const stmt = db.prepare(sql); stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free(); return rows;
|
||||
return db.prepare(sql).all(...params);
|
||||
} catch (e) {
|
||||
console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80));
|
||||
return [];
|
||||
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
@echo off
|
||||
title RainWeb Deploy
|
||||
|
||||
set REPO_URL=https://github.com/Xianyunah/rainwebblog.git
|
||||
set INSTALL_DIR=rainweb
|
||||
|
||||
echo ====================================
|
||||
echo RainWeb - One-Click Deploy
|
||||
echo Repo: %REPO_URL%
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
REM Check Node.js
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Node.js is not installed. Install from https://nodejs.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Node.js:
|
||||
node -v
|
||||
|
||||
REM Check git
|
||||
where git >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Git is not installed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Clone or pull
|
||||
if exist "%INSTALL_DIR%\.git" (
|
||||
echo [1/4] Updating existing installation...
|
||||
cd "%INSTALL_DIR%"
|
||||
git pull
|
||||
) else (
|
||||
echo [1/4] Cloning repository...
|
||||
git clone "%REPO_URL%" "%INSTALL_DIR%"
|
||||
cd "%INSTALL_DIR%"
|
||||
)
|
||||
|
||||
REM Install dependencies
|
||||
echo.
|
||||
echo [2/4] Installing dependencies...
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] npm install failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Dependencies installed.
|
||||
|
||||
REM Create wallpaper folder
|
||||
if not exist "public\wallpaper" mkdir "public\wallpaper"
|
||||
|
||||
REM Start
|
||||
echo.
|
||||
echo [3/4] Starting server...
|
||||
echo.
|
||||
echo ====================================
|
||||
echo Open http://localhost:3001
|
||||
echo Default admin: admin / admin123
|
||||
echo ====================================
|
||||
echo.
|
||||
start "" http://localhost:3001
|
||||
node server.js
|
||||
|
||||
pause
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
REPO_URL="https://github.com/Xianyunah/rainwebblog.git"
|
||||
INSTALL_DIR="${1:-rainweb}"
|
||||
|
||||
echo "===================================="
|
||||
echo " RainWeb - One-Click Deploy"
|
||||
echo " Repo: $REPO_URL"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Check Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "[ERROR] Node.js is not installed. Install from https://nodejs.org (LTS 20.x+)"
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Node.js: $(node -v)"
|
||||
|
||||
# Check git
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "[ERROR] Git is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clone or pull
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
echo "[1/4] Updating existing installation..."
|
||||
cd "$INSTALL_DIR"
|
||||
git pull
|
||||
else
|
||||
echo "[1/4] Cloning repository..."
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
echo ""
|
||||
echo "[2/4] Installing dependencies..."
|
||||
npm install --production
|
||||
echo "[OK] Dependencies installed."
|
||||
|
||||
# Create wallpaper directory
|
||||
mkdir -p public/wallpaper
|
||||
|
||||
# Start
|
||||
echo ""
|
||||
echo "[3/4] Starting server..."
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo " Open http://localhost:3001"
|
||||
echo " Default admin: admin / admin123"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
if command -v xdg-open &> /dev/null; then
|
||||
xdg-open http://localhost:3001 2>/dev/null || true
|
||||
elif command -v open &> /dev/null; then
|
||||
open http://localhost:3001 2>/dev/null || true
|
||||
fi
|
||||
|
||||
node server.js
|
||||
@@ -0,0 +1,174 @@
|
||||
# RainWeb 优化重构实施计划(P0 / P1 / P2)
|
||||
|
||||
> 依据:数据库优化师(better-sqlite3 选型调研)、后端架构师(架构评审)、应用安全工程师(安全审计)、前端路由专项深挖 四份报告合并。
|
||||
> 数据库路线已定:**better-sqlite3 + Node 22 LTS**(放弃 sql.js 与 PostgreSQL)。
|
||||
> 前端路由:用户确认问题很大 → 按「方案 A 止血 → 方案 B 协议收编」两步走(见 P1-14 / P2-19)。
|
||||
> 本计划仅执行顺序与细节,**未开始任何代码改动**。
|
||||
|
||||
## 全局前提(每阶段执行前)
|
||||
|
||||
1. 备份:`cp -r data data.bak-<日期>`(`data/rainweb.db` 是唯一数据文件)
|
||||
2. 每阶段一个 git commit,commit message 中文,如 `P0: 删除 Web 更新链路,修复鉴权漏洞`
|
||||
3. 无测试环境,验证方式 = curl 接口 + 浏览器刷新 + `node cli.js` 命令
|
||||
|
||||
---
|
||||
|
||||
## P0 紧急止损(独立项,可并行,约半天)
|
||||
|
||||
### P0-1 删除 Web 更新链路(消除 R6 + .git 覆盖风险)
|
||||
- `server.js`:删除 84-165 行 `/api/update/check` + `/api/update/run`,保留 `/api/version`(81-82 行)
|
||||
- `public/js/admin.js`:删除 checkUpdate/runUpdate 函数及更新按钮 UI(约 692-731 行区域)
|
||||
- `cli.js`:`cmdUpgrade` 保留 git pull 分支(走本地 Gitea origin),删除非 git 仓库的 zip 下载分支(266-346 行区域)
|
||||
- 理由:用户即上游,zip 覆盖式更新是错误范式;`/api/update/run` 无鉴权=RCE 面,且 exclude 未排除 `.git` 会冲掉 git 历史
|
||||
- 风险:低。注意前端引用一并删除,避免 404
|
||||
- 验证:`curl /api/update/check` → 404;admin 面板打开控制台无报错;`node cli.js upgrade` 走 git pull 分支正常
|
||||
|
||||
### P0-2 JWT_SECRET 随机化(消除 R4)
|
||||
- `middleware/auth.js`:改为 `process.env.JWT_SECRET` 必须存在(长度 ≥32),否则启动失败;删除硬编码回退
|
||||
- `server.js` `start()`:首次启动若无 `JWT_SECRET`,用 `crypto.randomBytes(48).toString('hex')` 生成并写入 `.env.json`
|
||||
- 风险:低;已签发旧 token 全部失效(自部署可接受)
|
||||
- 验证:删 `.env.json` 重启 → 自动生成 secret;登录 → `curl /api/auth/me` 200
|
||||
|
||||
### P0-3 setup 接管漏洞(消除 R5)
|
||||
- `routes/setup.js`:`POST /complete` 加 `authMiddleware, adminOnly`;`GET /setup/status` 响应移除 `default_password` 字段(只保留 `setup_complete`)
|
||||
- 风险:低
|
||||
- 验证:未登录 `curl -X POST /api/setup/complete` → 401;登录后正常
|
||||
|
||||
### P0-4 proxy SSRF 最小防护(消除 R3 的 SSRF 部分)
|
||||
- `routes/proxy.js`:`GET /fetch` 加 `authMiddleware, adminOnly`;`new URL(target)` 后校验 hostname 非 `127.0.0.1`/`::1`/`localhost`/`10.`/`192.168.`/`172.16-31.`/`169.254.`/`fc00:`/`fe80:`
|
||||
- 风险:低(本功能只服务于 admin_links 面板嵌入)
|
||||
- 验证:未登录请求 → 401;`/api/proxy/fetch?url=http://127.0.0.1:3001/` → 拒绝
|
||||
|
||||
### P0-5 邮件验证码回显与滥用(消除 R7)
|
||||
- `routes/email.js`:`send-verify` 响应删除 `code` 回显;验证码生成改 `crypto.randomInt(10000000, 100000000)`;补回"删除 pending 后重建"的 bug(或废弃该接口由 register 内联重发)
|
||||
- 风险:低
|
||||
- 验证:调 send-verify → 响应无 code 字段;邮件仍能收到验证码
|
||||
|
||||
---
|
||||
|
||||
## P1 安全加固 + 数据层统一(依赖顺序:11 → 12,其余独立)
|
||||
|
||||
### P1-6 上传白名单 + /uploads 防护头(消除 R1)
|
||||
- `routes/upload.js`:`shaFileUpload` 内校验 `path.extname(...).toLowerCase()` 在 `ALLOWED_EXT = ['.png','.jpg','.jpeg','.gif','.webp','.pdf','.zip','.txt','.md']` 内,否则报"不支持的文件类型";图片额外校验 `file.mimetype` 以 `image/` 开头
|
||||
- `server.js:63`:`/uploads` 静态服务加 `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox; default-src 'none'`;非白名单扩展名强制 `Content-Disposition: attachment`
|
||||
- 风险:低;已有历史上传文件不受影响(仅新上传受限)
|
||||
- 验证:登录后上传 `x.html` → 400;上传 png → 200;访问 `/uploads/<hash>.png` 响应头含 nosniff
|
||||
|
||||
### P1-7 内容净化双端统一(消除 R2)
|
||||
- `ssr.js`:引入 `dompurify + jsdom`(服务端),`renderSSR` 中 `marked.parse` 结果过 `DOMPurify.sanitize`;`ssrPage` 中 `title/siteName/ogDesc` 全部 HTML 转义,JSON-LD 用 `JSON.stringify` 序列化
|
||||
- `public/js/render.js`:引入 DOMPurify(本地化到 public/js/vendor/ 或 CDN + SRI),`marked.parse` 结果同样过 sanitize
|
||||
- 风险:中(渲染路径改动,需走查博客/论坛/SEO 页)
|
||||
- 验证:发帖内容含 `<img src=x onerror=alert(1)>` → SSR 页与 SPA 页均不弹窗、标签被剥;正常 markdown 渲染不变
|
||||
|
||||
### P1-8 登录限流 + 服务端验证码(消除 R8)
|
||||
- 新增 `express-rate-limit` 依赖:`/api/auth/login` 15 分钟 10 次;`/api/email/send-verify` 60s/次
|
||||
- `routes/auth.js`:login/register 按 `captcha_login`/`captcha_register` 设置服务端校验 captcha token(与 captcha.js 的 verify 存储联动,token 与 username 绑定);`routes/forum.js` 发帖同理(`captcha_forum`)
|
||||
- 删除 `verifyCaptchaToken` 桩函数(auth.js:39-52)
|
||||
- 风险:中(验证码链路前后端要同步改)
|
||||
- 验证:连错密码 11 次 → 429;开启 captcha_login 后登录需先过验证码
|
||||
|
||||
### P1-9 密码箱解锁限流 + KDF 加固(消除 M1)
|
||||
- `routes/passwords.js`:`/unlock` 加失败计数(3 次/5 分钟,指数退避);解锁会话滑动过期替换一次性 setTimeout
|
||||
- PBKDF2 迭代 100k → 600k(OWASP 2023 建议);注意同步执行阻塞事件循环,可接受(单用户站)
|
||||
- 风险:低
|
||||
- 验证:连续输错 PIN 3 次 → 锁定提示;正确解锁正常;旧 PIN 密文在 KDF 迭代变更后需重设(set-pin 已存在)
|
||||
|
||||
### P1-10 鉴权与信息收敛(消除 M2-M7)
|
||||
- `middleware/auth.js`:`adminOnly` 增加 `db.get('SELECT role FROM users WHERE id=?')` 复查(M2)
|
||||
- `routes/blog.js`:`?all=1` 加 `authMiddleware, adminOnly`;`GET /posts/:id` 未发布仅 admin/作者可见(M3)
|
||||
- 错误信息收敛:`routes/forum.js:21,35`、`server.js:163`、`routes/email.js:54,93` 对外统一 `{ error: '操作失败' }`,详情仅 console.error(M4)
|
||||
- `routes/email.js:17`、`auth.js:93`、`profile.js:25`、`proxy.js:22` 的 `rejectUnauthorized:false` 收敛为配置项(默认关闭校验仅当 SMTP 自签时开启)(M5)
|
||||
- `public/js/passwords.js:113`、`forum.js:76`:内联 onclick 字符串插值改 `data-id` + `addEventListener` 委托(M6)
|
||||
- **S3-3 escapeHtml 引号问题**:`escapeHtml` 不转义 `'`/`"`,用于属性/JS 字符串上下文可注入断链(forum.js:76、passwords.js:113、admin.js:74/402-404)——新增 `escapeAttr`(全转义)用于属性上下文,或随 M6 的 data-id 改造一并消除;帖子标题/分类为他人可见,属存储型 XSS 面
|
||||
- `routes/profile.js`:avatar 仅接受 `/uploads/avatars/` 站内路径(M7)
|
||||
- 风险:中;验证:逐项 curl 测试(未登录 401/403、草稿对匿名 404、报错无堆栈详情)
|
||||
|
||||
### P1-11 cli.js 统一数据层(ora-2 P1-1)
|
||||
- `cli.js`:删除 9-51 行重复封装(DB_PATH/getDb/dbRun/dbGet/dbAll),改为 `const db = require('./db')`;`cmdStatus/cmdPassword/cmdCaptcha/cmdConfig` 改用 `db.get/db.all`;各命令 `await getDb()`
|
||||
- `data.db` 旧路径彻底退役(server.js 启动迁移逻辑保留,把旧文件搬进 data/)
|
||||
- 风险:中低;验证:备份后依次跑 `node cli.js status/config/password/captcha` 全部成功且落库
|
||||
|
||||
### P1-12 schema_version 迁移框架(ora-2 P1-2,依赖 P1-11)
|
||||
- `db.js`:`initTables()` 收敛——"确保列存在"函数(幂等补列,逻辑同现有 try/catch)+ `PRAGMA user_version` 记录迁移版本;未来迁移写成 `{ version, up() }` 数组顺序执行
|
||||
- 风险:中;验证:旧库启动自动补列无报错;连续启动两次幂等
|
||||
|
||||
### P1-13 deploy 脚本处置(ora-2 P1-3)
|
||||
- 删除 `deploy.sh` + `deploy.bat`(指向 GitHub 旧仓库,对自托管用户无意义);README 相关段落同步清理
|
||||
- 风险:低;验证:`ls` 确认删除;README 无残留引用
|
||||
|
||||
### P1-14 前端路由止血(方案 A,约 0.5 天)
|
||||
> 专项深挖结论:PJAX 只替换 `<main>`、目标页 `<script>` 从不执行,而 5 个 PJAX 壳页各自加载不同共享脚本子集 → 从某些页面进入时目标页依赖缺失(结构性缺陷 S1-3)。已确认 bug:S1-1 首页空白、S1-2 个人中心空白、S1-3 脚本依赖矩阵、S2-1 监听器重复累积、S2-2 论坛详情后退进 SSR 页、S2-4 滚动不恢复、S2-5 登录态/设置变化后导航不刷新、S2-6 login/register 白跳。
|
||||
- **A1 PJAX_PATHS 收敛**:`router.js` 只保留 5 个有 `_pageConfig` 的页面(`/`、`/blog.html`、`/forum.html`、`/admin.html`、`/passwords.html`);移除 `login.html`/`register.html`/`profile.html`(整页导航约定,根治 S1-2、S2-6)
|
||||
- **A2 统一共享脚本集**:5 个 PJAX 壳页 html 全部加载完整共享集(marked + render + captcha + music-embed + theme + api + nav + router),消除 S1-3 全部矩阵缺口
|
||||
- **A3 首页逻辑外部化**:`public/index.html:54-96` 内联 HOMEPAGE 抽为 `public/js/homepage.js` 并注册进 `_pageConfig`(修 S1-1;顺带合并 S3-4 与 blog.js 的 loadSidebar 重复)
|
||||
- **A4 `_loadScript` 判重**:`router.js:91-99` 加已加载检测,修 S2-1 监听器累积
|
||||
- **A5 popstate 分支 + 滚动**:popstate 识别 `e.state.forumPostId` 直接还原论坛详情(修 S2-2 不再 reload 进 SSR 页);navigate 时 `scrollTo(0,0)`(修 S2-4)
|
||||
- **A6 导航重渲染钩子**:loadPage 完成后调 `NAV.render?.()`(修 S2-5)
|
||||
- 风险:低(login/register/profile 当前本就走整页兜底,无回归面);A2 可能触发 S3-2 重复定义,但各文件实现相同无碍
|
||||
- 验证:passwords.html 整页进入 → PJAX 依次进博客(markdown 正常)/论坛(发帖弹验证码、详情正常)/首页;论坛详情后退回 SPA 列表无 reload;循环 PJAX 5 次 forum.js 只加载一次
|
||||
|
||||
---
|
||||
|
||||
## P2 数据库换轨 + 工程卫生(依赖 P1-11/P1-12)
|
||||
|
||||
### P2-15 better-sqlite3 迁移(lib-1 调研结论)
|
||||
- **前置**:Node 升 22 LTS(宝塔 Node 版本管理器 / nvm);README 与 Docker 示例同步(`node:20` → `node:22`)
|
||||
- 依赖:`npm install better-sqlite3`(v13,预编译随包,宝塔 glibc x64 免编译)
|
||||
- `db.js`:`initSqlJs()` 异步初始化 → `new Database(DB_PATH)` 同步;`run/get/all` 重写:
|
||||
- `run` → `db.prepare(sql).run(...params)` + `info.lastInsertRowid`
|
||||
- `get` → `stmt.get(...params)`;`all` → `stmt.all(...params)`
|
||||
- 删除 `saveDb()`/`db.export()`(SQLite 事务原生落盘);返回签名不变,40+ 业务调用点零改动
|
||||
- `initTables()` 的 CREATE TABLE IF NOT EXISTS 与幂等补列原样保留(兼容旧库)
|
||||
- `cli.js`:同套改写(P1-11 统一后只需改 db.js 一处)
|
||||
- `routes/import.js`:上传文件先落临时路径再 `new Database(tmpPath)` 只读打开,`exec` 数组结果改对象结果,用后 close
|
||||
- **数据迁移**:零迁移——现有 `data/rainweb.db` 是标准 SQLite 文件,直接打开
|
||||
- 风险:中;验证:备份 data/ 后启动 → 管理后台 CRUD 走一遍 → `node cli.js status/password` → `sqlite3 data/rainweb.db "PRAGMA integrity_check;"`
|
||||
|
||||
### P2-16 死代码 + 前端重复清理
|
||||
- 删除 `routes/links.js`(引用不存在的 links 表,挂载即崩)、`public/js/main.js`(无引用)、`public/js/passwords.js` 内重复的 `deleteFromDetail`
|
||||
- S3-1:`register.html:85` 与 `:92` 重复调用 `CAPTCHA.checkRequired('register')`,删一行
|
||||
- S3-2:`escapeHtml/closeDialog/openDialog/showSnackbar` 在 nav/render/blog/forum/admin/passwords/index/profile 各定义一份 → 收敛到 nav.js(或公共 util)单份定义;admin.js 同文件内 6 个函数重复定义(setNavStyle/setCardStyle/previewWallpaperUrl/removeWallpaper/loadWallpaperList/selectUploadedWallpaper/uploadWallpaper,admin.js:179/263 等)删后一份
|
||||
- 风险:低;验证:全站走查控制台无 404、无重复定义报错
|
||||
|
||||
### P2-17 版本单源 + 文档同步
|
||||
- `VERSION` 文件为唯一源(server.js:81 已读它);`cli.js` HELP 版本改读 VERSION;`package.json` version 与 VERSION 同步
|
||||
- `README.md`:Node 要求改 `>= 22 LTS`;删除更新/部署章节中 GitHub 引用
|
||||
- `AGENTS.md`:数据层章节重写(better-sqlite3、无 saveDb、单文件直写、cli.js 已统一)
|
||||
- 风险:低;验证:`node cli.js help` 与 `/api/version` 显示一致
|
||||
|
||||
### P2-18 可选加固(H 系列,按需确认)
|
||||
- H1:`helmet()` + 收紧 CSP(CDN 引入 marked 的 5 个页面加 SRI)——建议做
|
||||
- H3:验证码 `Math.random()` → `crypto.randomInt`(auth.js:80、email.js:63、profile.js:68)——建议做,改动极小
|
||||
- H9:bcrypt cost 10 → 12;注册用户名格式校验——建议做
|
||||
- H2:JWT 从 localStorage 改 HttpOnly cookie——改动大(前端 api.js 全量),**默认不做**,列入后续
|
||||
- H4:首启随机管理员密码——与 setup 向导冲突,**默认不做**(P0-3 已封堵接管面)
|
||||
|
||||
### P2-19 前端路由协议收编(方案 B,约 1.5-2 天,依赖 P1-14)
|
||||
> 方案 A 修完当前全部可复现 bug;方案 B 是防复发投资——解决"下一次加页面还会不会踩坑"。
|
||||
- **B1 单一注册表**:`PAGES` 配置对象 `path → { pjax, title, scripts, global, init }`,`PJAX_PATHS`/`_pageConfig`/脚本依赖全部由它派生;命中且 `pjax:true` 才拦截,其余整页导航(无兜底分支)
|
||||
- **B2 页面结构规范**:每个 PJAX 页 `<main data-page="blog">`,router 读 `data-page` 查注册表,不再硬编码路径数组;共享依赖在注册表声明,`_loadScript` 按"已加载集合"增量补齐
|
||||
- **B3 页面逻辑全外部化**:HOMEPAGE(P1-14 A3 已抽)、profile 逻辑(profile.html:43-180)抽为独立 js;内联 `<script>` 仅保留极少量数据初始化;统一 `window[global].init()` 协议
|
||||
- **B4 pushState/popstate 统一由 router 管理**:页面不再自行 pushState 非标准 state;论坛/博客详情作为"子路由"由注册表定义,popstate 由 router 解析还原(根治 S2-2/S2-3)
|
||||
- **B5 生命周期钩子**:注册表条目可选 `destroy()`,音乐嵌入/定时器/事件解绑在此处理(根治 S2-1 结构性累积)
|
||||
- 整页应用(login/register/write/embed/forum-manage/setup)标记 `pjax:false` 不参与 PJAX,保留内联脚本
|
||||
- 风险:中(popstate 语义变化与音乐嵌入销毁/重建是重点回归面;11 个 html 全动,必须全导航路径走查)
|
||||
- 验证:全导航路径走查(每页进出、后退/前进、刷新直链、登录/退出、音乐嵌入显示与自动隐藏);控制台零报错
|
||||
- 明确不做(方案 C):引入构建链/框架——违反无构建约定,对单人无测试项目负收益
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
P0-1..P0-5 (独立,并行) ≈ 半天
|
||||
P1-6..P1-10 (安全加固,独立并行)
|
||||
P1-11 cli统一 → P1-12 schema_version ≈ 1-2 天
|
||||
P1-13 / P1-14(路由止血,独立) ≈ 0.5 天
|
||||
P2-15 better-sqlite3(依赖 P1-11/12)≈ 1 天
|
||||
P2-16 / P2-17 / P2-18(独立)
|
||||
P2-19 路由协议收编(依赖 P1-14) ≈ 1.5-2 天
|
||||
```
|
||||
|
||||
> 若时间有限:P0 + P1 全部完成后项目即处于"安全 + 可正常使用"状态(路由 8 个 bug 全修);P2-19 是防复发投资,可最后做或延后。
|
||||
|
||||
## 交付节奏
|
||||
|
||||
每个 P 阶段结束:跑通验证清单 → git commit → 可随时暂停(数据文件备份在手,sql.js→better-sqlite3 前任何时刻可回滚)。
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# RainWeb v2 全面升级计划
|
||||
|
||||
> v1(原生 HTML/CSS/JS + PJAX)→ v2(React 前后台分离 + 后端改造)。用户确认的选型全部定案,本计划为唯一执行依据。
|
||||
> 旧 `docs/refactor-plan.md` 的后端部分(P0/P1/P2 中安全与数据库项)并入本计划;其前端路由项(P1-14 方案 A、P2-19 方案 B)**作废**——由 React Router 取代。
|
||||
> 本计划未开始任何代码改动,待用户确认后按阶段执行。
|
||||
|
||||
## 0. 选型定案(已确认,不再变更)
|
||||
|
||||
| 项 | 决定 |
|
||||
|---|---|
|
||||
| 前端框架 | **React + Vite**(TS 可选,见 §4.1) |
|
||||
| 前台 UI | **不引 UI 库**,手写组件 + 复用 `public/css/style.css`,**UI 100% 还原** |
|
||||
| 后台 | **独立应用**(独立入口 `/admin`),引入 **MUI** |
|
||||
| 工程组织 | **单 Vite 项目多入口**(前台 + 后台两个 HTML 入口) |
|
||||
| SEO | **保留 ssr.js 双轨**(/blog/:id、/forum/:id、sitemap.xml 不变) |
|
||||
| 数据库 | **better-sqlite3 + Node 22 LTS**(替换 sql.js) |
|
||||
| 后端改造 | 原 P0 止损 + P1 安全加固照旧执行 |
|
||||
|
||||
## 1. 目标架构
|
||||
|
||||
```
|
||||
浏览器
|
||||
├── 前台 SPA(/) React + React Router,构建产物 + style.css,占位符机制保留
|
||||
│ └── 导航「管理后台」按钮 → 整页跳转 /admin(浏览器级导航,非 SPA 路由、非 iframe)
|
||||
├── 后台 SPA(/admin) React + MUI,完全独立入口/独立路由/独立登录态,外部面板嵌入保留
|
||||
└── SEO 直链(/blog/:id 等) ssr.js 服务端渲染(不变)
|
||||
↓
|
||||
Express 4(server.js)
|
||||
├── /api/* 15 个路由模块(契约不变,前端重写以现有 API 为基)
|
||||
├── 静态:构建产物 + uploads/ + public/wallpaper/
|
||||
└── SPA fallback:/ → 前台 index.html;/admin* → 后台 index.html
|
||||
↓
|
||||
better-sqlite3(data/rainweb.db,零迁移直接打开)
|
||||
```
|
||||
|
||||
**前后台关系**:两个独立 React 入口(单 Vite 多入口构建),互相之间仅通过整页跳转连接。前台无后台路由、后台无前台路由;登录态共用(同一 JWT,localStorage)。
|
||||
|
||||
## 2. v1 → v2 保留 / 废弃清单
|
||||
|
||||
**保留**:后端全部(routes/、middleware/、db.js 逻辑、ssr.js、cli.js 改造后)、`public/css/style.css`(搬入前台)、`public/wallpaper/`、`uploads/`、API 契约。
|
||||
|
||||
**废弃**:`public/index.html` 等全部页面 HTML(改 React 组件)、`public/js/` 全部(api/nav/router/theme/captcha/render/music-embed/main/blog/forum/admin/passwords → 拆成 React 模块)、PJAX 机制(router.js)、`public/js/main.js` 等死代码。
|
||||
|
||||
**保留功能点(迁移时必须覆盖)**:博客瀑布流、论坛多分类/子分类/发帖回复、密码管理器(PIN + AES-256-GCM,见 §4.4)、管理面板聚合(iframe 嵌入 + proxy 代理)、壁纸/磨砂玻璃/深浅色主题、音乐嵌入(自动隐藏)、验证码(内置/recaptcha)、邮箱验证注册、公告、附件上传([image:]/[file:] 标签渲染)。
|
||||
|
||||
## 3. 阶段划分(每阶段验证 + commit,可随时暂停)
|
||||
|
||||
### Phase 0 后端止损(≈半天,独立)
|
||||
- P0-1 删除 Web 更新链路(server.js /api/update/check + /api/update/run、admin.js 前端引用、cli.js zip 分支)→ 消除 RCE 面 + `.git` 覆盖风险
|
||||
- P0-2 JWT_SECRET 启动生成随机值写入 `.env.json`(消除 R4 硬编码密钥)
|
||||
- P0-3 /api/setup/complete 加 adminOnly;/setup/status 移除 default_password(消除 R5 接管)
|
||||
- P0-4 /api/proxy/fetch 加 adminOnly + 私网地址过滤(消除 R3 SSRF)
|
||||
- P0-5 邮件验证码不回显 + 修复 pending 重建 bug(消除 R7)
|
||||
- 验证:curl 逐项(未登录 401、内网 URL 拒绝、响应无 code)
|
||||
|
||||
### Phase 1 后端安全加固(≈1-1.5 天)
|
||||
- P1-1 上传白名单 + /uploads 防护头(R1)
|
||||
- P1-2 内容净化:ssr.js + 后端出口统一 DOMPurify/转义(R2 服务端侧;前端侧随 v2 组件实现)
|
||||
- P1-3 登录/发帖限流 + 服务端验证码校验(R8,express-rate-limit)
|
||||
- P1-4 密码箱解锁限流 + PBKDF2 600k(M1)
|
||||
- P1-5 adminOnly 查库复查、草稿权限、错误信息收敛、rejectUnauthorized 收敛、avatar 白名单(M2-M7 中后端项)
|
||||
- P1-6 cli.js 统一到 db.js(消除双封装 + data.db 路径分裂)
|
||||
- P1-7 schema_version 迁移框架(幂等补列 + PRAGMA user_version)
|
||||
- 验证:curl 安全用例 + `node cli.js status/config/password` 正常
|
||||
|
||||
### Phase 2 数据层换轨 better-sqlite3(≈1 天,依赖 P1-6/P1-7)
|
||||
- 前置:Node 升 22 LTS(宝塔 Node 版本管理器);README/Docker 示例同步
|
||||
- db.js:`new Database(DB_PATH)` 同步初始化;run/get/all 重写(prepare + info.lastInsertRowid);删 saveDb/export;initTables 保留幂等补列
|
||||
- routes/import.js:上传文件落临时路径只读打开
|
||||
- 数据零迁移:现有 data/rainweb.db 直接打开;`PRAGMA integrity_check` 预检
|
||||
- 验证:备份 → 启动 → CRUD 走查 → cli 命令 → sqlite3 命令行直查
|
||||
|
||||
### Phase 3 前端工程化搭建(≈1 天)
|
||||
- 项目根新建 `frontend/`(Vite + React + React Router;多入口:`index.html`(前台)+ `admin.html`(后台))
|
||||
- 共享层:api 客户端(封装现有 /api/*,token 仍 localStorage,错误处理统一)、工具(escapeHtml/日期/分页)、主题(深浅色状态管理)
|
||||
- `style.css` 迁入前台入口;`${site_name}` 等占位符保留在构建模板中(serveIndex 运行时替换机制不变)
|
||||
- Vite 配置:构建产物输出到 `public/dist/`;`base: '/'`;多入口 rollupOptions
|
||||
- package.json scripts:`dev`(vite)、`build`(vite build)、`start`(server.js)
|
||||
- 验证:dev server 起得来、两个入口都能打开、HMR 正常
|
||||
|
||||
### Phase 4 前台页面迁移(≈3-4 天,核心工作量)
|
||||
按页面迁移为 React 组件,**视觉逐页对照还原**:
|
||||
1. 布局壳(导航栏/主题切换/壁纸/磨砂玻璃)+ React Router 路由表(/、/blog、/forum、/passwords、/login、/register、/profile、/write、/forum-manage、/embed、/setup);导航栏「管理后台」按钮 = 整页跳转 `/admin`(`<a href="/admin">`,不走 SPA 路由)
|
||||
2. 首页(瀑布流、侧栏头像/简介、最新文章)→ 原 HOMEPAGE 逻辑
|
||||
3. 博客(列表/详情/编辑,markdown + [image:]/[file:] 渲染 → 组件化 DOMPurify 净化)
|
||||
4. 论坛(分类/子分类/发帖/回复/详情)
|
||||
5. 密码管理器(PIN 解锁、加密交互,见 §4.4)
|
||||
6. 登录/注册(验证码、邮箱验证流程)
|
||||
7. 个人中心(头像上传、资料编辑)
|
||||
8. 音乐嵌入、公告、其他全局组件
|
||||
- 验证:逐页对照 v1 截图/行为走查;无控制台报错
|
||||
|
||||
### Phase 5 后台独立应用(≈2 天)
|
||||
- MUI 后台(/admin):完全独立入口与路由;入口方式 = 前台导航「管理后台」按钮整页跳转(Phase 4 已建);后台内可返回前台(跳转 /)
|
||||
- 登录态守卫(未登录跳转 /login,JWT 与前台共用);布局(侧栏导航 + 顶栏)
|
||||
- 页面:仪表盘、站点设置(含主题/壁纸/玻璃参数)、博客管理、论坛管理、用户管理、公告、面板链接(嵌入 + proxy 代理保留)、验证码/邮件配置、上传管理、密码箱(admin 视角)、更新检查入口移除(P0-1 已删)
|
||||
- 原 admin.js 600 行的逻辑按 MUI 组件重写
|
||||
- 验证:后台全功能走查 + 前台联动(改设置 → 前台生效)
|
||||
|
||||
### Phase 6 集成上线(≈1 天)
|
||||
- server.js:静态服务指向构建产物;SPA fallback 分流(/admin* → 后台入口,其余 → 前台入口);serveIndex 占位符对构建产物生效
|
||||
- 旧 public/*.html 与 public/js/ 全部退役删除(ssr.js 引用除外——核对 ssr.js 是否引用 style.css/占位符,保留所需)
|
||||
- SEO 双轨验证:/blog/:id、/forum/:id、sitemap.xml、robots.txt 行为不变
|
||||
- 部署流程更新:宝塔 = npm install → npm run build → npm start
|
||||
- README/AGENTS.md 全量更新(v2 架构、命令、构建链说明)
|
||||
|
||||
## 4. 关键技术决策
|
||||
|
||||
### 4.1 TypeScript?
|
||||
默认**不用 TS**(v1 全 JS,单人无测试,减少迁移摩擦);如用户要 TS 可启用(React + Vite TS 模板),代价是迁移工作量 +20%。→ 计划默认 JS,待确认。
|
||||
|
||||
### 4.2 构建产物与占位符
|
||||
Vite 构建产物 index.html 保留 `${site_name}`/`${site_description}`/`${site_favicon}` 文本,serveIndex 运行时替换机制不变(构建模板中写占位符即可)。后台入口同理。
|
||||
|
||||
### 4.3 路由与 fallback
|
||||
- 前台:React Router(history 模式),server.js `app.get('*')` 回前台入口
|
||||
- 后台:/admin 前缀路由,server.js 增加 `/admin*` → 后台入口(注意在 SPA catch-all 前)
|
||||
- SSR 路由(/blog/:id、/forum/:id)优先级高于 SPA fallback(现状已如此,保持)
|
||||
|
||||
### 4.4 密码管理器加密
|
||||
v1 为"服务端保管密钥"模型(PIN 明文到服务端派生)。v2 前台重写时**默认保持行为不变**(服务端派生 + PBKDF2 600k,Phase 1 已加固)。真正 E2E(WebCrypto 客户端派生)列为 v2 后续可选项,不在本次范围。
|
||||
|
||||
### 4.5 音乐嵌入
|
||||
v1 的 music-embed(自动隐藏、位置、超时)迁移为 React 全局组件,行为参数从 site_settings 读取,逻辑照搬。
|
||||
|
||||
### 4.6 验证码
|
||||
captcha.js 前端逻辑(内置 SVG 验证码 + recaptcha)React 化;服务端校验在 Phase 1 已接通(P1-3)。
|
||||
|
||||
## 5. 验证策略(无测试环境)
|
||||
|
||||
- 每阶段:curl API 用例 + 浏览器手动走查 + `node cli.js` 命令
|
||||
- Phase 4/5:逐页对照 v1 行为清单(§2 保留功能点逐项打勾)
|
||||
- 上线前:完整回归清单(登录/发帖/上传/密码箱/主题/面板嵌入/SEO 直链)
|
||||
- 可选:引入 Vitest + React Testing Library 对共享层(api 客户端、渲染函数)补少量单测——默认不做,待确认
|
||||
|
||||
## 6. 风险与回滚
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 前端重写回归(11 页功能面广) | 阶段化推进、§2 功能清单逐项走查;v1 代码保留到 Phase 6 验收通过再删 |
|
||||
| 密码管理器迁移出错 | 加密逻辑独立模块化迁移,Phase 4 单独验收 |
|
||||
| better-sqlite3 换轨 | 数据零迁移 + 备份 + PRAGMA 预检;失败可随时回滚 db.js(v1 代码在 git 历史) |
|
||||
| 构建链引入部署变化 | Phase 6 单独验收部署流程;dev(vite dev)+ prod(build)双路径文档化 |
|
||||
| SEO 回归 | ssr.js 双轨不变,Phase 6 专门验证直链 |
|
||||
|
||||
## 7. 工时总览
|
||||
|
||||
| 阶段 | 内容 | 工时 |
|
||||
|---|---|---|
|
||||
| Phase 0 | 后端止损 | 0.5 天 |
|
||||
| Phase 1 | 后端安全加固 + cli 统一 + 迁移框架 | 1-1.5 天 |
|
||||
| Phase 2 | better-sqlite3 换轨 | 1 天 |
|
||||
| Phase 3 | 前端工程化搭建 | 1 天 |
|
||||
| Phase 4 | 前台页面迁移 | 3-4 天 |
|
||||
| Phase 5 | 后台独立应用(MUI) | 2 天 |
|
||||
| Phase 6 | 集成上线 + 文档 | 1 天 |
|
||||
| **合计** | | **9.5-11.5 天** |
|
||||
|
||||
每阶段结束:验证清单通过 → git commit → 可暂停验收。
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${site_name}</title>
|
||||
<meta name="description" content="${site_description}">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="icon" href="${site_favicon}">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider } from './theme.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import Blog from './pages/Blog.jsx';
|
||||
import BlogDetail from './pages/BlogDetail.jsx';
|
||||
import Tag from './pages/Tag.jsx';
|
||||
import Archive from './pages/Archive.jsx';
|
||||
import Forum from './pages/Forum.jsx';
|
||||
import ForumDetail from './pages/ForumDetail.jsx';
|
||||
import Passwords from './pages/Passwords.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Register from './pages/Register.jsx';
|
||||
import Profile from './pages/Profile.jsx';
|
||||
import Write from './pages/Write.jsx';
|
||||
import ForumManage from './pages/ForumManage.jsx';
|
||||
import Embed from './pages/Embed.jsx';
|
||||
import Setup from './pages/Setup.jsx';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Routes>
|
||||
{/* 前台布局壳:导航 / 壁纸 / 页脚 / 音乐嵌入 / 验证码弹窗 */}
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/blog.html" element={<Blog />} />
|
||||
<Route path="/blog/:id" element={<BlogDetail />} />
|
||||
<Route path="/tag/:name" element={<Tag />} />
|
||||
<Route path="/archive.html" element={<Archive />} />
|
||||
<Route path="/forum.html" element={<Forum />} />
|
||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||
<Route path="/passwords.html" element={<Passwords />} />
|
||||
<Route path="/login.html" element={<Login />} />
|
||||
<Route path="/register.html" element={<Register />} />
|
||||
<Route path="/profile.html" element={<Profile />} />
|
||||
<Route path="/write.html" element={<Write />} />
|
||||
<Route path="/forum-manage.html" element={<ForumManage />} />
|
||||
<Route path="/embed.html" element={<Embed />} />
|
||||
<Route path="/setup.html" element={<Setup />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import CampaignIcon from '@mui/icons-material/Campaign';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { logout } from '../api/auth.js';
|
||||
import SnackHost, { showSnack } from './snack.jsx';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/dashboard', label: '仪表盘', icon: <DashboardIcon /> },
|
||||
{ path: '/settings', label: '站点设置', icon: <SettingsIcon /> },
|
||||
{ path: '/captcha', label: '验证码', icon: <VerifiedUserIcon /> },
|
||||
{ path: '/theme', label: '主题', icon: <PaletteIcon /> },
|
||||
{ path: '/homepage', label: '首页设置', icon: <HomeIcon /> },
|
||||
{ path: '/email', label: '邮件配置', icon: <EmailIcon /> },
|
||||
{ path: '/blog', label: '博客管理', icon: <ArticleIcon /> },
|
||||
{ path: '/comments', label: '评论管理', icon: <ChatBubbleOutlineOutlinedIcon /> },
|
||||
{ path: '/forum', label: '论坛管理', icon: <ForumIcon /> },
|
||||
{ path: '/users', label: '用户管理', icon: <PeopleIcon /> },
|
||||
{ path: '/announcements', label: '公告管理', icon: <CampaignIcon /> },
|
||||
{ path: '/links', label: '面板链接', icon: <LinkIcon /> },
|
||||
{ path: '/uploads', label: '附件管理', icon: <AttachFileIcon /> },
|
||||
{ path: '/import', label: '数据导入', icon: <UploadFileIcon /> },
|
||||
];
|
||||
|
||||
/** 后台布局:AppBar(返回前台 + 退出)+ Drawer 导航 + 内容区 Outlet */
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const muiTheme = useMuiTheme();
|
||||
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const drawerContent = (
|
||||
<List>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => { navigate(item.path); setMobileOpen(false); }}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
showSnack('已退出登录');
|
||||
setTimeout(() => { window.location.href = '/login.html'; }, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 1 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography>
|
||||
<Button color="inherit" href="/" title="返回前台">
|
||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />退出
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
|
||||
{isMobile ? (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
|
||||
>
|
||||
<Toolbar />
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Drawer variant="permanent" sx={{ width: 240, '& .MuiDrawer-paper': { width: 240 } }}>
|
||||
<Toolbar />
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, p: { xs: 2, md: 3 }, minWidth: 0 }}>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
<SnackHost />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
/** 通用确认弹窗 */
|
||||
export default function ConfirmDialog({ open, title = '确认操作', message, onClose, onConfirm, confirmText = '确认', danger = true }) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent><Typography variant="body1">{message}</Typography></DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button color={danger ? 'error' : 'primary'} variant="contained" onClick={onConfirm}>{confirmText}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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 CommentManage from './pages/CommentManage.jsx';
|
||||
import ForumManage from './pages/ForumManage.jsx';
|
||||
import Users from './pages/Users.jsx';
|
||||
import Announcements from './pages/Announcements.jsx';
|
||||
import Links from './pages/Links.jsx';
|
||||
import Uploads from './pages/Uploads.jsx';
|
||||
import ImportDb from './pages/ImportDb.jsx';
|
||||
|
||||
// 生产:/admin/* 由后端 fallback 到 dist/admin.html(Phase 6 需求),basename=/admin;
|
||||
// 开发:vite 多入口直接访问 /admin.html,basename 自适应为空。
|
||||
const pathname = window.location.pathname;
|
||||
const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/admin' : '';
|
||||
|
||||
function AdminApp() {
|
||||
const [status, setStatus] = useState('checking'); // checking | ok
|
||||
const [primary, setPrimary] = useState('#6750a4');
|
||||
|
||||
// 深色模式跟随前台偏好:整页跳转后 data-theme 属性不保留,
|
||||
// 以 localStorage 的 theme 为兜底(前台 theme.jsx 持久化同一 key)。
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
|| localStorage.getItem('theme') === 'dark';
|
||||
useEffect(() => {
|
||||
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}, [dark]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') {
|
||||
window.alert('需要管理员权限');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
setStatus('ok');
|
||||
})
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
getSettings()
|
||||
.then((s) => { if (s.primary_color) setPrimary(s.primary_color); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (status !== 'ok') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const theme = createMD3Theme({ mode: dark ? 'dark' : 'light', primary });
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter basename={basename}>
|
||||
<Routes>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/captcha" element={<CaptchaSettings />} />
|
||||
<Route path="/theme" element={<ThemeSettings />} />
|
||||
<Route path="/homepage" element={<Homepage />} />
|
||||
<Route path="/email" element={<EmailSettings />} />
|
||||
<Route path="/blog" element={<BlogManage />} />
|
||||
<Route path="/comments" element={<CommentManage />} />
|
||||
<Route path="/forum" element={<ForumManage />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/announcements" element={<Announcements />} />
|
||||
<Route path="/links" element={<Links />} />
|
||||
<Route path="/uploads" element={<Uploads />} />
|
||||
<Route path="/import" element={<ImportDb />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<AdminApp />);
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { listAll, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = { title: '', content: '', active: true };
|
||||
|
||||
/** 公告管理:公告 CRUD(v2 新增模块,v1 无) */
|
||||
export default function Announcements() {
|
||||
const [list, setList] = useState(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAll()
|
||||
.then((rows) => setList(rows || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (a) => {
|
||||
setEditingId(a.id);
|
||||
setForm({ title: a.title || '', content: a.content, active: !!a.active });
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.content.trim()) { showSnack('内容不能为空', 'error'); return; }
|
||||
const data = { title: form.title.trim(), content: form.content, active: form.active };
|
||||
try {
|
||||
if (editingId) await updateAnnouncement(editingId, data);
|
||||
else await createAnnouncement(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAnnouncement(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">公告管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加公告</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>内容</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list === null ? (
|
||||
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5}>暂无公告</TableCell></TableRow>
|
||||
) : list.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{a.title || '(无标题)'}</Box></TableCell>
|
||||
<TableCell sx={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'text.secondary' }}>{a.content}</TableCell>
|
||||
<TableCell><Chip size="small" label={a.active ? '启用' : '停用'} color={a.active ? 'primary' : 'default'} variant={a.active ? 'filled' : 'outlined'} /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{a.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(a)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: a.id, label: a.title || a.content })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑公告' : '添加公告'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="标题" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="内容 *" multiline rows={4} value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} margin="normal" />
|
||||
<FormControlLabel control={<Switch checked={form.active} onChange={(e) => setForm((p) => ({ ...p, active: e.target.checked }))} />} label="启用" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listPosts, updatePost, deletePost } from '../../api/blog.js';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 博客管理:博客侧栏开关 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
|
||||
export default function BlogManage() {
|
||||
const [posts, setPosts] = useState(null);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [confirm, setConfirm] = useState(null); // { id, title }
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listPosts(true)
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
getSettings().then((s) => setShowSidebar(s.blog_show_sidebar !== '0')).catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
const saveSidebar = async () => {
|
||||
try {
|
||||
await saveSettings({ blog_show_sidebar: showSidebar ? '1' : '0' });
|
||||
showSnack('已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublish = async (p) => {
|
||||
try {
|
||||
await updatePost(p.id, {
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
excerpt: p.excerpt || '',
|
||||
published: p.published ? 0 : 1,
|
||||
use_markdown: p.use_markdown,
|
||||
});
|
||||
showSnack(p.published ? '已转为草稿' : '已发布');
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePost(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h5">博客管理</Typography>
|
||||
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2, maxWidth: 640 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
|
||||
label="博客页面左侧显示头像和简介"
|
||||
/>
|
||||
<Button variant="outlined" size="small" onClick={saveSidebar}>保存</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell>格式</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{posts === null ? (
|
||||
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
|
||||
) : posts.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5}>暂无文章</TableCell></TableRow>
|
||||
) : posts.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{p.title}</Box></TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={p.published ? '已发布' : '草稿'}
|
||||
color={p.published ? 'primary' : 'default'}
|
||||
onClick={() => togglePublish(p)}
|
||||
title="点击切换发布状态"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><Chip size="small" label={p.use_markdown ? 'Markdown' : '纯文本'} variant="outlined" /></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{p.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" component="a" href={`/write.html?edit=${p.id}`} target="_blank" title="编辑(前台)"><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: p.id, title: p.title })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.title : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText={deleting ? '删除中...' : '确认删除'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 验证码设置:类型/登录注册发帖开关/reCAPTCHA+Turnstile keys(支持 builtin/recaptcha/turnstile/both) */
|
||||
export default function CaptchaSettings() {
|
||||
const [form, setForm] = useState({
|
||||
captcha_type: 'none',
|
||||
captcha_login: false,
|
||||
captcha_register: false,
|
||||
captcha_forum: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: '',
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
captcha_type: s.captcha_type || 'none',
|
||||
captcha_login: s.captcha_login === '1',
|
||||
captcha_register: s.captcha_register === '1',
|
||||
captcha_forum: s.captcha_forum === '1',
|
||||
recaptcha_site_key: s.recaptcha_site_key || '',
|
||||
recaptcha_secret_key: s.recaptcha_secret_key || '',
|
||||
turnstile_site_key: s.turnstile_site_key || '',
|
||||
turnstile_secret_key: s.turnstile_secret_key || '',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
captcha_type: form.captcha_type,
|
||||
captcha_login: form.captcha_login ? '1' : '0',
|
||||
captcha_register: form.captcha_register ? '1' : '0',
|
||||
captcha_forum: form.captcha_forum ? '1' : '0',
|
||||
recaptcha_site_key: form.recaptcha_site_key.trim(),
|
||||
recaptcha_secret_key: form.recaptcha_secret_key.trim(),
|
||||
turnstile_site_key: form.turnstile_site_key.trim(),
|
||||
turnstile_secret_key: form.turnstile_secret_key.trim(),
|
||||
});
|
||||
showSnack('验证码设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const showScope = form.captcha_type !== 'none';
|
||||
const showRecaptcha = form.captcha_type === 'recaptcha' || form.captcha_type === 'both';
|
||||
const showTurnstile = form.captcha_type === 'turnstile' || form.captcha_type === 'both';
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>验证码类型</InputLabel>
|
||||
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
|
||||
<MenuItem value="none">不使用验证码</MenuItem>
|
||||
<MenuItem value="builtin">内置验证码(扭曲文字)</MenuItem>
|
||||
<MenuItem value="recaptcha">Google reCAPTCHA V2</MenuItem>
|
||||
<MenuItem value="turnstile">Cloudflare Turnstile</MenuItem>
|
||||
<MenuItem value="both">两者都启用(任一通过即可)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{showScope && (
|
||||
<FormGroup>
|
||||
<FormControlLabel control={<Switch checked={form.captcha_login} onChange={setSwitch('captcha_login')} />} label="登录验证" />
|
||||
<FormControlLabel control={<Switch checked={form.captcha_register} onChange={setSwitch('captcha_register')} />} label="注册验证" />
|
||||
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{showRecaptcha && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Google reCAPTCHA V2 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.recaptcha_site_key} onChange={set('recaptcha_site_key')} margin="normal" placeholder="6L..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.recaptcha_secret_key} onChange={set('recaptcha_secret_key')} margin="normal" placeholder="6L..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showTurnstile && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Cloudflare Turnstile 配置</Typography>
|
||||
<TextField fullWidth label="Site Key" value={form.turnstile_site_key} onChange={set('turnstile_site_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
<TextField fullWidth label="Secret Key" value={form.turnstile_secret_key} onChange={set('turnstile_secret_key')} margin="normal" placeholder="0x4AAAA..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存验证码设置</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { listPendingComments, approveComment, rejectComment } from '../../api/blog.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 时间格式化:yyyy-mm-dd hh:mm */
|
||||
function fmtTime(t) {
|
||||
if (!t) return '';
|
||||
return String(t).replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
/** 评论管理:待审核队列 + 全部评论占位(后端暂无全量评论列表接口) */
|
||||
export default function CommentManage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [pending, setPending] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setBusy(true);
|
||||
listPendingComments()
|
||||
.then((list) => setPending(list || []))
|
||||
.catch((e) => showSnack(e.message, 'error'))
|
||||
.finally(() => setBusy(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (id, fn, okMsg) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn(id);
|
||||
setPending((list) => list.filter((c) => c.id !== id));
|
||||
showSnack(okMsg);
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="h5">评论管理</Typography>
|
||||
<IconButton onClick={load} title="刷新" disabled={busy}><RefreshIcon /></IconButton>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
|
||||
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
|
||||
<Tab label="全部评论" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{tab === 0 ? (
|
||||
pending.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" sx={{ mb: 0.5 }}>暂无待审核评论</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
开启「评论审核模式」后,新评论会先进这里等待审核
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper>
|
||||
<List disablePadding>
|
||||
{pending.map((c) => (
|
||||
<ListItem
|
||||
key={c.id}
|
||||
divider
|
||||
alignItems="flex-start"
|
||||
sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, py: 2, px: { xs: 2, md: 3 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{c.author_name || '匿名'}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={`文章:${c.post_title || `#${c.post_id}`}`} />
|
||||
<Typography variant="caption" color="text.secondary">{fmtTime(c.created_at)}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'text.primary', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{c.content}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<CheckIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => act(c.id, approveComment, '已通过审核')}
|
||||
>通过</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<CloseIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => act(c.id, rejectComment, '已拒绝')}
|
||||
>拒绝</Button>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)
|
||||
) : (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
当前后端仅提供「待审核」评论的管理接口;已通过 / 已拒绝评论可在对应文章页查看。
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { listAdminLinks } from '../../api/adminLinks.js';
|
||||
import { listPosts as listBlogPosts } from '../../api/blog.js';
|
||||
import { listPosts as listForumPosts } from '../../api/forum.js';
|
||||
import { listUsers } from '../../api/auth.js';
|
||||
import { listAttachments } from '../../api/upload.js';
|
||||
|
||||
/** 仪表盘:版本 + 状态概览 + 面板链接(分组卡片,迁移自 v1 loadPanels) */
|
||||
export default function Dashboard() {
|
||||
const [version, setVersion] = useState('');
|
||||
const [stats, setStats] = useState({ blog: 0, forum: 0, users: 0, attachments: 0 });
|
||||
const [links, setLinks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/version')
|
||||
.then((r) => r.json())
|
||||
.then((v) => { if (v && v.version) setVersion(v.version); })
|
||||
.catch(() => {});
|
||||
Promise.allSettled([
|
||||
listAdminLinks(), listBlogPosts(true), listForumPosts(), listUsers(), listAttachments(),
|
||||
]).then(([l, b, f, u, a]) => {
|
||||
setLinks((l.value) || []);
|
||||
setStats({
|
||||
blog: (b.value || []).length,
|
||||
forum: (f.value || []).length,
|
||||
users: (u.value || []).length,
|
||||
attachments: (a.value || []).length,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const statCards = [
|
||||
{ label: '博客文章', value: stats.blog },
|
||||
{ label: '论坛帖子', value: stats.forum },
|
||||
{ label: '用户', value: stats.users },
|
||||
{ label: '附件', value: stats.attachments },
|
||||
];
|
||||
|
||||
// 面板链接按分类分组
|
||||
const cats = {};
|
||||
links.forEach((l) => { (cats[l.category || '默认'] = cats[l.category || '默认'] || []).push(l); });
|
||||
const sortedCats = Object.keys(cats).sort();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5">仪表盘</Typography>
|
||||
<Chip label={`RainWeb v${version}`} size="small" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{statCards.map((s) => (
|
||||
<Grid item xs={6} sm={3} key={s.label}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600 }}>{s.value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{s.label}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>面板</Typography>
|
||||
{links.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">暂无面板,请先在"面板链接"中添加</Typography>
|
||||
) : (
|
||||
sortedCats.map((cat) => (
|
||||
<Box key={cat} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600, mb: 1 }}>{cat}</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{cats[cat].map((l) => {
|
||||
const embedUrl = l.embed_url || l.url;
|
||||
const proxyParam = l.use_proxy ? '&proxy=1' : '';
|
||||
const href = embedUrl
|
||||
? `/embed.html?url=${encodeURIComponent(embedUrl)}&title=${encodeURIComponent(l.title)}${proxyParam}`
|
||||
: l.url;
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={l.id}>
|
||||
<Card>
|
||||
<CardActionArea component="a" href={href} target={embedUrl ? undefined : '_blank'} rel="noopener">
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>{l.icon ? l.icon.charAt(0).toUpperCase() : '🔗'}</Avatar>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" noWrap>
|
||||
{l.title}
|
||||
{l.version ? <Typography component="span" variant="caption" color="text.secondary"> {l.version}</Typography> : null}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>{l.description || l.url}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { testSmtp } from '../../api/email.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 邮件配置:SMTP + 发件人信息 + 测试发送(迁移自 v1 邮件卡片) */
|
||||
export default function EmailSettings() {
|
||||
const [form, setForm] = useState({
|
||||
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '',
|
||||
smtp_from_email: '', smtp_from_name: 'RainWeb',
|
||||
});
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
smtp_host: s.smtp_host || '',
|
||||
smtp_port: s.smtp_port || '587',
|
||||
smtp_user: s.smtp_user || '',
|
||||
smtp_pass: s.smtp_pass || '',
|
||||
smtp_from_email: s.smtp_from_email || '',
|
||||
smtp_from_name: s.smtp_from_name || 'RainWeb',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
smtp_host: form.smtp_host.trim(),
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_user: form.smtp_user.trim(),
|
||||
smtp_pass: form.smtp_pass,
|
||||
smtp_from_email: form.smtp_from_email.trim(),
|
||||
smtp_from_name: form.smtp_from_name.trim(),
|
||||
});
|
||||
showSnack('邮件配置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!testEmail.trim()) { showSnack('请输入测试邮箱地址', 'error'); return; }
|
||||
setTesting(true);
|
||||
try {
|
||||
await testSmtp(testEmail.trim());
|
||||
showSnack('测试邮件已发送至 ' + testEmail.trim());
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>SMTP 服务器</Typography>
|
||||
<TextField fullWidth label="SMTP 主机" value={form.smtp_host} onChange={set('smtp_host')} margin="normal" placeholder="smtp.example.com" />
|
||||
<TextField fullWidth label="端口" type="number" value={form.smtp_port} onChange={set('smtp_port')} margin="normal" placeholder="587" />
|
||||
<TextField fullWidth label="用户名" value={form.smtp_user} onChange={set('smtp_user')} margin="normal" />
|
||||
<TextField fullWidth label="密码" type="password" value={form.smtp_pass} onChange={set('smtp_pass')} margin="normal" />
|
||||
</Paper>
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>发件人信息</Typography>
|
||||
<TextField fullWidth label="发件人邮箱" type="email" value={form.smtp_from_email} onChange={set('smtp_from_email')} margin="normal" placeholder="noreply@example.com" />
|
||||
<TextField fullWidth label="发件人名称" value={form.smtp_from_name} onChange={set('smtp_from_name')} margin="normal" placeholder="RainWeb" />
|
||||
</Paper>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存邮件配置</Button>
|
||||
<TextField size="small" label="测试接收邮箱" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} placeholder="your@email.com" />
|
||||
<Button variant="outlined" onClick={test} disabled={testing}>发送测试邮件</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { listCategories, createCategory, updateCategory, deleteCategory, listPosts } from '../../api/forum.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
|
||||
|
||||
/** 论坛管理:板块卡片(帖子数)+ 添加/编辑弹窗 + 删除(MUI 版,同前台 ForumManage API) */
|
||||
export default function ForumManage() {
|
||||
const [cats, setCats] = useState([]);
|
||||
const [counts, setCounts] = useState({});
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listCategories()
|
||||
.then((cs) => setCats(cs || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
listPosts()
|
||||
.then((ps) => {
|
||||
const c = {};
|
||||
(ps || []).forEach((p) => { c[p.category_id] = (c[p.category_id] || 0) + 1; });
|
||||
setCounts(c);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (c) => {
|
||||
setEditingId(c.id);
|
||||
setForm({
|
||||
name: c.name,
|
||||
description: c.description || '',
|
||||
announcement: c.announcement || '',
|
||||
sub_categories: c.sub_categories || '',
|
||||
sort_order: String(c.sort_order || 0),
|
||||
});
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name.trim()) { showSnack('名称不能为空', 'error'); return; }
|
||||
setSaving(true);
|
||||
const data = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
announcement: form.announcement.trim(),
|
||||
sub_categories: form.sub_categories.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await updateCategory(editingId, data);
|
||||
else await createCategory(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteCategory(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">论坛管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
|
||||
</Box>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">暂无板块,点击"添加板块"创建</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{cats.map((c) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={c.id}>
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>{c.name}</Typography>
|
||||
<Chip size="small" label={`排序 ${c.sort_order}`} variant="outlined" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, mb: 1 }}>{c.description || '无描述'}</Typography>
|
||||
{c.announcement && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>📢 {c.announcement}</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>{counts[c.id] || 0} 个帖子</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(c)} title="编辑"><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: c.id, name: c.name })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="名称 *" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="板块公告" multiline rows={2} value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="帖子分类(逗号分隔)" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} margin="normal" placeholder="例: 求助,分享,讨论,建议" />
|
||||
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadFile } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 首页设置:个人信息(头像/简介/联系链接)/主页内容(Markdown)/音乐嵌入(迁移自 v1 首页卡片) */
|
||||
export default function Homepage() {
|
||||
const [form, setForm] = useState({
|
||||
homepage_avatar: '',
|
||||
homepage_bio: '',
|
||||
homepage_content: '',
|
||||
music_embed_enabled: false,
|
||||
music_embed_code: '',
|
||||
music_embed_position: 'right',
|
||||
music_embed_autohide: false,
|
||||
music_embed_idle_timeout: '10',
|
||||
});
|
||||
const [contacts, setContacts] = useState([{ icon: 'link', url: '', title: '' }]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => {
|
||||
let parsed = [];
|
||||
try { parsed = JSON.parse(s.homepage_contacts || '[]'); } catch { parsed = []; }
|
||||
setForm({
|
||||
homepage_avatar: s.homepage_avatar || '',
|
||||
homepage_bio: s.homepage_bio || '',
|
||||
homepage_content: s.homepage_content || '',
|
||||
music_embed_enabled: s.music_embed_enabled === '1',
|
||||
music_embed_code: s.music_embed_code || '',
|
||||
music_embed_position: s.music_embed_position || 'right',
|
||||
music_embed_autohide: s.music_embed_autohide === '1',
|
||||
music_embed_idle_timeout: s.music_embed_idle_timeout || '10',
|
||||
});
|
||||
setContacts(parsed.length > 0 ? parsed : [{ icon: 'link', url: '', title: '' }]);
|
||||
})
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
|
||||
|
||||
const updateContact = (i, k) => (e) => {
|
||||
const next = [...contacts];
|
||||
next[i] = { ...next[i], [k]: e.target.value };
|
||||
setContacts(next);
|
||||
};
|
||||
const addContact = () => {
|
||||
if (contacts.length >= 5) { showSnack('最多 5 个联系链接', 'error'); return; }
|
||||
setContacts([...contacts, { icon: 'link', url: '', title: '' }]);
|
||||
};
|
||||
const removeContact = (i) => setContacts(contacts.filter((_, idx) => idx !== i));
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await uploadFile(file);
|
||||
setForm((p) => ({ ...p, homepage_content: p.homepage_content + '\n' + data.tag + '\n' }));
|
||||
setUploadStatus('已插入: ' + data.tag);
|
||||
} catch (err) {
|
||||
showSnack(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const validContacts = contacts.filter((c) => c.url.trim());
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
homepage_avatar: form.homepage_avatar.trim(),
|
||||
homepage_bio: form.homepage_bio,
|
||||
homepage_content: form.homepage_content,
|
||||
homepage_contacts: JSON.stringify(validContacts),
|
||||
music_embed_enabled: form.music_embed_enabled ? '1' : '0',
|
||||
music_embed_code: form.music_embed_code.trim(),
|
||||
music_embed_position: form.music_embed_position,
|
||||
music_embed_autohide: form.music_embed_autohide ? '1' : '0',
|
||||
music_embed_idle_timeout: form.music_embed_idle_timeout,
|
||||
});
|
||||
showSnack('首页设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>个人信息</Typography>
|
||||
<TextField fullWidth label="头像 URL" type="url" value={form.homepage_avatar} onChange={set('homepage_avatar')} margin="normal" placeholder="https://example.com/avatar.jpg" />
|
||||
<TextField fullWidth label="个人简介" multiline rows={2} value={form.homepage_bio} onChange={set('homepage_bio')} margin="normal" placeholder="一段简短的自我介绍" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, mb: 1 }}>联系链接(最多5个)</Typography>
|
||||
{contacts.map((c, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<TextField size="small" label="图标名" value={c.icon} onChange={updateContact(i, 'icon')} sx={{ width: 110 }} />
|
||||
<TextField size="small" label="URL" value={c.url} onChange={updateContact(i, 'url')} sx={{ flex: 1 }} placeholder="https://..." />
|
||||
<TextField size="small" label="标题(选填)" value={c.title} onChange={updateContact(i, 'title')} sx={{ width: 120 }} />
|
||||
<IconButton size="small" color="error" onClick={() => removeContact(i)} title="删除"><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主页内容</Typography>
|
||||
<TextField fullWidth label="正文 (Markdown)" multiline rows={10} value={form.homepage_content} onChange={set('homepage_content')} margin="normal" placeholder="支持 Markdown 语法和 [image:filename] 标签" />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button variant="outlined" size="small" onClick={() => fileRef.current && fileRef.current.click()}>
|
||||
上传附件
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
{uploadStatus && <Typography variant="caption" color="text.secondary">{uploadStatus}</Typography>}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
|
||||
<FormControlLabel control={<Switch checked={form.music_embed_enabled} onChange={setSwitch('music_embed_enabled')} />} label="所有页面显示音乐播放器" />
|
||||
<TextField fullWidth label="嵌入代码" multiline rows={3} value={form.music_embed_code} onChange={set('music_embed_code')} margin="normal" placeholder="粘贴网易云音乐 iframe 代码" />
|
||||
<TextField select label="显示位置" value={form.music_embed_position} onChange={set('music_embed_position')} margin="normal" sx={{ maxWidth: 200 }}>
|
||||
<option value="right">右下角</option>
|
||||
<option value="left">左下角</option>
|
||||
</TextField>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControlLabel control={<Switch checked={form.music_embed_autohide} onChange={setSwitch('music_embed_autohide')} />} label="无操作后缩小为图标" />
|
||||
<TextField size="small" label="空闲超时(秒)" type="number" inputProps={{ min: 3, max: 120 }} value={form.music_embed_idle_timeout} onChange={set('music_embed_idle_timeout')} sx={{ width: 140 }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存首页设置</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import { getToken } from '../../api/client.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 数据导入:上传旧版 data.db,POST /api/import/database(FormData + Bearer) */
|
||||
export default function ImportDb() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState(null); // { total, details, errors } | { error }
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const doImport = async () => {
|
||||
const file = fileRef.current && fileRef.current.files && fileRef.current.files[0];
|
||||
if (!file) { showSnack('请选择 data.db 文件', 'error'); return; }
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch('/api/import/database', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '导入失败');
|
||||
setResult(data);
|
||||
showSnack('导入完成,共 ' + data.total + ' 条记录');
|
||||
} catch (e) {
|
||||
setResult({ error: e.message });
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>数据导入</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
上传旧版 RainWeb 的 <code>data.db</code> 文件,将数据导入当前数据库(重复数据自动跳过)。
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button variant="contained" component="label">
|
||||
选择文件
|
||||
<input type="file" hidden accept=".db" ref={fileRef} onChange={() => setResult(null)} />
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{fileRef.current && fileRef.current.files && fileRef.current.files[0] ? fileRef.current.files[0].name : '未选择文件'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="outlined" onClick={doImport} disabled={busy}>
|
||||
{busy ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||
导入
|
||||
</Button>
|
||||
</Box>
|
||||
{result && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{result.error ? (
|
||||
<Alert severity="error">导入失败: {result.error}</Alert>
|
||||
) : (
|
||||
<Alert severity="success">
|
||||
<Box>导入完成,共 {result.total} 条记录</Box>
|
||||
{Object.entries(result.details || {}).map(([table, count]) => (
|
||||
<Box key={table} component="div" sx={{ fontSize: 13 }}>{table}: {count} 条</Box>
|
||||
))}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<Box component="div" sx={{ mt: 1, color: 'warning.main', fontSize: 13 }}>
|
||||
警告:<br />
|
||||
{result.errors.slice(0, 5).map((e, i) => <Box key={i}>{e}</Box>)}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
const EMPTY = {
|
||||
title: '', url: '', embed_url: '', use_proxy: false, description: '',
|
||||
icon: '', category: '默认', version: '', sort_order: '0',
|
||||
};
|
||||
|
||||
/** 面板链接管理:CRUD + 代理嵌入开关(迁移自 v1 面板链接卡片) */
|
||||
export default function Links() {
|
||||
const [links, setLinks] = useState(null);
|
||||
const [dialog, setDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAdminLinks()
|
||||
.then((ls) => setLinks(ls || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
|
||||
const openEdit = (l) => {
|
||||
setEditingId(l.id);
|
||||
setForm({
|
||||
title: l.title,
|
||||
url: l.url,
|
||||
embed_url: l.embed_url || '',
|
||||
use_proxy: !!l.use_proxy,
|
||||
description: l.description || '',
|
||||
icon: l.icon || '',
|
||||
category: l.category || '默认',
|
||||
version: l.version || '',
|
||||
sort_order: String(l.sort_order || 0),
|
||||
});
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.title.trim() || !form.url.trim()) { showSnack('标题和链接不能为空', 'error'); return; }
|
||||
const data = {
|
||||
title: form.title.trim(),
|
||||
url: form.url.trim(),
|
||||
embed_url: form.embed_url.trim(),
|
||||
use_proxy: form.use_proxy ? 1 : 0,
|
||||
description: form.description.trim(),
|
||||
icon: form.icon.trim(),
|
||||
category: form.category.trim() || '默认',
|
||||
version: form.version.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await updateAdminLink(editingId, data);
|
||||
else await createAdminLink(data);
|
||||
showSnack('保存成功');
|
||||
setDialog(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAdminLink(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">面板链接管理</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加面板</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>排序</TableCell>
|
||||
<TableCell>标题</TableCell>
|
||||
<TableCell>版本</TableCell>
|
||||
<TableCell>URL</TableCell>
|
||||
<TableCell>嵌入URL</TableCell>
|
||||
<TableCell>分类</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{links === null ? (
|
||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
||||
) : links.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
|
||||
) : links.map((l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>{l.sort_order}</TableCell>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</Box></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{l.version || '-'}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 180 }}>
|
||||
<Box component="a" href={l.url} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{l.url}</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 140, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.embed_url || '-'}</TableCell>
|
||||
<TableCell><Chip size="small" label={l.category} variant="outlined" /></TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: l.id, label: l.title })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="标题 *" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="URL *" type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} margin="normal" placeholder="https://..." />
|
||||
<TextField fullWidth label="嵌入 URL(iframe嵌入用)" type="url" value={form.embed_url} onChange={(e) => setForm((p) => ({ ...p, embed_url: e.target.value }))} margin="normal" placeholder="留空则新标签页打开" />
|
||||
<FormControlLabel control={<Switch checked={form.use_proxy} onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))} />} label="通过代理嵌入(绕过 X-Frame-Options 限制)" />
|
||||
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="图标 (Material图标名)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="settings, dashboard, ..." />
|
||||
<TextField fullWidth label="分类" value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
|
||||
<TextField fullWidth label="版本号(可选)" value={form.version} onChange={(e) => setForm((p) => ({ ...p, version: e.target.value }))} margin="normal" placeholder="v2.1.0" />
|
||||
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialog(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={save}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
|
||||
const FOOTER_STYLES = [
|
||||
{ value: 'classic', label: '经典左右', desc: '左版权右版本,信息密度低,一眼扫过' },
|
||||
{ value: 'columns', label: '分栏导航', desc: '品牌 + 固定导航栏目 + 底部版权条,内容型站点首选' },
|
||||
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||
];
|
||||
|
||||
/** 基本设置 + 页脚设置(v2:页脚样式 / 版权 / Powered by 均支持自定义) */
|
||||
export default function Settings() {
|
||||
const [form, setForm] = useState({
|
||||
site_name: '',
|
||||
site_description: '',
|
||||
site_url: '',
|
||||
site_favicon: '',
|
||||
footer_style: 'classic',
|
||||
footer_copyright: '',
|
||||
footer_powered: '',
|
||||
footer_desc: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
site_name: s.site_name || '',
|
||||
site_description: s.site_description || '',
|
||||
site_url: s.site_url || '',
|
||||
site_favicon: s.site_favicon || '',
|
||||
footer_style: s.footer_style || 'classic',
|
||||
footer_copyright: s.footer_copyright || '',
|
||||
footer_powered: s.footer_powered || '',
|
||||
footer_desc: s.footer_desc || '',
|
||||
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
|
||||
comment_notify: s.comment_notify === '1' ? '1' : '0',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
const toggle = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked ? '1' : '0' }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings(form);
|
||||
showSnack('设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const footStyle = form.footer_style;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" />
|
||||
<TextField fullWidth label="网站描述(SEO)" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" />
|
||||
<TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" />
|
||||
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>评论设置</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.comment_moderate === '1'} onChange={toggle('comment_moderate')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论审核模式</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后新评论需后台审核通过才显示</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.comment_notify === '1'} onChange={toggle('comment_notify')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
|
||||
|
||||
<FormControl component="fieldset" sx={{ mb: 1 }}>
|
||||
<RadioGroup value={footStyle} onChange={set('footer_style')}>
|
||||
{FOOTER_STYLES.map((o) => (
|
||||
<FormControlLabel
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
control={<Radio />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>{o.label}</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>{o.desc}</Box>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{footStyle === 'columns' ? (
|
||||
<>
|
||||
<TextField fullWidth label="网站简介(页脚品牌区)" value={form.footer_desc} onChange={set('footer_desc')} margin="normal" placeholder="留空则自动使用网站描述" multiline minRows={2} />
|
||||
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
|
||||
<FormHelperText sx={{ mt: 1 }}>
|
||||
导航栏目固定不可编辑(首页 / 博客 / 论坛 / 管理后台 / 个人中心)
|
||||
</FormHelperText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
|
||||
<TextField fullWidth label="Powered by 文本" value={form.footer_powered} onChange={set('footer_powered')} margin="normal" placeholder="Powered by RainnyaWeb" />
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||
import { uploadWallpaper } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
|
||||
/** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */
|
||||
export default function ThemeSettings() {
|
||||
const [form, setForm] = useState({
|
||||
primary_color: '#6750a4',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: true,
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
glass_opacity: '0.6',
|
||||
theme_force_dark: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setForm({
|
||||
primary_color: s.primary_color || '#6750a4',
|
||||
theme_wallpaper: s.theme_wallpaper || '',
|
||||
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
|
||||
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
|
||||
nav_style: s.nav_style || 'default',
|
||||
card_style: s.card_style || 'default',
|
||||
glass_blur: s.glass_blur || '20',
|
||||
glass_opacity: s.glass_opacity || '0.6',
|
||||
theme_force_dark: s.theme_force_dark === '1',
|
||||
}))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const handleWallpaperUpload = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await uploadWallpaper(file);
|
||||
setForm((p) => ({ ...p, theme_wallpaper: data.url }));
|
||||
showSnack('壁纸已上传');
|
||||
} catch (err) {
|
||||
showSnack(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
primary_color: form.primary_color,
|
||||
theme_wallpaper: form.theme_wallpaper.trim(),
|
||||
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
||||
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
|
||||
theme_force_dark: form.theme_force_dark ? '1' : '0',
|
||||
nav_style: form.nav_style,
|
||||
card_style: form.card_style,
|
||||
glass_blur: form.glass_blur,
|
||||
glass_opacity: form.glass_opacity,
|
||||
});
|
||||
showSnack('主题设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="color" value={form.primary_color} onChange={set('primary_color')} style={{ width: 48, height: 48, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||
<TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" sx={{ width: 160 }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
||||
label="启用壁纸背景"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
|
||||
<Typography variant="caption" color="text.secondary">上传后自动填入 URL</Typography>
|
||||
</Box>
|
||||
<TextField fullWidth label="图片 URL" type="url" value={form.theme_wallpaper} onChange={set('theme_wallpaper')} margin="normal" placeholder="https://example.com/wallpaper.jpg" />
|
||||
{form.theme_wallpaper && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<img src={form.theme_wallpaper} alt="wallpaper" style={{ width: '100%', maxHeight: 120, objectFit: 'cover', borderRadius: 8 }} />
|
||||
</Box>
|
||||
)}
|
||||
<TextField fullWidth label="缩放方式" select value={form.theme_wallpaper_scale} onChange={set('theme_wallpaper_scale')} margin="normal" sx={{ maxWidth: 280 }}>
|
||||
<option value="cover">cover - 覆盖填充</option>
|
||||
<option value="contain">contain - 完整显示</option>
|
||||
<option value="repeat">repeat - 平铺重复</option>
|
||||
<option value="stretch">stretch - 拉伸填充</option>
|
||||
</TextField>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>样式设置</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>导航栏样式</Typography>
|
||||
<ToggleButtonGroup exclusive value={form.nav_style} onChange={(e, v) => v && setForm((p) => ({ ...p, nav_style: v }))} size="small" sx={{ mb: 2 }}>
|
||||
<ToggleButton value="default">默认</ToggleButton>
|
||||
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
|
||||
<ToggleButton value="capsule">胶囊</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>卡片样式</Typography>
|
||||
<ToggleButtonGroup exclusive value={form.card_style} onChange={(e, v) => v && setForm((p) => ({ ...p, card_style: v }))} size="small">
|
||||
<ToggleButton value="default">默认</ToggleButton>
|
||||
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>玻璃效果</Typography>
|
||||
<Typography variant="body2" color="text.secondary">模糊强度: {form.glass_blur}px</Typography>
|
||||
<Slider min={5} max={40} value={parseInt(form.glass_blur, 10) || 20} onChange={(e, v) => setForm((p) => ({ ...p, glass_blur: String(v) }))} sx={{ maxWidth: 400 }} />
|
||||
<Typography variant="body2" color="text.secondary">透明度: {form.glass_opacity}</Typography>
|
||||
<Slider min={0.1} max={0.95} step={0.05} value={parseFloat(form.glass_opacity) || 0.6} onChange={(e, v) => setForm((p) => ({ ...p, glass_opacity: String(v) }))} sx={{ maxWidth: 400 }} />
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
|
||||
label="强制深色模式(启用后用户无法切换为浅色)"
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Button variant="contained" onClick={save} disabled={saving}>保存主题设置</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { listAttachments, deleteAttachment } from '../../api/upload.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 附件管理:列表/删除(迁移自 v1 附件卡片) */
|
||||
export default function Uploads() {
|
||||
const [list, setList] = useState(null);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listAttachments()
|
||||
.then((rows) => setList(rows || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await deleteAttachment(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>附件管理</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>文件名</TableCell>
|
||||
<TableCell>原始名</TableCell>
|
||||
<TableCell>大小</TableCell>
|
||||
<TableCell>类型</TableCell>
|
||||
<TableCell>上传者</TableCell>
|
||||
<TableCell>时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list === null ? (
|
||||
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8}>暂无附件</TableCell></TableRow>
|
||||
) : list.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell>{f.id}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 160 }}>
|
||||
<Box component="a" href={`/uploads/${f.filename}`} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{f.filename}</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</TableCell>
|
||||
<TableCell>{((f.size || 0) / 1024).toFixed(0)} KB</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.mime_type || '-'}</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary' }}>UID {f.user_id}</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.created_at}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={() => setConfirm({ id: f.id, label: f.filename })}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import { listUsers, deleteUser, resetUserPassword, setUserRole, registerByAdmin } from '../../api/auth.js';
|
||||
import { showSnack } from '../snack.jsx';
|
||||
import ConfirmDialog from '../ConfirmDialog.jsx';
|
||||
|
||||
/** 用户管理:列表/添加/改密/改权/删除(迁移自 v1 用户卡片) */
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
|
||||
const [pwDialog, setPwDialog] = useState(null); // { id, username }
|
||||
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
|
||||
const [roleDialog, setRoleDialog] = useState(null); // { id, username, role }
|
||||
const [confirm, setConfirm] = useState(null); // { id, username }
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listUsers()
|
||||
.then((us) => setUsers(us || []))
|
||||
.catch((e) => showSnack(e.message, 'error'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addUser = async () => {
|
||||
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await registerByAdmin(addForm.username.trim(), addForm.password, addForm.role);
|
||||
showSnack('用户已创建');
|
||||
setAddOpen(false);
|
||||
setAddForm({ username: '', password: '', role: 'user' });
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const resetPw = async () => {
|
||||
if (!pwDialog) return;
|
||||
if (!pwForm.p1 || pwForm.p1.length < 6) { showSnack('密码至少6位', 'error'); return; }
|
||||
if (pwForm.p1 !== pwForm.p2) { showSnack('两次密码不一致', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await resetUserPassword(pwDialog.id, pwForm.p1);
|
||||
showSnack('密码已重置');
|
||||
setPwDialog(null);
|
||||
setPwForm({ p1: '', p2: '' });
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const changeRole = async () => {
|
||||
if (!roleDialog) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setUserRole(roleDialog.id, roleDialog.role);
|
||||
showSnack('角色已更新');
|
||||
setRoleDialog(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteUser(confirm.id);
|
||||
showSnack('已删除');
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">用户管理</Typography>
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)}>添加用户</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>用户名</TableCell>
|
||||
<TableCell>邮箱</TableCell>
|
||||
<TableCell>验证</TableCell>
|
||||
<TableCell>角色</TableCell>
|
||||
<TableCell>注册时间</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users === null ? (
|
||||
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>暂无用户</TableCell></TableRow>
|
||||
) : users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>{u.id}</TableCell>
|
||||
<TableCell><Box sx={{ fontWeight: 600 }}>{u.username}</Box></TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary' }}>{u.email || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.email_verified ? '已验证' : '未验证'} color={u.email_verified ? 'primary' : 'default'} variant={u.email_verified ? 'filled' : 'outlined'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.role === 'admin' ? '管理员' : '用户'} color={u.role === 'admin' ? 'secondary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Button size="small" onClick={() => { setPwDialog({ id: u.id, username: u.username }); setPwForm({ p1: '', p2: '' }); }}>改密</Button>
|
||||
<Button size="small" onClick={() => setRoleDialog({ id: u.id, username: u.username, role: u.role })}>改权</Button>
|
||||
<Button size="small" color="error" onClick={() => setConfirm({ id: u.id, username: u.username })}>删除</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>添加用户</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="用户名 *" value={addForm.username} onChange={(e) => setAddForm((p) => ({ ...p, username: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="密码 *" type="password" value={addForm.password} onChange={(e) => setAddForm((p) => ({ ...p, password: e.target.value }))} margin="normal" />
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>角色</InputLabel>
|
||||
<Select value={addForm.role} onChange={(e) => setAddForm((p) => ({ ...p, role: e.target.value }))} label="角色">
|
||||
<MenuItem value="user">用户</MenuItem>
|
||||
<MenuItem value="admin">管理员</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>取消</Button>
|
||||
<Button variant="contained" onClick={addUser} disabled={busy}>创建</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 重置密码 */}
|
||||
<Dialog open={!!pwDialog} onClose={() => setPwDialog(null)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>重置密码</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>重置用户 {pwDialog ? pwDialog.username : ''} 的密码</Typography>
|
||||
<TextField fullWidth label="新密码(至少6位)" type="password" value={pwForm.p1} onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))} margin="normal" />
|
||||
<TextField fullWidth label="确认新密码" type="password" value={pwForm.p2} onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))} margin="normal" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPwDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={resetPw} disabled={busy}>确认重置</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 修改角色 */}
|
||||
<Dialog open={!!roleDialog} onClose={() => setRoleDialog(null)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>修改角色</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>修改用户 {roleDialog ? roleDialog.username : ''} 的角色</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>角色</InputLabel>
|
||||
<Select value={roleDialog ? roleDialog.role : 'user'} onChange={(e) => setRoleDialog((p) => (p ? { ...p, role: e.target.value } : p))} label="角色">
|
||||
<MenuItem value="user">普通用户</MenuItem>
|
||||
<MenuItem value="admin">管理员</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRoleDialog(null)}>取消</Button>
|
||||
<Button variant="contained" onClick={changeRole} disabled={busy}>确认修改</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
message={`确定要删除 "${confirm ? confirm.username : ''}" 吗?`}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={doDelete}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
// 模块级单例:admin 任意处调用 showSnack,由 AdminLayout 内的 SnackHost 消费
|
||||
let showFn = null;
|
||||
|
||||
export function showSnack(msg, severity = 'success') {
|
||||
if (showFn) showFn(msg, severity);
|
||||
else window.alert(msg);
|
||||
}
|
||||
|
||||
export default function SnackHost() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [severity, setSeverity] = useState('success');
|
||||
|
||||
showFn = (m, s) => { setMsg(m); setSeverity(s || 'success'); setOpen(true); };
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={severity} variant="filled" onClose={() => setOpen(false)}>{msg}</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* ============================================================
|
||||
* 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节
|
||||
* 前台 token 变量未注入后台页面,这里按明暗各写一份。
|
||||
* ============================================================ */
|
||||
|
||||
/* 内容区入场动效(对应前台 contentFadeIn) */
|
||||
main {
|
||||
animation: md3ContentFadeIn 0.28s ease;
|
||||
}
|
||||
|
||||
@keyframes md3ContentFadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* 明暗切换过渡 */
|
||||
body {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 滚动条(对应前台细滚动条) */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cac4d0; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #79747e; }
|
||||
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; }
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; }
|
||||
|
||||
/* 减弱动效偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { createTheme, alpha } from '@mui/material/styles';
|
||||
|
||||
/* ============================================================
|
||||
* 后台 Material Design 3 主题
|
||||
*
|
||||
* - 与前台 public/css/style.css 的 MD3 token 保持同源:
|
||||
* 默认主色 #6750a4 时直接复用前台 token 值(浅/深两套),
|
||||
* 自定义主色时用 tonal ramp 近似生成整套 token。
|
||||
* - 组件形态按 MD3 规范覆盖:pill 按钮、大圆角卡片/弹窗、
|
||||
* surface 色阶、弱边框 + 低阴影(surface tint)。
|
||||
* ============================================================ */
|
||||
|
||||
const DEFAULT_SEED = '#6750a4';
|
||||
|
||||
/* ---------- 颜色工具 ---------- */
|
||||
|
||||
function clamp(v) { return Math.min(255, Math.max(0, Math.round(v))); }
|
||||
|
||||
function hexToRgb(hex) {
|
||||
let h = String(hex).replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
const n = parseInt(h, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
const to = (v) => clamp(v).toString(16).padStart(2, '0');
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
function mix(a, b, w) {
|
||||
const ca = hexToRgb(a);
|
||||
const cb = hexToRgb(b);
|
||||
return rgbToHex(
|
||||
ca[0] + (cb[0] - ca[0]) * w,
|
||||
ca[1] + (cb[1] - ca[1]) * w,
|
||||
ca[2] + (cb[2] - ca[2]) * w
|
||||
);
|
||||
}
|
||||
|
||||
function hexToHsl(hex) {
|
||||
const [r, g, b] = hexToRgb(hex).map((v) => v / 255);
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
}
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let rgb;
|
||||
if (h < 60) rgb = [c, x, 0];
|
||||
else if (h < 120) rgb = [x, c, 0];
|
||||
else if (h < 180) rgb = [0, c, x];
|
||||
else if (h < 240) rgb = [0, x, c];
|
||||
else if (h < 300) rgb = [x, 0, c];
|
||||
else rgb = [c, 0, x];
|
||||
return rgbToHex((rgb[0] + m) * 255, (rgb[1] + m) * 255, (rgb[2] + m) * 255);
|
||||
}
|
||||
|
||||
function grayscale(hex) {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
const v = clamp(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
return rgbToHex(v, v, v);
|
||||
}
|
||||
|
||||
/* MD3 tonal ramp 近似:tone t = 向白/黑按比例混合(t40 即 seed 本身) */
|
||||
function tone(seed, t) {
|
||||
if (t >= 40) return mix(seed, '#ffffff', (t - 40) / 60);
|
||||
return mix(seed, '#000000', (40 - t) / 40);
|
||||
}
|
||||
|
||||
/* 派生色:可旋转色相 / 降低饱和度后再套 tone */
|
||||
function tonal(seed, t, { hue = 0, sat = 1 } = {}) {
|
||||
let base = seed;
|
||||
if (hue) {
|
||||
const [h, s, l] = hexToHsl(base);
|
||||
base = hslToHex(h + hue, s, l);
|
||||
}
|
||||
if (sat < 1) base = mix(base, grayscale(base), 1 - sat);
|
||||
return tone(base, t);
|
||||
}
|
||||
|
||||
/* ---------- 前台同源 token(默认主色 #6750a4,取自 public/css/style.css) ---------- */
|
||||
|
||||
const LIGHT_TOKENS = {
|
||||
primary: '#6750a4',
|
||||
onPrimary: '#ffffff',
|
||||
primaryContainer: '#eadfff',
|
||||
onPrimaryContainer: '#21005d',
|
||||
secondary: '#625b71',
|
||||
onSecondary: '#ffffff',
|
||||
secondaryContainer: '#e8def8',
|
||||
onSecondaryContainer: '#1d192b',
|
||||
tertiary: '#7d5260',
|
||||
onTertiary: '#ffffff',
|
||||
tertiaryContainer: '#ffd8e4',
|
||||
onTertiaryContainer: '#31111d',
|
||||
surface: '#fffbfe',
|
||||
onSurface: '#1c1b1f',
|
||||
surfaceVariant: '#e7e0ec',
|
||||
onSurfaceVariant: '#49454f',
|
||||
surfaceContainerLow: '#f7f2fa',
|
||||
surfaceContainer: '#f3edf7',
|
||||
surfaceContainerHigh: '#ece6f0',
|
||||
outline: '#79747e',
|
||||
outlineVariant: '#cac4d0',
|
||||
error: '#b3261e',
|
||||
onError: '#ffffff',
|
||||
inverseSurface: '#313033',
|
||||
inverseOnSurface: '#f4eff4',
|
||||
};
|
||||
|
||||
const DARK_TOKENS = {
|
||||
primary: '#d0bcff',
|
||||
onPrimary: '#381e72',
|
||||
primaryContainer: '#4f378b',
|
||||
onPrimaryContainer: '#eadfff',
|
||||
secondary: '#ccc2dc',
|
||||
onSecondary: '#332d41',
|
||||
secondaryContainer: '#4a4458',
|
||||
onSecondaryContainer: '#e8def8',
|
||||
tertiary: '#efb8c8',
|
||||
onTertiary: '#492532',
|
||||
tertiaryContainer: '#61343f',
|
||||
onTertiaryContainer: '#ffd8e4',
|
||||
surface: '#1c1b1f',
|
||||
onSurface: '#e6e1e5',
|
||||
surfaceVariant: '#49454f',
|
||||
onSurfaceVariant: '#cac4d0',
|
||||
surfaceContainerLow: '#211f26',
|
||||
surfaceContainer: '#25232a',
|
||||
surfaceContainerHigh: '#2b2930',
|
||||
outline: '#938f99',
|
||||
outlineVariant: '#49454f',
|
||||
error: '#f2b8b5',
|
||||
onError: '#601410',
|
||||
inverseSurface: '#e6e1e5',
|
||||
inverseOnSurface: '#313033',
|
||||
};
|
||||
|
||||
function buildPalette(seed, dark) {
|
||||
if (String(seed).toLowerCase() === DEFAULT_SEED) return dark ? DARK_TOKENS : LIGHT_TOKENS;
|
||||
// 自定义主色:surface 色阶保持 MD3 基线,色相相关 token 由 tonal ramp 派生
|
||||
const sat = 0.55; // secondary 适度去饱和,贴近 MD3 参考值
|
||||
const hue = 32; // tertiary 色相偏移
|
||||
if (dark) {
|
||||
return {
|
||||
primary: tone(seed, 80),
|
||||
onPrimary: tone(seed, 20),
|
||||
primaryContainer: tone(seed, 30),
|
||||
onPrimaryContainer: tone(seed, 90),
|
||||
secondary: tonal(seed, 80, { sat }),
|
||||
onSecondary: tonal(seed, 20, { sat }),
|
||||
secondaryContainer: tonal(seed, 30, { sat }),
|
||||
onSecondaryContainer: tonal(seed, 90, { sat }),
|
||||
tertiary: tonal(seed, 80, { hue }),
|
||||
onTertiary: tonal(seed, 20, { hue }),
|
||||
tertiaryContainer: tonal(seed, 30, { hue }),
|
||||
onTertiaryContainer: tonal(seed, 90, { hue }),
|
||||
...DARK_TOKENS_DERIVED,
|
||||
};
|
||||
}
|
||||
return {
|
||||
primary: tone(seed, 40),
|
||||
onPrimary: '#ffffff',
|
||||
primaryContainer: tone(seed, 90),
|
||||
onPrimaryContainer: tone(seed, 10),
|
||||
secondary: tonal(seed, 40, { sat }),
|
||||
onSecondary: '#ffffff',
|
||||
secondaryContainer: tonal(seed, 90, { sat }),
|
||||
onSecondaryContainer: tonal(seed, 10, { sat }),
|
||||
tertiary: tonal(seed, 40, { hue }),
|
||||
onTertiary: '#ffffff',
|
||||
tertiaryContainer: tonal(seed, 90, { hue }),
|
||||
onTertiaryContainer: tonal(seed, 10, { hue }),
|
||||
...LIGHT_TOKENS_DERIVED,
|
||||
};
|
||||
}
|
||||
|
||||
// 派生模式下 surface 相关与 error 沿用 MD3 基线
|
||||
const LIGHT_TOKENS_DERIVED = {
|
||||
surface: '#fffbfe',
|
||||
onSurface: '#1c1b1f',
|
||||
surfaceVariant: '#e7e0ec',
|
||||
onSurfaceVariant: '#49454f',
|
||||
surfaceContainerLow: '#f7f2fa',
|
||||
surfaceContainer: '#f3edf7',
|
||||
surfaceContainerHigh: '#ece6f0',
|
||||
outline: '#79747e',
|
||||
outlineVariant: '#cac4d0',
|
||||
error: '#b3261e',
|
||||
onError: '#ffffff',
|
||||
inverseSurface: '#313033',
|
||||
inverseOnSurface: '#f4eff4',
|
||||
};
|
||||
|
||||
const DARK_TOKENS_DERIVED = {
|
||||
surface: '#1c1b1f',
|
||||
onSurface: '#e6e1e5',
|
||||
surfaceVariant: '#49454f',
|
||||
onSurfaceVariant: '#cac4d0',
|
||||
surfaceContainerLow: '#211f26',
|
||||
surfaceContainer: '#25232a',
|
||||
surfaceContainerHigh: '#2b2930',
|
||||
outline: '#938f99',
|
||||
outlineVariant: '#49454f',
|
||||
error: '#f2b8b5',
|
||||
onError: '#601410',
|
||||
inverseSurface: '#e6e1e5',
|
||||
inverseOnSurface: '#313033',
|
||||
};
|
||||
|
||||
/* ---------- MD3 柔和阴影(surface tint 而非硬投影) ---------- */
|
||||
|
||||
function md3Shadows() {
|
||||
const shadows = ['none'];
|
||||
for (let i = 1; i <= 24; i += 1) {
|
||||
const a = Math.min(0.26, 0.05 + i * 0.008).toFixed(3);
|
||||
const b = Math.min(0.15, 0.03 + i * 0.005).toFixed(3);
|
||||
const y1 = Math.min(3, 1 + Math.floor(i / 8));
|
||||
const y2 = Math.min(2, 1 + Math.floor(i / 12));
|
||||
const b1 = Math.min(8, 2 + Math.floor(i / 3));
|
||||
const b2 = Math.min(6, 1 + Math.floor(i / 5));
|
||||
shadows.push(`0px ${y1}px ${b1}px rgba(0,0,0,${a}), 0px ${y2}px ${b2}px rgba(0,0,0,${b})`);
|
||||
}
|
||||
return shadows;
|
||||
}
|
||||
|
||||
/* ---------- 主题工厂 ---------- */
|
||||
|
||||
export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) {
|
||||
const dark = mode === 'dark';
|
||||
const p = buildPalette(primary, dark);
|
||||
const shadows = md3Shadows();
|
||||
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
|
||||
|
||||
// filled / tonal 按钮的 hover 色(MD3 hover = 相邻 tone)
|
||||
const hoverPrimary = dark ? tone(p.primary, 90) : tone(p.primary, 35);
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: p.primary,
|
||||
contrastText: p.onPrimary,
|
||||
container: p.primaryContainer,
|
||||
onContainer: p.onPrimaryContainer,
|
||||
},
|
||||
secondary: {
|
||||
main: p.secondary,
|
||||
contrastText: p.onSecondary,
|
||||
container: p.secondaryContainer,
|
||||
onContainer: p.onSecondaryContainer,
|
||||
},
|
||||
tertiary: {
|
||||
main: p.tertiary,
|
||||
contrastText: p.onTertiary,
|
||||
container: p.tertiaryContainer,
|
||||
onContainer: p.onTertiaryContainer,
|
||||
},
|
||||
error: { main: p.error, contrastText: p.onError },
|
||||
background: { default: p.surface, paper: p.surfaceContainerLow },
|
||||
text: {
|
||||
primary: p.onSurface,
|
||||
secondary: p.onSurfaceVariant,
|
||||
disabled: alpha(p.onSurface, 0.38),
|
||||
},
|
||||
divider: p.outlineVariant,
|
||||
action: {
|
||||
hover: alpha(p.onSurface, 0.08),
|
||||
selected: alpha(p.onSurface, 0.08),
|
||||
disabled: alpha(p.onSurface, 0.38),
|
||||
disabledBackground: alpha(p.onSurface, 0.12),
|
||||
},
|
||||
},
|
||||
shape: { borderRadius: 8 },
|
||||
typography: {
|
||||
fontFamily,
|
||||
h4: { fontSize: 28, fontWeight: 500, letterSpacing: 0 },
|
||||
h5: { fontSize: 24, fontWeight: 500, letterSpacing: 0 },
|
||||
h6: { fontSize: 22, fontWeight: 500, letterSpacing: 0.15 },
|
||||
subtitle1: { fontSize: 16, fontWeight: 500, letterSpacing: 0.15 },
|
||||
body1: { fontSize: 16, lineHeight: 1.5 },
|
||||
body2: { fontSize: 14, lineHeight: 1.5 },
|
||||
button: { fontSize: 14, fontWeight: 500, letterSpacing: 0.1 },
|
||||
caption: { fontSize: 12, fontWeight: 400, letterSpacing: 0.4 },
|
||||
},
|
||||
shadows,
|
||||
components: {
|
||||
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
|
||||
MuiAppBar: {
|
||||
defaultProps: { color: 'inherit', elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: p.surfaceContainer,
|
||||
color: p.onSurface,
|
||||
boxShadow: 'none',
|
||||
borderBottom: `1px solid ${p.outlineVariant}`,
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 导航抽屉:surfaceContainerLow + 右侧细边框 */
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
borderRight: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 导航项:胶囊选中态(secondaryContainer) */
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 24,
|
||||
margin: '4px 10px',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
'& .MuiListItemIcon-root': { color: p.onSurfaceVariant, minWidth: 40 },
|
||||
'& .MuiListItemText-primary': { fontWeight: 500, fontSize: 14 },
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
'&:hover': { backgroundColor: p.secondaryContainer },
|
||||
'& .MuiListItemIcon-root': { color: p.onSecondaryContainer },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 按钮:pill 圆角、无阴影、文字不转大写 */
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.1px',
|
||||
height: 40,
|
||||
padding: '0 24px',
|
||||
},
|
||||
sizeSmall: { height: 32, padding: '0 16px', fontSize: 13 },
|
||||
sizeLarge: { height: 48, padding: '0 28px', fontSize: 15 },
|
||||
containedPrimary: {
|
||||
backgroundColor: p.primary,
|
||||
color: p.onPrimary,
|
||||
'&:hover': { backgroundColor: hoverPrimary, boxShadow: shadows[1] },
|
||||
},
|
||||
containedError: { boxShadow: 'none' },
|
||||
outlined: { borderColor: p.outline },
|
||||
outlinedPrimary: {
|
||||
borderColor: p.outline,
|
||||
'&:hover': { borderColor: p.onSurface, backgroundColor: alpha(p.primary, 0.08) },
|
||||
},
|
||||
outlinedError: { color: p.error, borderColor: p.outline, '&:hover': { backgroundColor: alpha(p.error, 0.08) } },
|
||||
textPrimary: { color: p.primary, '&:hover': { backgroundColor: alpha(p.primary, 0.08) } },
|
||||
textError: { color: p.error },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 卡片:surfaceContainerLow + 弱边框 + 12px 圆角 */
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* 设置页的普通 Paper(默认 elevation=1)同样 MD3 化 */
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
rounded: { borderRadius: 12 },
|
||||
elevation1: {
|
||||
backgroundColor: p.surfaceContainerLow,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
outlined: { borderColor: p.outlineVariant },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 弹窗:28px 大圆角 + surfaceContainerHigh */
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRadius: 28,
|
||||
backgroundColor: p.surfaceContainerHigh,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
boxShadow: shadows[4],
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialogTitle: {
|
||||
styleOverrides: { root: { fontSize: 22, fontWeight: 500, padding: '24px 24px 8px' } },
|
||||
},
|
||||
MuiDialogContent: {
|
||||
styleOverrides: { root: { padding: '8px 24px', color: p.onSurface } },
|
||||
},
|
||||
MuiDialogActions: {
|
||||
styleOverrides: { root: { padding: '16px 24px 24px', gap: 8 } },
|
||||
},
|
||||
|
||||
/* MD3 输入框:4px 圆角 + 中性 outline 边框 */
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 4 },
|
||||
notchedOutline: { borderColor: p.outline },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: p.onSurface },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 Chip:8px 圆角 + outline 边框 */
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 8, fontWeight: 500 },
|
||||
outlined: { borderColor: p.outline },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 表格:outline-variant 分隔线、紧凑密度 */
|
||||
MuiTableCell: {
|
||||
styleOverrides: {
|
||||
root: { borderBottom: `1px solid ${p.outlineVariant}`, padding: '14px 16px', fontSize: 14 },
|
||||
head: { color: p.onSurfaceVariant, fontWeight: 600, fontSize: 13 },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 开关:开 = primary 轨道 + 白 thumb,关 = surfaceContainerHigh 轨道 */
|
||||
MuiSwitch: {
|
||||
styleOverrides: {
|
||||
root: {},
|
||||
switchBase: {
|
||||
color: p.outline,
|
||||
'&.Mui-checked': { color: p.primary },
|
||||
'&.Mui-checked + .MuiSwitch-track': { backgroundColor: p.primary, opacity: 1 },
|
||||
},
|
||||
track: { backgroundColor: p.surfaceContainerHigh, opacity: 1 },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 分段按钮:secondaryContainer 选中态 */
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
textTransform: 'none',
|
||||
borderColor: p.outline,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
borderColor: p.outline,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 头像:primaryContainer 底 + onPrimaryContainer 字 */
|
||||
MuiAvatar: {
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: p.primaryContainer, color: p.onPrimaryContainer },
|
||||
},
|
||||
},
|
||||
|
||||
/* MD3 提示:inverseSurface 底 */
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
backgroundColor: p.inverseSurface,
|
||||
color: p.inverseOnSurface,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
padding: '6px 8px',
|
||||
},
|
||||
arrow: { color: p.inverseSurface },
|
||||
},
|
||||
},
|
||||
|
||||
/* 图标按钮去底色圆角适配 */
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createMD3Theme;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// 面板链接(routes/admin-links.js)
|
||||
export function listAdminLinks() {
|
||||
return request('/admin-links');
|
||||
}
|
||||
/** data = { title, url, embed_url?, description?, icon?, category?, sort_order?, use_proxy?, version? } */
|
||||
export function createAdminLink(data) {
|
||||
return request('/admin-links', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateAdminLink(id, data) {
|
||||
return request('/admin-links/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteAdminLink(id) {
|
||||
return request('/admin-links/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 启用的公告列表(active=1) */
|
||||
export function listActive() {
|
||||
return request('/announcements');
|
||||
}
|
||||
|
||||
// ── 管理员管理 ──────────────────────────────────
|
||||
export function listAll() {
|
||||
return request('/announcements?all=1');
|
||||
}
|
||||
export function createAnnouncement(data) {
|
||||
return request('/announcements', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateAnnouncement(id, data) {
|
||||
return request('/announcements/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteAnnouncement(id) {
|
||||
return request('/announcements/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { request, setToken, notifyAuthChange } from './client.js';
|
||||
import { applyCaptchaResult } from './captcha.js';
|
||||
|
||||
/** 登录;captcha 为 showCaptcha 的结果({type,value},可选):proof → captcha_proof,recaptcha/turnstile → 对应 token */
|
||||
export function login(username, password, captcha) {
|
||||
const body = { username, password };
|
||||
applyCaptchaResult(body, captcha);
|
||||
return request('/auth/login', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 注册;data = { username, password, email, captcha? }(captcha 同 login) */
|
||||
export function register(data) {
|
||||
const body = { username: data.username, password: data.password, email: data.email };
|
||||
applyCaptchaResult(body, data.captcha);
|
||||
return request('/auth/register', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息(需登录) */
|
||||
export function me() {
|
||||
return request('/auth/me');
|
||||
}
|
||||
|
||||
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
|
||||
export function logout() {
|
||||
setToken(null);
|
||||
notifyAuthChange();
|
||||
}
|
||||
|
||||
// ── 管理员用户管理 ──────────────────────────────
|
||||
export function registerByAdmin(username, password, role) {
|
||||
return request('/auth/register-by-admin', { method: 'POST', body: { username, password, role } });
|
||||
}
|
||||
export function listUsers() {
|
||||
return request('/auth/users');
|
||||
}
|
||||
export function deleteUser(id) {
|
||||
return request('/auth/users/' + id, { method: 'DELETE' });
|
||||
}
|
||||
export function resetUserPassword(id, newPassword) {
|
||||
return request('/auth/users/' + id + '/password', { method: 'PUT', body: { newPassword } });
|
||||
}
|
||||
export function setUserRole(id, role) {
|
||||
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { role } });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 文章列表;all=true 返回全部(含草稿,仅管理员) */
|
||||
export function listPosts(all) {
|
||||
return request('/blog/posts' + (all ? '?all=1' : ''));
|
||||
}
|
||||
export function getPost(id) {
|
||||
return request('/blog/posts/' + id);
|
||||
}
|
||||
/** data = { title, content, excerpt?, published?, use_markdown? }(仅管理员) */
|
||||
export function createPost(data) {
|
||||
return request('/blog/posts', { method: 'POST', body: data });
|
||||
}
|
||||
export function updatePost(id, data) {
|
||||
return request('/blog/posts/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deletePost(id) {
|
||||
return request('/blog/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 搜索 / 标签 / 归档 / 上下篇 / 点赞(博客增强 B2)──────────
|
||||
export function searchPosts(q) {
|
||||
return request('/blog/search?q=' + encodeURIComponent(q || ''));
|
||||
}
|
||||
export function getTags() {
|
||||
return request('/blog/tags');
|
||||
}
|
||||
export function getTagPosts(name) {
|
||||
return request('/blog/tag/' + encodeURIComponent(name));
|
||||
}
|
||||
export function getArchive() {
|
||||
return request('/blog/archive');
|
||||
}
|
||||
export function getPrevNext(id) {
|
||||
return request('/blog/posts/' + id + '/prevnext');
|
||||
}
|
||||
export function getLikeState(id) {
|
||||
return request('/blog/posts/' + id + '/like');
|
||||
}
|
||||
export function likePost(id) {
|
||||
return request('/blog/posts/' + id + '/like', { method: 'POST' });
|
||||
}
|
||||
export function unlikePost(id) {
|
||||
return request('/blog/posts/' + id + '/like', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 评论 ────────────────────────────────────────
|
||||
export function listComments(postId) {
|
||||
return request('/blog/comments/' + postId);
|
||||
}
|
||||
/** content 评论正文;parentId>0 表示回复某条评论(嵌套) */
|
||||
export function createComment(postId, content, parentId = 0) {
|
||||
return request('/blog/comments/' + postId, { method: 'POST', body: { content, parent_id: parentId || 0 } });
|
||||
}
|
||||
export function deleteComment(id) {
|
||||
return request('/blog/comments/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 评论审核(管理员)──────────────────────────
|
||||
export function listPendingComments() {
|
||||
return request('/blog/comments/pending');
|
||||
}
|
||||
export function approveComment(id) {
|
||||
return request('/blog/comments/' + id + '/approve', { method: 'POST' });
|
||||
}
|
||||
export function rejectComment(id) {
|
||||
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 获取内置 SVG 验证码:返回 { token, svg, expires_in } */
|
||||
export function getImage() {
|
||||
return request('/captcha/image');
|
||||
}
|
||||
|
||||
/** 校验答案:返回 { success, proof?, error? },成功时 proof 为一次性验证码证明令牌 */
|
||||
export function verify(token, answer) {
|
||||
return request('/captcha/verify', { method: 'POST', body: { token, answer } });
|
||||
}
|
||||
|
||||
/** 检查某个动作(login/register/forum)是否需要验证码:返回 { required, type } */
|
||||
export function required(action) {
|
||||
return request('/captcha/required', { method: 'POST', body: { action } });
|
||||
}
|
||||
|
||||
// ── Proof of Work(智能验证,内置验证码的防刷前置) ──
|
||||
export function powChallenge() {
|
||||
return request('/captcha/pow-challenge');
|
||||
}
|
||||
export function powVerify(token, nonce) {
|
||||
return request('/captcha/pow-verify', { method: 'POST', body: { token, nonce: String(nonce) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 showCaptcha 的结果按 type 写入请求体:
|
||||
* - proof → captcha_proof(内置验证码一次性证明令牌)
|
||||
* - recaptcha → recaptcha_token(reCAPTCHA siteverify token)
|
||||
* - turnstile → turnstile_token(Turnstile siteverify token)
|
||||
*/
|
||||
export function applyCaptchaResult(body, result) {
|
||||
if (!body || !result || !result.type || !result.value) return body;
|
||||
if (result.type === 'proof') body.captcha_proof = result.value;
|
||||
else if (result.type === 'recaptcha') body.recaptcha_token = result.value;
|
||||
else if (result.type === 'turnstile') body.turnstile_token = result.value;
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(t) {
|
||||
t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** 登录态变更通知:登录/退出/头像更新后触发,Layout 监听后重新拉取当前用户 */
|
||||
export function notifyAuthChange() {
|
||||
window.dispatchEvent(new Event('authchange'));
|
||||
}
|
||||
|
||||
export async function request(path, { method = 'GET', body, auth = true } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (auth && getToken()) headers.Authorization = 'Bearer ' + getToken();
|
||||
const res = await fetch('/api' + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { const err = new Error(data.error || '请求失败'); err.status = res.status; throw err; }
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 重新发送注册邮箱验证码(8 位数字,存入 pending_users) */
|
||||
export function sendVerify(email, username) {
|
||||
return request('/email/send-verify', { method: 'POST', body: { email, username } });
|
||||
}
|
||||
|
||||
/** 输入 8 位验证码完成注册:{ code, username, password } */
|
||||
export function completeRegister(code, username, password) {
|
||||
return request('/email/complete-register', { method: 'POST', body: { code, username, password } });
|
||||
}
|
||||
|
||||
/** 发送 SMTP 测试邮件(仅管理员) */
|
||||
export function testSmtp(email) {
|
||||
return request('/email/test', { method: 'POST', body: { email } });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── 分类 ────────────────────────────────────────
|
||||
export function listCategories() {
|
||||
return request('/forum/categories');
|
||||
}
|
||||
export function createCategory(data) {
|
||||
return request('/forum/categories', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateCategory(id, data) {
|
||||
return request('/forum/categories/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteCategory(id) {
|
||||
return request('/forum/categories/' + id, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── 帖子 ────────────────────────────────────────
|
||||
/** 帖子列表;categoryId 可选 */
|
||||
export function listPosts(categoryId) {
|
||||
return request('/forum/posts' + (categoryId ? '?category_id=' + encodeURIComponent(categoryId) : ''));
|
||||
}
|
||||
export function getPost(id) {
|
||||
return request('/forum/posts/' + id);
|
||||
}
|
||||
/** data = { category_id, title, content, use_markdown?, sub_category?, captcha_proof? }(需登录) */
|
||||
export function createPost(data) {
|
||||
return request('/forum/posts', { method: 'POST', body: data });
|
||||
}
|
||||
export function reply(postId, content) {
|
||||
return request('/forum/posts/' + postId + '/replies', { method: 'POST', body: { content } });
|
||||
}
|
||||
export function deletePost(id) {
|
||||
return request('/forum/posts/' + id, { method: 'DELETE' });
|
||||
}
|
||||
export function deleteReply(id) {
|
||||
return request('/forum/replies/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
// ── PIN / 解锁 ──────────────────────────────────
|
||||
export function pinStatus() {
|
||||
return request('/passwords/pin-status');
|
||||
}
|
||||
export function setPin(pin) {
|
||||
return request('/passwords/set-pin', { method: 'POST', body: { pin } });
|
||||
}
|
||||
export function unlock(pin) {
|
||||
return request('/passwords/unlock', { method: 'POST', body: { pin } });
|
||||
}
|
||||
export function lock() {
|
||||
return request('/passwords/lock', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 密码条目(需已解锁) ───────────────────────
|
||||
export function listEntries() {
|
||||
return request('/passwords');
|
||||
}
|
||||
/** data = { title, username?, password, url?, notes? } */
|
||||
export function createEntry(data) {
|
||||
return request('/passwords', { method: 'POST', body: data });
|
||||
}
|
||||
export function updateEntry(id, data) {
|
||||
return request('/passwords/' + id, { method: 'PUT', body: data });
|
||||
}
|
||||
export function deleteEntry(id) {
|
||||
return request('/passwords/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 获取当前用户资料(需登录) */
|
||||
export function getProfile() {
|
||||
return request('/profile');
|
||||
}
|
||||
|
||||
/** 更新资料(需登录);目前仅支持 avatar = /uploads/avatars/xxx 站内路径 */
|
||||
export function updateProfile(data) {
|
||||
return request('/profile', { method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
/** 发送修改密码的邮箱验证码(需登录) */
|
||||
export function sendPwCode() {
|
||||
return request('/profile/send-pw-code', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** 校验验证码并修改密码:{ code, oldPassword, newPassword } */
|
||||
export function changePassword(data) {
|
||||
return request('/profile/password', { method: 'PUT', body: data });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from './client.js';
|
||||
|
||||
/** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */
|
||||
export function getPublicSettings() {
|
||||
return request('/settings/public');
|
||||
}
|
||||
|
||||
/** 全量设置(仅管理员) */
|
||||
export function getSettings() {
|
||||
return request('/settings');
|
||||
}
|
||||
|
||||
/** 保存设置(仅管理员) */
|
||||
export function saveSettings(data) {
|
||||
return request('/settings', { method: 'PUT', body: data });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { request, getToken } from './client.js';
|
||||
|
||||
// client.request 走 JSON,上传必须独立走 fetch + FormData + Bearer
|
||||
|
||||
async function upload(path, file, extra) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (extra) {
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
|
||||
}
|
||||
}
|
||||
const headers = {};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
const res = await fetch('/api/upload' + path, { method: 'POST', headers, body: fd });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || '上传失败');
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 普通文件上传;opts 可选 { ref_type, ref_id }。返回 { id, url, tag, ... },tag 为 [image:]/[file:] 插入文本 */
|
||||
export function uploadFile(file, opts) {
|
||||
return upload('/file', file, opts);
|
||||
}
|
||||
|
||||
/** 头像上传,成功后直接更新用户 avatar,返回 { url } */
|
||||
export function uploadAvatar(file) {
|
||||
return upload('/avatar', file);
|
||||
}
|
||||
|
||||
/** 壁纸上传,返回 { url, filename } */
|
||||
export function uploadWallpaper(file) {
|
||||
return upload('/wallpaper', file);
|
||||
}
|
||||
|
||||
/** 按 uid 获取头像地址(支持 QQ 自动头像) */
|
||||
export function avatarUrl(uid) {
|
||||
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
|
||||
}
|
||||
|
||||
/** 附件列表;可按 ref_type + ref_id 过滤 */
|
||||
export function listAttachments(refType, refId) {
|
||||
const q = [];
|
||||
if (refType && refId) {
|
||||
q.push('ref_type=' + encodeURIComponent(refType), 'ref_id=' + encodeURIComponent(refId));
|
||||
}
|
||||
return request('/upload/list' + (q.length ? '?' + q.join('&') : ''));
|
||||
}
|
||||
|
||||
/** 删除附件(本人或管理员) */
|
||||
export function deleteAttachment(id) {
|
||||
return request('/upload/' + id, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { listPosts } from '../api/blog.js';
|
||||
|
||||
/**
|
||||
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
|
||||
* 头像 / 简介 / 联系方式来自站点设置(/api/settings/public);
|
||||
* showRecent=true 时附加"最新文章"卡片(首页用)。
|
||||
* settings 由父组件从 /api/settings/public 取得后传入,避免重复请求。
|
||||
*/
|
||||
export default function BlogSidebar({ settings = {}, showRecent = false, forceShow = false }) {
|
||||
const [recent, setRecent] = useState(null); // null=加载中
|
||||
const [recentError, setRecentError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRecent) return;
|
||||
setRecent(null);
|
||||
setRecentError(false);
|
||||
listPosts()
|
||||
.then((posts) => setRecent((posts || []).slice(0, 8)))
|
||||
.catch(() => { setRecentError(true); setRecent([]); });
|
||||
}, [showRecent]);
|
||||
|
||||
// blog_show_sidebar=0 时整栏隐藏(仅博客页;首页 forceShow 强制显示)
|
||||
if (!forceShow && settings.blog_show_sidebar === '0') return null;
|
||||
|
||||
let contacts = [];
|
||||
if (settings.homepage_contacts) {
|
||||
try { contacts = JSON.parse(settings.homepage_contacts); } catch { contacts = []; }
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="homepage-sidebar" id="blogSidebar">
|
||||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<div className="homepage-avatar">
|
||||
{settings.homepage_avatar
|
||||
? <img src={settings.homepage_avatar} alt="avatar" />
|
||||
: <span className="material-icons" style={{ fontSize: 64, color: 'var(--md-ref-on-surface-variant)' }}>person</span>}
|
||||
</div>
|
||||
<div className="homepage-bio">{settings.homepage_bio || ''}</div>
|
||||
{contacts.length > 0 && (
|
||||
<div className="homepage-contacts">
|
||||
{contacts.map((l, i) => (
|
||||
<a key={i} href={l.url} target="_blank" rel="noopener" className="hp-contact-link" title={l.title || ''}>
|
||||
<span className="material-icons">{l.icon || 'link'}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecent && (
|
||||
<div className="card">
|
||||
<div className="homepage-recent-header">最新文章</div>
|
||||
<div className="homepage-recent-list">
|
||||
{recentError
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载失败</div>
|
||||
: recent === null
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载中...</div>
|
||||
: recent.length === 0
|
||||
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>暂无文章</div>
|
||||
: recent.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="homepage-recent-item">
|
||||
<span className="homepage-recent-title">{p.title}</span>
|
||||
<span className="homepage-recent-date">{(p.created_at || '').slice(0, 10)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as captchaApi from '../api/captcha.js';
|
||||
import { useDialog } from '../lib/utils.js';
|
||||
|
||||
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
|
||||
let pending = null; // { action, type, resolve }
|
||||
let listener = null;
|
||||
|
||||
function notifyHost() {
|
||||
if (listener) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码流程入口(迁移自 public/js/captcha.js):
|
||||
* - null 无需验证 / 用户取消
|
||||
* - object { type: 'proof' | 'recaptcha' | 'turnstile', value }
|
||||
* proof → 内置验证码通过,value 为后端签发的 captcha_proof
|
||||
* recaptcha → reCAPTCHA 验证通过,value 为 siteverify token
|
||||
* turnstile → Turnstile 验证通过,value 为 siteverify token
|
||||
* 调用方提交时按 type 放入 captcha_proof / recaptcha_token / turnstile_token 字段。
|
||||
*/
|
||||
export function showCaptcha(action) {
|
||||
return new Promise((resolve) => {
|
||||
captchaApi.required(action)
|
||||
.then((r) => {
|
||||
if (!r || !r.required || r.type === 'none') { resolve(null); return; }
|
||||
pending = { action, type: r.type || 'builtin', resolve };
|
||||
notifyHost();
|
||||
})
|
||||
.catch(() => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||||
function ensureThirdPartyScript(type) {
|
||||
if (type === 'recaptcha') {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
||||
s.async = true; s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
} else {
|
||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
||||
s.async = true; s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SHA-256(Proof of Work 使用)
|
||||
async function sha256(str) {
|
||||
const buf = new TextEncoder().encode(str);
|
||||
const hash = await crypto.subtle.digest('SHA-256', buf);
|
||||
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// 内置 SVG 验证码弹窗
|
||||
function BuiltinCaptcha({ onFinish }) {
|
||||
const [svg, setSvg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [powDone, setPowDone] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const tokenRef = useRef(null);
|
||||
const imgRef = useRef(null);
|
||||
|
||||
const loadImage = async () => {
|
||||
setError('');
|
||||
setAnswer('');
|
||||
const data = await captchaApi.getImage();
|
||||
if (data && data.token) {
|
||||
tokenRef.current = data.token;
|
||||
setSvg(data.svg);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadImage();
|
||||
// 智能验证(Proof of Work)后台运行,20s 内找到满足难度的 nonce
|
||||
(async () => {
|
||||
try {
|
||||
const chal = await captchaApi.powChallenge();
|
||||
if (!chal || !chal.token) return;
|
||||
const target = '0'.repeat(chal.difficulty);
|
||||
const start = Date.now();
|
||||
let nonce = 0;
|
||||
while (Date.now() - start < 20000) {
|
||||
const hash = await sha256(chal.prefix + nonce);
|
||||
if (hash.startsWith(target)) {
|
||||
await captchaApi.powVerify(chal.token, String(nonce));
|
||||
setPowDone(true);
|
||||
return;
|
||||
}
|
||||
nonce++;
|
||||
}
|
||||
} catch { /* 失败不影响主流程 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 让后端返回的 SVG 自适应弹窗宽度(照搬 captcha.js 的样式处理)
|
||||
useEffect(() => {
|
||||
if (svg && imgRef.current) {
|
||||
const s = imgRef.current.querySelector('svg');
|
||||
if (s) s.setAttribute('style', 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block');
|
||||
}
|
||||
}, [svg]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!powDone) { setError('智能验证尚未完成,请稍候...'); return; }
|
||||
if (!answer.trim() || !tokenRef.current) { setError('请输入验证码'); return; }
|
||||
setVerifying(true);
|
||||
const v = await captchaApi.verify(tokenRef.current, answer.trim());
|
||||
setVerifying(false);
|
||||
if (v.success) {
|
||||
onFinish({ type: 'proof', value: v.proof || '' });
|
||||
} else {
|
||||
setError(v.error || '验证码错误');
|
||||
tokenRef.current = null;
|
||||
setAnswer('');
|
||||
loadImage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 12 }}>验证码</h3>
|
||||
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
|
||||
{svg
|
||||
? <div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
: <div className="spinner" style={{ width: 24, height: 24, margin: '16px auto' }} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
placeholder="输入验证码"
|
||||
aria-label="输入验证码"
|
||||
maxLength={6}
|
||||
autoComplete="off"
|
||||
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
|
||||
<span className="material-icons">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
{error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
|
||||
{powDone ? (
|
||||
<>
|
||||
<span className="material-icons" style={{ fontSize: 16, color: '#4caf50' }}>check_circle</span>
|
||||
<span style={{ color: '#4caf50' }}>智能验证通过</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>smart_toy</span>
|
||||
<span>智能验证中...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="actions" style={{ justifyContent: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||||
<button type="button" className="btn btn-filled" onClick={submit} disabled={verifying}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
|
||||
function ThirdPartyCaptcha({ type, onFinish }) {
|
||||
const siteKey = type === 'recaptcha'
|
||||
? (window._recaptchaSiteKey || '')
|
||||
: (window._turnstileSiteKey || '');
|
||||
const [status, setStatus] = useState('正在加载...');
|
||||
const widgetRef = useRef(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) { onFinish(null); return; }
|
||||
const container = widgetRef.current;
|
||||
const render = () => {
|
||||
try {
|
||||
if (type === 'recaptcha') {
|
||||
const wid = window.grecaptcha.render(container, {
|
||||
sitekey: siteKey,
|
||||
callback: () => {
|
||||
setStatus('验证通过');
|
||||
let token = '';
|
||||
try { token = window.grecaptcha.getResponse(wid); } catch { /* 忽略 */ }
|
||||
setTimeout(() => onFinish({ type: 'recaptcha', value: token || '' }), 300);
|
||||
},
|
||||
'expired-callback': () => setStatus('验证已过期'),
|
||||
});
|
||||
} else {
|
||||
const wid = window.turnstile.render(container, {
|
||||
sitekey: siteKey,
|
||||
callback: () => {
|
||||
setStatus('验证通过');
|
||||
let token = '';
|
||||
try { token = window.turnstile.getResponse(wid); } catch { /* 忽略 */ }
|
||||
setTimeout(() => onFinish({ type: 'turnstile', value: token || '' }), 300);
|
||||
},
|
||||
'expired-callback': () => setStatus('验证已过期'),
|
||||
});
|
||||
}
|
||||
setStatus('请完成验证');
|
||||
} catch {
|
||||
setStatus('加载失败');
|
||||
setTimeout(() => onFinish(null), 2000);
|
||||
}
|
||||
};
|
||||
if (type === 'recaptcha') {
|
||||
if (typeof window.grecaptcha !== 'undefined') render();
|
||||
else {
|
||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||
window.recaptchaCallbacks.push(render);
|
||||
ensureThirdPartyScript(type);
|
||||
}
|
||||
} else {
|
||||
if (typeof window.turnstile !== 'undefined') render();
|
||||
else {
|
||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||
window.turnstileCallbacks.push(render);
|
||||
ensureThirdPartyScript(type);
|
||||
}
|
||||
}
|
||||
// 卸载时不触发重复回调
|
||||
return () => { doneRef.current = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
||||
<div ref={widgetRef} style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }} />
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>{status}</p>
|
||||
<div className="actions" style={{ justifyContent: 'center' }}>
|
||||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局挂载的验证码弹窗宿主:放入 Layout 内,配合 showCaptcha(action) 使用。
|
||||
*/
|
||||
export default function CaptchaModalHost() {
|
||||
const [request, setRequest] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
listener = () => setRequest(pending);
|
||||
return () => { listener = null; };
|
||||
}, []);
|
||||
|
||||
const finish = (proof) => {
|
||||
if (request) {
|
||||
const resolve = request.resolve;
|
||||
pending = null;
|
||||
setRequest(null);
|
||||
resolve(proof);
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
|
||||
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="验证码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{request.type === 'recaptcha' ? (
|
||||
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
||||
) : request.type === 'turnstile' ? (
|
||||
<ThirdPartyCaptcha type="turnstile" onFinish={finish} />
|
||||
) : (
|
||||
<BuiltinCaptcha onFinish={finish} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
|
||||
* 纯文本(不含 `<`)按原样文本渲染;含 `<` 时按 HTML+CSS 渲染(self-XSS 与音乐嵌入同级)。
|
||||
* 仅用于这三个字段,不扩大到其他数据。
|
||||
*/
|
||||
function HtmlOrText({ value, className }) {
|
||||
if (typeof value === 'string' && value.includes('<')) {
|
||||
return <span className={className} dangerouslySetInnerHTML={{ __html: value }} />;
|
||||
}
|
||||
return <span className={className}>{value}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台页脚(按 settings.footer_style 渲染 3 种样式,视觉对齐 /tmp/footer-demo.html 的 02/03/04):
|
||||
* - classic 经典左右:左 Powered + 版权,右 站点名 v{version} 胶囊徽章
|
||||
* - columns 分栏导航:品牌 + 固定导航栏 + 底部版权条
|
||||
* - glass 玻璃卡片:磨砂玻璃卡片(backdrop-filter blur + 半透明底 + 细边框)
|
||||
* 所有样式都保留站点名 + v{version} 元素;footer_desc 为空时回退到 site_description。
|
||||
*/
|
||||
export default function Footer({ settings = {}, version = '' }) {
|
||||
const siteName = settings.site_name || 'RainWeb';
|
||||
const style = settings.footer_style || 'classic';
|
||||
const copyright = settings.footer_copyright || '© 2026 Rainnya Blog. All rights reserved.';
|
||||
const powered = settings.footer_powered || 'Powered by RainnyaWeb';
|
||||
const desc = settings.footer_desc || settings.site_description || '';
|
||||
|
||||
if (style === 'columns') {
|
||||
// 导航栏目固定不可编辑(后台提示),首页/博客/论坛走 SPA,管理后台整页跳转
|
||||
const navCols = [
|
||||
{ title: '导航', links: [
|
||||
{ to: '/', label: '首页' },
|
||||
{ to: '/blog.html', label: '博客' },
|
||||
{ to: '/forum.html', label: '论坛' },
|
||||
] },
|
||||
{ title: '其他', links: [
|
||||
{ to: '/admin', label: '管理后台', external: true },
|
||||
{ to: '/profile.html', label: '个人中心' },
|
||||
] },
|
||||
];
|
||||
return (
|
||||
<footer className="footer-columns">
|
||||
<div className="fc-grid">
|
||||
<div className="fc-brand">
|
||||
<div className="brand-row">
|
||||
<div className="brand-mark">{siteName.charAt(0)}</div>
|
||||
<div className="brand-name">{siteName}</div>
|
||||
{version && <span className="brand-version">v{version}</span>}
|
||||
</div>
|
||||
{desc && <HtmlOrText value={desc} className="brand-desc" />}
|
||||
</div>
|
||||
{navCols.map((col) => (
|
||||
<nav key={col.title} className="fc-col">
|
||||
<div className="col-title">{col.title}</div>
|
||||
<div className="col-links">
|
||||
{col.links.map((l) =>
|
||||
l.external ? (
|
||||
<a key={l.to} href={l.to}>{l.label}</a>
|
||||
) : (
|
||||
<Link key={l.to} to={l.to}>{l.label}</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
))}
|
||||
</div>
|
||||
<div className="fc-bottom">
|
||||
<HtmlOrText value={powered} />
|
||||
<span className="sep" />
|
||||
<HtmlOrText value={copyright} />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === 'glass') {
|
||||
return (
|
||||
<div className="footer-glass-wrap">
|
||||
<span className="blob blob-1"></span>
|
||||
<span className="blob blob-2"></span>
|
||||
<span className="blob blob-3"></span>
|
||||
<footer className="footer-glass">
|
||||
<div className="fg-left">
|
||||
<div className="fg-logo">{siteName.charAt(0)}</div>
|
||||
<div>
|
||||
<div className="fg-name">{siteName}</div>
|
||||
<div className="fg-tag">
|
||||
{desc && <HtmlOrText value={desc} />}
|
||||
{desc && <span> · </span>}v{version}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fg-right">
|
||||
<HtmlOrText value={copyright} className="fg-copyright" />
|
||||
<HtmlOrText value={powered} className="fg-powered" />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// classic(默认):经典左右
|
||||
return (
|
||||
<footer className="footer-classic">
|
||||
<div className="fc-left">
|
||||
<HtmlOrText value={powered} />
|
||||
<span className="sep" />
|
||||
<HtmlOrText value={copyright} />
|
||||
</div>
|
||||
<div className="fc-right">
|
||||
<span>{siteName}</span>
|
||||
{version && <span className="fc-version">v{version}</span>}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useTheme } from '../theme.jsx';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as settingsApi from '../api/settings.js';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
import MusicEmbed from './MusicEmbed.jsx';
|
||||
import CaptchaModalHost from './CaptchaModal.jsx';
|
||||
import Footer from './Footer.jsx';
|
||||
|
||||
/** QQ 邮箱自动头像(迁移自 nav.js) */
|
||||
function qqAvatar(user) {
|
||||
const m = user && user.email ? String(user.email).match(/^(\d+)@qq\.com$/i) : null;
|
||||
return m ? 'https://q1.qlogo.cn/g?b=qq&nk=' + m[1] + '&s=100' : '';
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { theme, toggleTheme, setTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const [settings, setSettings] = useState({});
|
||||
const [version, setVersion] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// 初始化向导:未完成安装时跳转 /setup.html(迁移自 nav.js)
|
||||
useEffect(() => {
|
||||
fetch('/api/setup/status')
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
if (s && !s.setup_complete && location.pathname !== '/setup.html') {
|
||||
window.location.href = '/setup.html';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新)
|
||||
useEffect(() => {
|
||||
settingsApi.getPublicSettings()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
// 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js)
|
||||
window._recaptchaSiteKey = s.recaptcha_site_key || '';
|
||||
window._turnstileSiteKey = s.turnstile_site_key || '';
|
||||
})
|
||||
.catch(() => {});
|
||||
fetch('/api/version')
|
||||
.then((r) => r.json())
|
||||
.then((v) => { if (v && v.version) setVersion(v.version); })
|
||||
.catch(() => {});
|
||||
const loadUser = () => {
|
||||
if (getToken()) {
|
||||
authApi.me().then(setUser).catch(() => { setToken(null); setUser(null); });
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
window.addEventListener('authchange', loadUser);
|
||||
return () => window.removeEventListener('authchange', loadUser);
|
||||
}, []);
|
||||
|
||||
// 强制深色模式(迁移自 nav.js applyTheme)
|
||||
useEffect(() => {
|
||||
if (settings.theme_force_dark === '1') {
|
||||
setTheme('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
}, [settings.theme_force_dark, setTheme]);
|
||||
|
||||
// 壁纸背景 + 玻璃磨砂(迁移自 nav.js applyTheme,直接作用于 body)
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
const wallpaper = settings.theme_wallpaper || '';
|
||||
const scale = settings.theme_wallpaper_scale || 'cover';
|
||||
|
||||
if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
|
||||
body.classList.add('has-wallpaper');
|
||||
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
|
||||
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
|
||||
const bgRepeatMap = { repeat: 'repeat', stretch: 'no-repeat', cover: 'no-repeat', contain: 'no-repeat' };
|
||||
body.style.backgroundSize = bgSizeMap[scale] || 'cover';
|
||||
body.style.backgroundRepeat = bgRepeatMap[scale] || 'no-repeat';
|
||||
document.documentElement.style.setProperty('--wallpaper-overlay',
|
||||
theme === 'dark' ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.15)');
|
||||
document.documentElement.style.setProperty('--wallpaper-text',
|
||||
theme === 'dark' ? '#e6e1e5' : '#1c1b1f');
|
||||
} else {
|
||||
body.classList.remove('has-wallpaper');
|
||||
body.style.removeProperty('--wallpaper');
|
||||
body.style.backgroundSize = '';
|
||||
body.style.backgroundRepeat = '';
|
||||
document.documentElement.style.removeProperty('--wallpaper-overlay');
|
||||
document.documentElement.style.removeProperty('--wallpaper-text');
|
||||
}
|
||||
document.documentElement.style.setProperty('--glass-blur', (settings.glass_blur || '20') + 'px');
|
||||
}, [settings, theme]);
|
||||
|
||||
// 卡片玻璃样式(导航/路由切换后对卡片类名统一应用)
|
||||
useEffect(() => {
|
||||
const cs = settings.card_style || 'default';
|
||||
document.querySelectorAll('.card, .blog-card, .panel-card, .link-card, .forum-post-card, .password-card, .forum-cat-item, .chip').forEach((el) => {
|
||||
el.classList.toggle('glass-card', cs === 'glass');
|
||||
});
|
||||
}, [settings.card_style, location.pathname]);
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (settings.theme_force_dark === '1') {
|
||||
showSnackbar('已强制启用深色模式无法更改');
|
||||
return;
|
||||
}
|
||||
toggleTheme();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
notifyAuthChange();
|
||||
showSnackbar('已退出登录');
|
||||
};
|
||||
|
||||
const siteName = settings.site_name || 'RainWeb';
|
||||
const isAdmin = user && user.role === 'admin';
|
||||
|
||||
const navClass = 'nav-bar'
|
||||
+ (settings.nav_style === 'glass' ? ' nav-glass' : '')
|
||||
+ (settings.nav_style === 'capsule' ? ' nav-capsule' : '');
|
||||
|
||||
// 导航标签(迁移自 nav.js 的显隐逻辑)
|
||||
const tabs = [
|
||||
{ to: '/', label: '首页', end: true, show: true },
|
||||
{ to: '/blog.html', label: '博客', show: true },
|
||||
{ to: '/forum.html', label: '论坛', show: !!user },
|
||||
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
||||
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
|
||||
];
|
||||
|
||||
let avatarUrl = user ? (user.avatar || '') : '';
|
||||
if (user && !avatarUrl) avatarUrl = qqAvatar(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className={navClass} id="mainNav">
|
||||
<div className="nav-left">
|
||||
<Link to="/" className="nav-brand">{siteName}</Link>
|
||||
<span className="nav-version">v{version}</span>
|
||||
<div className="nav-tabs">
|
||||
{tabs.filter((t) => t.show).map((t) =>
|
||||
t.fullPage ? (
|
||||
<a key={t.to} href={t.to} className="nav-tab">{t.label}</a>
|
||||
) : (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) => 'nav-tab' + (isActive ? ' active' : '')}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-right">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleToggleTheme}
|
||||
aria-label="切换主题"
|
||||
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
|
||||
>
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
</button>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/profile.html" className="btn btn-tonal btn-sm" title="个人中心">
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover', marginRight: 4 }} />
|
||||
: <span className="material-icons" style={{ fontSize: 18, marginRight: 2 }}>person</span>}
|
||||
{user.username}
|
||||
</Link>
|
||||
<button className="btn btn-text btn-sm" onClick={handleLogout}>退出</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login.html" className="btn btn-tonal btn-sm">登录</Link>
|
||||
<Link to="/register.html" className="btn btn-filled btn-sm">注册</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<Footer settings={settings} version={version} />
|
||||
|
||||
<MusicEmbed settings={settings} />
|
||||
<CaptchaModalHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { escapeHtml } from '../lib/utils.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
|
||||
/** [image:文件名] → /uploads/ 图片 */
|
||||
function imageTag(filename) {
|
||||
if (!filename) return '';
|
||||
return `<img src="/uploads/${encodeURIComponent(filename)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
|
||||
}
|
||||
|
||||
/** [file:文件名] → 带 token 的下载链接 */
|
||||
function fileTag(filename) {
|
||||
if (!filename) return '';
|
||||
const token = getToken() || '';
|
||||
return `<a href="/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}" target="_blank" class="file-link" style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--md-ref-surface-container);border-radius:8px;margin:4px 0;text-decoration:none;color:var(--md-ref-primary);font-size:14px">
|
||||
<span class="material-icons" style="font-size:18px">attachment</span> ${escapeHtml(filename)}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
|
||||
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
|
||||
*/
|
||||
export function renderContent(content, useMarkdown) {
|
||||
if (content == null) return '';
|
||||
|
||||
if (!useMarkdown) {
|
||||
let html = escapeHtml(content);
|
||||
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f));
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f));
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// Markdown 模式:先抽取自定义标签,避免被 marked 转义
|
||||
const images = [];
|
||||
const files = [];
|
||||
let html = content.replace(/\[image:([^\]]+)\]/g, (m, f) => { images.push(f); return `\x00IMG${images.length - 1}\x00`; });
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => { files.push(f); return `\x00FILE${files.length - 1}\x00`; });
|
||||
|
||||
html = marked.parse(html, { breaks: true, gfm: true });
|
||||
|
||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
|
||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)]));
|
||||
html = DOMPurify.sanitize(html);
|
||||
|
||||
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
||||
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
|
||||
let h2Index = 0;
|
||||
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
|
||||
const html = useMemo(() => renderContent(content, useMarkdown), [content, useMarkdown]);
|
||||
return <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 音乐嵌入(迁移自 public/js/music-embed.js):
|
||||
* 读取 settings 的 music_embed_enabled / music_embed_code / music_embed_position /
|
||||
* music_embed_autohide / music_embed_idle_timeout,支持自动隐藏与悬停展开。
|
||||
* 全局组件,挂在 Layout 内。
|
||||
*/
|
||||
export default function MusicEmbed({ settings }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const enabled = settings.music_embed_enabled === '1';
|
||||
const code = settings.music_embed_code || '';
|
||||
const position = settings.music_embed_position || 'right';
|
||||
const autoHide = settings.music_embed_autohide === '1';
|
||||
const idleTimeout = (parseInt(settings.music_embed_idle_timeout, 10) || 10) * 1000;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
|
||||
};
|
||||
|
||||
const expand = () => {
|
||||
clearTimer();
|
||||
setCollapsed(false);
|
||||
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
};
|
||||
|
||||
const startLeaveTimer = () => {
|
||||
if (!autoHide) return;
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !code) return;
|
||||
setCollapsed(false);
|
||||
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
|
||||
return clearTimer;
|
||||
}, [enabled, code, autoHide, idleTimeout]);
|
||||
|
||||
if (!enabled || !code) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`music-embed ${position} ${collapsed ? 'collapsed' : 'expanded'}`}
|
||||
onMouseEnter={expand}
|
||||
onMouseLeave={startLeaveTimer}
|
||||
>
|
||||
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
|
||||
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
|
||||
<button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
|
||||
<span className="material-icons">music_note</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/** HTML 转义:& < > " ' 全转义 */
|
||||
export function escapeHtml(str) {
|
||||
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
||||
switch (ch) {
|
||||
case '&': return '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
case "'": return ''';
|
||||
default: return ch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 格式化时间为 YYYY-MM-DD HH:mm */
|
||||
export function formatDate(input) {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 简单节流:fn 在 wait 毫秒内最多执行一次 */
|
||||
export function throttle(fn, wait = 200) {
|
||||
let last = 0;
|
||||
return function (...args) {
|
||||
const now = Date.now();
|
||||
if (now - last >= wait) {
|
||||
last = now;
|
||||
return fn.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 底部 toast(依赖 index.html 中的 #snackbar,类名沿用 v1) */
|
||||
export function showSnackbar(msg) {
|
||||
const el = document.getElementById('snackbar');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hide');
|
||||
el.classList.add('show');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => {
|
||||
el.classList.add('hide');
|
||||
setTimeout(() => el.classList.remove('show', 'hide'), 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button) */
|
||||
export function focusDialog(container) {
|
||||
if (!container) return;
|
||||
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
|
||||
if (el && typeof el.focus === 'function') el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗键盘/焦点管理(B3):
|
||||
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
|
||||
* - Esc 关闭弹窗
|
||||
* - 关闭后焦点还给触发元素
|
||||
* 返回 { dialogRef, onKeyDown }:dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
|
||||
*/
|
||||
export function useDialog(open, onClose) {
|
||||
const dialogRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement;
|
||||
focusDialog(dialogRef.current);
|
||||
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
if (onClose) onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return { dialogRef, onKeyDown };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getArchive, listPosts } from '../api/blog.js';
|
||||
|
||||
/** 归档页(路由 /archive.html):
|
||||
* archive API 只返回 {month, count},无按月文章列表接口;
|
||||
* 列表接口 /blog/posts 无分页返回全部已发布文章(文章量小),
|
||||
* 前端按 created_at 客户端分组,配合 archive API 的月份与计数展示。
|
||||
*/
|
||||
export default function Archive() {
|
||||
const [months, setMonths] = useState([]); // [{month, count, posts:[]}]
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getArchive(), listPosts()])
|
||||
.then(([arc, posts]) => {
|
||||
const byMonth = new Map();
|
||||
(posts || []).forEach((p) => {
|
||||
const m = (p.created_at || '').slice(0, 7); // YYYY-MM
|
||||
if (!m) return;
|
||||
if (!byMonth.has(m)) byMonth.set(m, []);
|
||||
byMonth.get(m).push(p);
|
||||
});
|
||||
// 以 archive API 的月份顺序为准(desc),count 以实际分组统计为准
|
||||
const countMap = new Map();
|
||||
(arc || []).forEach((a) => countMap.set(a.month, a.count));
|
||||
const list = Array.from(byMonth.entries()).map(([month, ps]) => ({
|
||||
month,
|
||||
count: ps.length,
|
||||
posts: ps,
|
||||
}));
|
||||
// 补齐 archive 里有、分组里没有的月份(理论上不会出现)
|
||||
(arc || []).forEach((a) => {
|
||||
if (!byMonth.has(a.month)) list.push({ month: a.month, count: a.count, posts: [] });
|
||||
});
|
||||
list.sort((a, b) => (a.month < b.month ? 1 : -1));
|
||||
setMonths(list);
|
||||
})
|
||||
.catch((e) => setError(e.message || '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const fmtMonth = (m) => {
|
||||
const [y, mm] = m.split('-');
|
||||
return `${y}年${mm}月`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
|
||||
</Link>
|
||||
<h1 className="article-title" style={{ fontSize: 26 }}>
|
||||
文章归档
|
||||
{!loading && months.length > 0 && (
|
||||
<span className="tag-title-count">(共 {months.reduce((s, m) => s + m.count, 0)} 篇)</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{loading && <div className="loading"><div className="spinner"></div></div>}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{!loading && !error && months.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🗂️</div><p>暂无文章</p></div>
|
||||
)}
|
||||
|
||||
<div className="archive-list">
|
||||
{months.map((m) => (
|
||||
<section key={m.month} className="archive-month">
|
||||
<h2 className="archive-month-title">
|
||||
{fmtMonth(m.month)}
|
||||
<span className="archive-month-count">{m.count} 篇</span>
|
||||
</h2>
|
||||
{m.posts.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>暂无文章</p>
|
||||
) : (
|
||||
m.posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="archive-item">
|
||||
<span className="archive-item-title">{p.title}</span>
|
||||
<span className="archive-item-date">{(p.created_at || '').slice(0, 10)}</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import BlogSidebar from '../components/BlogSidebar.jsx';
|
||||
import { listPosts, searchPosts, getTags } from '../api/blog.js';
|
||||
import { getPublicSettings } from '../api/settings.js';
|
||||
|
||||
/** 摘要:优先取 excerpt,否则从内容中剥离 markdown 符号截取(照搬 blog.js) */
|
||||
function excerptOf(p) {
|
||||
if (p.excerpt) return p.excerpt;
|
||||
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
|
||||
}
|
||||
|
||||
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
|
||||
* 顶部搜索框(/api/blog/search)+ 标签云(/api/blog/tags,点击进 /tag/:name)+ 归档入口 */
|
||||
export default function Blog() {
|
||||
const [settings, setSettings] = useState({});
|
||||
const [posts, setPosts] = useState(null); // null=加载中
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 搜索状态
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState(null); // null=未搜索
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
// 标签云
|
||||
const [tags, setTags] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings().then(setSettings).catch(() => {});
|
||||
listPosts()
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => setError(e.message || '加载失败'));
|
||||
getTags()
|
||||
.then((ts) => setTags(ts || []))
|
||||
.catch(() => setTags([]));
|
||||
}, []);
|
||||
|
||||
const doSearch = async (e) => {
|
||||
e && e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q) { setResults(null); return; }
|
||||
setSearching(true);
|
||||
try {
|
||||
const rs = await searchPosts(q);
|
||||
setResults(rs || []);
|
||||
} catch (err) {
|
||||
setResults([]);
|
||||
setError(err.message || '搜索失败');
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// 搜索结果以列表呈现(区别于瀑布流卡片)
|
||||
const renderResults = () => (
|
||||
<div className="search-results">
|
||||
<div className="search-results-head">
|
||||
<span>搜索结果({results.length})</span>
|
||||
<button className="btn btn-text btn-sm" onClick={clearSearch}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>close</span> 清空
|
||||
</button>
|
||||
</div>
|
||||
{results.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">🔍</div><p>没有找到相关内容</p></div>
|
||||
) : (
|
||||
<div className="search-results-list">
|
||||
{results.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="search-result-item">
|
||||
<div className="sri-title">{p.title}</div>
|
||||
{p.excerpt && <div className="sri-excerpt">{p.excerpt}</div>}
|
||||
<div className="sri-meta">
|
||||
<span>{p.author_name || '管理员'}</span>
|
||||
<span className="sep">·</span>
|
||||
<span>{p.created_at}</span>
|
||||
{p.tags && <span className="sri-tags">{p.tags}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="homepage-layout blog-layout">
|
||||
<h1 className="sr-only">博客</h1>
|
||||
<BlogSidebar settings={settings} />
|
||||
<div className="homepage-content">
|
||||
{/* 工具条:搜索框 + 归档 */}
|
||||
<form className="blog-toolbar" onSubmit={doSearch}>
|
||||
<div className="search-box">
|
||||
<span className="material-icons">search</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索文章标题 / 内容..."
|
||||
aria-label="搜索文章"
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" className="search-clear" onClick={() => setQuery('')} aria-label="清空输入">
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn-tonal btn-sm" disabled={searching}>
|
||||
{searching ? '搜索中...' : '搜索'}
|
||||
</button>
|
||||
<Link to="/archive.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
|
||||
</Link>
|
||||
</form>
|
||||
|
||||
{/* 标签云 */}
|
||||
{tags.length > 0 && (
|
||||
<div className="tag-cloud card">
|
||||
<div className="tag-cloud-title">
|
||||
<span className="material-icons">local_offer</span> 标签
|
||||
</div>
|
||||
<div className="tag-cloud-body">
|
||||
{tags.map((t) => (
|
||||
<Link key={t.name} to={`/tag/${encodeURIComponent(t.name)}`} className="tag-chip" style={{ fontSize: tagSize(t.count) }}>
|
||||
{t.name}
|
||||
<span className="tag-count">{t.count}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索结果 / 瀑布流 */}
|
||||
{results !== null ? (
|
||||
renderResults()
|
||||
) : (
|
||||
<>
|
||||
{!posts && !error && (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{posts && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="blog-waterfall">
|
||||
{posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
|
||||
<div className="blog-featured"></div>
|
||||
<div className="blog-title">{p.title}</div>
|
||||
<div className="blog-excerpt">{excerptOf(p)}</div>
|
||||
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签字号随文章数渐变(经典标签云效果):1 篇 ~12px,8 篇及以上 ~18px */
|
||||
function tagSize(count) {
|
||||
const c = Number(count) || 1;
|
||||
const size = 12 + Math.min(c, 8) * 0.75;
|
||||
return size.toFixed(1) + 'px';
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import * as blogApi from '../api/blog.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
|
||||
function countWords(content) {
|
||||
const plain = String(content || '')
|
||||
.replace(/\[image:[^\]]*\]|\[file:[^\]]*\]/g, '')
|
||||
.replace(/[#*`\[\]()>|~_!-]/g, '');
|
||||
const cjk = (plain.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const words = plain.replace(/[\u4e00-\u9fff\u3400-\u4dbf]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
|
||||
return cjk + words;
|
||||
}
|
||||
|
||||
/**
|
||||
* 博客详情页(路由 /blog/:id):
|
||||
* 正文(MarkdownRenderer)+ 阅读量/标签/点赞 + 目录/字数 + 上一篇/下一篇 +
|
||||
* 嵌套评论(parent_id 回复)。
|
||||
*/
|
||||
export default function BlogDetail() {
|
||||
const { id } = useParams();
|
||||
const [post, setPost] = useState(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [prevnext, setPrevnext] = useState(null);
|
||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||
const [toc, setToc] = useState([]);
|
||||
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setReplyTo(null);
|
||||
try {
|
||||
const [p, cs, pn, lk] = await Promise.all([
|
||||
blogApi.getPost(id),
|
||||
blogApi.listComments(id),
|
||||
blogApi.getPrevNext(id),
|
||||
blogApi.getLikeState(id),
|
||||
]);
|
||||
setPost(p);
|
||||
setComments(cs || []);
|
||||
setPrevnext(pn);
|
||||
setLike(lk || { liked: false, count: 0 });
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
if (getToken()) {
|
||||
me().then(setUser).catch(() => setUser(null));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
||||
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
||||
|
||||
// 目录:从已渲染的 .md-body 提取 h2(仅一级章节)打锚点;短文章(<500 字)不提取
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
if (wordCount < 500) { setToc([]); return; }
|
||||
const body = document.querySelector('.blog-article .md-body');
|
||||
if (!body) { setToc([]); return; }
|
||||
const items = [...body.querySelectorAll('h2')].map((h, i) => {
|
||||
if (!h.id) h.id = 'toc-' + i;
|
||||
return { id: h.id, text: (h.textContent || '').trim(), level: 2 };
|
||||
});
|
||||
setToc(items);
|
||||
}, [post, wordCount]);
|
||||
|
||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
||||
|
||||
// ── 点赞(乐观更新)──
|
||||
const toggleLike = async () => {
|
||||
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
||||
const next = !like.liked;
|
||||
setLike((s) => ({ liked: next, count: Math.max(0, s.count + (next ? 1 : -1)) }));
|
||||
try {
|
||||
const r = next ? await blogApi.likePost(id) : await blogApi.unlikePost(id);
|
||||
setLike({ liked: r.liked, count: r.count });
|
||||
} catch (e) {
|
||||
setLike((s) => ({ liked: !s.liked, count: Math.max(0, s.count + (s.liked ? 1 : -1)) }));
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 嵌套评论 ──
|
||||
const childrenOf = useCallback((pid) => comments.filter((c) => c.parent_id === pid), [comments]);
|
||||
const rootComments = comments.filter((c) => !c.parent_id);
|
||||
|
||||
const renderComment = (c, depth = 0) => {
|
||||
const kids = childrenOf(c.id);
|
||||
return (
|
||||
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
|
||||
<div className="reply-meta">
|
||||
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
|
||||
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
|
||||
</div>
|
||||
<div className="reply-body">{c.content}</div>
|
||||
{user && (
|
||||
<button
|
||||
className="btn btn-text btn-sm reply-btn"
|
||||
onClick={() => {
|
||||
setReplyTo({ id: c.id, name: c.author_name || '游客' });
|
||||
setCommentText(`@${c.author_name || '游客'} `);
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 14 }}>reply</span> 回复
|
||||
</button>
|
||||
)}
|
||||
{kids.length > 0 && (
|
||||
<div className="reply-children">
|
||||
{kids.map((k) => renderComment(k, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
const content = commentText.trim();
|
||||
if (!content) { showSnackbar('评论不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await blogApi.createComment(id, content, replyTo ? replyTo.id : 0);
|
||||
showSnackbar('评论已发表');
|
||||
setCommentText('');
|
||||
setReplyTo(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{error || '文章不存在'}</p>
|
||||
<Link to="/blog.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回博客</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="article-layout">
|
||||
<div className="blog-article article-main">
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
|
||||
</Link>
|
||||
|
||||
<h1 className="article-title">{post.title}</h1>
|
||||
|
||||
<div className="article-meta">
|
||||
{post.author_name || '管理员'} · {post.created_at}
|
||||
<span className="meta-stat" title="阅读量">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
|
||||
</span>
|
||||
<span className="meta-stat" title="字数">约 {wordCount} 字</span>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="article-tags">
|
||||
{tags.map((t) => (
|
||||
<Link key={t} to={`/tag/${encodeURIComponent(t)}`} className="chip article-tag-chip">{t}</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-actions">
|
||||
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||
<span className="like-count">{like.count}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
||||
|
||||
{/* 上一篇 / 下一篇 */}
|
||||
{prevnext && (prevnext.prev || prevnext.next) && (
|
||||
<nav className="prevnext-nav">
|
||||
<div className="pn-col pn-prev">
|
||||
{prevnext.prev ? (
|
||||
<Link to={`/blog/${prevnext.prev.id}`}>
|
||||
<span className="pn-label">上一篇</span>
|
||||
<span className="pn-title">{prevnext.prev.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">上一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pn-col pn-next">
|
||||
{prevnext.next ? (
|
||||
<Link to={`/blog/${prevnext.next.id}`}>
|
||||
<span className="pn-label">下一篇</span>
|
||||
<span className="pn-title">{prevnext.next.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">下一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
|
||||
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
|
||||
) : (
|
||||
<div className="comment-list">
|
||||
{rootComments.map((c) => renderComment(c))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
<div className="comment-form">
|
||||
{replyTo && (
|
||||
<div className="reply-to-hint">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>reply</span>
|
||||
回复 <strong>@{replyTo.name}</strong>
|
||||
<button
|
||||
className="btn btn-text btn-sm"
|
||||
onClick={() => { setReplyTo(null); setCommentText(''); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>close</span> 取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: replyTo ? 8 : 0 }}>
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={replyTo ? `回复 @${replyTo.name}...` : '写下你的评论...'}
|
||||
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
|
||||
/>
|
||||
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
|
||||
发表评论
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}>
|
||||
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="article-toc-side">
|
||||
<div className="toc-title">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
|
||||
目录 <span className="toc-count">{toc.length}</span>
|
||||
</div>
|
||||
<ul className="toc-list">
|
||||
{toc.map((t) => (
|
||||
<li key={t.id} className="toc-item">
|
||||
<a
|
||||
href={'#' + t.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(t.id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTheme } from '../theme.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
|
||||
/**
|
||||
* 面板嵌入页(迁移自 embed.html):
|
||||
* 入口参数 ?url=&title=&proxy=1 —— iframe 展示目标站点;
|
||||
* proxy=1 或 HTTPS 页内嵌 HTTP 目标时走 /api/proxy/fetch?url=(代理剥 X-Frame-Options/CSP)。
|
||||
* 工具栏:关闭(有来源则 history.back)、标题、原站(新标签)、主题切换。
|
||||
*/
|
||||
export default function Embed() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const url = searchParams.get('url');
|
||||
const title = searchParams.get('title') || url || '嵌入';
|
||||
const useProxy = searchParams.get('proxy') === '1';
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
|
||||
const isHttpsPage = window.location.protocol === 'https:';
|
||||
const isHttpTarget = url ? url.startsWith('http:') : false;
|
||||
const needsProxy = useProxy || (isHttpsPage && isHttpTarget);
|
||||
// proxy 模式:iframe 无法携带 Authorization header,改用 ?token= 认证(后端 queryTokenAuth 转写)
|
||||
const iframeSrc = url
|
||||
? (needsProxy
|
||||
? '/api/proxy/fetch?url=' + encodeURIComponent(url) + '&token=' + encodeURIComponent(getToken() || '')
|
||||
: url)
|
||||
: '';
|
||||
|
||||
// 非代理嵌入时 2s 后提示"无法嵌入?安装扩展"(照 v1)
|
||||
useEffect(() => {
|
||||
if (!url || needsProxy) return;
|
||||
const t = setTimeout(() => setShowHint(true), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [url, needsProxy]);
|
||||
|
||||
const handleClose = (e) => {
|
||||
// 有来源页时返回上一页,否则跳首页
|
||||
if (document.referrer && document.referrer !== window.location.href) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<h1 className="sr-only">嵌入页面</h1>
|
||||
<div
|
||||
className="embed-toolbar"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
|
||||
>
|
||||
<a href="/" className="btn-icon" onClick={handleClose} title="关闭"><span className="material-icons">close</span></a>
|
||||
<span className="embed-title" style={{ flex: 1, fontWeight: 500, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
|
||||
{url && (
|
||||
<a href={url} target="_blank" rel="noopener" className="btn btn-text btn-sm" title="在新标签页中打开">
|
||||
<span className="material-icons">open_in_new</span> 原站
|
||||
</a>
|
||||
)}
|
||||
<button className="btn-icon" onClick={toggleTheme} title="切换主题">
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showHint && (
|
||||
<div style={{ padding: '8px 16px', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', fontSize: 13, textAlign: 'center', flexShrink: 0 }}>
|
||||
无法嵌入?试试安装{' '}
|
||||
<a href="https://chromewebstore.google.com/search/ignore%20x-frame" target="_blank" rel="noopener" style={{ color: 'inherit', fontWeight: 600, textDecoration: 'underline' }}>
|
||||
Ignore X-Frame-Headers
|
||||
</a>{' '}
|
||||
扩展,或点击右上角「原站」在新标签页打开
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHint(false)}
|
||||
aria-label="关闭提示"
|
||||
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{iframeSrc ? (
|
||||
<iframe
|
||||
className="embed-container"
|
||||
title={title}
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
|
||||
loading="lazy"
|
||||
src={iframeSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state" style={{ flex: 1 }}>
|
||||
<div className="empty-icon">🔗</div>
|
||||
<p>缺少 url 参数,无法加载嵌入内容</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { uploadFile } from '../api/upload.js';
|
||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/** 板块的子分类列表 */
|
||||
function subCatsOf(cat) {
|
||||
if (!cat || !cat.sub_categories) return [];
|
||||
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
|
||||
}
|
||||
|
||||
/** 论坛首页:分类导航(侧栏 + 子分类 chips 筛选)+ 帖子列表 + 发帖弹窗(含验证码) */
|
||||
export default function Forum() {
|
||||
const navigate = useNavigate();
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [currentCatId, setCurrentCatId] = useState(null); // null=全部最新
|
||||
const [filterSub, setFilterSub] = useState('');
|
||||
const [posts, setPosts] = useState(null); // null=加载中
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// 发帖弹窗状态
|
||||
const [showNewPost, setShowNewPost] = useState(false);
|
||||
const [npCategory, setNpCategory] = useState('');
|
||||
const [npSub, setNpSub] = useState('');
|
||||
const [npTitle, setNpTitle] = useState('');
|
||||
const [npContent, setNpContent] = useState('');
|
||||
const [npStatus, setNpStatus] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const load = useCallback(async (catId, sub) => {
|
||||
setPosts(null);
|
||||
setLoadError('');
|
||||
try {
|
||||
const ps = catId ? await forumApi.listPosts(catId) : await forumApi.listPosts();
|
||||
const filtered = sub ? (ps || []).filter((p) => p.sub_category === sub) : (ps || []);
|
||||
setPosts(filtered);
|
||||
} catch (e) {
|
||||
setLoadError(e.message || '加载失败');
|
||||
setPosts([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
forumApi.listCategories().then((cs) => setCategories(cs || [])).catch(() => {});
|
||||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
||||
else setUser(null);
|
||||
load(null, '');
|
||||
}, [load]);
|
||||
|
||||
const selectCategory = (catId) => {
|
||||
setCurrentCatId(catId);
|
||||
setFilterSub('');
|
||||
load(catId, '');
|
||||
};
|
||||
|
||||
const showAllPosts = () => {
|
||||
setCurrentCatId(null);
|
||||
setFilterSub('');
|
||||
load(null, '');
|
||||
};
|
||||
|
||||
// 发帖按钮:未登录显示"登录发帖"并跳登录页(照 forum.js)
|
||||
const handleNewPostBtn = () => {
|
||||
if (user) {
|
||||
setNpCategory(categories.length ? String(categories[0].id) : '');
|
||||
setNpSub('');
|
||||
setNpTitle('');
|
||||
setNpContent('');
|
||||
setNpStatus('');
|
||||
setShowNewPost(true);
|
||||
} else {
|
||||
navigate('/login.html');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = (e) => {
|
||||
setNpCategory(e.target.value);
|
||||
setNpSub(''); // 切换板块后重置子分类
|
||||
};
|
||||
|
||||
const doUpload = async (file) => {
|
||||
try {
|
||||
const data = await uploadFile(file);
|
||||
setNpContent((c) => c + '\n' + data.tag + '\n');
|
||||
setNpStatus('已插入: ' + data.tag);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (f) doUpload(f);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const submitPost = async () => {
|
||||
if (!npTitle.trim() || !npContent.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const base = {
|
||||
category_id: parseInt(npCategory, 10),
|
||||
title: npTitle.trim(),
|
||||
content: npContent.trim(),
|
||||
sub_category: npSub,
|
||||
use_markdown: 1,
|
||||
};
|
||||
// 验证码:captcha_forum 开启时走 showCaptcha 拿 proof / 第三方 token
|
||||
const cap = await captchaRequired('forum');
|
||||
if (cap && cap.required) {
|
||||
const result = await showCaptcha('forum');
|
||||
if (result === null) { setSubmitting(false); return; } // 取消
|
||||
applyCaptchaResult(base, result);
|
||||
}
|
||||
await forumApi.createPost(base);
|
||||
showSnackbar('发布成功');
|
||||
setShowNewPost(false);
|
||||
if (currentCatId) selectCategory(currentCatId);
|
||||
else showAllPosts();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const currentCat = categories.find((c) => c.id === currentCatId);
|
||||
const subCats = subCatsOf(currentCat);
|
||||
|
||||
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
|
||||
|
||||
return (
|
||||
<div className="forum-layout">
|
||||
<h1 className="sr-only">论坛</h1>
|
||||
<aside className="forum-sidebar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
||||
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
|
||||
{user ? '发帖' : '登录发帖'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="forum-cat-list">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.id}
|
||||
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
|
||||
onClick={() => selectCategory(c.id)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>forum</span> {c.name}
|
||||
{c.announcement ? (
|
||||
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
|
||||
onClick={showAllPosts}
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div className="forum-content">
|
||||
{currentCat && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
|
||||
<span className="chip chip-static">板块</span>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
|
||||
{currentCat.announcement && (
|
||||
<div className="announcement-bar">
|
||||
<span className="material-icons ann-icon">campaign</span>
|
||||
<span className="ann-content">{currentCat.announcement}</span>
|
||||
</div>
|
||||
)}
|
||||
{subCats.length > 0 && (
|
||||
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
|
||||
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
|
||||
{subCats.map((s) => (
|
||||
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
|
||||
{loadError && <div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败</p></div>}
|
||||
{posts !== null && !loadError && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="forum-post-list">
|
||||
{posts.map((p) => {
|
||||
const catName = categories.find((c) => c.id === p.category_id)?.name || '';
|
||||
return (
|
||||
<Link key={p.id} to={`/forum/${p.id}`} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
|
||||
<div className="post-title">{p.title}</div>
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{p.author_name || '匿名'}</span>
|
||||
<span>{p.created_at}</span>
|
||||
<span>{p.reply_count || 0} 回复</span>
|
||||
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
|
||||
<span className="chip chip-static">{catName}</span>
|
||||
{p.sub_category ? (
|
||||
<span className="chip chip-tonal">{p.sub_category}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 发帖弹窗 */}
|
||||
{showNewPost && (
|
||||
<div
|
||||
ref={npDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="发布新帖"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
||||
onKeyDown={npDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>发布新帖</h3>
|
||||
<div className="form-group">
|
||||
<label>板块</label>
|
||||
<select value={npCategory} onChange={handleCategoryChange}>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>帖子分类(可选)</label>
|
||||
<select value={npSub} onChange={(e) => setNpSub(e.target.value)}>
|
||||
<option value="">无</option>
|
||||
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={npTitle} onChange={(e) => setNpTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>内容 *</label>
|
||||
<textarea value={npContent} onChange={(e) => setNpContent(e.target.value)} style={{ minHeight: 150, fontFamily: 'monospace' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
|
||||
<span className="material-icons">upload</span> 上传附件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||||
<span className="text-muted" style={{ fontSize: 13 }}>{npStatus}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setShowNewPost(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={submitPost} disabled={submitting}>发布</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 论坛帖子详情(路由 /forum/:id,SPA 内路由):
|
||||
* 标题/meta/正文(MarkdownRenderer)+ 回复列表/回复框(登录检查)+ 删除按钮(作者/admin)。
|
||||
* 回复无需验证码(照 v1:仅发帖走 captcha_forum)。
|
||||
*/
|
||||
export default function ForumDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [post, setPost] = useState(null);
|
||||
const [replies, setReplies] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await forumApi.getPost(id);
|
||||
setPost(data.post);
|
||||
setReplies(data.replies || []);
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
||||
else setUser(null);
|
||||
}, [load]);
|
||||
|
||||
const canDeletePost = user && post && (user.id === post.author_id || user.role === 'admin');
|
||||
|
||||
const submitReply = async () => {
|
||||
const content = replyText.trim();
|
||||
if (!content) { showSnackbar('回复内容不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await forumApi.reply(id, content);
|
||||
showSnackbar('回复成功');
|
||||
setReplyText('');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const deletePost = async () => {
|
||||
if (!window.confirm('确定删除?')) return;
|
||||
try {
|
||||
await forumApi.deletePost(post.id);
|
||||
showSnackbar('已删除');
|
||||
navigate('/forum.html');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReply = async (replyId) => {
|
||||
if (!window.confirm('确定删除?')) return;
|
||||
try {
|
||||
await forumApi.deleteReply(replyId);
|
||||
showSnackbar('已删除');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{error || '帖子不存在'}</p>
|
||||
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回论坛</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="post-detail">
|
||||
<div className="post-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Link to="/forum.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回
|
||||
</Link>
|
||||
{canDeletePost && (
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 'auto' }} onClick={deletePost}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
|
||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{post.author_name || '匿名'}</span>
|
||||
<span>{post.created_at}</span>
|
||||
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
|
||||
{post.category_name || ''}
|
||||
</span>
|
||||
{post.sub_category && (
|
||||
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
|
||||
{post.sub_category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '2px solid var(--md-ref-primary-container)', margin: '24px 0', borderRadius: 2 }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>回复 ({replies.length})</h4>
|
||||
|
||||
{user ? (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="写下你的回复...(支持 Markdown)"
|
||||
style={{ flex: 1, minHeight: 60, fontSize: 14, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitReply} disabled={submitting}>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>
|
||||
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以回复
|
||||
</p>
|
||||
)}
|
||||
|
||||
{replies.length === 0 ? (
|
||||
<div className="text-muted" style={{ padding: 16 }}>暂无回复</div>
|
||||
) : (
|
||||
replies.map((r) => {
|
||||
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin');
|
||||
return (
|
||||
<div className="reply-item" key={r.id}>
|
||||
<div className="reply-meta">
|
||||
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
|
||||
{canDeleteReply && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除回复"
|
||||
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
||||
onClick={() => deleteReply(r.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="reply-body">{r.content}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as forumApi from '../api/forum.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
const EMPTY_FORM = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
|
||||
|
||||
/** 单个板块的编辑卡片:名称/描述/公告/子分类(逗号分隔)/排序 */
|
||||
function CategoryEditCard({ cat, onSave, onDelete }) {
|
||||
const [f, setF] = useState({ ...cat });
|
||||
const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value }));
|
||||
return (
|
||||
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label>板块名称 *</label>
|
||||
<input type="text" value={f.name} onChange={set('name')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<input type="text" value={f.description} onChange={set('description')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>板块公告</label>
|
||||
<textarea value={f.announcement} onChange={set('announcement')} style={{ minHeight: 60 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>帖子分类(逗号分隔)</label>
|
||||
<input type="text" value={f.sub_categories} onChange={set('sub_categories')} placeholder="例: 求助,分享,讨论,建议" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>排序</label>
|
||||
<input type="number" value={f.sort_order} onChange={set('sort_order')} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)' }} onClick={() => onDelete(cat)}>
|
||||
删除板块
|
||||
</button>
|
||||
<button className="btn btn-filled btn-sm" onClick={() => onSave(f)}>保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 板块管理(admin):分类增删改 + 子分类编辑(迁移自 forum-manage.html,扩展为全板块管理) */
|
||||
export default function ForumManage() {
|
||||
const navigate = useNavigate();
|
||||
const [authed, setAuthed] = useState(null); // null=校验中
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { navigate('/login.html'); return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') {
|
||||
showSnackbar('需要管理员权限');
|
||||
setAuthed(false);
|
||||
setTimeout(() => navigate('/'), 1000);
|
||||
return;
|
||||
}
|
||||
setAuthed(true);
|
||||
loadCategories();
|
||||
})
|
||||
.catch(() => navigate('/login.html'));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cs = await forumApi.listCategories();
|
||||
setCategories(cs || []);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const saveCategory = async (f) => {
|
||||
const data = {
|
||||
name: (f.name || '').trim(),
|
||||
description: f.description || '',
|
||||
announcement: f.announcement || '',
|
||||
sub_categories: f.sub_categories || '',
|
||||
sort_order: parseInt(f.sort_order, 10) || 0,
|
||||
};
|
||||
if (!data.name) { showSnackbar('名称不能为空'); return; }
|
||||
try {
|
||||
await forumApi.updateCategory(f.id, data);
|
||||
showSnackbar('保存成功');
|
||||
loadCategories();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategory = async (cat) => {
|
||||
if (!window.confirm(`确定删除板块「${cat.name}」?板块下的帖子将一并删除`)) return;
|
||||
try {
|
||||
await forumApi.deleteCategory(cat.id);
|
||||
showSnackbar('已删除');
|
||||
loadCategories();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const createCategory = async () => {
|
||||
const data = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
announcement: form.announcement.trim(),
|
||||
sub_categories: form.sub_categories.trim(),
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
if (!data.name) { showSnackbar('名称不能为空'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await forumApi.createCategory(data);
|
||||
showSnackbar('创建成功');
|
||||
setForm(EMPTY_FORM);
|
||||
setShowCreate(false);
|
||||
loadCategories();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (authed === null) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
if (authed === false) return null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<a href="/forum.html" className="btn-icon"><span className="material-icons">arrow_back</span></a>
|
||||
<h1 className="page-title" style={{ margin: 0, flex: 1 }}>板块管理</h1>
|
||||
<button className="btn btn-filled btn-sm" onClick={() => setShowCreate((v) => !v)}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>add</span> 新建板块
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label>板块名称 *</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<input type="text" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>板块公告</label>
|
||||
<textarea value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} style={{ minHeight: 60 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>帖子分类(逗号分隔)</label>
|
||||
<input type="text" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} placeholder="例: 求助,分享,讨论,建议" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>排序</label>
|
||||
<input type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-text btn-sm" onClick={() => setShowCreate(false)}>取消</button>
|
||||
<button className="btn btn-filled btn-sm" onClick={createCategory} disabled={saving}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无板块</p></div>
|
||||
) : (
|
||||
categories.map((c) => (
|
||||
<CategoryEditCard key={c.id} cat={c} onSave={saveCategory} onDelete={deleteCategory} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import BlogSidebar from '../components/BlogSidebar.jsx';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { getPublicSettings } from '../api/settings.js';
|
||||
|
||||
/**
|
||||
* 首页(迁移自 index.html 的内联 HOMEPAGE):
|
||||
* 侧栏(头像/简介/联系方式 + 最新文章)+ 主页内容(settings.homepage_content,Markdown)。
|
||||
*/
|
||||
export default function Home() {
|
||||
const [settings, setSettings] = useState({});
|
||||
const [contentError, setContentError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings()
|
||||
.then((s) => { setSettings(s); setContentError(false); })
|
||||
.catch(() => { setSettings({}); setContentError(true); });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="homepage-layout">
|
||||
<h1 className="sr-only">首页</h1>
|
||||
<BlogSidebar settings={settings} showRecent forceShow />
|
||||
<div className="homepage-content">
|
||||
<div className="card">
|
||||
{contentError
|
||||
? <p style={{ color: 'var(--md-ref-on-surface-variant)' }}>内容加载失败</p>
|
||||
: <MarkdownRenderer content={settings.homepage_content || ''} useMarkdown />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||
|
||||
/**
|
||||
* 登录页(迁移自 login.html):
|
||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||
*/
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
captchaRequired('login')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
}, [navigate]);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('login');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.login(username.trim(), password, capResult || undefined);
|
||||
setToken(data.token);
|
||||
notifyAuthChange();
|
||||
const from = location.state && location.state.from;
|
||||
navigate(from || '/');
|
||||
} catch (e) {
|
||||
setError(e.message || '登录失败');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card login-card">
|
||||
<h1>登录</h1>
|
||||
<p className="subtitle">欢迎回到 RainWeb</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="输入用户名"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') document.getElementById('loginPw').focus(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id="loginPw"
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="输入密码"
|
||||
autoComplete="current-password"
|
||||
style={{ width: '100%', paddingRight: 44 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex="-1"
|
||||
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: 'var(--md-ref-on-surface-variant)', cursor: 'pointer', padding: 4, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{showPw ? 'visibility' : 'visibility_off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{capRequired && (
|
||||
<button
|
||||
className="btn w-full"
|
||||
onClick={doCaptcha}
|
||||
disabled={capDone}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||
: { background: '#fff', color: '#333', border: '1px solid #333' }),
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||||
{busy ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<span className="text-muted">没有账户?</span>
|
||||
<Link to="/register.html" className="btn-text btn" style={{ fontSize: 14 }}>注册</Link>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}>← 返回主页</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as pwApi from '../api/passwords.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
|
||||
* 可选长度与字符集,确保每个选中的字符集至少出现一次后洗牌。
|
||||
*/
|
||||
function makePassword({ length = 16, lower = true, upper = true, digits = true, symbols = true } = {}) {
|
||||
const sets = [];
|
||||
if (lower) sets.push('abcdefghijklmnopqrstuvwxyz');
|
||||
if (upper) sets.push('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
||||
if (digits) sets.push('0123456789');
|
||||
if (symbols) sets.push('!@#$%^&*()_+');
|
||||
if (sets.length === 0) return '';
|
||||
const all = sets.join('');
|
||||
const total = Math.max(length, sets.length);
|
||||
const rand = new Uint32Array(total);
|
||||
crypto.getRandomValues(rand);
|
||||
const chars = [];
|
||||
let idx = 0;
|
||||
// 每个选中字符集至少贡献一个字符,保证复杂度
|
||||
for (const s of sets) chars.push(s[rand[idx++] % s.length]);
|
||||
for (; idx < length; idx++) chars.push(all[rand[idx] % all.length]);
|
||||
// Fisher-Yates 洗牌(同样走 crypto)
|
||||
const shuf = new Uint32Array(chars.length);
|
||||
crypto.getRandomValues(shuf);
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = shuf[i] % (i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码箱(迁移自 passwords.html + passwords.js):
|
||||
* PIN 状态检查 → 未设 PIN 引导设置 / 已设解锁 / 解锁后条目列表 + 增删改 + 复制 + 生成器 + 锁定。
|
||||
* 全部交互用 React 数据绑定,无字符串拼接事件(修复 v1 M6 onclick 注入点)。
|
||||
* 注意:GET /api/passwords/ 返回的是会话解锁后服务端解密出的明文密码。
|
||||
*/
|
||||
export default function Passwords() {
|
||||
const navigate = useNavigate();
|
||||
const [phase, setPhase] = useState('loading'); // loading | setup | locked | unlocked
|
||||
const [entries, setEntries] = useState(null); // null=加载中
|
||||
const [entriesError, setEntriesError] = useState('');
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [pinBusy, setPinBusy] = useState(false);
|
||||
|
||||
// 设置/修改 PIN 弹窗
|
||||
const [pinDialog, setPinDialog] = useState(false);
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [confirmPin, setConfirmPin] = useState('');
|
||||
const [pinSaving, setPinSaving] = useState(false);
|
||||
|
||||
// 修改 PIN 警告确认(修改后旧密文无法解密,需重新添加条目)
|
||||
const [pinChangeWarn, setPinChangeWarn] = useState(false);
|
||||
|
||||
// 添加/编辑条目弹窗
|
||||
const [pwDialog, setPwDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [fTitle, setFTitle] = useState('');
|
||||
const [fUsername, setFUsername] = useState('');
|
||||
const [fPassword, setFPassword] = useState('');
|
||||
const [fUrl, setFUrl] = useState('');
|
||||
const [fNotes, setFNotes] = useState('');
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
// 详情 / 生成器 / 删除确认
|
||||
const [detailId, setDetailId] = useState(null);
|
||||
const [genDialog, setGenDialog] = useState(false);
|
||||
const [genLen, setGenLen] = useState(16);
|
||||
const [genLower, setGenLower] = useState(true);
|
||||
const [genUpper, setGenUpper] = useState(true);
|
||||
const [genDigits, setGenDigits] = useState(true);
|
||||
const [genSymbols, setGenSymbols] = useState(true);
|
||||
const [genResult, setGenResult] = useState('');
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { showSnackbar('请先登录'); setTimeout(() => navigate('/login.html'), 1000); return; }
|
||||
me()
|
||||
.then((u) => {
|
||||
if (u.role !== 'admin') {
|
||||
showSnackbar('需要管理员权限');
|
||||
setTimeout(() => navigate('/'), 1000);
|
||||
return;
|
||||
}
|
||||
pwApi.pinStatus()
|
||||
.then((s) => {
|
||||
if (!s.hasPin) setPhase('setup');
|
||||
else if (s.unlocked) { setPhase('unlocked'); loadEntries(); }
|
||||
else setPhase('locked');
|
||||
})
|
||||
.catch((e) => showSnackbar(e.message));
|
||||
})
|
||||
.catch(() => navigate('/login.html'));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadEntries = async () => {
|
||||
setEntries(null);
|
||||
setEntriesError('');
|
||||
try {
|
||||
const list = await pwApi.listEntries();
|
||||
setEntries(list || []);
|
||||
} catch (e) {
|
||||
setEntriesError(e.message || '加载失败');
|
||||
setEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
const savePin = async () => {
|
||||
// 校验文案与后端 setPin 保持一致(PIN ≥6 位,建议含字母;v1 的"至少4位"已修正)
|
||||
if (!newPin || newPin.length < 6) { showSnackbar('PIN 至少 6 位,建议包含字母'); return; }
|
||||
if (newPin !== confirmPin) { showSnackbar('两次输入的 PIN 不一致'); return; }
|
||||
setPinSaving(true);
|
||||
try {
|
||||
await pwApi.setPin(newPin);
|
||||
showSnackbar('PIN 设置成功');
|
||||
setPinDialog(false);
|
||||
setNewPin(''); setConfirmPin('');
|
||||
setPhase('unlocked');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPinSaving(false);
|
||||
};
|
||||
|
||||
const submitPin = async () => {
|
||||
if (!pinInput) { showSnackbar('请输入 PIN 码'); return; }
|
||||
setPinBusy(true);
|
||||
try {
|
||||
await pwApi.unlock(pinInput);
|
||||
showSnackbar('已解锁');
|
||||
setPinInput('');
|
||||
setPinError('');
|
||||
setPhase('unlocked');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
// 后端错误直显:401 'PIN 错误' / 429 '尝试次数过多,请稍后再试'(3 次/5 分钟锁定)
|
||||
setPinError(e.message || '解锁失败');
|
||||
setPinInput('');
|
||||
}
|
||||
setPinBusy(false);
|
||||
};
|
||||
|
||||
const lockVault = async () => {
|
||||
try {
|
||||
await pwApi.lock();
|
||||
setPhase('locked');
|
||||
setPinInput('');
|
||||
setPinError('');
|
||||
setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false); setPinChangeWarn(false);
|
||||
showSnackbar('已锁定');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const openPinDialog = () => {
|
||||
setNewPin('');
|
||||
setConfirmPin('');
|
||||
setPinDialog(true);
|
||||
};
|
||||
|
||||
// 修改 PIN:先弹警告确认,确认后再打开设置弹窗
|
||||
const openPinChange = () => {
|
||||
setPinChangeWarn(true);
|
||||
};
|
||||
|
||||
const proceedPinChange = () => {
|
||||
setPinChangeWarn(false);
|
||||
openPinDialog();
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setEditingId(null);
|
||||
setFTitle(''); setFUsername(''); setFPassword(''); setFUrl(''); setFNotes('');
|
||||
setPwDialog(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (entry) => {
|
||||
setEditingId(entry.id);
|
||||
setFTitle(entry.title);
|
||||
setFUsername(entry.username || '');
|
||||
setFPassword(entry.password || '');
|
||||
setFUrl(entry.url || '');
|
||||
setFNotes(entry.notes || '');
|
||||
setPwDialog(true);
|
||||
};
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!fTitle.trim() || !fPassword) { showSnackbar('标题和密码不能为空'); return; }
|
||||
const data = {
|
||||
title: fTitle.trim(),
|
||||
username: fUsername.trim(),
|
||||
password: fPassword,
|
||||
url: fUrl.trim(),
|
||||
notes: fNotes.trim(),
|
||||
};
|
||||
setPwSaving(true);
|
||||
try {
|
||||
if (editingId) await pwApi.updateEntry(editingId, data);
|
||||
else await pwApi.createEntry(data);
|
||||
showSnackbar('保存成功');
|
||||
setPwDialog(false);
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwSaving(false);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setConfirmBusy(true);
|
||||
try {
|
||||
await pwApi.deleteEntry(detailId);
|
||||
showSnackbar('已删除');
|
||||
setConfirmDialog(false);
|
||||
setDetailId(null);
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setConfirmBusy(false);
|
||||
};
|
||||
|
||||
const openGenerator = () => {
|
||||
setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }));
|
||||
setGenDialog(true);
|
||||
};
|
||||
|
||||
const useGenerated = () => {
|
||||
setFPassword(genResult);
|
||||
setGenDialog(false);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, label) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => showSnackbar(label + ' 已复制'))
|
||||
.catch(() => showSnackbar('复制失败'));
|
||||
};
|
||||
|
||||
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
|
||||
|
||||
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
|
||||
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
|
||||
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
|
||||
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
|
||||
|
||||
// 加载中
|
||||
if (phase === 'loading') {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
// 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={openPinChange}>
|
||||
<span className="material-icons">edit</span> 修改 PIN
|
||||
</button>
|
||||
<button className="btn btn-filled" onClick={openAddDialog}>
|
||||
<span className="material-icons">add</span> 添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries === null && <div className="loading"><div className="spinner"></div></div>}
|
||||
{entriesError && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败: {entriesError}</p></div>
|
||||
)}
|
||||
{entries && entries.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🔒</div><p>暂无密码记录</p></div>
|
||||
)}
|
||||
{entries && entries.length > 0 && (
|
||||
<div className="password-grid">
|
||||
{entries.map((p) => (
|
||||
<div key={p.id} className="password-card-wrap">
|
||||
<button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||
<div className="pw-title">{p.title}</div>
|
||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||
</button>
|
||||
<div className="pw-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ width: 40, height: 40, fontSize: 16 }}
|
||||
title="复制密码"
|
||||
aria-label={`复制 ${p.title} 的密码`}
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设置/修改 PIN 弹窗 */}
|
||||
{pinDialog && (
|
||||
<div
|
||||
ref={pinDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置 PIN 码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
|
||||
onKeyDown={pinDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 360 }}>
|
||||
<h3>设置 PIN 码</h3>
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>PIN 码用于加密保护您的密码数据。</p>
|
||||
<div className="form-group">
|
||||
<label>PIN 码 *(至少6位,建议包含字母)</label>
|
||||
<input type="password" value={newPin} onChange={(e) => setNewPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认 PIN 码 *</label>
|
||||
<input type="password" value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPinDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={savePin} disabled={pinSaving}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑条目弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editingId ? '编辑密码' : '添加密码'}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={fTitle} onChange={(e) => setFTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" value={fUsername} onChange={(e) => setFUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码 *</label>
|
||||
<input type="text" value={fPassword} onChange={(e) => setFPassword(e.target.value)} />
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button className="btn btn-text btn-sm" onClick={openGenerator}>生成随机密码</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>网址</label>
|
||||
<input type="url" value={fUrl} onChange={(e) => setFUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea value={fNotes} onChange={(e) => setFNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={savePassword} disabled={pwSaving}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detailEntry && (
|
||||
<div
|
||||
ref={detailDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={detailEntry.title}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
|
||||
onKeyDown={detailDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 500 }}>
|
||||
<h3>{detailEntry.title}</h3>
|
||||
<div className="password-detail">
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">用户名</span>
|
||||
<span className="pw-value">{detailEntry.username || ''}</span>
|
||||
<button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">密码</span>
|
||||
<span className="pw-value">{detailEntry.password}</span>
|
||||
<button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
{detailEntry.url ? (
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">网址</span>
|
||||
<span className="pw-value">
|
||||
<a href={detailEntry.url} target="_blank" rel="noopener" style={{ color: 'var(--md-ref-primary)' }}>{detailEntry.url}</a>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{detailEntry.notes ? (
|
||||
<div className="pw-field" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||
<span className="pw-label">备注</span>
|
||||
<span style={{ fontSize: 14 }}>{detailEntry.notes}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="actions" style={{ marginTop: 16 }}>
|
||||
<button className="btn btn-text" onClick={() => { setDetailId(null); openEditDialog(detailEntry); }}>编辑</button>
|
||||
<button className="btn btn-text" style={{ color: 'var(--md-ref-error)' }} onClick={() => { setDetailId(null); setConfirmDialog(true); }}>删除</button>
|
||||
<button className="btn btn-text" onClick={() => setDetailId(null)}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 密码生成器弹窗 */}
|
||||
{genDialog && (
|
||||
<div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
|
||||
<div className="dialog" style={{ maxWidth: 380 }}>
|
||||
<h3>生成随机密码</h3>
|
||||
<div className="form-group">
|
||||
<label>长度</label>
|
||||
<input type="number" min={6} max={64} value={genLen} onChange={(e) => setGenLen(parseInt(e.target.value, 10) || 16)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genLower} onChange={(e) => setGenLower(e.target.checked)} /> 小写</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genUpper} onChange={(e) => setGenUpper(e.target.checked)} /> 大写</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genDigits} onChange={(e) => setGenDigits(e.target.checked)} /> 数字</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genSymbols} onChange={(e) => setGenSymbols(e.target.checked)} /> 符号</label>
|
||||
</div>
|
||||
{genResult && (
|
||||
<div style={{ padding: 12, borderRadius: 8, background: 'var(--md-ref-surface-container)', fontFamily: 'monospace', fontSize: 16, textAlign: 'center', marginBottom: 12, wordBreak: 'break-all' }}>
|
||||
{genResult}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setGenDialog(false)}>取消</button>
|
||||
<button className="btn btn-text" onClick={() => setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }))}>重新生成</button>
|
||||
<button className="btn btn-filled" onClick={useGenerated} disabled={!genResult}>使用此密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改 PIN 警告确认弹窗 */}
|
||||
{pinChangeWarn && (
|
||||
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认修改 PIN</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>⚠️ 修改 PIN 后,现有密码条目将无法解密,需要重新添加。确定继续?</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPinChangeWarn(false)}>取消</button>
|
||||
<button className="btn btn-danger" onClick={proceedPinChange}>确定继续</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDialog && (
|
||||
<div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认操作</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录?</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setConfirmDialog(false)}>取消</button>
|
||||
<button className="btn btn-danger" onClick={confirmDelete} disabled={confirmBusy}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Placeholder({ name }) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card" style={{ textAlign: 'center', padding: '64px 16px' }}>
|
||||
<h2 style={{ marginBottom: '12px' }}>{name}</h2>
|
||||
<p style={{ color: 'var(--md-ref-on-surface-variant)' }}>建设中</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as profileApi from '../api/profile.js';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
||||
import { getToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||
|
||||
/** FileReader + Image 加载图片(头像裁剪前读取) */
|
||||
function loadImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = reject;
|
||||
i.src = r.result;
|
||||
};
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** 个人中心(迁移自 profile.html):资料 / 头像上传(512x512 裁剪)/ 邮箱状态 / 修改密码 / 退出 */
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
// 修改密码弹窗(两步:发送验证码 → 输入验证码/新旧密码)
|
||||
const [pwDialog, setPwDialog] = useState(false);
|
||||
const [pwStep, setPwStep] = useState(1);
|
||||
const [pwCode, setPwCode] = useState('');
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwBusy, setPwBusy] = useState(false);
|
||||
|
||||
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
|
||||
useEffect(() => {
|
||||
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
|
||||
}, [pwDialog, pwStep]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const u = await profileApi.getProfile();
|
||||
setUser(u);
|
||||
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
|
||||
const av = await avatarUrl(u.id);
|
||||
setAvatar(av.url || '');
|
||||
} catch (e) {
|
||||
setError(e.message || '加载失败');
|
||||
if (e.status === 401) { navigate('/login.html'); return; }
|
||||
}
|
||||
setLoading(false);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) { navigate('/login.html'); return; }
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
|
||||
const handleAvatarChange = async (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
// 客户端裁剪为 512x512(照 profile.html)
|
||||
const img = await loadImage(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, 512, 512);
|
||||
const s = Math.min(img.width, img.height);
|
||||
const sx = (img.width - s) / 2;
|
||||
const sy = (img.height - s) / 2;
|
||||
ctx.drawImage(img, sx, sy, s, s, 0, 0, 512, 512);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
||||
// 用 File 携带文件名,满足后端扩展名/MIME 校验
|
||||
const avatarFile = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
await uploadAvatar(avatarFile);
|
||||
showSnackbar('头像已更新');
|
||||
notifyAuthChange(); // 刷新全局登录态头像
|
||||
load();
|
||||
} catch (err) {
|
||||
showSnackbar(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const sendPwCode = async () => {
|
||||
setPwBusy(true);
|
||||
try {
|
||||
await profileApi.sendPwCode();
|
||||
showSnackbar('验证码已发送');
|
||||
setPwStep(2);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwBusy(false);
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!pwCode || pwCode.length !== 8) { showSnackbar('请输入完整的 8 位验证码'); return; }
|
||||
if (!oldPw || !newPw) { showSnackbar('请填写完整'); return; }
|
||||
if (newPw.length < 6) { showSnackbar('新密码至少6位'); return; }
|
||||
if (newPw !== confirmPw) { showSnackbar('两次密码不一致'); return; }
|
||||
setPwBusy(true);
|
||||
try {
|
||||
await profileApi.changePassword({ code: pwCode.trim(), oldPassword: oldPw, newPassword: newPw });
|
||||
showSnackbar('密码已修改');
|
||||
setPwDialog(false);
|
||||
setPwStep(1);
|
||||
setPwCode(''); setOldPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setPwBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<h1 className="page-title">个人中心</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
) : error || !user ? (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error || '加载失败'}</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="avatar" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 64, height: 64, borderRadius: '50%', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, fontWeight: 600 }}>
|
||||
{(user.username || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="更换头像"
|
||||
onClick={() => fileRef.current && fileRef.current.click()}
|
||||
style={{ position: 'absolute', bottom: -4, right: -4, width: 28, height: 28, background: 'var(--md-ref-surface-container)', fontSize: 14, boxShadow: '0 2px 8px var(--md-shadow)' }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>camera_alt</span>
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarChange} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 20, fontWeight: 600 }}>{user.username}</h3>
|
||||
<span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>邮箱(注册后不可修改)</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px', borderRadius: 12, background: 'var(--md-ref-surface-container)', fontSize: 15 }}>
|
||||
<span>{user.email || '未设置'}</span>
|
||||
{user.email_verified
|
||||
? <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)' }}>已验证</span>
|
||||
: <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-error)', color: 'var(--md-ref-on-error)' }}>未验证</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '24px 0' }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 12 }}>安全设置</h4>
|
||||
<button className="btn btn-outline" onClick={() => { setPwStep(1); setPwDialog(true); }}>修改密码</button>
|
||||
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 8 }} onClick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ textAlign: 'center', padding: 16, color: 'var(--md-ref-on-surface-variant)', fontSize: 13 }}>
|
||||
UID: {user.id} · 注册时间: {user.created_at}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="修改密码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>修改密码</h3>
|
||||
{pwStep === 1 ? (
|
||||
<>
|
||||
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>验证码将发送到您的注册邮箱</p>
|
||||
<button className="btn btn-filled w-full" onClick={sendPwCode} disabled={pwBusy}>
|
||||
{pwBusy ? '发送中...' : '发送验证码'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>验证码</label>
|
||||
<input type="text" value={pwCode} onChange={(e) => setPwCode(e.target.value)} placeholder="8 位验证码" maxLength={8} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>原密码</label>
|
||||
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>新密码(至少6位)</label>
|
||||
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
|
||||
<button className="btn btn-filled" onClick={changePassword} disabled={pwBusy}>确认修改</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import * as emailApi from '../api/email.js';
|
||||
import { required as captchaRequired } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 注册页(迁移自 register.html):
|
||||
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
||||
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
||||
*/
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [verifyMode, setVerifyMode] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [capRequired, setCapRequired] = useState(false);
|
||||
const [capDone, setCapDone] = useState(false);
|
||||
const [capResult, setCapResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||
captchaRequired('register')
|
||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||
.catch(() => {});
|
||||
}, [navigate]);
|
||||
|
||||
const doCaptcha = async () => {
|
||||
const result = await showCaptcha('register');
|
||||
if (result) { setCapResult(result); setCapDone(true); }
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
||||
if (password.length < 6) { setError('密码至少6位'); return; }
|
||||
if (password !== confirm) { setError('两次密码不一致'); return; }
|
||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await authApi.register({
|
||||
username: username.trim(),
|
||||
password,
|
||||
email: email.trim(),
|
||||
captcha: capResult || undefined,
|
||||
});
|
||||
if (data.requires_verification) {
|
||||
setVerifyMode(true); // 进入邮箱验证步骤
|
||||
} else {
|
||||
showSnackbar('注册成功,请登录');
|
||||
setTimeout(() => navigate('/login.html'), 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || '注册失败');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!code || code.length !== 8) { setError('请输入完整的 8 位验证码'); return; }
|
||||
setError('');
|
||||
try {
|
||||
await emailApi.completeRegister(code.trim(), username.trim(), password);
|
||||
showSnackbar('注册成功,请登录');
|
||||
setTimeout(() => navigate('/login.html'), 1000);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const resendCode = async () => {
|
||||
try {
|
||||
await emailApi.sendVerify(email.trim(), username.trim());
|
||||
showSnackbar('验证码已重新发送');
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 8 }}>
|
||||
{verifyMode ? '邮箱验证' : '创建账户'}
|
||||
</h1>
|
||||
<p className="subtitle" style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
|
||||
{verifyMode ? '请输入邮箱中的验证码' : '加入 RainWeb'}
|
||||
</p>
|
||||
|
||||
{!verifyMode && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>用户名 *</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="用户名" autoComplete="username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>邮箱 *</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" autoComplete="email" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码 *</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认密码 *</label>
|
||||
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="再次输入密码" autoComplete="new-password" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{verifyMode && (
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>邮箱验证码</label>
|
||||
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>验证码已发送到您的邮箱,请输入 8 位数字验证码</p>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="输入 8 位验证码"
|
||||
maxLength={8}
|
||||
inputMode="numeric"
|
||||
style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={handleVerify} disabled={busy}>验证并完成注册</button>
|
||||
<button className="btn btn-text btn-sm mt-16" onClick={resendCode} style={{ marginTop: 8 }}>重新发送验证码</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{!verifyMode && (
|
||||
<>
|
||||
{capRequired && (
|
||||
<button
|
||||
className="btn w-full"
|
||||
onClick={doCaptcha}
|
||||
disabled={capDone}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||
: { background: '#fff', color: '#333', border: '1px solid #333' }),
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-filled w-full" onClick={handleRegister} disabled={busy}>
|
||||
{busy ? '注册中...' : '注册'}
|
||||
</button>
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<span className="text-muted">已有账户?</span>
|
||||
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
|
||||
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}>← 返回主页</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { request } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
const THEMES = [
|
||||
{ color: '#6750a4', name: 'default', label: '默认' },
|
||||
{ color: '#0288d1', name: 'ocean', label: '海洋' },
|
||||
{ color: '#2e7d32', name: 'nature', label: '自然' },
|
||||
{ color: '#e65100', name: 'sunset', label: '日落' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 初始化向导(迁移自 setup.html + routes/setup.js 三步流程):
|
||||
* 1. 管理员密码(≥6 位、两次一致、不能等于 admin123)→ 2. 网站名称 + 主题色 → 3. 确认并完成。
|
||||
* 提交 POST /api/setup/complete;已完成初始化时提示并跳首页。
|
||||
*/
|
||||
export default function Setup() {
|
||||
const [status, setStatus] = useState(null); // null=检查中
|
||||
const [step, setStep] = useState(1);
|
||||
const [adminPw, setAdminPw] = useState('');
|
||||
const [adminPwConfirm, setAdminPwConfirm] = useState('');
|
||||
const [siteName, setSiteName] = useState('RainWeb');
|
||||
const [theme, setTheme] = useState({ color: '#6750a4', name: 'default', label: '默认' });
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request('/setup/status')
|
||||
.then((s) => setStatus(s || {}))
|
||||
.catch(() => setStatus({ setup_complete: true })); // 接口异常不阻塞,视为已完成
|
||||
}, []);
|
||||
|
||||
const next = (n) => {
|
||||
setError('');
|
||||
if (n === 2) {
|
||||
if (adminPw.length < 6) { setError('密码至少6位'); return; }
|
||||
if (adminPw !== adminPwConfirm) { setError('两次密码不一致'); return; }
|
||||
if (adminPw === 'admin123') { setError('新密码不能与默认密码相同'); return; }
|
||||
}
|
||||
setStep(n);
|
||||
};
|
||||
|
||||
const complete = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await request('/setup/complete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
password: adminPw,
|
||||
site_name: siteName.trim() || 'RainWeb',
|
||||
primary_color: theme.color,
|
||||
theme_preset: theme.name,
|
||||
},
|
||||
});
|
||||
showSnackbar('初始化完成!');
|
||||
setTimeout(() => { window.location.href = '/'; }, 1000);
|
||||
} catch (e) {
|
||||
setError(e.message || '初始化失败');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === null) {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
// 已完成初始化:提示并跳首页
|
||||
if (status.setup_complete) {
|
||||
return (
|
||||
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>✅ 已完成初始化</h1>
|
||||
<p className="text-muted" style={{ marginBottom: 24, fontSize: 14 }}>RainWeb 已初始化完成,可正常使用。</p>
|
||||
<a href="/" className="btn btn-filled w-full">进入首页</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stepDot = (n) => ({
|
||||
width: n === step ? 28 : 10,
|
||||
height: 10,
|
||||
borderRadius: n === step ? 5 : '50%',
|
||||
background: n < step ? 'var(--md-ref-primary-container)' : (n === step ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 480, padding: '40px 32px' }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 4 }}>🌧️ RainWeb</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
|
||||
欢迎使用!请完成以下初始化设置
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 24 }}>
|
||||
{[1, 2, 3].map((n) => <div key={n} style={stepDot(n)} />)}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>设置管理员密码</h3>
|
||||
<div className="form-group">
|
||||
<label>新密码 *</label>
|
||||
<input type="password" value={adminPw} onChange={(e) => setAdminPw(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>确认密码 *</label>
|
||||
<input type="password" value={adminPwConfirm} onChange={(e) => setAdminPwConfirm(e.target.value)} placeholder="再次输入" autoComplete="new-password" onKeyDown={(e) => { if (e.key === 'Enter') next(2); }} />
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={() => next(2)}>下一步</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>网站设置</h3>
|
||||
<div className="form-group">
|
||||
<label>网站名称</label>
|
||||
<input type="text" value={siteName} onChange={(e) => setSiteName(e.target.value)} placeholder="我的网站" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>选择主题色</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
||||
{THEMES.map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
onClick={() => setTheme({ color: t.color, name: t.name, label: t.label })}
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
border: '2px solid ' + (theme.name === t.name ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
background: theme.name === t.name ? 'var(--md-ref-primary-container)' : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', margin: '0 auto 8px', background: t.color }} />
|
||||
<div>{t.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={() => next(3)}>下一步</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>确认并完成</h3>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 14 }}>
|
||||
<div style={{ marginBottom: 8 }}><strong>管理员密码</strong>: 已设置</div>
|
||||
<div style={{ marginBottom: 8 }}><strong>网站名称</strong>: {siteName.trim() || 'RainWeb'}</div>
|
||||
<div><strong>主题色</strong>: {theme.label}</div>
|
||||
</div>
|
||||
<button className="btn btn-filled w-full" onClick={complete} disabled={busy}>
|
||||
{busy ? '处理中...' : '完成初始化'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginTop: 12 }}>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { getTagPosts } from '../api/blog.js';
|
||||
|
||||
/** 摘要:优先 excerpt,否则剥离 markdown 符号截取 */
|
||||
function excerptOf(p) {
|
||||
if (p.excerpt) return p.excerpt;
|
||||
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
|
||||
}
|
||||
|
||||
/** 标签页(路由 /tag/:name):该标签下的文章列表,复用博客卡片样式 */
|
||||
export default function Tag() {
|
||||
const { name } = useParams();
|
||||
const [posts, setPosts] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setPosts(null);
|
||||
setError('');
|
||||
getTagPosts(name)
|
||||
.then((ps) => setPosts(ps || []))
|
||||
.catch((e) => setError(e.message || '加载失败'));
|
||||
}, [name]);
|
||||
|
||||
const displayName = name || '';
|
||||
|
||||
return (
|
||||
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
|
||||
</Link>
|
||||
<h1 className="article-title" style={{ fontSize: 26 }}>
|
||||
标签:{displayName}
|
||||
{posts && <span className="tag-title-count">({posts.length} 篇)</span>}
|
||||
</h1>
|
||||
|
||||
{!posts && !error && (
|
||||
<div className="loading"><div className="spinner"></div></div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>{error}</p></div>
|
||||
)}
|
||||
{posts && posts.length === 0 && (
|
||||
<div className="empty-state"><div className="empty-icon">🏷️</div><p>该标签下暂无文章</p></div>
|
||||
)}
|
||||
{posts && posts.length > 0 && (
|
||||
<div className="blog-waterfall">
|
||||
{posts.map((p) => (
|
||||
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
|
||||
<div className="blog-featured"></div>
|
||||
<div className="blog-title">{p.title}</div>
|
||||
<div className="blog-excerpt">{excerptOf(p)}</div>
|
||||
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import * as blogApi from '../api/blog.js';
|
||||
import { uploadFile } from '../api/upload.js';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 写文章页(路由 /write.html;?edit=id 进入编辑模式):
|
||||
* 标题/摘要/内容(Markdown)/发布开关;附件上传用 uploadFile + [image:]/[file:] 标签插入;
|
||||
* 支持拖拽图片上传与预览(迁移自 write.html 内联脚本)。
|
||||
*/
|
||||
export default function Write() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const editId = searchParams.get('edit') ? parseInt(searchParams.get('edit'), 10) : null;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [excerpt, setExcerpt] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [published, setPublished] = useState(true);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
const fileRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
// 编辑模式:加载已有文章
|
||||
useEffect(() => {
|
||||
if (!editId) return;
|
||||
blogApi.getPost(editId)
|
||||
.then((p) => {
|
||||
setTitle(p.title);
|
||||
setExcerpt(p.excerpt || '');
|
||||
setTags(p.tags || '');
|
||||
setContent(p.content);
|
||||
setPublished(!!p.published);
|
||||
})
|
||||
.catch((e) => setLoadError(e.message || '加载文章失败'));
|
||||
}, [editId]);
|
||||
|
||||
const insertTag = (tag) => {
|
||||
setContent((c) => c + '\n' + tag + '\n');
|
||||
};
|
||||
|
||||
const doUpload = async (file) => {
|
||||
try {
|
||||
const data = await uploadFile(file);
|
||||
insertTag(data.tag);
|
||||
setUploadStatus('已插入: ' + data.tag);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (f) doUpload(f);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
if (contentRef.current) contentRef.current.style.borderColor = '';
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length === 0) { showSnackbar('请拖入图片文件'); return; }
|
||||
for (const f of files) await doUpload(f);
|
||||
if (contentRef.current) contentRef.current.focus();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||||
setSaving(true);
|
||||
const data = {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
excerpt: excerpt.trim(),
|
||||
tags: tags.trim(),
|
||||
published,
|
||||
use_markdown: 1,
|
||||
};
|
||||
try {
|
||||
let id = editId;
|
||||
if (editId) {
|
||||
await blogApi.updatePost(editId, data);
|
||||
showSnackbar('已更新');
|
||||
} else {
|
||||
const created = await blogApi.createPost(data);
|
||||
id = created.id;
|
||||
showSnackbar('已发布');
|
||||
}
|
||||
navigate('/blog/' + id);
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="write-page" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 16px' }}>
|
||||
<div className="write-header" style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>
|
||||
{editId ? '编辑文章' : '写文章'}
|
||||
</h1>
|
||||
<a href="/blog.html" className="btn btn-text btn-sm">
|
||||
<span className="material-icons">arrow_back</span> 返回博客
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
{loadError ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⚠️</div>
|
||||
<p>{loadError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="文章标题" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>摘要</label>
|
||||
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标签</label>
|
||||
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="多个标签用英文逗号分隔,如:技术,生活,随笔" />
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>多个标签用英文逗号分隔,用于标签云与标签页</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>内容 *</label>
|
||||
<textarea
|
||||
ref={contentRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{ minHeight: 350, fontFamily: 'monospace', fontSize: 14 }}
|
||||
placeholder="支持 Markdown 语法,拖入图片自动上传"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (contentRef.current) contentRef.current.style.borderColor = 'var(--md-ref-primary)';
|
||||
}}
|
||||
onDragLeave={() => { if (contentRef.current) contentRef.current.style.borderColor = ''; }}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
|
||||
<span className="material-icons">upload</span> 上传附件/图片
|
||||
</button>
|
||||
<button className="btn btn-text btn-sm" onClick={() => setPreview((v) => !v)}>
|
||||
<span className="material-icons">visibility</span> 预览
|
||||
</button>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto', fontSize: 14, fontWeight: 500, color: 'var(--md-ref-on-surface-variant)' }}>
|
||||
<input type="checkbox" checked={published} onChange={(e) => setPublished(e.target.checked)} /> 发布
|
||||
</label>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||||
{uploadStatus && (
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>{uploadStatus}</div>
|
||||
)}
|
||||
{preview && (
|
||||
<div style={{ padding: 16, background: 'var(--md-ref-surface-container-low)', borderRadius: 12, marginBottom: 16, maxHeight: 400, overflowY: 'auto' }}>
|
||||
<MarkdownRenderer content={content} useMarkdown />
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-filled" onClick={submit} disabled={saving}>
|
||||
{editId ? '保存修改' : '发布文章'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制
|
||||
// (public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类)
|
||||
const THEME_KEY = 'theme';
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
if (saved === 'dark' || saved === 'light') return saved;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
toggleTheme: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
|
||||
setTheme,
|
||||
}),
|
||||
[theme]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
|
||||
const SECRET = process.env.JWT_SECRET || 'rainweb-secret-key-2024';
|
||||
const SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
@@ -17,7 +18,9 @@ function authMiddleware(req, res, next) {
|
||||
}
|
||||
|
||||
function adminOnly(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
// 查库复查角色,防止 JWT 中过期/被篡改的角色信息直接放行
|
||||
const u = db.get('SELECT role FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!u || u.role !== 'admin') {
|
||||
return res.status(403).json({ error: '需要管理员权限' });
|
||||
}
|
||||
next();
|
||||
|
||||
Generated
+2462
-12
File diff suppressed because it is too large
Load Diff
+19
-3
@@ -1,21 +1,37 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "1.4.2",
|
||||
"version": "2.1.0",
|
||||
"description": "链接聚合管理平台",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js",
|
||||
"dev": "vite --config vite.config.js",
|
||||
"build": "vite build --config vite.config.js",
|
||||
"preview": "vite preview --config vite.config.js",
|
||||
"cli": "node cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^9.3.1",
|
||||
"@mui/material": "^9.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dompurify": "^3.4.13",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"marked": "^18.0.5",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"sql.js": "^1.11.0"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理面板 - RainWeb</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav-bar" id="mainNav"></nav>
|
||||
<main class="page page-wide">
|
||||
<div class="admin-header">
|
||||
<h2 id="adminTitle">管理面板</h2>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('panels')" id="tabPanelsBtn">面板</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('links')" id="tabLinksBtn">面板链接</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('settings')" id="tabSettingsBtn">站点设置</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('theme')" id="tabThemeBtn">主题</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('homepage')" id="tabHomepageBtn">首页</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('attachments')" id="tabAttachmentsBtn">附件</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('forum')" id="tabForumBtn">论坛</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('blog')" id="tabBlogBtn">博客</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('users')" id="tabUsersBtn">用户</button>
|
||||
<button class="btn btn-tonal btn-sm" onclick="switchTab('email')" id="tabEmailBtn">邮件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panels Dashboard -->
|
||||
<div id="tabPanels" class="tab-content">
|
||||
<div id="panelsGrid" class="admin-panels"></div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Links Management -->
|
||||
<div id="tabLinks" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>面板链接管理</h2><button class="btn btn-filled" onclick="openLinkDialog()"><span class="material-icons">add</span> 添加面板</button></div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>排序</th><th>标题</th><th>版本</th><th>URL</th><th>嵌入URL</th><th>分类</th><th style="width:120px">操作</th></tr></thead><tbody id="linksBody"><tr><td colspan="7" class="text-center text-muted">加载中...</td></tr></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- Site Settings -->
|
||||
<div id="tabSettings" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>基本设置</h3>
|
||||
<div class="form-group"><label>网站名称</label><input type="text" id="setSiteName" placeholder="显示在标题和导航栏"></div>
|
||||
<div class="form-group"><label>网站描述(SEO)</label><input type="text" id="setSiteDesc" placeholder="搜索引擎结果中显示的描述"></div>
|
||||
<div class="form-group"><label>网站域名</label><input type="url" id="setSiteUrl" placeholder="https://你的域名.com"></div>
|
||||
<div class="form-group"><label>网站图标 URL</label><input type="url" id="setSiteFavicon" placeholder="https://example.com/favicon.ico"></div>
|
||||
<button class="btn btn-filled" onclick="saveSettings()">保存基本设置</button>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card">
|
||||
<h3>验证码设置</h3>
|
||||
<div class="form-group">
|
||||
<label>验证码类型</label>
|
||||
<select id="captchaType" onchange="toggleCaptchaConfig()">
|
||||
<option value="none">不使用验证码</option>
|
||||
<option value="builtin">使用普通验证码(内置扭曲文字)</option>
|
||||
<option value="recaptcha">使用 Google reCAPTCHA V2</option>
|
||||
<option value="turnstile">使用 Cloudflare Turnstile</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="captchaScopeConfig" style="display:none">
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">登录验证</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="capLogin"> 启用</label>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">注册验证</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="capRegister"> 启用</label>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">发帖验证</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="capForum"> 启用</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recaptchaConfig" style="display:none">
|
||||
<h4>Google reCAPTCHA V2 配置</h4>
|
||||
<div class="form-group"><label>Site Key</label><input type="text" id="setRecaptchaSite" placeholder="6L..."></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="text" id="setRecaptchaSecret" placeholder="6L..."></div>
|
||||
</div>
|
||||
<div id="turnstileConfig" style="display:none">
|
||||
<h4>Cloudflare Turnstile 配置</h4>
|
||||
<div class="form-group"><label>Site Key</label><input type="text" id="setTurnstileSite" placeholder="0x4AAAA..."></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="text" id="setTurnstileSecret" placeholder="0x4AAAA..."></div>
|
||||
</div>
|
||||
<button class="btn btn-filled" onclick="saveSettings()">保存验证码设置</button>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card">
|
||||
<h3>系统更新</h3>
|
||||
<div id="updateInfo" style="margin-bottom:12px;font-size:14px">
|
||||
<span>当前版本: v<strong id="localVersion">-</strong></span>
|
||||
<span style="margin-left:16px">最新版本: v<strong id="remoteVersion">-</strong></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-outline" onclick="checkUpdate()"><span class="material-icons">refresh</span> 检查更新</button>
|
||||
<button class="btn btn-filled" id="updateBtn" style="display:none" onclick="runUpdate()"><span class="material-icons">download</span> 立即更新</button>
|
||||
</div>
|
||||
<div id="updateStatus" style="margin-top:12px;display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card">
|
||||
<h3>数据导入</h3>
|
||||
<p class="text-muted" style="font-size:14px;margin-bottom:12px">上传旧版 RainWeb 的 <code>data.db</code> 文件,将数据导入当前数据库(重复数据自动跳过)。</p>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="file" id="importDbFile" accept=".db" style="flex:1">
|
||||
<button class="btn btn-tonal" onclick="importDatabase()">导入</button>
|
||||
</div>
|
||||
<div id="importResult" style="margin-top:12px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<div id="tabTheme" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>主题色</h3>
|
||||
<div class="form-group"><label>主色调</label><input type="color" id="setPrimaryColor" class="color-input"></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>壁纸背景</h3>
|
||||
<div class="form-group"><label>上传图片</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="file" id="wallpaperFile" accept="image/*" style="flex:1">
|
||||
<button class="btn btn-tonal btn-sm" onclick="uploadWallpaper()">上传</button>
|
||||
</div>
|
||||
<div id="wallpaperPreview" style="margin-top:8px;display:none">
|
||||
<img id="wallpaperPreviewImg" style="width:100%;max-height:120px;object-fit:cover;border-radius:8px;border:1px solid var(--md-ref-outline-variant)">
|
||||
<div style="margin-top:4px;display:flex;gap:8px;align-items:center">
|
||||
<span id="wallpaperFilename" class="text-muted" style="font-size:13px"></span>
|
||||
<button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="removeWallpaper()">移除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>图片 URL</label><input type="url" id="setWallpaper" placeholder="https://example.com/wallpaper.jpg" oninput="previewWallpaperUrl(this.value)"></div>
|
||||
<div class="form-group"><label>缩放方式</label>
|
||||
<select id="setWallpaperScale">
|
||||
<option value="cover">cover - 覆盖填充</option>
|
||||
<option value="contain">contain - 完整显示</option>
|
||||
<option value="repeat">repeat - 平铺重复</option>
|
||||
<option value="stretch">stretch - 拉伸填充</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="uploadedWallpapers" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:8px;margin-bottom:12px"></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>样式设置</h3>
|
||||
<h4>导航栏样式</h4>
|
||||
<div class="toggle-group" id="navStyleGroup">
|
||||
<span class="toggle-btn active" data-val="default" onclick="setNavStyle('default')">默认</span>
|
||||
<span class="toggle-btn" data-val="glass" onclick="setNavStyle('glass')">磨砂玻璃</span>
|
||||
<span class="toggle-btn" data-val="capsule" onclick="setNavStyle('capsule')">胶囊</span>
|
||||
</div>
|
||||
<h4>卡片样式</h4>
|
||||
<div class="toggle-group" id="cardStyleGroup">
|
||||
<span class="toggle-btn active" data-val="default" onclick="setCardStyle('default')">默认</span>
|
||||
<span class="toggle-btn" data-val="glass" onclick="setCardStyle('glass')">磨砂玻璃</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>玻璃效果</h3>
|
||||
<div class="form-group"><label>模糊强度 (px)</label><input type="range" id="setGlassBlur" min="5" max="40" value="20" oninput="document.getElementById('blurVal').textContent=this.value+'px'"><span id="blurVal" class="text-muted" style="font-size:14px">20px</span></div>
|
||||
<div class="form-group"><label>透明度</label><input type="range" id="setGlassOpacity" min="0.1" max="0.95" step="0.05" value="0.6" oninput="document.getElementById('opacityVal').textContent=this.value"><span id="opacityVal" class="text-muted" style="font-size:14px">0.6</span></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>深色模式</h3>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:120px">强制深色模式</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="setForceDark"> 启用后用户无法切换为浅色</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-filled" onclick="saveThemeSettings()">保存</button>
|
||||
</div>
|
||||
|
||||
<!-- Homepage Settings -->
|
||||
<div id="tabHomepage" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>个人信息</h3>
|
||||
<div class="form-group"><label>头像 URL</label><input type="url" id="hpAvatar" placeholder="https://example.com/avatar.jpg"></div>
|
||||
<div class="form-group"><label>个人简介</label><textarea id="hpBio" style="min-height:60px" placeholder="一段简短的自我介绍"></textarea></div>
|
||||
<h4 style="font-size:14px;font-weight:500;margin:12px 0 8px;color:var(--md-ref-on-surface-variant)">联系链接(最多5个)</h4>
|
||||
<div id="contactsEditor" style="display:flex;flex-direction:column;gap:8px;margin-bottom:8px"></div>
|
||||
<button class="btn btn-text btn-sm" onclick="addContactRow({icon:'link',url:'',title:''})"><span class="material-icons" style="font-size:16px">add</span> 添加链接</button>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>主页内容</h3>
|
||||
<div class="form-group"><label>正文 (Markdown)</label>
|
||||
<textarea id="hpContent" style="min-height:300px;font-family:monospace" placeholder="支持 Markdown 语法和 [image:filename] 标签"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn btn-tonal btn-sm" onclick="uploadHomepageFile()"><span class="material-icons">upload</span> 上传附件</button>
|
||||
<span id="hpUploadStatus" class="text-muted" style="font-size:13px"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>音乐嵌入</h3>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:80px">启用音乐</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="musicEmbedEnabled"> 所有页面显示音乐播放器</label>
|
||||
</div>
|
||||
<div class="form-group"><label>嵌入代码</label>
|
||||
<textarea id="musicEmbedCode" style="min-height:80px;font-family:monospace;font-size:13px" placeholder="粘贴网易云音乐 iframe 代码"></textarea>
|
||||
</div>
|
||||
<div class="form-group"><label>显示位置</label>
|
||||
<select id="musicEmbedPosition">
|
||||
<option value="right">右下角</option>
|
||||
<option value="left">左下角</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:12px">
|
||||
<label style="margin:0;min-width:100px">自动收缩</label>
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center"><input type="checkbox" id="musicEmbedAutohide"> 无操作后缩小为图标</label>
|
||||
</div>
|
||||
<div class="form-group"><label>空闲超时(秒)</label>
|
||||
<input type="number" id="musicEmbedIdleTimeout" min="3" max="120" value="10" style="width:80px">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-filled" onclick="saveHomepage()">保存</button>
|
||||
</div>
|
||||
|
||||
<!-- Attachments Management -->
|
||||
<div id="tabAttachments" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>附件管理</h2></div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>ID</th><th>文件名</th><th>原始名</th><th>大小</th><th>类型</th><th>上传者</th><th>时间</th><th style="width:80px">操作</th></tr></thead><tbody id="attachmentsBody"></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- Forum Management -->
|
||||
<div id="tabForum" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>论坛管理</h2><button class="btn btn-filled" onclick="openForumCatDialog()"><span class="material-icons">add</span> 添加板块</button></div>
|
||||
<div id="forumCards" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Blog Management -->
|
||||
<div id="tabBlog" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>博客管理</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<a href="/write.html" class="btn btn-filled" target="_blank"><span class="material-icons">edit</span> 写文章</a>
|
||||
<button class="btn btn-outline" onclick="openBlogDialog()"><span class="material-icons">add</span> 快速添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card settings-card" style="max-width:640px;margin-bottom:16px">
|
||||
<h3>博客页面设置</h3>
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<label style="font-weight:400;gap:6px;display:flex;align-items:center;flex:1"><input type="checkbox" id="setBlogSidebar" checked> 博客页面左侧显示头像和简介</label>
|
||||
<button class="btn btn-filled btn-sm" onclick="saveBlogSidebar()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>标题</th><th>状态</th><th>Markdown</th><th>时间</th><th style="width:120px">操作</th></tr></thead><tbody id="blogBody"></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- User Management -->
|
||||
<div id="tabUsers" class="tab-content" style="display:none">
|
||||
<div class="admin-header"><h2>用户管理</h2><button class="btn btn-filled" onclick="openUserDialog()"><span class="material-icons">person_add</span> 添加用户</button></div>
|
||||
<div class="table-wrapper"><table><thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>验证</th><th>角色</th><th>注册时间</th><th style="width:80px">操作</th></tr></thead><tbody id="usersBody"></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<!-- Email Settings -->
|
||||
<div id="tabEmail" class="tab-content" style="display:none;max-width:640px">
|
||||
<div class="card settings-card">
|
||||
<h3>SMTP 服务器</h3>
|
||||
<div class="form-group"><label>SMTP 主机</label><input type="text" id="setSmtpHost" placeholder="smtp.example.com"></div>
|
||||
<div class="form-group"><label>端口</label><input type="number" id="setSmtpPort" placeholder="587"></div>
|
||||
<div class="form-group"><label>用户名</label><input type="text" id="setSmtpUser"></div>
|
||||
<div class="form-group"><label>密码</label><input type="password" id="setSmtpPass"></div>
|
||||
</div>
|
||||
<div class="card settings-card">
|
||||
<h3>发件人信息</h3>
|
||||
<div class="form-group"><label>发件人邮箱</label><input type="email" id="setSmtpFrom" placeholder="noreply@example.com"></div>
|
||||
<div class="form-group"><label>发件人名称</label><input type="text" id="setSmtpFromName" placeholder="RainWeb"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-filled" onclick="saveSmtpSettings()">保存邮件配置</button>
|
||||
<button class="btn btn-tonal" onclick="testSmtp()">发送测试邮件</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Dialogs: Link, Forum Cat, Blog, User -->
|
||||
<div class="dialog-overlay" id="linkDialog"><div class="dialog"><h3 id="linkDialogTitle">添加面板</h3><input type="hidden" id="linkId">
|
||||
<div class="form-group"><label>标题 *</label><input type="text" id="linkTitle"></div>
|
||||
<div class="form-group"><label>URL *</label><input type="url" id="linkUrl" placeholder="https://..."></div>
|
||||
<div class="form-group"><label>嵌入 URL(iframe嵌入用)</label><input type="url" id="linkEmbedUrl" placeholder="留空则新标签页打开"></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="linkProxy"> 通过代理嵌入(绕过 X-Frame-Options 限制)</label></div>
|
||||
<div class="form-group"><label>描述</label><input type="text" id="linkDesc"></div>
|
||||
<div class="form-group"><label>图标 (Material图标名)</label><input type="text" id="linkIcon" placeholder="settings, dashboard, ..."></div>
|
||||
<div class="form-group"><label>分类</label><input type="text" id="linkCategory" placeholder="默认"></div>
|
||||
<div class="form-group"><label>版本号(可选)</label><input type="text" id="linkVersion" placeholder="v2.1.0"></div>
|
||||
<div class="form-group"><label>排序</label><input type="number" id="linkSort" value="0"></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('linkDialog')">取消</button><button class="btn btn-filled" onclick="saveLink()">保存</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="forumCatDialog"><div class="dialog"><h3 id="forumCatDialogTitle">添加板块</h3><input type="hidden" id="forumCatId">
|
||||
<div class="form-group"><label>名称 *</label><input type="text" id="forumCatName"></div>
|
||||
<div class="form-group"><label>描述</label><input type="text" id="forumCatDesc"></div>
|
||||
<div class="form-group"><label>板块公告</label><textarea id="forumCatAnnounce" style="min-height:60px" placeholder="板块公告内容,留空不显示"></textarea></div>
|
||||
<div class="form-group"><label>帖子分类(逗号分隔)</label><input type="text" id="forumCatSubCats" placeholder="例如: 求助,分享,讨论,建议"></div>
|
||||
<div class="form-group"><label>排序</label><input type="number" id="forumCatSort" value="0"></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('forumCatDialog')">取消</button><button class="btn btn-filled" onclick="saveForumCat()">保存</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="blogDialog"><div class="dialog" style="max-width:640px"><h3 id="blogDialogTitle">写文章</h3><input type="hidden" id="blogId">
|
||||
<div class="form-group"><label>标题 *</label><input type="text" id="blogTitle"></div>
|
||||
<div class="form-group"><label>摘要</label><input type="text" id="blogExcerpt"></div>
|
||||
<div class="form-group"><label>内容 *</label><textarea id="blogContent" style="min-height:250px;font-family:monospace"></textarea></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
|
||||
<button class="btn btn-tonal btn-sm" onclick="uploadBlogFile()"><span class="material-icons">upload</span> 上传附件</button>
|
||||
<span id="blogUploadStatus" class="text-muted" style="font-size:13px"></span>
|
||||
</div>
|
||||
<div id="blogPreview" class="md-body" style="display:none;padding:16px;background:var(--md-ref-surface-container-low);border-radius:12px;margin-bottom:16px"></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="blogPublished" checked> 发布</label></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('blogDialog')">取消</button><button class="btn btn-filled" onclick="saveBlogPost()">保存</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="userDialog"><div class="dialog"><h3>添加用户</h3>
|
||||
<div class="form-group"><label>用户名 *</label><input type="text" id="newUsername"></div>
|
||||
<div class="form-group"><label>密码 *</label><input type="password" id="newPassword"></div>
|
||||
<div class="form-group"><label>角色</label><select id="newRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('userDialog')">取消</button><button class="btn btn-filled" onclick="saveUser()">创建</button></div></div></div>
|
||||
|
||||
<!-- Reset Password Dialog -->
|
||||
<div class="dialog-overlay" id="resetPwDialog"><div class="dialog"><h3>重置密码</h3><p id="resetPwUser" class="text-muted" style="margin-bottom:16px"></p>
|
||||
<div class="form-group"><label>新密码(至少6位)</label><input type="password" id="resetPwInput"></div>
|
||||
<div class="form-group"><label>确认新密码</label><input type="password" id="resetPwConfirm"></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('resetPwDialog')">取消</button><button class="btn btn-filled" onclick="confirmResetPw()">确认重置</button></div></div></div>
|
||||
|
||||
<!-- Change Role Dialog -->
|
||||
<div class="dialog-overlay" id="roleDialog"><div class="dialog"><h3>修改角色</h3><p id="roleUser" class="text-muted" style="margin-bottom:16px"></p>
|
||||
<div class="form-group"><label>角色</label>
|
||||
<select id="roleSelect"><option value="user">普通用户</option><option value="admin">管理员</option></select></div>
|
||||
<div class="actions"><button class="btn btn-text" onclick="closeDialog('roleDialog')">取消</button><button class="btn btn-filled" onclick="confirmRoleChange()">确认修改</button></div></div></div>
|
||||
|
||||
<div class="dialog-overlay" id="confirmDialog"><div class="dialog"><h3>确认操作</h3><p id="confirmMsg" style="margin-bottom:24px;font-size:16px"></p><div class="actions"><button class="btn btn-text" onclick="closeDialog('confirmDialog')">取消</button><button class="btn btn-danger" id="confirmBtn">确认删除</button></div></div></div>
|
||||
|
||||
|
||||
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/router.js"></script>
|
||||
<script src="/js/music-embed.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
<script>document.addEventListener('DOMContentLoaded',function(){if(window.ADMIN)ADMIN.init();});</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>博客 - RainWeb</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav-bar" id="mainNav"></nav>
|
||||
<main class="page">
|
||||
<div class="homepage-layout">
|
||||
<aside class="homepage-sidebar" id="blogSidebar">
|
||||
<div class="card" style="text-align:center">
|
||||
<div class="homepage-avatar" id="hpAvatarWrap">
|
||||
<img id="hpAvatar" src="" alt="avatar" style="display:none">
|
||||
<span id="hpAvatarPlaceholder" class="material-icons" style="font-size:64px;color:var(--md-ref-on-surface-variant)">person</span>
|
||||
</div>
|
||||
<div id="hpBio" class="homepage-bio"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="homepage-content">
|
||||
<div id="blogApp">
|
||||
<div id="blogList"><div class="loading"><div class="spinner"></div></div></div>
|
||||
<div id="blogDetail" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div id="musicEmbed"></div>
|
||||
<div id="snackbar" class="snackbar"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/router.js"></script>
|
||||
<script src="/js/music-embed.js"></script>
|
||||
<script src="/js/render.js"></script>
|
||||
<script src="/js/blog.js"></script>
|
||||
<script>document.addEventListener('DOMContentLoaded',function(){if(window.BLOG)BLOG.init();});</script>
|
||||
</body>
|
||||
</html>
|
||||
+1061
-17
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Binary file not shown.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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()); };
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -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());
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -1,60 +1,87 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const db = require('../db');
|
||||
const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth');
|
||||
const { consumeProof } = require('./captcha');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function checkCaptcha(action) {
|
||||
const type = db.getSetting('captcha_type') || 'none';
|
||||
if (type === 'none') return false;
|
||||
const setting = db.getSetting('captcha_' + action);
|
||||
return setting === '1';
|
||||
// 登录限流:15 分钟窗口内最多 10 次尝试
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: '尝试次数过多,请 15 分钟后再试' },
|
||||
});
|
||||
|
||||
// 校验并一次性消费验证码证明令牌:不存在、无效或已使用返回 false
|
||||
function validateCaptchaProof(proof) {
|
||||
return consumeProof(proof).ok;
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA V2 token
|
||||
async function verifyCaptcha(token) {
|
||||
const secret = db.getSetting('recaptcha_secret_key');
|
||||
if (!secret) return true; // captcha not configured, skip
|
||||
// 第三方验证码服务端校验(reCAPTCHA / Cloudflare Turnstile siteverify)
|
||||
async function verifyThirdParty(token, type) {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const https = require('https');
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
const qs = `secret=${encodeURIComponent(secret)}&response=${encodeURIComponent(token)}`;
|
||||
const req = https.request({ hostname: 'www.google.com', path: '/recaptcha/api/siteverify', method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }}, res => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => resolve(JSON.parse(body)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(qs);
|
||||
req.end();
|
||||
let url, secret;
|
||||
if (type === 'recaptcha') {
|
||||
url = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
secret = db.getSetting('recaptcha_secret_key');
|
||||
} else if (type === 'turnstile') {
|
||||
url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
secret = db.getSetting('turnstile_secret_key');
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (!secret) return false;
|
||||
const params = new URLSearchParams({ secret, response: token });
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
return data.success;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Verify captcha helper
|
||||
function verifyCaptchaToken(captcha_token, captcha_answer) {
|
||||
if (!captcha_token || !captcha_answer) return false;
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
// Check reCAPTCHA
|
||||
const recaptchaSecret = db.getSetting('recaptcha_secret_key');
|
||||
if (recaptchaSecret) {
|
||||
// Async verification for reCAPTCHA is handled differently
|
||||
// For built-in captcha, we verify against our store
|
||||
const data = await res.json().catch(() => null);
|
||||
return !!(data && data.success);
|
||||
} catch (e) {
|
||||
console.error('Third-party captcha verify error:', e.message);
|
||||
return false;
|
||||
}
|
||||
// Built-in captcha: verify against stored entries
|
||||
// This is simplified - in production use a verified token approach
|
||||
return true; // Will be verified by the verify endpoint flow
|
||||
}
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
// 统一验证码校验(供 login/register/forum 共用):
|
||||
// 按 captcha_type 与 captcha_<action> 设置判断;builtin/both 校验 captcha_proof,
|
||||
// recaptcha/turnstile/both 校验 recaptcha_token/turnstile_token(siteverify 直验);
|
||||
// both 模式:任一通过即可。
|
||||
async function resolveCaptcha(req, action) {
|
||||
const type = db.getSetting('captcha_type') || 'none';
|
||||
if (type === 'none') return true;
|
||||
if (db.getSetting('captcha_' + action) !== '1') return true;
|
||||
|
||||
if (type === 'builtin' || type === 'both') {
|
||||
if (validateCaptchaProof(req.body.captcha_proof)) return true;
|
||||
}
|
||||
if (type === 'recaptcha' || type === 'both') {
|
||||
if (await verifyThirdParty(req.body.recaptcha_token, 'recaptcha')) return true;
|
||||
}
|
||||
if (type === 'turnstile' || type === 'both') {
|
||||
if (await verifyThirdParty(req.body.turnstile_token, 'turnstile')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' });
|
||||
|
||||
// 服务端强制验证码校验:开启后必须通过内置 proof 或第三方 token 其一
|
||||
if (!(await resolveCaptcha(req, 'login'))) {
|
||||
return res.status(400).json({ error: '请先完成验证码验证' });
|
||||
}
|
||||
|
||||
const user = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
@@ -65,10 +92,14 @@ router.post('/login', (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
const { username, password, email, captcha_token } = req.body;
|
||||
const { username, password, email } = req.body;
|
||||
if (!username || !password || !email)
|
||||
return res.status(400).json({ error: '请填写所有必填项' });
|
||||
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
|
||||
// 服务端强制验证码校验
|
||||
if (!(await resolveCaptcha(req, 'register'))) {
|
||||
return res.status(400).json({ error: '请先完成验证码验证' });
|
||||
}
|
||||
if (db.get('SELECT id FROM users WHERE username = ?', [username]))
|
||||
return res.status(400).json({ error: '用户名已存在' });
|
||||
if (db.get('SELECT id FROM users WHERE email = ?', [email]))
|
||||
@@ -77,7 +108,7 @@ router.post('/register', async (req, res) => {
|
||||
const smtpHost = db.getSetting('smtp_host');
|
||||
if (smtpHost) {
|
||||
// Email verification flow - 8-digit code
|
||||
const code = Math.floor(10000000 + Math.random() * 90000000).toString();
|
||||
const code = crypto.randomInt(10000000, 100000000).toString();
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
db.run('DELETE FROM pending_users WHERE email = ?', [email]);
|
||||
db.run('INSERT INTO pending_users (username, password, email, token) VALUES (?, ?, ?, ?)',
|
||||
@@ -175,3 +206,4 @@ router.put('/users/:id/role', authMiddleware, adminOnly, (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.resolveCaptcha = resolveCaptcha;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user