Compare commits
10
Commits
608adac6d4
...
e7f8b3f173
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f8b3f173 | ||
|
|
a30a628772 | ||
|
|
cd290ff237 | ||
|
|
f7f30af569 | ||
|
|
4465130e60 | ||
|
|
bd0c0442ab | ||
|
|
b80c5a1277 | ||
|
|
73e145df07 | ||
|
|
6ffbf19aff | ||
|
|
2e6eeadfc1 |
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.2",
|
||||
"description": "链接聚合管理平台",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+199
-83
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>');
|
||||
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, '<').replace(/>/g, '>');
|
||||
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,'<')}</h1>
|
||||
`<nav class="breadcrumb"><a href="/">首页</a><span>/</span><span>${post.title}</span></nav>
|
||||
<h1>${post.title.replace(/</g,'<')}</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,'<')}</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 };
|
||||
|
||||
Reference in New Issue
Block a user