10 Commits
Author SHA1 Message Date
miaomiao e7f8b3f173 v1.4.2: PJAX router, auto-hide music embed, update script fix
- PJAX navigation: nav clicks fetch & replace <main> seamlessly
- router.js dynamically loads page-specific JS (blog/forum/admin)
- Music embed: global on/off toggle, hover-based auto-collapse
- Update script restored to download-based (no git dependency)
- All PJAX pages have #musicEmbed container (index, blog, forum, admin, passwords)
- Version bump to 1.4.2
2026-07-02 02:22:09 +08:00
miaomiao a30a628772 fix: add musicEmbed container to admin.html for PJAX persistence 2026-07-02 02:09:32 +08:00
miaomiao cd290ff237 PJAX router + music embed with auto-hide
- router.js: intercept nav clicks, fetch page via AJAX, replace <main> content
- Blog/Forum/Admin/Homepage init functions extracted for PJAX re-init
- music-embed.js: hover-based auto-hide (leave → 10s timer → collapse)
- Settings: music_embed_code, position, pages, autohide, idle_timeout
- New keys replace old homepage_music_embed/position (auto-migrated)
2026-07-02 02:00:49 +08:00
miaomiao f7f30af569 v1.4.1: theme color refactor, UI restructuring, markdown improvements 2026-07-01 23:46:01 +08:00
miaomiao 4465130e60 fix: update download URL follows redirects, use codeload directly 2026-07-01 20:05:28 +08:00
miaomiao bd0c0442ab feat: add public settings endpoint and update settings retrieval in frontend 2026-07-01 19:41:06 +08:00
shanshuilala b80c5a1277 v1.3.1 2026-06-21 18:07:03 +08:00
shanshuilala 73e145df07 v1.1.1: captcha refactor, Turnstile support, SEO, forum boards, icon fixes 2026-06-21 18:05:05 +08:00
shanshuilala 6ffbf19aff Refactor forum.js for improved post and category handling; add sub-category support and enhance captcha verification in login and registration; implement update check and run functionality in server.js; improve captcha generation with noise and difficulty adjustments. 2026-06-21 00:54:49 +08:00
shanshuilala 2e6eeadfc1 feat: 增加数据库导入功能,支持从旧版 RainWeb 导入数据,优化验证码处理逻辑 2026-06-20 21:57:04 +08:00
39 changed files with 2274 additions and 609 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1.1.0
1.4.2
+14 -2
View File
@@ -263,7 +263,7 @@ async function cmdRestart() {
// === Upgrade ===
const REPO_URL = 'https://github.com/Xianyunah/rainwebblog.git';
const REPO_ZIP = 'https://github.com/Xianyunah/rainwebblog/archive/refs/heads/main.zip';
const REPO_ZIP = 'https://codeload.github.com/Xianyunah/rainwebblog/zip/refs/heads/master';
async function cmdUpgrade() {
console.log('=== RainWeb Upgrade ===\n');
@@ -294,7 +294,19 @@ async function cmdUpgrade() {
const https = require('https');
const fs = require('fs');
const f = fs.createWriteStream('${zipPath.replace(/\\/g, '/')}');
https.get('${REPO_ZIP}', r => r.pipe(f));
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 });
}
BIN
View File
Binary file not shown.
+63 -18
View File
@@ -4,10 +4,15 @@ const path = require('path');
const bcrypt = require('bcryptjs');
const DB_PATH = path.join(__dirname, 'data', 'rainweb.db');
const DATA_DIR = path.dirname(DB_PATH);
let db = null;
async function getDb() {
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);
@@ -25,8 +30,13 @@ async function getDb() {
function saveDb() {
if (!db) return;
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
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() {
@@ -59,17 +69,22 @@ function initTables() {
db.run(`CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)`);
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0,
announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`);
try { db.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 {}
db.run(`CREATE TABLE IF NOT EXISTS forum_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL,
title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL,
use_markdown INTEGER DEFAULT 1,
use_markdown INTEGER DEFAULT 1, sub_category TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (category_id) REFERENCES forum_categories(id),
FOREIGN KEY (author_id) REFERENCES users(id))`);
try { db.run('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
try { db.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 {}
db.run(`CREATE TABLE IF NOT EXISTS blog_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
@@ -122,6 +137,12 @@ function initTables() {
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)');
// 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 {}
}
function seedAdmin() {
@@ -136,9 +157,12 @@ function seedDefaults() {
const defaults = {
site_name: 'RainWeb',
site_description: '个人云平台',
site_url: '',
primary_color: '#6750a4',
recaptcha_site_key: '',
recaptcha_secret_key: '',
turnstile_site_key: '',
turnstile_secret_key: '',
smtp_host: '',
smtp_port: '587',
smtp_user: '',
@@ -155,6 +179,12 @@ function seedDefaults() {
captcha_register: '0',
captcha_forum: '0',
captcha_type: 'builtin',
site_favicon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>',
music_embed_enabled: '0',
music_embed_code: '',
music_embed_position: 'right',
music_embed_autohide: '0',
music_embed_idle_timeout: '10',
};
for (const [k, v] of Object.entries(defaults)) {
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
@@ -164,24 +194,39 @@ function seedDefaults() {
}
function run(sql, params = []) {
db.run(sql, params);
const r = db.exec("SELECT last_insert_rowid()");
const rowid = r && r[0] && r[0].values ? r[0].values[0][0] : 0;
saveDb();
return rowid;
try {
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;
} catch (e) {
console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80));
return 0;
}
}
function get(sql, params = []) {
const stmt = db.prepare(sql); stmt.bind(params);
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
stmt.free(); return null;
try {
const stmt = db.prepare(sql); stmt.bind(params);
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
stmt.free(); return null;
} catch (e) {
console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80));
return null;
}
}
function all(sql, params = []) {
const stmt = db.prepare(sql); stmt.bind(params);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); return rows;
try {
const stmt = db.prepare(sql); stmt.bind(params);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); return rows;
} catch (e) {
console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80));
return [];
}
}
function getSetting(key) {
@@ -208,8 +253,8 @@ function seedSampleData() {
if (adminUser) {
const aid = adminUser.id;
// Forum posts
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid]);
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid]);
run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid, '分享']);
run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid, '讨论']);
// Blog posts
const blogMarkdown = `## 欢迎使用 RainWeb\n\nRainWeb 是一个多功能的个人云平台,集成了 **博客、论坛、密码管理器** 等功能。\n\n- 🎨 Material Design 3 风格\n- 🌓 深色/浅色主题切换\n- 📧 邮箱验证注册\n- 🔒 密码管理器 (AES-256-GCM 加密)\n\n### 快速开始\n\n1. 点击右上角「登录」使用默认账号 \`admin / admin123\`\n2. 在管理后台配置 SMTP 邮件和 reCAPTCHA\n3. 在「面板链接」中添加你的各个管理后台\n4. 发布你的第一篇博客文章!`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "rainweb-links",
"version": "1.0.0",
"version": "1.4.2",
"description": "链接聚合管理平台",
"main": "server.js",
"scripts": {
+199 -83
View File
@@ -17,6 +17,7 @@
<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>
@@ -37,84 +38,183 @@
</div>
<!-- Site Settings -->
<div id="tabSettings" class="tab-content settings-section" style="display:none">
<h3>基本设置</h3>
<div class="form-group"><label>网站名称</label><input type="text" id="setSiteName"></div>
<div class="form-group"><label>网站描述</label><input type="text" id="setSiteDesc"></div>
<div class="form-group"><label>主题色</label><input type="color" id="setPrimaryColor" class="color-input"></div>
<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>
</select>
<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 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 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 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 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 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 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 id="recaptchaConfig" style="display:none">
<h3>Google reCAPTCHA V2 配置</h3>
<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 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>
<button class="btn btn-filled" onclick="saveSettings()">保存设置</button>
</div>
<!-- Theme Settings -->
<div id="tabTheme" class="tab-content settings-section" style="display:none">
<div class="form-group"><label>主题色</label><input type="color" id="setPrimaryColor" class="color-input"></div>
<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 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 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 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="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 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>
<div id="uploadedWallpapers" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:8px;margin-bottom:12px"></div>
<h3>导航栏样式</h3>
<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>
<h3>卡片样式</h3>
<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>
<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>
<button class="btn btn-filled" onclick="saveThemeSettings()">保存</button>
<button class="btn btn-filled" onclick="saveHomepage()">保存</button>
</div>
<!-- Attachments Management -->
@@ -125,19 +225,25 @@
<!-- 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 class="table-wrapper" style="margin-bottom:24px"><table><thead><tr><th>分类</th><th>描述</th><th>排序</th><th style="width:100px">操作</th></tr></thead><tbody id="forumCatsBody"></tbody></table></div>
<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 id="forumPostsBody"></tbody></table></div>
<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 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>
@@ -148,14 +254,19 @@
</div>
<!-- Email Settings -->
<div id="tabEmail" class="tab-content settings-section" style="display:none">
<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 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 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>
@@ -176,9 +287,11 @@
<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="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>
@@ -190,7 +303,6 @@
<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 style="margin-bottom:16px"><button class="btn btn-text btn-sm" onclick="previewMarkdown()">预览</button></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>
@@ -217,12 +329,16 @@
<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>
+22 -4
View File
@@ -9,17 +9,35 @@
</head>
<body>
<nav class="nav-bar" id="mainNav"></nav>
<main class="page" style="max-width:720px">
<div id="blogApp">
<div id="blogList"><div class="loading"><div class="spinner"></div></div></div>
<div id="blogDetail" style="display:none"></div>
<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>
+218 -5
View File
@@ -225,12 +225,24 @@ button:active, .btn:active {
background: var(--md-ref-primary-container);
}
/* Material Icons - ensure perfect centering */
.material-icons {
vertical-align: middle;
line-height: 1;
font-size: 24px;
display: inline-block;
}
.btn-icon {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
background: transparent;
color: var(--md-ref-on-surface-variant);
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--md-ref-surface-variant);
@@ -315,7 +327,7 @@ input, select, textarea {
input:focus, select:focus, textarea:focus {
border-color: var(--md-ref-primary);
box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.15);
box-shadow: 0 0 0 3px rgba(var(--md-ref-primary-rgb, 103, 80, 164), 0.15);
}
textarea {
@@ -1330,8 +1342,20 @@ table tr:hover td {
border-radius: 12px;
overflow-x: auto;
margin: 16px 0;
font-size: 13px;
line-height: 1.5;
tab-size: 2;
}
.md-body pre code { background: none; padding: 0; }
.md-body pre code { background: none; padding: 0; font-size: inherit; }
.md-body pre::-webkit-scrollbar { height: 4px; }
.md-body pre::-webkit-scrollbar-thumb { background: var(--md-ref-outline-variant); border-radius: 2px; }
.md-body table { width: 100%; border-collapse: collapse; margin: 16px 0; }
.md-body th, .md-body td { padding: 8px 12px; border: 1px solid var(--md-ref-outline-variant); text-align: left; }
.md-body th { background: var(--md-ref-surface-container); font-weight: 600; }
.md-body tr:nth-child(even) td { background: var(--md-ref-surface-container-low); }
.md-body ul.task-list { list-style: none; padding-left: 0; }
.md-body .task-list-item { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
.md-body .task-list-item input[type="checkbox"] { pointer-events: none; }
.md-body blockquote {
border-left: 3px solid var(--md-ref-primary);
padding: 8px 16px;
@@ -1343,9 +1367,6 @@ table tr:hover td {
.md-body a { color: var(--md-ref-primary); }
.md-body img { max-width: 100%; border-radius: 8px; margin: 12px 0; }
.md-body hr { border: none; border-top: 1px solid var(--md-ref-outline-variant); margin: 24px 0; }
.md-body table { width: 100%; border-collapse: collapse; margin: 16px 0; }
.md-body th, .md-body td { padding: 8px 12px; border: 1px solid var(--md-ref-outline-variant); text-align: left; }
.md-body th { background: var(--md-ref-surface-container); font-weight: 600; }
/* ===== Blog Detail ===== */
.blog-article { max-width: 720px; margin: 0 auto; }
@@ -1390,6 +1411,10 @@ table tr:hover td {
/* ===== Settings Page ===== */
.settings-section { max-width: 600px; }
.settings-section h3 { font-size: 18px; font-weight: 500; margin: 24px 0 16px; padding-bottom: 8px; border-bottom: 1px solid var(--md-ref-outline-variant); }
.settings-card { margin-bottom: 20px; }
.settings-card h3 { font-size: 17px; font-weight: 600; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--md-ref-outline-variant); }
.settings-card h4 { font-size: 14px; font-weight: 500; margin: 16px 0 8px; color: var(--md-ref-on-surface-variant); }
.settings-card .btn-filled { margin-top: 4px; }
.color-input { width: 48px; height: 48px; padding: 4px; border-radius: 12px; cursor: pointer; }
/* ===== Markdown toggle ===== */
@@ -1555,3 +1580,191 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
.blog-waterfall .blog-card { padding: 16px; }
.nav-tab { font-size: 13px; padding: 4px 10px; }
}
/* ===== Homepage Layout ===== */
.homepage-layout {
display: flex;
gap: 24px;
align-items: flex-start;
}
.homepage-sidebar {
flex: 0 0 240px;
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 88px;
}
.homepage-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
overflow: hidden;
background: var(--md-ref-surface-container);
border: 3px solid var(--md-ref-primary);
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
}
.homepage-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.homepage-bio {
font-size: 14px;
color: var(--md-ref-on-surface-variant);
line-height: 1.6;
white-space: pre-wrap;
}
.homepage-contacts {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
flex-wrap: wrap;
}
.hp-contact-link {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--md-ref-surface-container);
color: var(--md-ref-on-surface-variant);
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
transition: all 0.2s;
}
.hp-contact-link {
position: relative;
}
.hp-contact-link:hover {
background: var(--md-ref-primary-container);
color: var(--md-ref-on-primary-container);
transform: translateY(-2px);
}
.hp-contact-link .material-icons {
font-size: 18px;
}
/* Tooltip */
.hp-contact-link::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) scale(0.8);
background: var(--md-ref-inverse-surface);
color: var(--md-ref-inverse-on-surface);
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: all 0.2s;
}
.hp-contact-link:hover::after {
opacity: 1;
transform: translateX(-50%) scale(1);
}
.homepage-content {
flex: 1;
min-width: 0;
font-size: 16px;
line-height: 1.8;
}
.homepage-recent-header {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
color: var(--md-ref-on-surface);
}
.homepage-recent-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.homepage-recent-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
text-decoration: none;
border-bottom: 1px solid var(--md-ref-outline-variant);
transition: opacity 0.2s;
}
.homepage-recent-item:last-child {
border-bottom: none;
}
.homepage-recent-item:hover {
opacity: 0.7;
}
.homepage-recent-title {
font-size: 14px;
font-weight: 500;
color: var(--md-ref-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.homepage-recent-date {
font-size: 12px;
color: var(--md-ref-on-surface-variant);
}
@media (max-width: 768px) {
.homepage-layout {
flex-direction: column;
align-items: center;
}
.homepage-sidebar {
flex: none;
width: 100%;
position: static;
}
.homepage-avatar {
width: 80px;
height: 80px;
}
}
/* ===== Music Embed ===== */
.music-embed {
position: fixed;
bottom: 24px;
z-index: 100;
line-height: 0;
transition: all 0.3s ease;
}
.music-embed.right { right: 24px; }
.music-embed.left { left: 24px; }
.music-embed iframe {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.music-embed.collapsed {
width: 48px;
height: 48px;
}
.music-embed.collapsed .music-embed-player { display: none; }
.music-embed.collapsed .music-embed-icon {
display: flex !important;
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--md-ref-primary-container);
color: var(--md-ref-on-primary-container);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: background 0.2s;
}
.music-embed.collapsed .music-embed-icon:hover {
background: var(--md-ref-primary);
color: var(--md-ref-on-primary);
}
+132
View File
@@ -0,0 +1,132 @@
<!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>
+14 -3
View File
@@ -14,32 +14,43 @@
<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>
<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="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 class="actions">
<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>
+79 -7
View File
@@ -3,26 +3,98 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RainWeb</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🌧</text></svg>">
<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 id="blogHeader" style="margin-bottom:24px">
<h1 class="page-title" id="pageTitle">博客</h1>
<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>
<div id="blogList" class="blog-waterfall"></div>
<div id="blogDetail" style="display:none"></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/main.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>
+239 -58
View File
@@ -23,10 +23,10 @@ async function checkAuth() {
function switchTab(tab) {
currentTab = tab;
document.getElementById('adminTitle').textContent =
({ panels: '管理面板', links: '面板链接', settings: '站点设置', forum: '论坛管理', blog: '博客管理', users: '用户管理', email: '邮件配置' })[tab] || '管理面板';
({ 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, attachments: loadAttachments, forum: () => { loadForumCats(); loadForumPosts(); }, blog: loadBlogPosts, users: loadUsers, email: loadEmailSettings };
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]();
}
@@ -124,10 +124,12 @@ async function loadSettings() {
const s = await API.getSettings();
document.getElementById('setSiteName').value = s.site_name || '';
document.getElementById('setSiteDesc').value = s.site_description || '';
document.getElementById('setPrimaryColor').value = s.primary_color || '#6750a4';
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 = '';
// Captcha settings
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';
@@ -140,9 +142,12 @@ async function saveSettings() {
await API.saveSettings({
site_name: document.getElementById('setSiteName').value.trim(),
site_description: document.getElementById('setSiteDesc').value.trim(),
primary_color: document.getElementById('setPrimaryColor').value,
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',
@@ -165,6 +170,7 @@ async function loadThemeSettings() {
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); }
@@ -237,6 +243,7 @@ async function saveThemeSettings() {
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,
@@ -330,27 +337,6 @@ async function uploadWallpaper() {
} catch (e) { showSnackbar(e.message); }
}
async function saveThemeSettings() {
try {
const data = {
theme_preset: window._selectedPreset || 'default',
theme_wallpaper: document.getElementById('setWallpaper').value.trim(),
theme_wallpaper_scale: document.getElementById('setWallpaperScale').value,
nav_style: window._navStyle || 'default',
card_style: window._cardStyle || 'default',
glass_blur: document.getElementById('setGlassBlur').value,
glass_opacity: document.getElementById('setGlassOpacity').value,
};
// Also include primary_color if changed by preset
const colorInput = document.getElementById('setPrimaryColor');
if (colorInput) data.primary_color = colorInput.value;
await API.saveSettings(data);
showSnackbar('主题设置已保存');
if (window.NAV) NAV.init(); // refresh
} catch (e) { showSnackbar(e.message); }
}
// === Background Color ===
function applyBgColor(color) {
document.body.style.setProperty('--md-ref-background', color);
@@ -362,6 +348,7 @@ 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 ===
@@ -395,50 +382,171 @@ async function testSmtp() {
} catch (e) { showSnackbar(e.message); }
}
// === Forum ===
async function loadForumCats() {
const tbody = document.getElementById('forumCatsBody');
try {
const cats = await API.getForumCategories();
tbody.innerHTML = cats.length === 0 ? '<tr><td colspan="4" class="text-center text-muted">暂无分类</td></tr>' :
cats.map(c => `<tr><td><strong>${escapeHtml(c.name)}</strong></td><td class="text-muted">${escapeHtml(c.description||'')}</td><td>${c.sort_order}</td>
<td><button class="btn btn-text btn-sm" onclick="editForumCat(${c.id})">编辑</button><button class="btn btn-text btn-sm" style="color:var(--md-ref-error)" onclick="confirmDelete('forumcat',${c.id},'${escapeHtml(c.name)}')">删除</button></td></tr>`).join('');
} catch (e) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">加载失败</td></tr>'; }
// === 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: '' });
}
}
async function loadForumPosts() {
const tbody = document.getElementById('forumPostsBody');
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 posts = await API.getForumPosts();
const cats = await API.getForumCategories();
const catMap = {}; cats.forEach(c => catMap[c.id] = c.name);
tbody.innerHTML = posts.length === 0 ? '<tr><td colspan="6" 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">${escapeHtml(catMap[p.category_id]||'')}</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="confirmDelete('forumpost',${p.id},'${escapeHtml(p.title)}')">删除</button></td></tr>`).join('');
} catch (e) { tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">加载失败</td></tr>'; }
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 ? '编辑分类' : '添加分类';
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(), sort_order: parseInt(document.getElementById('forumCatSort').value) || 0 };
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'); loadForumCats();
} catch (e) { showSnackbar(e.message); }
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>' :
@@ -581,6 +689,78 @@ async function uploadBlogFile() {
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) {
@@ -601,18 +781,19 @@ async function executeDelete() {
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') { loadForumCats(); loadForumPosts(); }
else if (type === 'forumcat' || type === 'forumpost') { loadForumCards(); }
else if (type === 'blog') loadBlogPosts();
else if (type === 'user') loadUsers();
} catch (e) { showSnackbar(e.message); }
}
// === Init ===
document.addEventListener('DOMContentLoaded', async () => {
const user = await checkAuth();
if (user) {
// Auto-switch to tab from URL query
const tabMatch = location.search.match(/tab=(\w+)/);
switchTab(tabMatch ? tabMatch[1] : 'panels');
var ADMIN = {
init: async function () {
var user = await checkAuth();
if (user) {
var tabMatch = location.search.match(/tab=(\w+)/);
switchTab(tabMatch ? tabMatch[1] : 'panels');
}
}
});
};
+1
View File
@@ -31,6 +31,7 @@ const API = {
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); },
+24 -7
View File
@@ -1,7 +1,19 @@
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
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() {
@@ -18,7 +30,7 @@ async function loadPosts() {
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.slice(0, 100))}</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>';
@@ -42,11 +54,16 @@ async function viewPost(id) {
</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">${escapeHtml(post.content)}</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>';
}
}
document.addEventListener('DOMContentLoaded', loadPosts);
var BLOG = {
init: async function () {
await loadSidebar();
loadPosts();
}
};
+164 -158
View File
@@ -1,173 +1,179 @@
const CAPTCHA = {
currentToken: null,
modalOverlay: null,
currentToken: null, verified: false, modalOverlay: null,
// Check if captcha is required for an action (login/register/forum)
async checkRequired(action) {
try {
const r = await API.request('POST', '/captcha/required', { action });
return r;
} catch { return { required: false }; }
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 }; }
},
// Show captcha modal, returns Promise<boolean> (true = verified)
async verify(action) {
const r = await this.checkRequired(action);
if (!r.required) return true;
if (r.type === 'recaptcha') {
return await this._showRecaptchaModal();
}
return await this._showBuiltinModal();
},
// Built-in captcha modal
_showBuiltinModal() {
showModal(action) {
return new Promise(async (resolve) => {
// Create overlay
const r = await this.checkRequired(action);
if (!r.required) { resolve(true); return; }
this._removeModal();
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay active';
overlay.style.cssText = 'display:flex;z-index:9999';
overlay.innerHTML = `
<div class="dialog" style="max-width:380px;text-align:center">
<h3 style="margin-bottom:12px">验证码</h3>
<div id="captchaModalImage" style="margin:0 auto 12px;max-width:240px"></div>
<div style="display:flex;gap:8px;align-items:center;justify-content:center">
<input type="text" id="captchaModalInput" 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="captchaModalRefresh" title="刷新" style="flex-shrink:0"><span class="material-icons">refresh</span></button>
</div>
<div id="captchaModalError" style="color:var(--md-ref-error);font-size:13px;margin-top:8px;display:none"></div>
<div class="actions" style="justify-content:center;margin-top:16px">
<button class="btn btn-text" id="captchaModalCancel">取消</button>
<button class="btn btn-filled" id="captchaModalConfirm">确认</button>
</div>
</div>`;
document.body.appendChild(overlay);
this.modalOverlay = overlay;
const input = overlay.querySelector('#captchaModalInput');
const errEl = overlay.querySelector('#captchaModalError');
const loadImage = async () => {
try {
const data = await API.request('GET', '/captcha/image');
this.currentToken = data.token;
const imgContainer = overlay.querySelector('#captchaModalImage');
imgContainer.innerHTML = data.svg;
const svg = imgContainer.querySelector('svg');
if (svg) svg.style.cssText = 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block';
errEl.style.display = 'none';
} catch (e) {
errEl.textContent = '加载验证码失败: ' + (e.message || '网络错误');
errEl.style.display = 'block';
}
};
await loadImage();
overlay.querySelector('#captchaModalRefresh').onclick = () => {
input.value = '';
errEl.style.display = 'none';
loadImage();
};
const doVerify = async () => {
const answer = input.value.trim();
if (!answer || !this.currentToken) {
errEl.textContent = '请输入验证码';
errEl.style.display = 'block'; return;
}
try {
const r = await API.request('POST', '/captcha/verify', { token: this.currentToken, answer });
if (r.success) {
this._removeModal();
resolve(true);
} else {
errEl.textContent = r.error || '验证码错误';
errEl.style.display = 'block';
this.currentToken = null;
input.value = '';
loadImage();
}
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
};
overlay.querySelector('#captchaModalConfirm').onclick = doVerify;
overlay.querySelector('#captchaModalCancel').onclick = () => {
this._removeModal();
resolve(false);
};
input.onkeydown = (e) => { if (e.key === 'Enter') doVerify(); };
setTimeout(() => input.focus(), 100);
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);
});
},
// reCAPTCHA verification - separate, standalone
_showRecaptchaModal() {
return new Promise((resolve) => {
this._removeModal();
const siteKey = window._recaptchaSiteKey || '';
if (!siteKey) { resolve(false); return; }
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay active';
overlay.style.cssText = 'display:flex;z-index:9999';
overlay.innerHTML = `
<div class="dialog" style="max-width:400px;text-align:center">
<h3 style="margin-bottom:16px">请完成验证</h3>
<div id="recaptchaWidgetContainer" style="display:flex;justify-content:center;margin:16px 0"></div>
<p id="recaptchaStatus" class="text-muted" style="font-size:13px">正在加载...</p>
<div class="actions" style="justify-content:center">
<button class="btn btn-text" id="recaptchaCancelBtn">取消</button>
</div>
</div>`;
document.body.appendChild(overlay);
this.modalOverlay = overlay;
const widgetDiv = overlay.querySelector('#recaptchaWidgetContainer');
const statusEl = overlay.querySelector('#recaptchaStatus');
let resolved = false;
const done = (ok) => { if (!resolved) { resolved = true; this._removeModal(); resolve(ok); } };
overlay.querySelector('#recaptchaCancelBtn').onclick = () => done(false);
// Render the reCAPTCHA widget, auto-resolve on success
const renderWidget = () => {
try {
grecaptcha.render(widgetDiv, {
sitekey: siteKey,
callback: () => { statusEl.textContent = '验证通过'; setTimeout(() => done(true), 300); },
'expired-callback': () => { statusEl.textContent = '验证已过期,请重新验证'; },
});
statusEl.textContent = '请点击验证框';
} catch (e) {
statusEl.textContent = 'reCAPTCHA 加载失败';
setTimeout(() => done(false), 2000);
}
};
if (typeof grecaptcha !== 'undefined') {
renderWidget();
} else {
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
window.recaptchaCallbacks.push(renderWidget);
if (!document.querySelector('script[src*="recaptcha/api"]')) {
const s = document.createElement('script');
s.src = 'https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
s.async = true; s.defer = true;
document.head.appendChild(s);
}
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);
},
_removeModal() {
if (this.modalOverlay) { this.modalOverlay.remove(); this.modalOverlay = null; }
}
_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.onRecaptchaLoad = function() { (window.recaptchaCallbacks || []).forEach(cb => cb()); };
window.onTurnstileLoad = function() { (window.turnstileCallbacks || []).forEach(cb => cb()); };
+148 -115
View File
@@ -3,29 +3,14 @@ let currentCatId = null;
let currentPostId = null;
let currentUser = null;
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
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'); }
function closeDialog(id) {
document.getElementById(id).classList.remove('active');
}
function openDialog(id) {
// Close any other open dialogs first
document.querySelectorAll('.dialog-overlay.active').forEach(el => el.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
// Check auth
async function checkAuth() {
const token = localStorage.getItem('token');
if (!token) return null;
try {
currentUser = await API.getMe();
return currentUser;
} catch { return null; }
try { currentUser = await API.getMe(); return currentUser; } catch { return null; }
}
async function loadCategories() {
@@ -33,70 +18,120 @@ async function loadCategories() {
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">chat</span> ${escapeHtml(c.name)}
<span class="text-muted" style="margin-left:auto;font-size:12px">${escapeHtml(c.description || '')}</span>
<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;
loadPosts(catId);
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');
}
async function loadPosts(catId) {
// 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);
if (posts.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-icon">📝</div><p>暂无帖子</p></div>';
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="forum-post-list">' + posts.map(p => `
<div class="card forum-post-card" onclick="viewPost(${p.id})">
<div class="post-title">${escapeHtml(p.title)}</div>
<div class="post-meta">
<span>${escapeHtml(p.author_name || '匿名')}</span>
<span>${p.created_at}</span>
<span>${p.reply_count || 0} 回复</span>
</div>
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>
`).join('') + '</div>';
} catch (e) {
container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></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>';
// Update URL for sharing
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="selectCategory(${post.category_id})">
<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">
<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>
@@ -120,120 +155,118 @@ async function viewPost(postId) {
</div>
`).join('')}
</div>`;
} catch (e) {
container.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><p>加载失败</p></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 captchaOk = await CAPTCHA.verify('forum');
if (!captchaOk) return;
const capOk = await CAPTCHA.showModal('forum');
if (!capOk) return;
try {
await API.createForumPost(data);
showSnackbar('发布成功');
closeDialog('newPostDialog');
loadPosts(currentCatId || categories[0]?.id);
if (currentCatId) loadCategoryPosts(currentCatId);
else loadAllPosts();
} catch (e) { showSnackbar(e.message); }
}
function showReply(postId) {
if (!currentUser) { showSnackbar('请先登录'); return; }
document.getElementById('forumReplyInput')?.focus();
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); }
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);
showSnackbar('已删除');
if (currentCatId) loadPosts(currentCatId);
else loadCategories();
} 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);
showSnackbar('已删除');
if (currentPostId) viewPost(currentPostId);
} 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); } }
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();
}
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';
document.addEventListener('DOMContentLoaded', async () => {
await checkAuth();
await loadCategories();
if (categories.length > 0) {
// Check if URL has a forum post ID
const match = location.pathname.match(/^\/forum\/(\d+)$/);
if (match) { viewPost(parseInt(match[1])); }
else { selectCategory(categories[0].id); }
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'; }; }
}
if (!currentUser) {
document.getElementById('newPostBtn').textContent = '登录发帖';
document.getElementById('newPostBtn').onclick = () => window.location.href = '/login.html';
}
});
};
// Handle browser back/forward
window.addEventListener('popstate', (e) => {
const match = location.pathname.match(/^\/forum\/(\d+)$/);
if (match) { viewPost(parseInt(match[1])); }
else if (categories.length > 0) { selectCategory(categories[0].id); }
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();
});
+124
View File
@@ -0,0 +1,124 @@
(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();
});
})();
+116 -9
View File
@@ -1,3 +1,106 @@
// ── 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: {},
@@ -18,8 +121,9 @@ const NAV = {
try { this.currentUser = await API.getMe(); } catch { localStorage.removeItem('token'); }
}
try {
this.siteSettings = await API.getSettings();
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');
@@ -37,7 +141,7 @@ const NAV = {
const siteName = this.siteSettings.site_name || 'RainWeb';
const path = location.pathname;
let tabs = `<a href="/" class="nav-tab ${path === '/' ? 'active' : ''}">博客</a>`;
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>`;
@@ -68,10 +172,17 @@ const NAV = {
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';
const color = s.primary_color || '#6750a4';
document.documentElement.style.setProperty('--md-source', color);
document.documentElement.style.setProperty('--md-ref-primary', color);
injectThemeStyle(s);
const body = document.body;
const wallpaper = s.theme_wallpaper || '';
@@ -109,10 +220,6 @@ const NAV = {
el.classList.toggle('glass-card', cs === 'glass');
});
// Primary container color
const r = parseInt(color.slice(1,3),16), g = parseInt(color.slice(3,5),16), b = parseInt(color.slice(5,7),16);
document.documentElement.style.setProperty('--md-ref-primary-container', `rgba(${r},${g},${b},0.15)`);
// Brightness-based text readability for wallpaper backgrounds
// When wallpaper is present, compute luminance and add overlay
if (wallpaper) {
+3 -1
View File
@@ -217,7 +217,9 @@ function copyToClipboard(text, label) {
navigator.clipboard.writeText(text).then(() => showSnackbar(label + ' 已复制'));
}
document.addEventListener('DOMContentLoaded', initPinScreen);
var PASSWORDS = {
init: initPinScreen
};
// Allow Enter key to submit PIN
document.getElementById('pinInput')?.addEventListener('keydown', e => {
+36 -16
View File
@@ -1,25 +1,45 @@
// Content renderer: handles Markdown + [image:xxx] / [file:xxx] tags
function renderContent(content, useMarkdown) {
// Step 1: Escape HTML to prevent XSS
let html = escapeHtml(content);
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;
}
// Step 2: Replace [image:filename] with <img> tags
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">`;
});
// Step 3: Replace [file:filename] with download links
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>`;
});
// Step 4: Render Markdown if enabled
if (useMarkdown && typeof marked !== 'undefined') {
html = marked.parse(html, { breaks: true });
} else if (!useMarkdown) {
// 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;
}
+118
View File
@@ -0,0 +1,118 @@
(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();
});
})();
+4
View File
@@ -7,6 +7,10 @@ function initTheme() {
}
function toggleTheme() {
if (window._forceDark) {
showSnackbar('已强制启用深色模式无法更改');
return;
}
const html = document.documentElement;
const isDark = html.getAttribute('data-theme') === 'dark';
if (isDark) {
+38 -3
View File
@@ -24,6 +24,10 @@
<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>
@@ -38,11 +42,29 @@
<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');
@@ -50,6 +72,18 @@
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;
@@ -57,9 +91,10 @@
const btn = document.getElementById('loginBtn');
if (!username || !password) { errEl.textContent = '请输入用户名和密码'; errEl.style.display = 'block'; return; }
// Show captcha modal if needed
const captchaOk = await CAPTCHA.verify('login');
if (!captchaOk) return; // user cancelled
if (captchaNeeded && !CAPTCHA.verified) {
errEl.textContent = '请先点击验证按钮完成验证';
errEl.style.display = 'block'; return;
}
errEl.style.display = 'none';
btn.disabled = true;
+4
View File
@@ -46,10 +46,14 @@
<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>
+40 -5
View File
@@ -50,9 +50,11 @@
</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">
@@ -69,11 +71,43 @@
<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();
@@ -96,8 +130,10 @@
}
let captcha_token = '';
const captchaOk = await CAPTCHA.verify('register');
if (!captchaOk) return;
if (captchaNeeded && !CAPTCHA.verified) {
errEl.textContent = '请先点击验证按钮完成验证';
errEl.style.display = 'block'; return;
}
errEl.style.display = 'none';
btn.disabled = true;
@@ -122,7 +158,6 @@
errEl.style.display = 'block';
btn.disabled = false;
btn.textContent = '注册';
if (recaptchaContainer.dataset.sitekey) grecaptcha.reset();
}
}
+22
View File
@@ -58,6 +58,28 @@
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 () => {
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router();
router.get('/', (req, res) => {
router.get('/', authMiddleware, adminOnly, (req, res) => {
res.json(db.all('SELECT * FROM admin_links ORDER BY sort_order ASC, id ASC'));
});
+1
View File
@@ -90,6 +90,7 @@ router.post('/register', async (req, res) => {
host: smtpHost, port: parseInt(db.getSetting('smtp_port')) || 587,
secure: parseInt(db.getSetting('smtp_port')) === 465,
auth: { user: db.getSetting('smtp_user'), pass: db.getSetting('smtp_pass') },
tls: { rejectUnauthorized: false },
});
const siteName = db.getSetting('site_name') || 'RainWeb';
const color = db.getSetting('primary_color') || '#6750a4';
+73 -49
View File
@@ -1,11 +1,10 @@
const express = require('express');
const crypto = require('crypto');
const db = require('../db');
const router = express.Router();
// In-memory captcha store: token -> { answer, expires }
// In-memory captcha store
const captchaStore = new Map();
// Clean expired entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, val] of captchaStore) {
@@ -13,7 +12,8 @@ setInterval(() => {
}
}, 300000);
function generateAnswer(len = 5) {
// Harder captcha: longer answer, more noise
function generateAnswer(len = 6) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
let ans = '';
for (let i = 0; i < len; i++) ans += chars[Math.floor(Math.random() * chars.length)];
@@ -21,58 +21,62 @@ function generateAnswer(len = 5) {
}
function generateSvgCaptcha(answer) {
const w = 240, h = 64;
const w = 280, h = 72;
const colors = ['#d32f2f','#1976d2','#388e3c','#f57c00','#7b1fa2','#e91e63','#0097a7'];
let bg = `<rect width="${w}" height="${h}" fill="#f5f5f5" rx="10"/>`;
// More noise lines
let lines = '';
const colors = ['#d32f2f','#1976d2','#388e3c','#f57c00','#7b1fa2'];
// Background noise lines
for (let i = 0; i < 6; i++) {
for (let i = 0; i < 12; i++) {
const x1 = Math.random() * w, y1 = Math.random() * h;
const x2 = Math.random() * w, y2 = Math.random() * h;
lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${colors[i % colors.length]}" stroke-width="${1 + Math.random() * 2}" opacity="0.3"/>`;
lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${colors[i % colors.length]}" stroke-width="${1 + Math.random() * 3}" opacity="0.25"/>`;
}
// Dots
for (let i = 0; i < 40; i++) {
lines += `<circle cx="${Math.random() * w}" cy="${Math.random() * h}" r="${1 + Math.random() * 2}" fill="${colors[i % colors.length]}" opacity="0.2"/>`;
// More dots
for (let i = 0; i < 80; i++) {
lines += `<circle cx="${Math.random() * w}" cy="${Math.random() * h}" r="${1 + Math.random() * 3}" fill="${colors[i % colors.length]}" opacity="0.15"/>`;
}
// Letters
// Curved background paths
for (let i = 0; i < 4; i++) {
const x1 = Math.random() * w, y1 = Math.random() * h;
const cx = Math.random() * w, cy = Math.random() * h;
const x2 = Math.random() * w, y2 = Math.random() * h;
lines += `<path d="M${x1} ${y1} Q${cx} ${cy} ${x2} ${y2}" stroke="${colors[i]}" fill="none" stroke-width="1.5" opacity="0.2"/>`;
}
// Letters with more variation
let letters = '';
const spacing = w / (answer.length + 1);
for (let i = 0; i < answer.length; i++) {
const x = spacing * (i + 0.5) + (Math.random() - 0.5) * 12;
const y = 38 + (Math.random() - 0.5) * 16;
const rotation = (Math.random() - 0.5) * 35;
const fontSize = 30 + Math.random() * 10;
const x = spacing * (i + 0.5) + (Math.random() - 0.5) * 15;
const y = 40 + (Math.random() - 0.5) * 20;
const rotation = (Math.random() - 0.5) * 45;
const fontSize = 28 + Math.random() * 14;
const color = colors[i % colors.length];
letters += `<text x="${x}" y="${y}" transform="rotate(${rotation},${x},${y})" font-size="${fontSize}" font-family="Arial,sans-serif" font-weight="bold" fill="${color}" text-anchor="middle" dominant-baseline="middle">${answer[i]}</text>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<rect width="${w}" height="${h}" fill="#f5f5f5" rx="8"/>
${lines}
${letters}
</svg>`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${bg}${lines}${letters}</svg>`;
}
// Generate captcha
// Generate image captcha
router.get('/image', (req, res) => {
try {
const answer = generateAnswer(5);
const answer = generateAnswer(6);
const token = crypto.randomBytes(16).toString('hex');
captchaStore.set(token, { answer, expires: Date.now() + 300000 }); // 5 min
captchaStore.set(token, { answer, expires: Date.now() + 300000 });
const svg = generateSvgCaptcha(answer);
res.json({ token, svg, expires_in: 300 });
} catch (e) {
console.error('Captcha generation error:', e.message);
console.error('Captcha error:', e.message);
res.status(500).json({ error: '验证码生成失败' });
}
});
// Verify captcha
// Verify image captcha
router.post('/verify', (req, res) => {
try {
const { token, answer } = req.body;
if (!token || !answer) return res.json({ success: false, error: '参数不完整' });
const entry = captchaStore.get(token);
if (!entry) return res.json({ success: false, error: '验证码已过期,请刷新' });
if (!entry) return res.json({ success: false, error: '验证码已过期' });
captchaStore.delete(token);
if (entry.answer.toLowerCase() === String(answer).toLowerCase().trim()) {
res.json({ success: true });
@@ -85,29 +89,49 @@ router.post('/verify', (req, res) => {
}
});
// Check if a captcha is required for a given action
// Proof-of-Work challenge
router.get('/pow-challenge', (req, res) => {
const prefix = crypto.randomBytes(8).toString('hex');
const difficulty = 3; // leading hex zeros needed
const token = crypto.randomBytes(8).toString('hex');
captchaStore.set('pow:' + token, { prefix, difficulty, expires: Date.now() + 120000 });
res.json({ token, prefix, difficulty });
});
// Verify PoW result
router.post('/pow-verify', (req, res) => {
try {
const { token, nonce } = req.body;
if (!token || !nonce) return res.json({ success: false, error: '参数不完整' });
const entry = captchaStore.get('pow:' + token);
if (!entry) return res.json({ success: false, error: '挑战已过期' });
captchaStore.delete('pow:' + token);
const hash = crypto.createHash('sha256').update(entry.prefix + nonce).digest('hex');
if (hash.startsWith('0'.repeat(entry.difficulty))) {
res.json({ success: true });
} else {
res.json({ success: false, error: '验证失败' });
}
} catch { res.json({ success: false, error: '验证失败' }); }
});
// Check captcha required
router.post('/required', (req, res) => {
const db = require('../db');
const { action } = req.body;
const captchaType = db.getSetting('captcha_type') || 'none';
const hasRecaptcha = !!db.getSetting('recaptcha_site_key');
// If captcha is disabled globally, nothing requires it
if (captchaType === 'none') {
return res.json({ required: false, type: 'none', has_recaptcha: false });
try {
const { action } = req.body;
const captchaType = db.getSetting('captcha_type') || 'none';
const hasRecaptcha = !!db.getSetting('recaptcha_site_key');
const hasTurnstile = !!db.getSetting('turnstile_site_key');
if (captchaType === 'none') return res.json({ required: false, type: 'none' });
const val = db.getSetting('captcha_' + action);
const isRequired = val === '1';
if (captchaType === 'recaptcha' && !hasRecaptcha) return res.json({ required: false, type: 'recaptcha' });
if (captchaType === 'turnstile' && !hasTurnstile) return res.json({ required: false, type: 'turnstile' });
res.json({ required: isRequired, type: captchaType });
} catch (e) {
console.error('Captcha required error:', e.message);
res.status(500).json({ error: e.message });
}
// Check if this specific action requires captcha
const key = 'captcha_' + action;
const val = db.getSetting(key);
const isRequired = val === '1';
// For reCAPTCHA, only enable if keys are configured
if (captchaType === 'recaptcha' && !hasRecaptcha) {
return res.json({ required: false, type: 'recaptcha', has_recaptcha: false, error: 'reCAPTCHA 未配置' });
}
res.json({ required: isRequired, type: captchaType, has_recaptcha: hasRecaptcha });
});
module.exports = router;
+1
View File
@@ -14,6 +14,7 @@ function getTransporter() {
host, port: parseInt(db.getSetting('smtp_port')) || 587,
secure: parseInt(db.getSetting('smtp_port')) === 465,
auth: { user: db.getSetting('smtp_user'), pass: db.getSetting('smtp_pass') },
tls: { rejectUnauthorized: false },
});
}
+24 -16
View File
@@ -9,22 +9,30 @@ router.get('/categories', (req, res) => {
});
router.post('/categories', authMiddleware, (req, res) => {
const { name, description, sort_order } = req.body;
if (!name) return res.status(400).json({ error: '名称不能为空' });
const existing = db.get('SELECT id FROM forum_categories WHERE name = ?', [name]);
if (existing) return res.status(400).json({ error: '分类已存在' });
const id = db.run('INSERT INTO forum_categories (name, description, sort_order) VALUES (?, ?, ?)',
[name, description || '', sort_order || 0]);
res.json(db.get('SELECT * FROM forum_categories WHERE id = ?', [id]));
try {
const { name, description, sort_order, announcement, sub_categories } = req.body;
if (!name) return res.status(400).json({ error: '名称不能为空' });
const existing = db.get('SELECT id FROM forum_categories WHERE name = ?', [name]);
if (existing) return res.status(400).json({ error: '分类已存在' });
const sc = Array.isArray(sub_categories) ? sub_categories.join(',') : (sub_categories || '');
const id = db.run('INSERT INTO forum_categories (name, description, sort_order, announcement, sub_categories) VALUES (?, ?, ?, ?, ?)',
[name, description || '', sort_order || 0, announcement || '', sc]);
res.json(db.get('SELECT * FROM forum_categories WHERE id = ?', [id]));
} catch (e) { console.error('Create category error:', e.message); res.status(500).json({ error: e.message }); }
});
router.put('/categories/:id', authMiddleware, (req, res) => {
const { name, description, sort_order } = req.body;
const existing = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
if (!existing) return res.status(404).json({ error: '分类不存在' });
db.run('UPDATE forum_categories SET name=?, description=?, sort_order=? WHERE id=?',
[name || '', description || '', sort_order || 0, req.params.id]);
res.json(db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]));
try {
const { name, description, sort_order, announcement, sub_categories } = req.body;
const existing = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
if (!existing) return res.status(404).json({ error: '分类不存在' });
const sc = Array.isArray(sub_categories) ? sub_categories.join(',') : (sub_categories || '');
db.run('UPDATE forum_categories SET name=?, description=?, sort_order=?, announcement=?, sub_categories=? WHERE id=?',
[name || '', description || '', sort_order || 0, announcement || '', sc, req.params.id]);
const updated = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]);
if (!updated) return res.status(500).json({ error: '更新后读取失败' });
res.json(updated);
} catch (e) { console.error('Update category error:', e.message); res.status(500).json({ error: e.message }); }
});
router.delete('/categories/:id', authMiddleware, (req, res) => {
@@ -68,11 +76,11 @@ router.get('/posts/:id', (req, res) => {
});
router.post('/posts', authMiddleware, (req, res) => {
const { category_id, title, content, use_markdown } = req.body;
const { category_id, title, content, use_markdown, sub_category } = req.body;
if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' });
const id = db.run(
'INSERT INTO forum_posts (category_id, title, content, author_id, use_markdown) VALUES (?, ?, ?, ?, ?)',
[category_id, title, content, req.user.id, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1]);
'INSERT INTO forum_posts (category_id, title, content, author_id, use_markdown, sub_category) VALUES (?, ?, ?, ?, ?, ?)',
[category_id, title, content, req.user.id, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, sub_category || '']);
const post = db.get(
`SELECT fp.*, u.username as author_name
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
+80
View File
@@ -0,0 +1,80 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const os = require('os');
const initSqlJs = require('sql.js');
const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router();
const IMPORT_TABLES = [
'users', 'announcements', 'forum_categories', 'forum_posts', 'forum_replies',
'blog_posts', 'blog_comments', 'password_entries', 'admin_links',
'attachments', 'site_settings', 'user_settings'
];
router.post('/database', authMiddleware, adminOnly, async (req, res) => {
const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 50 * 1024 * 1024 } }).single('file');
upload(req, res, async (err) => {
if (err) return res.status(400).json({ error: '上传失败: ' + err.message });
if (!req.file) return res.status(400).json({ error: '请选择数据库文件' });
try {
const SQL = await initSqlJs();
const buffer = fs.readFileSync(req.file.path);
const oldDb = new SQL.Database(buffer);
const report = { imported: {}, errors: [], total: 0 };
for (const table of IMPORT_TABLES) {
try {
// Check if table exists in old db
const check = oldDb.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='" + table + "'");
if (!check || check.length === 0 || check[0].values.length === 0) {
report.errors.push(table + ': 表不存在,跳过');
continue;
}
// Get columns from old table
const colInfo = oldDb.exec('PRAGMA table_info(' + table + ')');
const columns = colInfo[0].values.map(v => v[1]); // column names
const rows = oldDb.exec('SELECT * FROM ' + table);
if (!rows || rows.length === 0 || rows[0].values.length === 0) {
report.imported[table] = 0;
continue;
}
const colNames = columns.join(',');
const placeholders = columns.map(() => '?').join(',');
let count = 0;
for (const row of rows[0].values) {
try {
db.run('INSERT OR IGNORE INTO ' + table + ' (' + colNames + ') VALUES (' + placeholders + ')', row);
count++;
} catch (e) {
report.errors.push(table + ': 行跳过 (' + e.message.substring(0, 50) + ')');
}
}
report.imported[table] = count;
report.total += count;
} catch (e) {
report.errors.push(table + ': ' + e.message.substring(0, 80));
}
}
oldDb.close();
try { fs.unlinkSync(req.file.path); } catch {}
res.json({
message: '导入完成',
total: report.total,
details: report.imported,
errors: report.errors.length > 0 ? report.errors.slice(0, 20) : []
});
} catch (e) {
res.status(500).json({ error: '导入失败: ' + e.message });
}
});
});
module.exports = router;
+1
View File
@@ -22,6 +22,7 @@ function sendCodeEmail(email, code, username) {
host, port: parseInt(db.getSetting('smtp_port')) || 587,
secure: parseInt(db.getSetting('smtp_port')) === 465,
auth: { user: db.getSetting('smtp_user'), pass: db.getSetting('smtp_pass') },
tls: { rejectUnauthorized: false },
});
const siteName = db.getSetting('site_name') || 'RainWeb';
const color = db.getSetting('primary_color') || '#6750a4';
+21 -5
View File
@@ -4,14 +4,30 @@ const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router();
const ALL_KEYS = ['site_name','site_description','primary_color','recaptcha_site_key',
const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
'recaptcha_site_key','turnstile_site_key',
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style',
'glass_blur','glass_opacity','theme_force_dark',
'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout'];
const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key',
'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name',
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style','glass_blur','glass_opacity',
'captcha_type','captcha_login','captcha_register','captcha_forum'];
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark',
'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout'];
const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass'];
const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key'];
router.get('/', (req, res) => {
router.get('/public', (req, res) => {
const settings = {};
PUBLIC_KEYS.forEach(k => settings[k] = db.getSetting(k));
res.json(settings);
});
router.get('/', authMiddleware, adminOnly, (req, res) => {
const settings = {};
ALL_KEYS.forEach(k => settings[k] = db.getSetting(k));
res.json(settings);
+140 -8
View File
@@ -15,6 +15,7 @@ const captchaRoutes = require('./routes/captcha');
const uploadRoutes = require('./routes/upload');
const setupRoutes = require('./routes/setup');
const proxyRoutes = require('./routes/proxy');
const importRoutes = require('./routes/import');
const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
const app = express();
@@ -28,10 +29,33 @@ const PORT = process.env.PORT || configPort;
app.use(cors());
app.use(express.json({ limit: '5mb' }));
// Serve index.html with dynamic settings injection (must be before static to take precedence)
function serveIndex(req, res) {
const indexPath = path.join(__dirname, 'public', 'index.html');
const fs = require('fs');
if (fs.existsSync(indexPath)) {
let html = fs.readFileSync(indexPath, 'utf8');
try {
const { getSetting } = require('./db');
const siteName = getSetting('site_name') || 'Rainnya Blog';
const siteDesc = getSetting('site_description') || '个人云平台';
const siteFavicon = getSetting('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>';
html = html.replace(/\$\{site_name\}/g, siteName);
html = html.replace(/\$\{site_description\}/g, siteDesc);
html = html.replace(/\$\{site_favicon\}/g, siteFavicon);
} catch {}
res.send(html);
} else {
res.status(500).send('Index file not found. Please reinstall the application.');
}
}
app.get('/', serveIndex);
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: 0,
setHeaders(res, path) {
if (path.endsWith('.html')) {
if (path.endsWith('.html') || path.endsWith('.js')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
}
@@ -51,11 +75,95 @@ app.use('/api/captcha', captchaRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/setup', setupRoutes);
app.use('/api/proxy', proxyRoutes);
app.use('/api/import', importRoutes);
// Version info
// Version & Update
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
app.get('/api/version', (req, res) => res.json({ version }));
app.get('/api/update/check', async (req, res) => {
const https = require('https');
const tryFetch = (url) => new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': 'RainWeb' } }, (r) => {
let b = ''; r.on('data', c => b += c); r.on('end', () => resolve(b.trim()));
}).on('error', reject);
});
try {
const remote = await tryFetch('https://raw.githubusercontent.com/Xianyunah/rainwebblog/master/VERSION');
const local = require('fs').readFileSync('./VERSION', 'utf8').trim();
res.json({ local, remote, hasUpdate: remote !== local });
} catch (e) {
try {
const remote = await tryFetch('https://api.github.com/repos/Xianyunah/rainwebblog/contents/VERSION');
const local = require('fs').readFileSync('./VERSION', 'utf8').trim();
res.json({ local, remote, hasUpdate: remote !== local });
} catch (e2) {
res.json({ local: version, remote: null, error: '无法检查更新', hasUpdate: false });
}
}
});
app.post('/api/update/run', async (req, res) => {
const https = require('https');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const tmpDir = path.join(__dirname, '.update-tmp');
try {
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
fs.mkdirSync(tmpDir, { recursive: true });
// Download latest source zip
const zipPath = path.join(tmpDir, 'update.zip');
await new Promise((resolve, reject) => {
const f = fs.createWriteStream(zipPath);
const url = 'https://codeload.github.com/Xianyunah/rainwebblog/zip/refs/heads/master';
https.get(url, (r) => {
let redirects = 0;
const follow = (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirects < 5) {
redirects++;
https.get(new URL(res.headers.location, url), follow).on('error', reject);
return;
}
res.pipe(f); f.on('finish', resolve);
};
follow(r);
}).on('error', reject);
});
// Extract
const extractDir = path.join(tmpDir, 'extracted');
fs.mkdirSync(extractDir, { recursive: true });
execSync(`unzip -o "${zipPath}" -d "${extractDir}"`, { stdio: 'pipe', timeout: 30000 });
// Find inner dir
const items = fs.readdirSync(extractDir).filter(f => fs.statSync(path.join(extractDir, f)).isDirectory());
const srcDir = path.join(extractDir, items[0] || '.');
// Copy files excluding local data
const exclude = ['data', 'uploads', 'node_modules', '.env.json', 'server.pid', 'releases'];
const cp = (s, d) => {
fs.readdirSync(s).forEach(f => {
if (exclude.includes(f)) return;
const src = path.join(s, f), dest = path.join(d, f);
if (fs.statSync(src).isDirectory()) { if (!fs.existsSync(dest)) fs.mkdirSync(dest); cp(src, dest); }
else fs.copyFileSync(src, dest);
});
};
cp(srcDir, __dirname);
fs.rmSync(tmpDir, { recursive: true });
// Run npm install
execSync('npm install', { cwd: __dirname, stdio: 'pipe', timeout: 60000 });
res.json({ message: '更新完成,请重启服务生效' });
} catch (e) {
res.status(500).json({ error: '更新失败: ' + e.message });
}
});
// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err.message);
@@ -65,17 +173,25 @@ app.use((err, req, res, next) => {
// SEO: Server-side rendered pages for search engines
app.get('/blog/:id', blogSSR);
app.get('/forum/:id', forumSSR);
app.get('/forum/manage/:id', (req, res) => {
if (req.path.startsWith('/forum/manage/')) return res.sendFile(path.join(__dirname, 'public', 'forum-manage.html'));
forumSSR(req, res);
});
app.get('/sitemap.xml', sitemapXml);
app.get('/robots.txt', (req, res) => {
const db = require('./db');
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
const domain = siteUrl.replace(/\/$/, '');
res.type('text/plain');
res.send(`User-agent: *
Allow: /
Sitemap: ${domain}/sitemap.xml`);
});
// SPA fallback: serve index.html for all non-API, non-static routes
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
const indexPath = path.join(__dirname, 'public', 'index.html');
if (require('fs').existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
res.status(500).send('Index file not found. Please reinstall the application.');
}
serveIndex(req, res);
});
// Prevent crash on unhandled promise rejections
@@ -88,6 +204,22 @@ process.on('uncaughtException', (err) => {
async function start() {
try {
const fs = require('fs');
['data', 'uploads', 'uploads/avatars'].forEach(d => {
const dir = path.join(__dirname, d);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Auto-migrate old data.db to new location
const oldDb = path.join(__dirname, 'data.db');
const newDb = path.join(__dirname, 'data', 'rainweb.db');
if (fs.existsSync(oldDb) && !fs.existsSync(newDb)) {
console.log('Migrating old data.db to data/rainweb.db...');
fs.copyFileSync(oldDb, newDb);
fs.renameSync(oldDb, oldDb + '.bak');
console.log('Migration complete (old file renamed to data.db.bak)');
}
await getDb();
app.listen(PORT, '0.0.0.0', () => {
console.log(`RainWeb running on port ${PORT}`);
+108 -34
View File
@@ -1,33 +1,56 @@
const db = require('./db');
const marked = require('marked');
function ssrPage(title, contentHtml, metaDesc) {
function ssrPage(title, contentHtml, metaDesc, extra = {}) {
const siteName = db.getSetting('site_name') || 'RainWeb';
const siteDesc = db.getSetting('site_description') || '个人云管理平台';
const siteFavicon = db.getSetting('site_favicon') || '';
const color = db.getSetting('primary_color') || '#6750a4';
const baseUrl = extra.url || '';
const ogTitle = title + ' - ' + siteName;
const ogDesc = metaDesc || siteDesc;
const canonical = baseUrl ? `<link rel="canonical" href="${baseUrl}">` : '';
const jsonld = extra.jsonld || '';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - ${siteName}</title>
<meta name="description" content="${metaDesc || title}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${metaDesc || title}">
<meta name="description" content="${ogDesc}">
<meta name="keywords" content="${siteName},${title},博客,论坛">
<meta name="robots" content="index,follow">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="icon" href="${siteFavicon || '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>'}">
${canonical}
<!-- Open Graph -->
<meta property="og:title" content="${ogTitle}">
<meta property="og:description" content="${ogDesc}">
<meta property="og:type" content="${extra.ogType || 'website'}">
<meta property="og:site_name" content="${siteName}">
${baseUrl ? `<meta property="og:url" content="${baseUrl}">` : ''}
${extra.ogImage ? `<meta property="og:image" content="${extra.ogImage}">` : ''}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="${ogTitle}">
<meta name="twitter:description" content="${ogDesc}">
${jsonld}
<link rel="stylesheet" href="/css/style.css">
<style>
.ssr-content { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.ssr-content h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; color: var(--md-ref-on-surface); }
.ssr-content .meta { font-size: 14px; color: var(--md-ref-on-surface-variant); margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--md-ref-outline-variant); }
.ssr-content .body { font-size: 16px; line-height: 1.8; white-space: pre-wrap; color: var(--md-ref-on-surface); }
.ssr-content .body h1, .ssr-content .body h2, .ssr-content .body h3 { margin: 20px 0 10px; }
.ssr-content .body p { margin: 10px 0; }
.ssr-content .body img { max-width: 100%; border-radius: 8px; }
.ssr-nav { display:flex;align-items:center;gap:8px;padding:0 16px;height:56px;background:var(--md-ref-surface-container);border-bottom:1px solid var(--md-ref-outline-variant);position:sticky;top:0;z-index:100;}
.ssr-nav a { color:var(--md-ref-primary);text-decoration:none;font-size:14px;font-weight:500;display:flex;align-items:center;gap:4px;}
.ssr-nav span { color:var(--md-ref-on-surface-variant);font-size:14px;font-weight:500;flex:1; }
@media (prefers-color-scheme:dark){:root{--md-ref-background:#1c1b1f;--md-ref-on-surface:#e6e1e5;--md-ref-on-surface-variant:#cac4d0;--md-ref-surface-container:#25232a;--md-ref-primary:${color};--md-card-bg:#25232a;--md-ref-outline-variant:#49454f;--md-shadow:rgba(0,0,0,0.32)}}
.ssr-content{max-width:720px;margin:0 auto;padding:24px 16px}
.ssr-content h1{font-size:28px;font-weight:600;margin-bottom:8px;color:var(--md-ref-on-surface)}
.ssr-content .meta{font-size:14px;color:var(--md-ref-on-surface-variant);margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid var(--md-ref-outline-variant)}
.ssr-content .body{font-size:16px;line-height:1.8;white-space:pre-wrap;color:var(--md-ref-on-surface)}
.ssr-content .body h1,.ssr-content .body h2,.ssr-content .body h3{margin:20px 0 10px}
.ssr-content .body p{margin:10px 0}
.ssr-content .body img{max-width:100%;border-radius:8px}
.ssr-nav{display:flex;align-items:center;gap:8px;padding:0 16px;height:56px;background:var(--md-ref-surface-container);border-bottom:1px solid var(--md-ref-outline-variant);position:sticky;top:0;z-index:100}
.ssr-nav a{color:var(--md-ref-primary);text-decoration:none;font-size:14px;font-weight:500;display:flex;align-items:center;gap:4px}
.ssr-nav span{color:var(--md-ref-on-surface-variant);font-size:14px;font-weight:500;flex:1}
@media(prefers-color-scheme:dark){:root{--md-ref-background:#1c1b1f;--md-ref-on-surface:#e6e1e5;--md-ref-on-surface-variant:#cac4d0;--md-ref-surface-container:#25232a;--md-ref-primary:${color};--md-card-bg:#25232a;--md-ref-outline-variant:#49454f;--md-shadow:rgba(0,0,0,0.32)}}
body{font-family:'Segoe UI',Roboto,sans-serif;background:var(--md-ref-background);color:var(--md-ref-on-surface);margin:0}
.breadcrumb{display:flex;align-items:center;gap:4px;font-size:13px;color:var(--md-ref-on-surface-variant);margin-bottom:12px;padding:0 16px;padding-top:12px}
.breadcrumb a{color:var(--md-ref-primary);text-decoration:none}
</style>
</head>
<body>
@@ -35,27 +58,61 @@ function ssrPage(title, contentHtml, metaDesc) {
<a href="/"><span class="material-icons" style="font-size:20px">arrow_back</span> </a>
<span>${siteName}</span>
</nav>
${extra.breadcrumb || ''}
<div class="ssr-content">${contentHtml}</div>
</body>
</html>`;
}
function renderSSR(content) {
let html = content.replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => `<img src="/uploads/${encodeURIComponent(f)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`);
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => `<a href="/uploads/${encodeURIComponent(f)}" target="_blank" style="color:#6750a4;text-decoration:underline">📎 ${f}</a>`);
return html.replace(/\n/g, '<br>');
function renderSSR(content, useMarkdown) {
// Extract custom tags before markdown, restore after
const images = [];
const files = [];
let html = String(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 (useMarkdown) {
html = marked.parse(html, { breaks: true, gfm: true });
} else {
html = html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(/\n/g, '<br>');
}
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => {
const fn = images[parseInt(i)];
return fn ? `<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)];
return fn ? `<a href="/uploads/${encodeURIComponent(fn)}" target="_blank" style="color:#6750a4;text-decoration:underline">📎 ${fn}</a>` : '';
});
return html;
}
function blogSSR(req, res) {
const post = db.get('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?', [req.params.id]);
if (!post || !post.published) return res.status(404).send('文章不存在');
const body = renderSSR(post.content);
const body = renderSSR(post.content, post.use_markdown);
const excerpt = post.excerpt || post.content.slice(0, 150);
const siteName = db.getSetting('site_name') || 'RainWeb';
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
const baseDomain = siteUrl.replace(/\/$/, '');
const baseUrl = baseDomain + '/blog/' + post.id;
const jsonld = `<script type="application/ld+json">{
"@context":"https://schema.org",
"@type":"Article",
"headline":"${post.title.replace(/"/g,'\\"')}",
"author":{"@type":"Person","name":"${post.author_name || '管理员'}"},
"datePublished":"${post.created_at}",
"description":"${excerpt.replace(/"/g,'\\"')}"
}</script>`;
const html = ssrPage(post.title,
`<h1>${post.title.replace(/</g,'&lt;')}</h1>
`<nav class="breadcrumb"><a href="/">首页</a><span>/</span><span>${post.title}</span></nav>
<h1>${post.title.replace(/</g,'&lt;')}</h1>
<div class="meta">${post.author_name || '管理员'} · ${post.created_at}</div>
<div class="body">${body}</div>`, excerpt);
<div class="body">${body}</div>`, excerpt,
{ url: baseUrl, ogType: 'article', jsonld, breadcrumb: '' });
res.send(html);
}
@@ -63,8 +120,7 @@ function forumSSR(req, res) {
const post = db.get(
`SELECT fp.*, u.username as author_name, fc.name as category_name
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
LEFT JOIN forum_categories fc ON fp.category_id = fc.id
WHERE fp.id = ?`, [req.params.id]);
LEFT JOIN forum_categories fc ON fp.category_id = fc.id WHERE fp.id = ?`, [req.params.id]);
if (!post) return res.status(404).send('帖子不存在');
const replies = db.all(
@@ -72,7 +128,20 @@ function forumSSR(req, res) {
LEFT JOIN users u ON fr.author_id = u.id
WHERE fr.post_id = ? ORDER BY fr.created_at ASC`, [req.params.id]);
const body = renderSSR(post.content);
const body = renderSSR(post.content, post.use_markdown);
const siteName = db.getSetting('site_name') || 'RainWeb';
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
const baseDomain = siteUrl.replace(/\/$/, '');
const baseUrl = baseDomain + '/forum/' + post.id;
const jsonld = `<script type="application/ld+json">{
"@context":"https://schema.org",
"@type":"DiscussionForumPosting",
"headline":"${post.title.replace(/"/g,'\\"')}",
"author":{"@type":"Person","name":"${post.author_name || '匿名'}"},
"datePublished":"${post.created_at}",
"interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":${replies.length}}
}</script>`;
const repliesHtml = replies.map(r =>
`<div style="padding:12px;margin-bottom:8px;background:var(--md-card-bg);border-radius:8px;border:1px solid var(--md-ref-outline-variant)">
<div style="font-size:13px;color:var(--md-ref-on-surface-variant);margin-bottom:4px">${r.author_name || '匿名'} · ${r.created_at}</div>
@@ -81,22 +150,26 @@ function forumSSR(req, res) {
).join('');
const html = ssrPage(post.title,
`<div style="margin-bottom:8px"><span class="material-icons" style="font-size:14px;color:var(--md-ref-on-surface-variant);vertical-align:middle">chat</span> <span style="font-size:14px;color:var(--md-ref-on-surface-variant)">${post.category_name || ''}</span></div>
`<nav class="breadcrumb"><a href="/">首页</a><span>/</span><a href="/forum.html">论坛</a><span>/</span><span>${post.title}</span></nav>
<div style="margin-bottom:8px"><span style="font-size:14px;color:var(--md-ref-on-surface-variant)">${post.category_name || '论坛'}</span></div>
<h1>${post.title.replace(/</g,'&lt;')}</h1>
<div class="meta">${post.author_name || '匿名'} · ${post.created_at}</div>
<div class="body">${body}</div>
<h3 style="margin-top:32px;font-weight:500">回复 (${replies.length})</h3>
${repliesHtml || '<p style="color:var(--md-ref-on-surface-variant)">暂无回复</p>'}`, post.title);
${repliesHtml || '<p style="color:var(--md-ref-on-surface-variant)">暂无回复</p>'}`, post.title,
{ url: baseUrl, ogType: 'article', jsonld, breadcrumb: '' });
res.send(html);
}
function sitemapXml(req, res) {
const siteName = db.getSetting('site_name') || 'RainWeb';
const baseUrl = `${req.protocol}://${req.get('host')}`;
const blogPosts = db.all('SELECT id, created_at FROM blog_posts WHERE published = 1 ORDER BY created_at DESC');
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
const baseUrl = siteUrl.replace(/\/$/, '');
const blogPosts = db.all('SELECT id, created_at, title FROM blog_posts WHERE published = 1 ORDER BY created_at DESC');
const forumPosts = db.all('SELECT id, created_at FROM forum_posts ORDER BY created_at DESC');
let urls = `<url><loc>${baseUrl}/</loc><priority>1.0</priority></url>`;
let urls = `<url><loc>${baseUrl}/</loc><priority>1.0</priority></url>
<url><loc>${baseUrl}/forum.html</loc><priority>0.7</priority></url>
<url><loc>${baseUrl}/login.html</loc><priority>0.3</priority></url>`;
blogPosts.forEach(p => {
urls += `<url><loc>${baseUrl}/blog/${p.id}</loc><lastmod>${p.created_at}</lastmod><priority>0.8</priority></url>`;
});
@@ -105,7 +178,8 @@ function sitemapXml(req, res) {
});
res.header('Content-Type', 'application/xml');
res.send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
res.send(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
}
module.exports = { blogSSR, forumSSR, sitemapXml };