37 Commits
Author SHA1 Message Date
miaomiao 2cb9592d56 v2.1.0: 版本号更新 2026-08-07 20:09:06 +08:00
miaomiao 8a08932d78 fix: 控件三态统一(forum-cat-item/chip/toggle 对齐 surface-container→surface-variant→primary-container 体系) 2026-08-07 20:04:44 +08:00
miaomiao b152a2a9c9 fix: 论坛分类按钮点击后深色背景丢失(基础态 surface-container 主题化底色) 2026-08-07 19:56:14 +08:00
miaomiao 4af71185d5 feat: Markdown Indigo 定制主题(文本级渲染/背景透明走磨砂玻璃/主题色变量/深浅色/okaidia 代码块) 2026-08-07 19:50:29 +08:00
miaomiao 6d60b10c14 fix: 论坛板块按钮深色模式白底(button UA 默认样清除,基础态透明) 2026-08-07 19:24:58 +08:00
miaomiao cf4c7d4ca4 fix: UI/无障碍审查修复(焦点可见/键盘可达/dialog 语义/对比度/触控目标等 B1-B10) 2026-08-07 16:13:01 +08:00
miaomiao 5acc461e88 fix: 壁纸下玻璃表面统一抬升可读性(--glass-bg 0.82/0.74,逐表面应用,壁纸间隙可见) 2026-08-07 16:00:30 +08:00
miaomiao 391cd1bdae fix: 删除壁纸下 .page 遮罩(保留卡片/页脚磨砂) 2026-08-07 15:48:24 +08:00
miaomiao 5b166ef0e2 fix: 壁纸下内容区统一磨砂背景(可读性修复,浅深色自适应) 2026-08-07 15:26:03 +08:00
miaomiao 4f89c63b28 feat: 壁纸开关(后台主题设置可启用/禁用壁纸) 2026-08-07 15:24:09 +08:00
miaomiao 7bfeffaaa0 feat: 目录侧边独立卡片(正文 720 居中不重叠)+ 锚点根治(HTML 生成时注入 h2 id) 2026-08-07 04:50:34 +08:00
miaomiao aff5764b9b fix: 目录悬浮定位修正(≥1500px 才显示,避免与正文重叠) 2026-08-07 04:32:09 +08:00
miaomiao bb830f779c feat: 目录独立悬浮卡片(右侧留白区,正文不缩宽;<1280px 隐藏) 2026-08-07 04:30:52 +08:00
miaomiao 63d329db5f fix: 修复详情页 TDZ 白屏(wordCount 重复声明清理) 2026-08-07 04:22:42 +08:00
miaomiao e83a4e6ee1 feat: 详情页侧边目录(仅 h2/500 字阈值)+ meta 间距修正 2026-08-07 04:19:18 +08:00
miaomiao 0153f770f6 revert: 移除博客列表页磨砂背景(保留详情页) 2026-08-07 04:16:09 +08:00
miaomiao 789a2d9b33 feat: 博客文章详情页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:14:29 +08:00
miaomiao 5a878c1aab feat: 博客页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:13:50 +08:00
miaomiao 5e4cdf26b9 feat: 页脚三样式统一磨砂玻璃(半透明+blur,浅深色自适应) 2026-08-07 04:12:43 +08:00
miaomiao 2faace4810 fix: 登录/注册页改回完全透明(透壁纸) 2026-08-07 04:08:01 +08:00
miaomiao 45002b809a feat: 登录/注册页磨砂玻璃背景(半透明+blur,浅深色自适应) 2026-08-07 04:06:34 +08:00
miaomiao 1b62bf99ce fix: 登录/注册页去掉深色遮罩背景(透出壁纸,保留居中) 2026-08-07 04:05:45 +08:00
miaomiao 6fafa6eb00 fix: 注册弹窗与登录一致(flex 居中+背景遮罩);页脚背景透明(壁纸透出) 2026-08-07 04:00:04 +08:00
miaomiao 6649eba777 v2.0.3: 版本号更新 2026-08-07 03:24:05 +08:00
miaomiao 6aedc5f632 fix: 页脚分隔点去文本化(sep 纯发光圆点,去掉 JSX 里的 '·' 字符) 2026-08-07 03:22:56 +08:00
miaomiao ed82908a0d fix: 版权文本去掉多余分隔点(保留发光圆点分隔) 2026-08-07 03:18:00 +08:00
miaomiao 274291f8fe feat: 页脚微调 - 发光分隔点 + Powered 改中文带 Gitea 链接(RainWeb Engine) 2026-08-07 03:17:00 +08:00
miaomiao 588df32306 feat: 炫酷 Powered by 徽章(流光渐变/描边/扫光动画) 2026-08-07 03:14:27 +08:00
miaomiao 3c98084909 feat: 炫酷版权提示 - 霓虹发光胶囊(呼吸光晕/渐变文字/模糊底,浅深色自适应) 2026-08-07 03:11:18 +08:00
miaomiao 1c0339bae1 docs: 移除页脚 HTML 支持提示文案(后台即管理员无需说明) 2026-08-07 03:06:31 +08:00
miaomiao 651bbbb22e docs: 页脚 HTML 提示文案澄清(效果所有人可见,仅管理员可编辑) 2026-08-07 03:05:52 +08:00
miaomiao 36be795af5 fix: 前台页脚落于视口外(.page min-height 撑满视口剩余,滚动才见页脚) 2026-08-07 02:32:28 +08:00
miaomiao 632d19f49e feat: 后台评论管理 + 评论设置开关 + 技术债清理
- 后台评论管理页(待审核 Tab + 通过/拒绝),侧栏入口
- 站点设置加评论审核模式/评论邮件通知开关
- Material Icons 自托管(本地 woff2)+ ssr 图标修复
- /admin* 路由收紧为显式两条
- 删除死代码 routes/links.js
- 密码箱改 PIN 增加数据丢失警告确认
2026-08-07 02:14:07 +08:00
miaomiao 3cf560f932 feat: 博客功能前台 - 搜索/标签云/标签页/归档/详情增强/嵌套评论
- Blog 列表: 搜索框 + 标签云 + 归档入口
- 新路由: /tag/:name、/archive.html
- 详情: views/字数/标签 chips/点赞(乐观更新)/前后篇/可折叠 TOC
- 嵌套评论任意层级递归渲染 + 回复预填; Write 加 tags 输入
2026-08-07 02:06:01 +08:00
miaomiao ad8883cc61 feat: 博客功能后端 - 搜索/RSS/标签/归档/views/点赞/评论增强/备份
- migrateSchema v2: tags/views/parent_id/status 列 + post_likes 表
- 接口: search/tags/tag/archive/prevnext/点赞三连/评论审核三连/feed.xml
- 评论: 嵌套回复/审核模式(comment_moderate)/限流/邮件通知(comment_notify)
- cli backup 命令 + status 显示最近备份;backups/ 入 gitignore
- ssr blogSSR 阅读量计数;settings 白名单补新键
2026-08-07 01:57:01 +08:00
miaomiao f8d495b128 feat: 页脚文案支持 HTML/CSS 渲染(仅管理员字段,self-XSS 与音乐嵌入同级) 2026-08-07 01:47:12 +08:00
miaomiao c2ca0f2327 feat: 页脚三样式(经典/分栏/玻璃)+ 后台样式编辑卡 + 博客页首屏修复
- Footer.jsx 按 footer_style 渲染三变体(含 Powered by RainnyaWeb + 版权)
- 后台站点设置页脚卡:样式选择 + 按样式显示可编辑字段(分栏导航固定不可编辑)
- db 默认值 footer_style/copyright/powered/desc;settings 白名单接入
- 博客页移动端内容优先排列(首屏可见标题)
2026-08-07 01:44:12 +08:00
41 changed files with 2522 additions and 185 deletions
+1
View File
@@ -7,3 +7,4 @@ uploads/*
server.pid server.pid
releases/ releases/
public/dist/ public/dist/
backups/
-1
View File
@@ -28,7 +28,6 @@ node cli.js <cmd> # status | start | stop | restart | port [N] | password [pw
- 集中挂载所有 `/api/*`auth、admin-links、announcements、forum、blog、passwords、settings、email、profile、captcha、upload、setup、proxy、import)。**新路由必须在此挂载**——后面有 SPA catch-all:非 `/api/` 一律回 dist/index.html。 - 集中挂载所有 `/api/*`auth、admin-links、announcements、forum、blog、passwords、settings、email、profile、captcha、upload、setup、proxy、import)。**新路由必须在此挂载**——后面有 SPA catch-all:非 `/api/` 一律回 dist/index.html。
- **挂载顺序关键**`serveIndex('/')``/assets`dist/assetsimmutable)→ static(public) → `/uploads``/api/*``/admin*`dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。 - **挂载顺序关键**`serveIndex('/')``/assets`dist/assetsimmutable)→ static(public) → `/uploads``/api/*``/admin*`dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。
- `routes/links.js` 未被挂载,是死代码(勿依赖)。
- `middleware/auth.js`Bearer JWT`SECRET` = `process.env.JWT_SECRET`**无硬编码回退**——server.js 启动时若 `.env.json``jwt_secret` 则随机生成写入并设置环境变量);`adminOnly` 会查库复查角色(用户被删/降权立即失效)。 - `middleware/auth.js`Bearer JWT`SECRET` = `process.env.JWT_SECRET`**无硬编码回退**——server.js 启动时若 `.env.json``jwt_secret` 则随机生成写入并设置环境变量);`adminOnly` 会查库复查角色(用户被删/降权立即失效)。
- `routes/setup.js``/complete``setup_complete` 门禁('1' 后 403),新密码禁止等于默认 `admin123` - `routes/setup.js``/complete``setup_complete` 门禁('1' 后 403),新密码禁止等于默认 `admin123`
- `routes/proxy.js`:面板嵌入代理——支持 `Authorization` header 或 `?token=` queryiframe 无法带 header);有内网地址拦截(SSRF)。 - `routes/proxy.js`:面板嵌入代理——支持 `Authorization` header 或 `?token=` queryiframe 无法带 header);有内网地址拦截(SSRF)。
+1 -1
View File
@@ -1 +1 @@
2.0.0 2.1.0
+56
View File
@@ -23,6 +23,7 @@ Commands:
password [new-pass] Change admin password (leave empty for prompt) password [new-pass] Change admin password (leave empty for prompt)
captcha Interactive captcha rule configuration captcha Interactive captcha rule configuration
config Show all current settings config Show all current settings
backup Backup database to backups/ (readonly, safe to run while running)
upgrade Git pull + npm install + restart (one-click upgrade) upgrade Git pull + npm install + restart (one-click upgrade)
help Show this help help Show this help
@@ -31,6 +32,7 @@ Examples:
node cli.js port 8080 node cli.js port 8080
node cli.js password MyNewP@ss123 node cli.js password MyNewP@ss123
node cli.js captcha node cli.js captcha
node cli.js backup
node cli.js upgrade node cli.js upgrade
`; `;
@@ -46,6 +48,7 @@ async function main() {
case 'password': return cmdPassword(); case 'password': return cmdPassword();
case 'captcha': return cmdCaptcha(); case 'captcha': return cmdCaptcha();
case 'config': return cmdConfig(); case 'config': return cmdConfig();
case 'backup': return cmdBackup();
case 'upgrade': return cmdUpgrade(); case 'upgrade': return cmdUpgrade();
case 'help': case 'help':
default: default:
@@ -60,6 +63,10 @@ async function cmdStatus() {
console.log(`Platform: ${process.platform}`); console.log(`Platform: ${process.platform}`);
console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`); console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`);
// 最近一次备份时间
const latestBackup = getLatestBackup();
console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份'));
// Check if server is running // Check if server is running
try { try {
await httpGet('http://localhost:' + (getConfigPort())); await httpGet('http://localhost:' + (getConfigPort()));
@@ -223,6 +230,55 @@ async function cmdRestart() {
await cmdStart(); await cmdStart();
} }
// === Backup ===
async function cmdBackup() {
const srcPath = path.join(__dirname, 'data', 'rainweb.db');
if (!fs.existsSync(srcPath)) {
console.error('数据库不存在:' + srcPath + '(首次启动 server 后才会创建)');
process.exit(1);
}
const backupDir = path.join(__dirname, 'backups');
fs.mkdirSync(backupDir, { recursive: true });
const now = new Date();
const destPath = path.join(backupDir,
`rainweb-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.db`);
// 只读打开源库,不写数据,安全(server 运行中也可执行)
// better-sqlite3 v13src.backup(destPath) 接受目标文件路径(string),返回 Promise
const Database = require('better-sqlite3');
const src = new Database(srcPath, { readonly: true });
try {
await src.backup(destPath);
} finally {
src.close();
}
const size = fs.statSync(destPath).size;
console.log(`备份成功:${destPath}${size} bytes`);
}
// 读取 backups/ 目录下最新的备份文件(按 mtime),无备份返回 null
function getLatestBackup() {
const backupDir = path.join(__dirname, 'backups');
if (!fs.existsSync(backupDir)) return null;
let latest = null;
for (const f of fs.readdirSync(backupDir)) {
if (!f.startsWith('rainweb-') || !f.endsWith('.db')) continue;
const filePath = path.join(backupDir, f);
const mtime = fs.statSync(filePath).mtime;
if (!latest || mtime > latest.mtime) latest = { file: f, mtime };
}
return latest;
}
function pad2(n) { return String(n).padStart(2, '0'); }
function formatMtime(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
// === Upgrade === // === Upgrade ===
async function cmdUpgrade() { async function cmdUpgrade() {
console.log('=== RainWeb Upgrade ===\n'); console.log('=== RainWeb Upgrade ===\n');
+25 -1
View File
@@ -72,7 +72,9 @@ function initTables() {
db.exec(`CREATE TABLE IF NOT EXISTS blog_comments ( db.exec(`CREATE TABLE IF NOT EXISTS blog_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
content TEXT NOT NULL, author_id INTEGER, content TEXT NOT NULL, author_id INTEGER,
author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')), author_name TEXT DEFAULT '', parent_id INTEGER DEFAULT 0,
status TEXT DEFAULT 'approved',
created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (post_id) REFERENCES blog_posts(id))`); FOREIGN KEY (post_id) REFERENCES blog_posts(id))`);
db.exec(`CREATE TABLE IF NOT EXISTS forum_replies ( db.exec(`CREATE TABLE IF NOT EXISTS forum_replies (
@@ -86,10 +88,16 @@ function initTables() {
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL, content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL,
published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1, published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1,
tags TEXT DEFAULT '', views INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (author_id) REFERENCES users(id))`); FOREIGN KEY (author_id) REFERENCES users(id))`);
db.exec(`CREATE TABLE IF NOT EXISTS post_likes (
post_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
PRIMARY KEY (post_id, user_id))`);
db.exec(`CREATE TABLE IF NOT EXISTS user_settings ( db.exec(`CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL,
pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '', pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '',
@@ -116,6 +124,7 @@ function initTables() {
// Indexes for performance // Indexes for performance
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)'); db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
@@ -137,6 +146,14 @@ function migrateSchema() {
// v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑, // v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑,
// 默认 100000:存量用户保持旧迭代数,旧密文仍可解密) // 默认 100000:存量用户保持旧迭代数,旧密文仍可解密)
{ version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } }, { version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } },
// v2: 博客增强——博文标签/阅读量、评论嵌套与审核、点赞表
{ version: 2, up: () => {
try { db.exec("ALTER TABLE blog_posts ADD COLUMN tags TEXT DEFAULT ''"); } catch {}
try { db.exec("ALTER TABLE blog_posts ADD COLUMN views INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE blog_comments ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
} },
]; ];
for (const m of migrations) { for (const m of migrations) {
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); } if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
@@ -169,6 +186,7 @@ function seedDefaults() {
smtp_from_name: 'RainWeb', smtp_from_name: 'RainWeb',
theme_wallpaper: '', theme_wallpaper: '',
theme_wallpaper_scale: 'cover', theme_wallpaper_scale: 'cover',
theme_wallpaper_enabled: '1',
nav_style: 'default', nav_style: 'default',
card_style: 'default', card_style: 'default',
glass_blur: '20', glass_blur: '20',
@@ -183,6 +201,12 @@ function seedDefaults() {
music_embed_position: 'right', music_embed_position: 'right',
music_embed_autohide: '0', music_embed_autohide: '0',
music_embed_idle_timeout: '10', music_embed_idle_timeout: '10',
footer_style: 'classic',
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
footer_desc: '',
comment_moderate: '0',
comment_notify: '0',
}; };
for (const [k, v] of Object.entries(defaults)) { for (const [k, v] of Object.entries(defaults)) {
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) { if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
+1 -2
View File
@@ -7,13 +7,12 @@
<meta name="description" content="${site_description}"> <meta name="description" content="${site_description}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<link rel="icon" href="${site_favicon}"> <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"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<div id="musicEmbed"></div> <div id="musicEmbed"></div>
<div id="snackbar" class="snackbar"></div> <div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>
+4
View File
@@ -5,6 +5,8 @@ import Layout from './components/Layout.jsx';
import Home from './pages/Home.jsx'; import Home from './pages/Home.jsx';
import Blog from './pages/Blog.jsx'; import Blog from './pages/Blog.jsx';
import BlogDetail from './pages/BlogDetail.jsx'; import BlogDetail from './pages/BlogDetail.jsx';
import Tag from './pages/Tag.jsx';
import Archive from './pages/Archive.jsx';
import Forum from './pages/Forum.jsx'; import Forum from './pages/Forum.jsx';
import ForumDetail from './pages/ForumDetail.jsx'; import ForumDetail from './pages/ForumDetail.jsx';
import Passwords from './pages/Passwords.jsx'; import Passwords from './pages/Passwords.jsx';
@@ -25,6 +27,8 @@ export default function App() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/blog.html" element={<Blog />} /> <Route path="/blog.html" element={<Blog />} />
<Route path="/blog/:id" element={<BlogDetail />} /> <Route path="/blog/:id" element={<BlogDetail />} />
<Route path="/tag/:name" element={<Tag />} />
<Route path="/archive.html" element={<Archive />} />
<Route path="/forum.html" element={<Forum />} /> <Route path="/forum.html" element={<Forum />} />
<Route path="/forum/:id" element={<ForumDetail />} /> <Route path="/forum/:id" element={<ForumDetail />} />
<Route path="/passwords.html" element={<Passwords />} /> <Route path="/passwords.html" element={<Passwords />} />
+2
View File
@@ -23,6 +23,7 @@ import PaletteIcon from '@mui/icons-material/Palette';
import EmailIcon from '@mui/icons-material/Email'; import EmailIcon from '@mui/icons-material/Email';
import ArticleIcon from '@mui/icons-material/Article'; import ArticleIcon from '@mui/icons-material/Article';
import ForumIcon from '@mui/icons-material/Forum'; import ForumIcon from '@mui/icons-material/Forum';
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from '@mui/icons-material/People';
import CampaignIcon from '@mui/icons-material/Campaign'; import CampaignIcon from '@mui/icons-material/Campaign';
import LinkIcon from '@mui/icons-material/Link'; import LinkIcon from '@mui/icons-material/Link';
@@ -39,6 +40,7 @@ const NAV_ITEMS = [
{ path: '/homepage', label: '首页设置', icon: <HomeIcon /> }, { path: '/homepage', label: '首页设置', icon: <HomeIcon /> },
{ path: '/email', label: '邮件配置', icon: <EmailIcon /> }, { path: '/email', label: '邮件配置', icon: <EmailIcon /> },
{ path: '/blog', label: '博客管理', icon: <ArticleIcon /> }, { path: '/blog', label: '博客管理', icon: <ArticleIcon /> },
{ path: '/comments', label: '评论管理', icon: <ChatBubbleOutlineOutlinedIcon /> },
{ path: '/forum', label: '论坛管理', icon: <ForumIcon /> }, { path: '/forum', label: '论坛管理', icon: <ForumIcon /> },
{ path: '/users', label: '用户管理', icon: <PeopleIcon /> }, { path: '/users', label: '用户管理', icon: <PeopleIcon /> },
{ path: '/announcements', label: '公告管理', icon: <CampaignIcon /> }, { path: '/announcements', label: '公告管理', icon: <CampaignIcon /> },
+2
View File
@@ -18,6 +18,7 @@ import ThemeSettings from './pages/ThemeSettings.jsx';
import Homepage from './pages/Homepage.jsx'; import Homepage from './pages/Homepage.jsx';
import EmailSettings from './pages/EmailSettings.jsx'; import EmailSettings from './pages/EmailSettings.jsx';
import BlogManage from './pages/BlogManage.jsx'; import BlogManage from './pages/BlogManage.jsx';
import CommentManage from './pages/CommentManage.jsx';
import ForumManage from './pages/ForumManage.jsx'; import ForumManage from './pages/ForumManage.jsx';
import Users from './pages/Users.jsx'; import Users from './pages/Users.jsx';
import Announcements from './pages/Announcements.jsx'; import Announcements from './pages/Announcements.jsx';
@@ -84,6 +85,7 @@ function AdminApp() {
<Route path="/homepage" element={<Homepage />} /> <Route path="/homepage" element={<Homepage />} />
<Route path="/email" element={<EmailSettings />} /> <Route path="/email" element={<EmailSettings />} />
<Route path="/blog" element={<BlogManage />} /> <Route path="/blog" element={<BlogManage />} />
<Route path="/comments" element={<CommentManage />} />
<Route path="/forum" element={<ForumManage />} /> <Route path="/forum" element={<ForumManage />} />
<Route path="/users" element={<Users />} /> <Route path="/users" element={<Users />} />
<Route path="/announcements" element={<Announcements />} /> <Route path="/announcements" element={<Announcements />} />
+126
View File
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import RefreshIcon from '@mui/icons-material/Refresh';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import { listPendingComments, approveComment, rejectComment } from '../../api/blog.js';
import { showSnack } from '../snack.jsx';
/** 时间格式化:yyyy-mm-dd hh:mm */
function fmtTime(t) {
if (!t) return '';
return String(t).replace('T', ' ').slice(0, 16);
}
/** 评论管理:待审核队列 + 全部评论占位(后端暂无全量评论列表接口) */
export default function CommentManage() {
const [tab, setTab] = useState(0);
const [pending, setPending] = useState([]);
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
setBusy(true);
listPendingComments()
.then((list) => setPending(list || []))
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setBusy(false));
}, []);
useEffect(() => { load(); }, [load]);
const act = async (id, fn, okMsg) => {
setBusy(true);
try {
await fn(id);
setPending((list) => list.filter((c) => c.id !== id));
showSnack(okMsg);
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h5">评论管理</Typography>
<IconButton onClick={load} title="刷新" disabled={busy}><RefreshIcon /></IconButton>
</Box>
<Paper sx={{ mb: 2 }}>
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
<Tab label="全部评论" />
</Tabs>
</Paper>
{tab === 0 ? (
pending.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" sx={{ mb: 0.5 }}>暂无待审核评论</Typography>
<Typography variant="body2" color="text.secondary">
开启评论审核模式新评论会先进这里等待审核
</Typography>
</Paper>
) : (
<Paper>
<List disablePadding>
{pending.map((c) => (
<ListItem
key={c.id}
divider
alignItems="flex-start"
sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, py: 2, px: { xs: 2, md: 3 } }}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{c.author_name || '匿名'}
</Typography>
<Chip size="small" variant="outlined" label={`文章:${c.post_title || `#${c.post_id}`}`} />
<Typography variant="caption" color="text.secondary">{fmtTime(c.created_at)}</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.primary', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{c.content}
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
variant="contained"
startIcon={<CheckIcon />}
disabled={busy}
onClick={() => act(c.id, approveComment, '已通过审核')}
>通过</Button>
<Button
size="small"
variant="outlined"
color="error"
startIcon={<CloseIcon />}
disabled={busy}
onClick={() => act(c.id, rejectComment, '已拒绝')}
>拒绝</Button>
</Box>
</ListItem>
))}
</List>
</Paper>
)
) : (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
<Typography variant="body2" color="text.secondary">
当前后端仅提供待审核评论的管理接口已通过 / 已拒绝评论可在对应文章页查看
</Typography>
</Paper>
)}
</Box>
);
}
+103 -6
View File
@@ -4,12 +4,36 @@ import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import Switch from '@mui/material/Switch';
import { getSettings, saveSettings } from '../../api/settings.js'; import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx'; import { showSnack } from '../snack.jsx';
/** 基本设置:站名/描述/站点URL/favicon(迁移自 v1 设置卡片 */ /** 页脚样式选项(与前台 Footer.jsx 渲染一致 */
const FOOTER_STYLES = [
{ value: 'classic', label: '经典左右', desc: '左版权右版本,信息密度低,一眼扫过' },
{ value: 'columns', label: '分栏导航', desc: '品牌 + 固定导航栏目 + 底部版权条,内容型站点首选' },
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
];
/** 基本设置 + 页脚设置(v2:页脚样式 / 版权 / Powered by 均支持自定义) */
export default function Settings() { export default function Settings() {
const [form, setForm] = useState({ site_name: '', site_description: '', site_url: '', site_favicon: '' }); const [form, setForm] = useState({
site_name: '',
site_description: '',
site_url: '',
site_favicon: '',
footer_style: 'classic',
footer_copyright: '',
footer_powered: '',
footer_desc: '',
comment_moderate: '0',
comment_notify: '0',
});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -19,11 +43,18 @@ export default function Settings() {
site_description: s.site_description || '', site_description: s.site_description || '',
site_url: s.site_url || '', site_url: s.site_url || '',
site_favicon: s.site_favicon || '', site_favicon: s.site_favicon || '',
footer_style: s.footer_style || 'classic',
footer_copyright: s.footer_copyright || '',
footer_powered: s.footer_powered || '',
footer_desc: s.footer_desc || '',
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
comment_notify: s.comment_notify === '1' ? '1' : '0',
})) }))
.catch((e) => showSnack(e.message, 'error')); .catch((e) => showSnack(e.message, 'error'));
}, []); }, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value })); const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const toggle = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked ? '1' : '0' }));
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
@@ -36,18 +67,84 @@ export default function Settings() {
setSaving(false); setSaving(false);
}; };
const footStyle = form.footer_style;
return ( return (
<Box sx={{ maxWidth: 640 }}> <Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>基本设置</Typography> <Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3 }}>
<TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" /> <TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" />
<TextField fullWidth label="网站描述(SEO" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" /> <TextField fullWidth label="网站描述(SEO" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" />
<TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" /> <TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" />
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" /> <TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={save} disabled={saving}>保存基本设置</Button>
</Box>
</Paper> </Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>评论设置</Typography>
<FormControlLabel
control={<Switch checked={form.comment_moderate === '1'} onChange={toggle('comment_moderate')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论审核模式</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后新评论需后台审核通过才显示</Box>
</Box>
)}
/>
<FormControlLabel
control={<Switch checked={form.comment_notify === '1'} onChange={toggle('comment_notify')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
</Box>
)}
/>
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
<FormControl component="fieldset" sx={{ mb: 1 }}>
<RadioGroup value={footStyle} onChange={set('footer_style')}>
{FOOTER_STYLES.map((o) => (
<FormControlLabel
key={o.value}
value={o.value}
control={<Radio />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>{o.label}</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>{o.desc}</Box>
</Box>
)}
sx={{ alignItems: 'flex-start' }}
/>
))}
</RadioGroup>
</FormControl>
{footStyle === 'columns' ? (
<>
<TextField fullWidth label="网站简介(页脚品牌区)" value={form.footer_desc} onChange={set('footer_desc')} margin="normal" placeholder="留空则自动使用网站描述" multiline minRows={2} />
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
<FormHelperText sx={{ mt: 1 }}>
导航栏目固定不可编辑首页 / 博客 / 论坛 / 管理后台 / 个人中心
</FormHelperText>
</>
) : (
<>
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
<TextField fullWidth label="Powered by 文本" value={form.footer_powered} onChange={set('footer_powered')} margin="normal" placeholder="Powered by RainnyaWeb" />
</>
)}
</Paper>
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
</Box>
</Box> </Box>
); );
} }
@@ -19,6 +19,7 @@ export default function ThemeSettings() {
primary_color: '#6750a4', primary_color: '#6750a4',
theme_wallpaper: '', theme_wallpaper: '',
theme_wallpaper_scale: 'cover', theme_wallpaper_scale: 'cover',
theme_wallpaper_enabled: true,
nav_style: 'default', nav_style: 'default',
card_style: 'default', card_style: 'default',
glass_blur: '20', glass_blur: '20',
@@ -34,6 +35,7 @@ export default function ThemeSettings() {
primary_color: s.primary_color || '#6750a4', primary_color: s.primary_color || '#6750a4',
theme_wallpaper: s.theme_wallpaper || '', theme_wallpaper: s.theme_wallpaper || '',
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover', theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
nav_style: s.nav_style || 'default', nav_style: s.nav_style || 'default',
card_style: s.card_style || 'default', card_style: s.card_style || 'default',
glass_blur: s.glass_blur || '20', glass_blur: s.glass_blur || '20',
@@ -65,6 +67,7 @@ export default function ThemeSettings() {
primary_color: form.primary_color, primary_color: form.primary_color,
theme_wallpaper: form.theme_wallpaper.trim(), theme_wallpaper: form.theme_wallpaper.trim(),
theme_wallpaper_scale: form.theme_wallpaper_scale, theme_wallpaper_scale: form.theme_wallpaper_scale,
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
theme_force_dark: form.theme_force_dark ? '1' : '0', theme_force_dark: form.theme_force_dark ? '1' : '0',
nav_style: form.nav_style, nav_style: form.nav_style,
card_style: form.card_style, card_style: form.card_style,
@@ -92,6 +95,11 @@ export default function ThemeSettings() {
<Paper sx={{ p: 3, mb: 2 }}> <Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography> <Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
<FormControlLabel
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
label="启用壁纸背景"
sx={{ mb: 1 }}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button> <Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} /> <input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
+40 -2
View File
@@ -18,13 +18,51 @@ export function deletePost(id) {
return request('/blog/posts/' + id, { method: 'DELETE' }); return request('/blog/posts/' + id, { method: 'DELETE' });
} }
// ── 搜索 / 标签 / 归档 / 上下篇 / 点赞(博客增强 B2)──────────
export function searchPosts(q) {
return request('/blog/search?q=' + encodeURIComponent(q || ''));
}
export function getTags() {
return request('/blog/tags');
}
export function getTagPosts(name) {
return request('/blog/tag/' + encodeURIComponent(name));
}
export function getArchive() {
return request('/blog/archive');
}
export function getPrevNext(id) {
return request('/blog/posts/' + id + '/prevnext');
}
export function getLikeState(id) {
return request('/blog/posts/' + id + '/like');
}
export function likePost(id) {
return request('/blog/posts/' + id + '/like', { method: 'POST' });
}
export function unlikePost(id) {
return request('/blog/posts/' + id + '/like', { method: 'DELETE' });
}
// ── 评论 ──────────────────────────────────────── // ── 评论 ────────────────────────────────────────
export function listComments(postId) { export function listComments(postId) {
return request('/blog/comments/' + postId); return request('/blog/comments/' + postId);
} }
export function createComment(postId, content) { /** content 评论正文;parentId>0 表示回复某条评论(嵌套) */
return request('/blog/comments/' + postId, { method: 'POST', body: { content } }); export function createComment(postId, content, parentId = 0) {
return request('/blog/comments/' + postId, { method: 'POST', body: { content, parent_id: parentId || 0 } });
} }
export function deleteComment(id) { export function deleteComment(id) {
return request('/blog/comments/' + id, { method: 'DELETE' }); return request('/blog/comments/' + id, { method: 'DELETE' });
} }
// ── 评论审核(管理员)──────────────────────────
export function listPendingComments() {
return request('/blog/comments/pending');
}
export function approveComment(id) {
return request('/blog/comments/' + id + '/approve', { method: 'POST' });
}
export function rejectComment(id) {
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
}
+12 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import * as captchaApi from '../api/captcha.js'; import * as captchaApi from '../api/captcha.js';
import { useDialog } from '../lib/utils.js';
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ── // ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
let pending = null; // { action, type, resolve } let pending = null; // { action, type, resolve }
@@ -140,15 +141,16 @@ function BuiltinCaptcha({ onFinish }) {
onChange={(e) => setAnswer(e.target.value)} onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
placeholder="输入验证码" placeholder="输入验证码"
aria-label="输入验证码"
maxLength={6} maxLength={6}
autoComplete="off" autoComplete="off"
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }} style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
/> />
<button type="button" className="btn btn-icon" title="刷新" onClick={loadImage} style={{ flexShrink: 0 }}> <button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
<span className="material-icons">refresh</span> <span className="material-icons">refresh</span>
</button> </button>
</div> </div>
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>} {error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
{powDone ? ( {powDone ? (
<> <>
@@ -264,13 +266,21 @@ export default function CaptchaModalHost() {
} }
}; };
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
if (!request) return null; if (!request) return null;
return ( return (
<div <div
ref={dialogRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="验证码"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }} onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
onKeyDown={onKeyDown}
> >
{request.type === 'recaptcha' ? ( {request.type === 'recaptcha' ? (
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} /> <ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
+118
View File
@@ -0,0 +1,118 @@
import React from 'react';
import { Link } from 'react-router-dom';
/**
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
* 纯文本(不含 `<`)按原样文本渲染;含 `<` 时按 HTML+CSS 渲染(self-XSS 与音乐嵌入同级)。
* 仅用于这三个字段,不扩大到其他数据。
*/
function HtmlOrText({ value, className }) {
if (typeof value === 'string' && value.includes('<')) {
return <span className={className} dangerouslySetInnerHTML={{ __html: value }} />;
}
return <span className={className}>{value}</span>;
}
/**
* 前台页脚(按 settings.footer_style 渲染 3 种样式,视觉对齐 /tmp/footer-demo.html 的 02/03/04):
* - classic 经典左右:左 Powered + 版权,右 站点名 v{version} 胶囊徽章
* - columns 分栏导航:品牌 + 固定导航栏 + 底部版权条
* - glass 玻璃卡片:磨砂玻璃卡片(backdrop-filter blur + 半透明底 + 细边框)
* 所有样式都保留站点名 + v{version} 元素;footer_desc 为空时回退到 site_description。
*/
export default function Footer({ settings = {}, version = '' }) {
const siteName = settings.site_name || 'RainWeb';
const style = settings.footer_style || 'classic';
const copyright = settings.footer_copyright || '© 2026 Rainnya Blog. All rights reserved.';
const powered = settings.footer_powered || 'Powered by RainnyaWeb';
const desc = settings.footer_desc || settings.site_description || '';
if (style === 'columns') {
// 导航栏目固定不可编辑(后台提示),首页/博客/论坛走 SPA,管理后台整页跳转
const navCols = [
{ title: '导航', links: [
{ to: '/', label: '首页' },
{ to: '/blog.html', label: '博客' },
{ to: '/forum.html', label: '论坛' },
] },
{ title: '其他', links: [
{ to: '/admin', label: '管理后台', external: true },
{ to: '/profile.html', label: '个人中心' },
] },
];
return (
<footer className="footer-columns">
<div className="fc-grid">
<div className="fc-brand">
<div className="brand-row">
<div className="brand-mark">{siteName.charAt(0)}</div>
<div className="brand-name">{siteName}</div>
{version && <span className="brand-version">v{version}</span>}
</div>
{desc && <HtmlOrText value={desc} className="brand-desc" />}
</div>
{navCols.map((col) => (
<nav key={col.title} className="fc-col">
<div className="col-title">{col.title}</div>
<div className="col-links">
{col.links.map((l) =>
l.external ? (
<a key={l.to} href={l.to}>{l.label}</a>
) : (
<Link key={l.to} to={l.to}>{l.label}</Link>
)
)}
</div>
</nav>
))}
</div>
<div className="fc-bottom">
<HtmlOrText value={powered} />
<span className="sep" />
<HtmlOrText value={copyright} />
</div>
</footer>
);
}
if (style === 'glass') {
return (
<div className="footer-glass-wrap">
<span className="blob blob-1"></span>
<span className="blob blob-2"></span>
<span className="blob blob-3"></span>
<footer className="footer-glass">
<div className="fg-left">
<div className="fg-logo">{siteName.charAt(0)}</div>
<div>
<div className="fg-name">{siteName}</div>
<div className="fg-tag">
{desc && <HtmlOrText value={desc} />}
{desc && <span> · </span>}v{version}
</div>
</div>
</div>
<div className="fg-right">
<HtmlOrText value={copyright} className="fg-copyright" />
<HtmlOrText value={powered} className="fg-powered" />
</div>
</footer>
</div>
);
}
// classic(默认):经典左右
return (
<footer className="footer-classic">
<div className="fc-left">
<HtmlOrText value={powered} />
<span className="sep" />
<HtmlOrText value={copyright} />
</div>
<div className="fc-right">
<span>{siteName}</span>
{version && <span className="fc-version">v{version}</span>}
</div>
</footer>
);
}
+4 -11
View File
@@ -7,6 +7,7 @@ import { getToken, setToken, notifyAuthChange } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar } from '../lib/utils.js';
import MusicEmbed from './MusicEmbed.jsx'; import MusicEmbed from './MusicEmbed.jsx';
import CaptchaModalHost from './CaptchaModal.jsx'; import CaptchaModalHost from './CaptchaModal.jsx';
import Footer from './Footer.jsx';
/** QQ 邮箱自动头像(迁移自 nav.js) */ /** QQ 邮箱自动头像(迁移自 nav.js) */
function qqAvatar(user) { function qqAvatar(user) {
@@ -75,7 +76,7 @@ export default function Layout() {
const wallpaper = settings.theme_wallpaper || ''; const wallpaper = settings.theme_wallpaper || '';
const scale = settings.theme_wallpaper_scale || 'cover'; const scale = settings.theme_wallpaper_scale || 'cover';
if (wallpaper) { if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
body.classList.add('has-wallpaper'); body.classList.add('has-wallpaper');
body.style.setProperty('--wallpaper', `url(${wallpaper})`); body.style.setProperty('--wallpaper', `url(${wallpaper})`);
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' }; const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
@@ -166,6 +167,7 @@ export default function Layout() {
<button <button
className="btn-icon" className="btn-icon"
onClick={handleToggleTheme} onClick={handleToggleTheme}
aria-label="切换主题"
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'} title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
> >
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span> <span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
@@ -193,16 +195,7 @@ export default function Layout() {
<Outlet /> <Outlet />
</main> </main>
<footer <Footer settings={settings} version={version} />
style={{
textAlign: 'center',
padding: '20px 16px 64px',
fontSize: 13,
color: 'var(--md-ref-outline)',
}}
>
{siteName} v{version}
</footer>
<MusicEmbed settings={settings} /> <MusicEmbed settings={settings} />
<CaptchaModalHost /> <CaptchaModalHost />
+7 -1
View File
@@ -43,7 +43,13 @@ export function renderContent(content, useMarkdown) {
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)])); html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)])); html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)]));
return DOMPurify.sanitize(html); html = DOMPurify.sanitize(html);
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
let h2Index = 0;
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
return html;
} }
export default function MarkdownRenderer({ content = '', useMarkdown = true }) { export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
+2 -2
View File
@@ -49,9 +49,9 @@ export default function MusicEmbed({ settings }) {
> >
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */} {/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} /> <div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
<div className="music-embed-icon" onClick={expand} title="展开播放器"> <button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
<span className="material-icons">music_note</span> <span className="material-icons">music_note</span>
</div> </button>
</div> </div>
); );
} }
+41
View File
@@ -1,3 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
/** HTML 转义:& < > " ' 全转义 */ /** HTML 转义:& < > " ' 全转义 */
export function escapeHtml(str) { export function escapeHtml(str) {
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => { return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
@@ -45,3 +47,42 @@ export function showSnackbar(msg) {
setTimeout(() => el.classList.remove('show', 'hide'), 300); setTimeout(() => el.classList.remove('show', 'hide'), 300);
}, 2500); }, 2500);
} }
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button */
export function focusDialog(container) {
if (!container) return;
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
if (el && typeof el.focus === 'function') el.focus();
}
/**
* 弹窗键盘/焦点管理(B3):
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
* - Esc 关闭弹窗
* - 关闭后焦点还给触发元素
* 返回 { dialogRef, onKeyDown }dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
*/
export function useDialog(open, onClose) {
const dialogRef = useRef(null);
const triggerRef = useRef(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
focusDialog(dialogRef.current);
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
triggerRef.current.focus();
triggerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
e.stopPropagation();
if (onClose) onClose();
}
}, [onClose]);
return { dialogRef, onKeyDown };
}
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getArchive, listPosts } from '../api/blog.js';
/** 归档页(路由 /archive.html):
* archive API 只返回 {month, count},无按月文章列表接口;
* 列表接口 /blog/posts 无分页返回全部已发布文章(文章量小),
* 前端按 created_at 客户端分组,配合 archive API 的月份与计数展示。
*/
export default function Archive() {
const [months, setMonths] = useState([]); // [{month, count, posts:[]}]
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([getArchive(), listPosts()])
.then(([arc, posts]) => {
const byMonth = new Map();
(posts || []).forEach((p) => {
const m = (p.created_at || '').slice(0, 7); // YYYY-MM
if (!m) return;
if (!byMonth.has(m)) byMonth.set(m, []);
byMonth.get(m).push(p);
});
// 以 archive API 的月份顺序为准(desc),count 以实际分组统计为准
const countMap = new Map();
(arc || []).forEach((a) => countMap.set(a.month, a.count));
const list = Array.from(byMonth.entries()).map(([month, ps]) => ({
month,
count: ps.length,
posts: ps,
}));
// 补齐 archive 里有、分组里没有的月份(理论上不会出现)
(arc || []).forEach((a) => {
if (!byMonth.has(a.month)) list.push({ month: a.month, count: a.count, posts: [] });
});
list.sort((a, b) => (a.month < b.month ? 1 : -1));
setMonths(list);
})
.catch((e) => setError(e.message || '加载失败'))
.finally(() => setLoading(false));
}, []);
const fmtMonth = (m) => {
const [y, mm] = m.split('-');
return `${y}${mm}`;
};
return (
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
</Link>
<h1 className="article-title" style={{ fontSize: 26 }}>
文章归档
{!loading && months.length > 0 && (
<span className="tag-title-count"> {months.reduce((s, m) => s + m.count, 0)} </span>
)}
</h1>
{loading && <div className="loading"><div className="spinner"></div></div>}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>{error}</p></div>
)}
{!loading && !error && months.length === 0 && (
<div className="empty-state"><div className="empty-icon">🗂</div><p>暂无文章</p></div>
)}
<div className="archive-list">
{months.map((m) => (
<section key={m.month} className="archive-month">
<h2 className="archive-month-title">
{fmtMonth(m.month)}
<span className="archive-month-count">{m.count} </span>
</h2>
{m.posts.length === 0 ? (
<p className="text-muted" style={{ fontSize: 13 }}>暂无文章</p>
) : (
m.posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="archive-item">
<span className="archive-item-title">{p.title}</span>
<span className="archive-item-date">{(p.created_at || '').slice(0, 10)}</span>
</Link>
))
)}
</section>
))}
</div>
</div>
);
}
+142 -22
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import BlogSidebar from '../components/BlogSidebar.jsx'; import BlogSidebar from '../components/BlogSidebar.jsx';
import { listPosts } from '../api/blog.js'; import { listPosts, searchPosts, getTags } from '../api/blog.js';
import { getPublicSettings } from '../api/settings.js'; import { getPublicSettings } from '../api/settings.js';
/** 摘要:优先取 excerpt,否则从内容中剥离 markdown 符号截取(照搬 blog.js */ /** 摘要:优先取 excerpt,否则从内容中剥离 markdown 符号截取(照搬 blog.js */
@@ -10,45 +10,165 @@ function excerptOf(p) {
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200); return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
} }
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情 */ /** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情
* 顶部搜索框(/api/blog/search+ 标签云(/api/blog/tags,点击进 /tag/:name+ 归档入口 */
export default function Blog() { export default function Blog() {
const [settings, setSettings] = useState({}); const [settings, setSettings] = useState({});
const [posts, setPosts] = useState(null); // null=加载中 const [posts, setPosts] = useState(null); // null=加载中
const [error, setError] = useState(''); const [error, setError] = useState('');
// 搜索状态
const [query, setQuery] = useState('');
const [results, setResults] = useState(null); // null=未搜索
const [searching, setSearching] = useState(false);
// 标签云
const [tags, setTags] = useState([]);
useEffect(() => { useEffect(() => {
getPublicSettings().then(setSettings).catch(() => {}); getPublicSettings().then(setSettings).catch(() => {});
listPosts() listPosts()
.then((ps) => setPosts(ps || [])) .then((ps) => setPosts(ps || []))
.catch((e) => setError(e.message || '加载失败')); .catch((e) => setError(e.message || '加载失败'));
getTags()
.then((ts) => setTags(ts || []))
.catch(() => setTags([]));
}, []); }, []);
const doSearch = async (e) => {
e && e.preventDefault();
const q = query.trim();
if (!q) { setResults(null); return; }
setSearching(true);
try {
const rs = await searchPosts(q);
setResults(rs || []);
} catch (err) {
setResults([]);
setError(err.message || '搜索失败');
}
setSearching(false);
};
const clearSearch = () => {
setQuery('');
setResults(null);
setError('');
};
// 搜索结果以列表呈现(区别于瀑布流卡片)
const renderResults = () => (
<div className="search-results">
<div className="search-results-head">
<span>搜索结果{results.length}</span>
<button className="btn btn-text btn-sm" onClick={clearSearch}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span> 清空
</button>
</div>
{results.length === 0 ? (
<div className="empty-state"><div className="empty-icon">🔍</div><p>没有找到相关内容</p></div>
) : (
<div className="search-results-list">
{results.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="search-result-item">
<div className="sri-title">{p.title}</div>
{p.excerpt && <div className="sri-excerpt">{p.excerpt}</div>}
<div className="sri-meta">
<span>{p.author_name || '管理员'}</span>
<span className="sep">·</span>
<span>{p.created_at}</span>
{p.tags && <span className="sri-tags">{p.tags}</span>}
</div>
</Link>
))}
</div>
)}
</div>
);
return ( return (
<div className="homepage-layout"> <div className="homepage-layout blog-layout">
<h1 className="sr-only">博客</h1>
<BlogSidebar settings={settings} /> <BlogSidebar settings={settings} />
<div className="homepage-content"> <div className="homepage-content">
{!posts && !error && ( {/* 工具条:搜索框 + 归档 */}
<div className="loading"><div className="spinner"></div></div> <form className="blog-toolbar" onSubmit={doSearch}>
)} <div className="search-box">
{error && ( <span className="material-icons">search</span>
<div className="empty-state"><div className="empty-icon"></div><p>加载失败</p></div> <input
)} type="text"
{posts && posts.length === 0 && ( value={query}
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div> onChange={(e) => setQuery(e.target.value)}
)} placeholder="搜索文章标题 / 内容..."
{posts && posts.length > 0 && ( aria-label="搜索文章"
<div className="blog-waterfall"> />
{posts.map((p) => ( {query && (
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}> <button type="button" className="search-clear" onClick={() => setQuery('')} aria-label="清空输入">
<div className="blog-featured"></div> <span className="material-icons">close</span>
<div className="blog-title">{p.title}</div> </button>
<div className="blog-excerpt">{excerptOf(p)}</div> )}
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
</Link>
))}
</div> </div>
<button type="submit" className="btn btn-tonal btn-sm" disabled={searching}>
{searching ? '搜索中...' : '搜索'}
</button>
<Link to="/archive.html" className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
</Link>
</form>
{/* 标签云 */}
{tags.length > 0 && (
<div className="tag-cloud card">
<div className="tag-cloud-title">
<span className="material-icons">local_offer</span> 标签
</div>
<div className="tag-cloud-body">
{tags.map((t) => (
<Link key={t.name} to={`/tag/${encodeURIComponent(t.name)}`} className="tag-chip" style={{ fontSize: tagSize(t.count) }}>
{t.name}
<span className="tag-count">{t.count}</span>
</Link>
))}
</div>
</div>
)}
{/* 搜索结果 / 瀑布流 */}
{results !== null ? (
renderResults()
) : (
<>
{!posts && !error && (
<div className="loading"><div className="spinner"></div></div>
)}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>{error}</p></div>
)}
{posts && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div>
)}
{posts && posts.length > 0 && (
<div className="blog-waterfall">
{posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
<div className="blog-featured"></div>
<div className="blog-title">{p.title}</div>
<div className="blog-excerpt">{excerptOf(p)}</div>
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
</Link>
))}
</div>
)}
</>
)} )}
</div> </div>
</div> </div>
); );
} }
/** 标签字号随文章数渐变(经典标签云效果):1 篇 ~12px,8 篇及以上 ~18px */
function tagSize(count) {
const c = Number(count) || 1;
const size = 12 + Math.min(c, 8) * 0.75;
return size.toFixed(1) + 'px';
}
+197 -25
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import * as blogApi from '../api/blog.js'; import * as blogApi from '../api/blog.js';
import { me } from '../api/auth.js'; import { me } from '../api/auth.js';
@@ -6,9 +6,20 @@ import { getToken } from '../api/client.js';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx'; import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar } from '../lib/utils.js';
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
function countWords(content) {
const plain = String(content || '')
.replace(/\[image:[^\]]*\]|\[file:[^\]]*\]/g, '')
.replace(/[#*`\[\]()>|~_!-]/g, '');
const cjk = (plain.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
const words = plain.replace(/[\u4e00-\u9fff\u3400-\u4dbf]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
return cjk + words;
}
/** /**
* 博客详情页(路由 /blog/:id,SPA 内走前端路由,SSR 直链由后端处理): * 博客详情页(路由 /blog/:id):
* 文章正文(MarkdownRenderer+ 评论列表/发表 + 编辑按钮(作者/管理员可见)。 * 正文(MarkdownRenderer+ 阅读量/标签/点赞 + 目录/字数 + 上一篇/下一篇 +
* 嵌套评论(parent_id 回复)。
*/ */
export default function BlogDetail() { export default function BlogDetail() {
const { id } = useParams(); const { id } = useParams();
@@ -19,15 +30,26 @@ export default function BlogDetail() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [prevnext, setPrevnext] = useState(null);
const [like, setLike] = useState({ liked: false, count: 0 });
const [toc, setToc] = useState([]);
const [replyTo, setReplyTo] = useState(null); // {id, name}
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
setReplyTo(null);
try { try {
const p = await blogApi.getPost(id); const [p, cs, pn, lk] = await Promise.all([
blogApi.getPost(id),
blogApi.listComments(id),
blogApi.getPrevNext(id),
blogApi.getLikeState(id),
]);
setPost(p); setPost(p);
const cs = await blogApi.listComments(id);
setComments(cs || []); setComments(cs || []);
setPrevnext(pn);
setLike(lk || { liked: false, count: 0 });
} catch (e) { } catch (e) {
setError(e.message || '加载失败'); setError(e.message || '加载失败');
} finally { } finally {
@@ -45,15 +67,79 @@ export default function BlogDetail() {
}, [load]); }, [load]);
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id); const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
// 目录:从已渲染的 .md-body 提取 h2(仅一级章节)打锚点;短文章(<500 字)不提取
useEffect(() => {
if (!post) return;
if (wordCount < 500) { setToc([]); return; }
const body = document.querySelector('.blog-article .md-body');
if (!body) { setToc([]); return; }
const items = [...body.querySelectorAll('h2')].map((h, i) => {
if (!h.id) h.id = 'toc-' + i;
return { id: h.id, text: (h.textContent || '').trim(), level: 2 };
});
setToc(items);
}, [post, wordCount]);
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
// ── 点赞(乐观更新)──
const toggleLike = async () => {
if (!user) { showSnackbar('登录后可以点赞'); return; }
const next = !like.liked;
setLike((s) => ({ liked: next, count: Math.max(0, s.count + (next ? 1 : -1)) }));
try {
const r = next ? await blogApi.likePost(id) : await blogApi.unlikePost(id);
setLike({ liked: r.liked, count: r.count });
} catch (e) {
setLike((s) => ({ liked: !s.liked, count: Math.max(0, s.count + (s.liked ? 1 : -1)) }));
showSnackbar(e.message);
}
};
// ── 嵌套评论 ──
const childrenOf = useCallback((pid) => comments.filter((c) => c.parent_id === pid), [comments]);
const rootComments = comments.filter((c) => !c.parent_id);
const renderComment = (c, depth = 0) => {
const kids = childrenOf(c.id);
return (
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
<div className="reply-meta">
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
</div>
<div className="reply-body">{c.content}</div>
{user && (
<button
className="btn btn-text btn-sm reply-btn"
onClick={() => {
setReplyTo({ id: c.id, name: c.author_name || '游客' });
setCommentText(`@${c.author_name || '游客'} `);
}}
>
<span className="material-icons" style={{ fontSize: 14 }}>reply</span> 回复
</button>
)}
{kids.length > 0 && (
<div className="reply-children">
{kids.map((k) => renderComment(k, depth + 1))}
</div>
)}
</div>
);
};
const submitComment = async () => { const submitComment = async () => {
const content = commentText.trim(); const content = commentText.trim();
if (!content) { showSnackbar('评论不能为空'); return; } if (!content) { showSnackbar('评论不能为空'); return; }
setSubmitting(true); setSubmitting(true);
try { try {
await blogApi.createComment(id, content); await blogApi.createComment(id, content, replyTo ? replyTo.id : 0);
showSnackbar('评论已发表'); showSnackbar('评论已发表');
setCommentText(''); setCommentText('');
setReplyTo(null);
await load(); await load();
} catch (e) { } catch (e) {
showSnackbar(e.message); showSnackbar(e.message);
@@ -76,51 +162,137 @@ export default function BlogDetail() {
} }
return ( return (
<div className="blog-article"> <div className="article-layout">
<div className="blog-article article-main">
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}> <Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表 <span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
</Link> </Link>
<h1 className="article-title">{post.title}</h1> <h1 className="article-title">{post.title}</h1>
<div className="article-meta"> <div className="article-meta">
{post.author_name || '管理员'} · {post.created_at} {post.author_name || '管理员'} · {post.created_at}
<span className="meta-stat" title="阅读量">
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
</span>
<span className="meta-stat" title="字数"> {wordCount} </span>
</div>
{tags.length > 0 && (
<div className="article-tags">
{tags.map((t) => (
<Link key={t} to={`/tag/${encodeURIComponent(t)}`} className="chip article-tag-chip">{t}</Link>
))}
</div>
)}
<div className="article-actions">
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
<span className="like-count">{like.count}</span>
</button>
{canEdit && ( {canEdit && (
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm" style={{ marginLeft: 12 }}>编辑</Link> <Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
)} )}
</div> </div>
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} /> <MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
{/* 上一篇 / 下一篇 */}
{prevnext && (prevnext.prev || prevnext.next) && (
<nav className="prevnext-nav">
<div className="pn-col pn-prev">
{prevnext.prev ? (
<Link to={`/blog/${prevnext.prev.id}`}>
<span className="pn-label">上一篇</span>
<span className="pn-title">{prevnext.prev.title}</span>
</Link>
) : (
<span className="pn-disabled"><span className="pn-label">上一篇</span><span className="pn-title">没有了</span></span>
)}
</div>
<div className="pn-col pn-next">
{prevnext.next ? (
<Link to={`/blog/${prevnext.next.id}`}>
<span className="pn-label">下一篇</span>
<span className="pn-title">{prevnext.next.title}</span>
</Link>
) : (
<span className="pn-disabled"><span className="pn-label">下一篇</span><span className="pn-title">没有了</span></span>
)}
</div>
</nav>
)}
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} /> <hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4> <h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
{comments.length === 0 ? ( {comments.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p> <p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
) : ( ) : (
comments.map((c) => ( <div className="comment-list">
<div className="reply-item" key={c.id}> {rootComments.map((c) => renderComment(c))}
<div className="reply-meta"><strong>{c.author_name || '游客'}</strong> · {c.created_at}</div> </div>
<div className="reply-body">{c.content}</div>
</div>
))
)} )}
{user ? ( {user ? (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <div className="comment-form">
<textarea {replyTo && (
value={commentText} <div className="reply-to-hint">
onChange={(e) => setCommentText(e.target.value)} <span className="material-icons" style={{ fontSize: 15 }}>reply</span>
placeholder="写下你的评论..." 回复 <strong>@{replyTo.name}</strong>
style={{ flex: 1, minHeight: 60, fontSize: 14 }} <button
/> className="btn btn-text btn-sm"
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}> onClick={() => { setReplyTo(null); setCommentText(''); }}
发表评论 >
</button> <span className="material-icons" style={{ fontSize: 15 }}>close</span> 取消
</button>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: replyTo ? 8 : 0 }}>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={replyTo ? `回复 @${replyTo.name}...` : '写下你的评论...'}
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
/>
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
发表评论
</button>
</div>
</div> </div>
) : ( ) : (
<p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}> <p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}>
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论 <Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
</p> </p>
)} )}
</div>
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
{toc.length > 0 && (
<aside className="article-toc-side">
<div className="toc-title">
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
目录 <span className="toc-count">{toc.length}</span>
</div>
<ul className="toc-list">
{toc.map((t) => (
<li key={t.id} className="toc-item">
<a
href={'#' + t.id}
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(t.id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
>
{t.text}
</a>
</li>
))}
</ul>
</aside>
)}
</div> </div>
); );
} }
+9 -1
View File
@@ -44,6 +44,7 @@ export default function Embed() {
return ( return (
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<h1 className="sr-only">嵌入页面</h1>
<div <div
className="embed-toolbar" className="embed-toolbar"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
@@ -67,7 +68,14 @@ export default function Embed() {
Ignore X-Frame-Headers Ignore X-Frame-Headers
</a>{' '} </a>{' '}
扩展或点击右上角原站在新标签页打开 扩展或点击右上角原站在新标签页打开
<span onClick={() => setShowHint(false)} style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600 }}></span> <button
type="button"
onClick={() => setShowHint(false)}
aria-label="关闭提示"
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
>
</button>
</div> </div>
)} )}
+21 -10
View File
@@ -6,7 +6,7 @@ import { getToken } from '../api/client.js';
import { uploadFile } from '../api/upload.js'; import { uploadFile } from '../api/upload.js';
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js'; import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
import { showCaptcha } from '../components/CaptchaModal.jsx'; import { showCaptcha } from '../components/CaptchaModal.jsx';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar, useDialog } from '../lib/utils.js';
/** 板块的子分类列表 */ /** 板块的子分类列表 */
function subCatsOf(cat) { function subCatsOf(cat) {
@@ -133,8 +133,12 @@ export default function Forum() {
const currentCat = categories.find((c) => c.id === currentCatId); const currentCat = categories.find((c) => c.id === currentCatId);
const subCats = subCatsOf(currentCat); const subCats = subCatsOf(currentCat);
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
return ( return (
<div className="forum-layout"> <div className="forum-layout">
<h1 className="sr-only">论坛</h1>
<aside className="forum-sidebar"> <aside className="forum-sidebar">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span> <span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
@@ -144,7 +148,8 @@ export default function Forum() {
</div> </div>
<div className="forum-cat-list"> <div className="forum-cat-list">
{categories.map((c) => ( {categories.map((c) => (
<div <button
type="button"
key={c.id} key={c.id}
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')} className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
onClick={() => selectCategory(c.id)} onClick={() => selectCategory(c.id)}
@@ -153,17 +158,18 @@ export default function Forum() {
{c.announcement ? ( {c.announcement ? (
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span> <span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
) : null} ) : null}
</div> </button>
))} ))}
</div> </div>
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} /> <hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
<div <button
type="button"
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')} className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
onClick={showAllPosts} onClick={showAllPosts}
style={{ fontWeight: 500 }} style={{ fontWeight: 500 }}
> >
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新 <span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
</div> </button>
</aside> </aside>
<div className="forum-content"> <div className="forum-content">
@@ -171,7 +177,7 @@ export default function Forum() {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3> <h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
<span className="chip" style={{ cursor: 'default', fontSize: 12, padding: '1px 8px', color: 'var(--md-ref-on-surface-variant)', border: '1px solid var(--md-ref-outline-variant)' }}>板块</span> <span className="chip chip-static">板块</span>
</div> </div>
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p> <p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
{currentCat.announcement && ( {currentCat.announcement && (
@@ -182,9 +188,9 @@ export default function Forum() {
)} )}
{subCats.length > 0 && ( {subCats.length > 0 && (
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}> <div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
<span className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</span> <button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
{subCats.map((s) => ( {subCats.map((s) => (
<span key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</span> <button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
))} ))}
</div> </div>
)} )}
@@ -208,9 +214,9 @@ export default function Forum() {
<span>{p.created_at}</span> <span>{p.created_at}</span>
<span>{p.reply_count || 0} 回复</span> <span>{p.reply_count || 0} 回复</span>
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span> <span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
<span className="chip" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'transparent', border: '1px solid var(--md-ref-outline-variant)', color: 'var(--md-ref-on-surface-variant)' }}>{catName}</span> <span className="chip chip-static">{catName}</span>
{p.sub_category ? ( {p.sub_category ? (
<span className="chip" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>{p.sub_category}</span> <span className="chip chip-tonal">{p.sub_category}</span>
) : null} ) : null}
</div> </div>
</Link> </Link>
@@ -223,9 +229,14 @@ export default function Forum() {
{/* 发帖弹窗 */} {/* 发帖弹窗 */}
{showNewPost && ( {showNewPost && (
<div <div
ref={npDialogRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="发布新帖"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
onKeyDown={npDialogKey}
> >
<div className="dialog"> <div className="dialog">
<h3>发布新帖</h3> <h3>发布新帖</h3>
+5 -3
View File
@@ -108,7 +108,7 @@ export default function ForumDetail() {
</button> </button>
)} )}
</div> </div>
<h3 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h3> <h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{post.author_name || '匿名'}</span> <span>{post.author_name || '匿名'}</span>
<span>{post.created_at}</span> <span>{post.created_at}</span>
@@ -158,12 +158,14 @@ export default function ForumDetail() {
<div className="reply-meta"> <div className="reply-meta">
<strong>{r.author_name || '匿名'}</strong> · {r.created_at} <strong>{r.author_name || '匿名'}</strong> · {r.created_at}
{canDeleteReply && ( {canDeleteReply && (
<span <button
type="button"
aria-label="删除回复"
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }} style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
onClick={() => deleteReply(r.id)} onClick={() => deleteReply(r.id)}
> >
删除 删除
</span> </button>
)} )}
</div> </div>
<div className="reply-body">{r.content}</div> <div className="reply-body">{r.content}</div>
+1
View File
@@ -19,6 +19,7 @@ export default function Home() {
return ( return (
<div className="homepage-layout"> <div className="homepage-layout">
<h1 className="sr-only">首页</h1>
<BlogSidebar settings={settings} showRecent forceShow /> <BlogSidebar settings={settings} showRecent forceShow />
<div className="homepage-content"> <div className="homepage-content">
<div className="card"> <div className="card">
+67 -13
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import * as pwApi from '../api/passwords.js'; import * as pwApi from '../api/passwords.js';
import { me } from '../api/auth.js'; import { me } from '../api/auth.js';
import { getToken } from '../api/client.js'; import { getToken } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar, useDialog } from '../lib/utils.js';
/** /**
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。 * 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
@@ -56,6 +56,9 @@ export default function Passwords() {
const [confirmPin, setConfirmPin] = useState(''); const [confirmPin, setConfirmPin] = useState('');
const [pinSaving, setPinSaving] = useState(false); const [pinSaving, setPinSaving] = useState(false);
// 修改 PIN 警告确认(修改后旧密文无法解密,需重新添加条目)
const [pinChangeWarn, setPinChangeWarn] = useState(false);
// 添加/编辑条目弹窗 // 添加/编辑条目弹窗
const [pwDialog, setPwDialog] = useState(false); const [pwDialog, setPwDialog] = useState(false);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
@@ -154,7 +157,7 @@ export default function Passwords() {
setPhase('locked'); setPhase('locked');
setPinInput(''); setPinInput('');
setPinError(''); setPinError('');
setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false); setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false); setPinChangeWarn(false);
showSnackbar('已锁定'); showSnackbar('已锁定');
} catch (e) { } catch (e) {
showSnackbar(e.message); showSnackbar(e.message);
@@ -167,6 +170,16 @@ export default function Passwords() {
setPinDialog(true); setPinDialog(true);
}; };
// 修改 PIN:先弹警告确认,确认后再打开设置弹窗
const openPinChange = () => {
setPinChangeWarn(true);
};
const proceedPinChange = () => {
setPinChangeWarn(false);
openPinDialog();
};
const openAddDialog = () => { const openAddDialog = () => {
setEditingId(null); setEditingId(null);
setFTitle(''); setFUsername(''); setFPassword(''); setFUrl(''); setFNotes(''); setFTitle(''); setFUsername(''); setFPassword(''); setFUrl(''); setFNotes('');
@@ -237,6 +250,14 @@ export default function Passwords() {
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null; const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
// 加载中 // 加载中
if (phase === 'loading') { if (phase === 'loading') {
return <div className="loading"><div className="spinner"></div></div>; return <div className="loading"><div className="spinner"></div></div>;
@@ -281,7 +302,7 @@ export default function Passwords() {
<button className="btn btn-tonal" onClick={lockVault}> <button className="btn btn-tonal" onClick={lockVault}>
<span className="material-icons">lock</span> 锁定 <span className="material-icons">lock</span> 锁定
</button> </button>
<button className="btn btn-text" onClick={openPinDialog}> <button className="btn btn-text" onClick={openPinChange}>
<span className="material-icons">edit</span> 修改 PIN <span className="material-icons">edit</span> 修改 PIN
</button> </button>
<button className="btn btn-filled" onClick={openAddDialog}> <button className="btn btn-filled" onClick={openAddDialog}>
@@ -300,14 +321,18 @@ export default function Passwords() {
{entries && entries.length > 0 && ( {entries && entries.length > 0 && (
<div className="password-grid"> <div className="password-grid">
{entries.map((p) => ( {entries.map((p) => (
<div key={p.id} className="card password-card" onClick={() => setDetailId(p.id)}> <div key={p.id} className="password-card-wrap">
<div className="pw-title">{p.title}</div> <button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
<div className="pw-username">{p.username || '无用户名'}</div> <div className="pw-title">{p.title}</div>
<div className="pw-username">{p.username || '无用户名'}</div>
</button>
<div className="pw-actions"> <div className="pw-actions">
<button <button
type="button"
className="btn-icon" className="btn-icon"
style={{ width: 32, height: 32, fontSize: 16 }} style={{ width: 40, height: 40, fontSize: 16 }}
title="复制密码" title="复制密码"
aria-label={`复制 ${p.title} 的密码`}
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }} onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
> >
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> <span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
@@ -321,9 +346,14 @@ export default function Passwords() {
{/* 设置/修改 PIN 弹窗 */} {/* 设置/修改 PIN 弹窗 */}
{pinDialog && ( {pinDialog && (
<div <div
ref={pinDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="设置 PIN 码"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
onKeyDown={pinDlgKey}
> >
<div className="dialog" style={{ maxWidth: 360 }}> <div className="dialog" style={{ maxWidth: 360 }}>
<h3>设置 PIN </h3> <h3>设置 PIN </h3>
@@ -347,9 +377,14 @@ export default function Passwords() {
{/* 添加/编辑条目弹窗 */} {/* 添加/编辑条目弹窗 */}
{pwDialog && ( {pwDialog && (
<div <div
ref={pwDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label={editingId ? '编辑密码' : '添加密码'}
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
onKeyDown={pwDlgKey}
> >
<div className="dialog"> <div className="dialog">
<h3>{editingId ? '编辑密码' : '添加密码'}</h3> <h3>{editingId ? '编辑密码' : '添加密码'}</h3>
@@ -387,9 +422,14 @@ export default function Passwords() {
{/* 详情弹窗 */} {/* 详情弹窗 */}
{detailEntry && ( {detailEntry && (
<div <div
ref={detailDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label={detailEntry.title}
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
onKeyDown={detailDlgKey}
> >
<div className="dialog" style={{ maxWidth: 500 }}> <div className="dialog" style={{ maxWidth: 500 }}>
<h3>{detailEntry.title}</h3> <h3>{detailEntry.title}</h3>
@@ -397,16 +437,16 @@ export default function Passwords() {
<div className="pw-field"> <div className="pw-field">
<span className="pw-label">用户名</span> <span className="pw-label">用户名</span>
<span className="pw-value">{detailEntry.username || ''}</span> <span className="pw-value">{detailEntry.username || ''}</span>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}> <button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span> <span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span> </button>
</div> </div>
<div className="pw-field"> <div className="pw-field">
<span className="pw-label">密码</span> <span className="pw-label">密码</span>
<span className="pw-value">{detailEntry.password}</span> <span className="pw-value">{detailEntry.password}</span>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.password, '密码')}> <button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span> <span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span> </button>
</div> </div>
{detailEntry.url ? ( {detailEntry.url ? (
<div className="pw-field"> <div className="pw-field">
@@ -434,7 +474,7 @@ export default function Passwords() {
{/* 密码生成器弹窗 */} {/* 密码生成器弹窗 */}
{genDialog && ( {genDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}> <div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
<div className="dialog" style={{ maxWidth: 380 }}> <div className="dialog" style={{ maxWidth: 380 }}>
<h3>生成随机密码</h3> <h3>生成随机密码</h3>
<div className="form-group"> <div className="form-group">
@@ -461,9 +501,23 @@ export default function Passwords() {
</div> </div>
)} )}
{/* 修改 PIN 警告确认弹窗 */}
{pinChangeWarn && (
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
<div className="dialog">
<h3>确认修改 PIN</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}> 修改 PIN 现有密码条目将无法解密需要重新添加确定继续</p>
<div className="actions">
<button className="btn btn-text" onClick={() => setPinChangeWarn(false)}>取消</button>
<button className="btn btn-danger" onClick={proceedPinChange}>确定继续</button>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */} {/* 删除确认弹窗 */}
{confirmDialog && ( {confirmDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}> <div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
<div className="dialog"> <div className="dialog">
<h3>确认操作</h3> <h3>确认操作</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录</p> <p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录</p>
+13 -1
View File
@@ -4,7 +4,7 @@ import * as profileApi from '../api/profile.js';
import * as authApi from '../api/auth.js'; import * as authApi from '../api/auth.js';
import { avatarUrl, uploadAvatar } from '../api/upload.js'; import { avatarUrl, uploadAvatar } from '../api/upload.js';
import { getToken, notifyAuthChange } from '../api/client.js'; import { getToken, notifyAuthChange } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
/** FileReader + Image 加载图片(头像裁剪前读取) */ /** FileReader + Image 加载图片(头像裁剪前读取) */
function loadImage(file) { function loadImage(file) {
@@ -38,6 +38,13 @@ export default function Profile() {
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [pwBusy, setPwBusy] = useState(false); const [pwBusy, setPwBusy] = useState(false);
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
useEffect(() => {
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
}, [pwDialog, pwStep]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -186,9 +193,14 @@ export default function Profile() {
{/* 修改密码弹窗 */} {/* 修改密码弹窗 */}
{pwDialog && ( {pwDialog && (
<div <div
ref={pwDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="修改密码"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
onKeyDown={pwDlgKey}
> >
<div className="dialog"> <div className="dialog">
<h3>修改密码</h3> <h3>修改密码</h3>
+60
View File
@@ -0,0 +1,60 @@
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getTagPosts } from '../api/blog.js';
/** 摘要:优先 excerpt,否则剥离 markdown 符号截取 */
function excerptOf(p) {
if (p.excerpt) return p.excerpt;
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
}
/** 标签页(路由 /tag/:name):该标签下的文章列表,复用博客卡片样式 */
export default function Tag() {
const { name } = useParams();
const [posts, setPosts] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
setPosts(null);
setError('');
getTagPosts(name)
.then((ps) => setPosts(ps || []))
.catch((e) => setError(e.message || '加载失败'));
}, [name]);
const displayName = name || '';
return (
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
</Link>
<h1 className="article-title" style={{ fontSize: 26 }}>
标签{displayName}
{posts && <span className="tag-title-count">{posts.length} </span>}
</h1>
{!posts && !error && (
<div className="loading"><div className="spinner"></div></div>
)}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>{error}</p></div>
)}
{posts && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">🏷</div><p>该标签下暂无文章</p></div>
)}
{posts && posts.length > 0 && (
<div className="blog-waterfall">
{posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
<div className="blog-featured"></div>
<div className="blog-title">{p.title}</div>
<div className="blog-excerpt">{excerptOf(p)}</div>
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
</Link>
))}
</div>
)}
</div>
);
}
+8
View File
@@ -17,6 +17,7 @@ export default function Write() {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState(''); const [excerpt, setExcerpt] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [published, setPublished] = useState(true); const [published, setPublished] = useState(true);
const [preview, setPreview] = useState(false); const [preview, setPreview] = useState(false);
@@ -34,6 +35,7 @@ export default function Write() {
.then((p) => { .then((p) => {
setTitle(p.title); setTitle(p.title);
setExcerpt(p.excerpt || ''); setExcerpt(p.excerpt || '');
setTags(p.tags || '');
setContent(p.content); setContent(p.content);
setPublished(!!p.published); setPublished(!!p.published);
}) })
@@ -76,6 +78,7 @@ export default function Write() {
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
excerpt: excerpt.trim(), excerpt: excerpt.trim(),
tags: tags.trim(),
published, published,
use_markdown: 1, use_markdown: 1,
}; };
@@ -123,6 +126,11 @@ export default function Write() {
<label>摘要</label> <label>摘要</label>
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" /> <input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
</div> </div>
<div className="form-group">
<label>标签</label>
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="多个标签用英文逗号分隔,如:技术,生活,随笔" />
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>多个标签用英文逗号分隔用于标签云与标签页</div>
</div>
<div className="form-group"> <div className="form-group">
<label>内容 *</label> <label>内容 *</label>
<textarea <textarea
+7 -7
View File
@@ -1,12 +1,12 @@
{ {
"name": "rainweb-links", "name": "rainweb-links",
"version": "2.0.0", "version": "2.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rainweb-links", "name": "rainweb-links",
"version": "2.0.0", "version": "2.1.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
@@ -506,7 +506,7 @@
} }
}, },
"node_modules/@emotion/utils": { "node_modules/@emotion/utils": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT" "license": "MIT"
@@ -1320,7 +1320,7 @@
} }
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [ "engines": [
@@ -1480,7 +1480,7 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT", "license": "MIT",
@@ -1557,7 +1557,7 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/encodeurl": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT", "license": "MIT",
@@ -2557,7 +2557,7 @@
} }
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "rainweb-links", "name": "rainweb-links",
"version": "2.0.0", "version": "2.1.0",
"description": "链接聚合管理平台", "description": "链接聚合管理平台",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+1061 -17
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+200 -12
View File
@@ -1,10 +1,20 @@
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const db = require('../db'); const db = require('../db');
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth'); const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
// 评论限流:10 分钟窗口内最多 15 次
const commentLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 15,
standardHeaders: true,
legacyHeaders: false,
message: { error: '评论过于频繁,请稍后再试' },
});
// 可选鉴权:有效 token 时解析出 req.user,匿名请求直接放行(用于草稿的"作者可见"判断) // 可选鉴权:有效 token 时解析出 req.user,匿名请求直接放行(用于草稿的"作者可见"判断)
function optionalAuth(req, res, next) { function optionalAuth(req, res, next) {
const header = req.headers.authorization; const header = req.headers.authorization;
@@ -16,6 +26,47 @@ function optionalAuth(req, res, next) {
next(); next();
} }
// LIKE 通配符转义(% _ \),配合 ESCAPE '\' 使用
function escapeLike(s) {
return String(s).replace(/[\\%_]/g, (m) => '\\' + m);
}
// 标签规范化:逗号分隔、去空白、去空项、逗号无空格连接(保证 LIKE 边界匹配可靠)
function normalizeTags(tags) {
return String(tags || '').split(',').map(t => t.trim()).filter(Boolean).join(',');
}
// 评论邮件通知:comment_notify='1' 且评论者不是文章作者时通知作者(失败不影响评论创建)
async function notifyCommentToAuthor(post, userId, comment) {
try {
if (db.getSetting('comment_notify') !== '1') return;
if (post.author_id === userId) return; // 作者本人评论不通知
const author = db.get('SELECT id, email FROM users WHERE id = ?', [post.author_id]);
if (!author || !author.email) return;
const { getTransporter, emailTemplate } = require('./email');
const transporter = getTransporter();
if (!transporter) return;
const siteName = db.getSetting('site_name') || 'RainWeb';
const siteUrl = db.getSetting('site_url') || '';
const base = siteUrl.replace(/\/$/, '');
const link = base + '/blog/' + post.id;
const user = db.get('SELECT username FROM users WHERE id = ?', [userId]);
const commenterName = (user && user.username) || '匿名';
await transporter.sendMail({
from: `"${db.getSetting('smtp_from_name')}" <${db.getSetting('smtp_from_email')}>`,
to: author.email,
subject: `您有新评论 - ${post.title}`,
html: emailTemplate('新评论通知',
`<p>您的文章《<strong>${post.title}</strong>》收到一条新评论:</p>
<p style="padding:12px;background:#f5f5f5;border-radius:8px;margin:12px 0">${String(comment.content).replace(/</g, '&lt;')}</p>
<p style="color:#79747e">评论者${commenterName}</p>
<a class="btn" href="${link}">查看评论</a>`),
});
} catch (e) {
console.error('评论通知邮件发送失败:', e.message);
}
}
// 公开列表:仅返回已发布文章 // 公开列表:仅返回已发布文章
router.get('/posts', (req, res, next) => { router.get('/posts', (req, res, next) => {
if (req.query.all !== '1') { if (req.query.all !== '1') {
@@ -30,6 +81,53 @@ router.get('/posts', authMiddleware, adminOnly, (req, res) => {
res.json(db.all('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id ORDER BY bp.created_at DESC')); res.json(db.all('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id ORDER BY bp.created_at DESC'));
}); });
// 搜索:标题/正文/摘要模糊匹配(参数化 + 通配符转义),仅已发布,最多 20 条
router.get('/search', (req, res) => {
const q = (req.query.q || '').trim();
if (!q) return res.json([]);
const pattern = '%' + escapeLike(q) + '%';
const posts = db.all(
`SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name
FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id
WHERE bp.published = 1 AND (bp.title LIKE ? ESCAPE '\\' OR bp.content LIKE ? ESCAPE '\\' OR bp.excerpt LIKE ? ESCAPE '\\')
ORDER BY bp.created_at DESC LIMIT 20`,
[pattern, pattern, pattern]);
res.json(posts);
});
// 标签聚合:返回 [{name, count}],按 count 降序,空标签跳过
router.get('/tags', (req, res) => {
const posts = db.all("SELECT tags FROM blog_posts WHERE published = 1 AND tags != ''");
const map = new Map();
posts.forEach(p => {
String(p.tags || '').split(',').forEach(t => {
const name = t.trim();
if (name) map.set(name, (map.get(name) || 0) + 1);
});
});
const result = Array.from(map.entries()).map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
res.json(result);
});
// 按标签精确匹配(边界匹配,避免子串误中,如 'java' 不匹配 'javascript'
router.get('/tag/:name', (req, res) => {
const name = String(req.params.name || '').trim();
if (!name) return res.json([]);
const posts = db.all(
`SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name
FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id
WHERE bp.published = 1 AND (bp.tags = ? OR bp.tags LIKE ? OR bp.tags LIKE ? OR bp.tags LIKE ?)
ORDER BY bp.created_at DESC`,
[name, name + ',%', '%,' + name + ',%', '%,' + name]);
res.json(posts);
});
// 归档:按月统计已发布文章数
router.get('/archive', (req, res) => {
res.json(db.all("SELECT strftime('%Y-%m', created_at) as month, COUNT(*) as count FROM blog_posts WHERE published = 1 GROUP BY month ORDER BY month DESC"));
});
router.get('/posts/:id', optionalAuth, (req, res) => { router.get('/posts/:id', optionalAuth, (req, res) => {
const post = db.get( const post = db.get(
'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?', 'SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?',
@@ -39,26 +137,77 @@ router.get('/posts/:id', optionalAuth, (req, res) => {
if (post.published !== 1) { if (post.published !== 1) {
if (!req.user || !(req.user.role === 'admin' || req.user.id === post.author_id)) if (!req.user || !(req.user.role === 'admin' || req.user.id === post.author_id))
return res.status(404).json({ error: '文章不存在' }); return res.status(404).json({ error: '文章不存在' });
} else {
// 已发布文章:阅读量 +1(草稿预览不计数)
db.run('UPDATE blog_posts SET views = views + 1 WHERE id = ?', [post.id]);
post.views = (post.views || 0) + 1;
} }
res.json(post); res.json(post);
}); });
// 上一篇 / 下一篇(按 created_at/id 相邻,仅已发布)
router.get('/posts/:id/prevnext', (req, res) => {
const post = db.get('SELECT id, created_at FROM blog_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).json({ error: '文章不存在' });
const prev = db.get(
`SELECT id, title FROM blog_posts
WHERE published = 1 AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC LIMIT 1`,
[post.created_at, post.created_at, post.id]);
const next = db.get(
`SELECT id, title FROM blog_posts
WHERE published = 1 AND (created_at > ? OR (created_at = ? AND id > ?))
ORDER BY created_at ASC, id ASC LIMIT 1`,
[post.created_at, post.created_at, post.id]);
res.json({ prev: prev || null, next: next || null });
});
// 点赞状态(匿名 liked=false
router.get('/posts/:id/like', optionalAuth, (req, res) => {
const post = db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).json({ error: '文章不存在' });
const count = db.get('SELECT COUNT(*) as c FROM post_likes WHERE post_id = ?', [post.id]).c;
let liked = false;
if (req.user) {
liked = !!db.get('SELECT 1 as x FROM post_likes WHERE post_id = ? AND user_id = ?', [post.id, req.user.id]);
}
res.json({ liked, count });
});
// 点赞
router.post('/posts/:id/like', authMiddleware, (req, res) => {
const post = db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).json({ error: '文章不存在' });
db.run('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)', [post.id, req.user.id]);
const count = db.get('SELECT COUNT(*) as c FROM post_likes WHERE post_id = ?', [post.id]).c;
res.json({ liked: true, count });
});
// 取消点赞
router.delete('/posts/:id/like', authMiddleware, (req, res) => {
const post = db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).json({ error: '文章不存在' });
db.run('DELETE FROM post_likes WHERE post_id = ? AND user_id = ?', [post.id, req.user.id]);
const count = db.get('SELECT COUNT(*) as c FROM post_likes WHERE post_id = ?', [post.id]).c;
res.json({ liked: false, count });
});
router.post('/posts', authMiddleware, adminOnly, (req, res) => { router.post('/posts', authMiddleware, adminOnly, (req, res) => {
const { title, content, excerpt, published, use_markdown } = req.body; const { title, content, excerpt, published, use_markdown, tags } = req.body;
if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' }); if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' });
const id = db.run( const id = db.run(
'INSERT INTO blog_posts (title, content, excerpt, author_id, published, use_markdown) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO blog_posts (title, content, excerpt, author_id, published, use_markdown, tags) VALUES (?, ?, ?, ?, ?, ?, ?)',
[title, content, excerpt || '', req.user.id, published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1]); [title, content, excerpt || '', req.user.id, published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags)]);
res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [id])); res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [id]));
}); });
router.put('/posts/:id', authMiddleware, adminOnly, (req, res) => { router.put('/posts/:id', authMiddleware, adminOnly, (req, res) => {
const { title, content, excerpt, published, use_markdown } = req.body; const { title, content, excerpt, published, use_markdown, tags } = req.body;
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id])) if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]))
return res.status(404).json({ error: '文章不存在' }); return res.status(404).json({ error: '文章不存在' });
db.run( db.run(
"UPDATE blog_posts SET title=?, content=?, excerpt=?, published=?, use_markdown=?, updated_at=datetime('now') WHERE id=?", "UPDATE blog_posts SET title=?, content=?, excerpt=?, published=?, use_markdown=?, tags=?, updated_at=datetime('now') WHERE id=?",
[title || '', content || '', excerpt || '', published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, req.params.id]); [title || '', content || '', excerpt || '', published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags), req.params.id]);
res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [req.params.id])); res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [req.params.id]));
}); });
@@ -66,32 +215,71 @@ router.delete('/posts/:id', authMiddleware, adminOnly, (req, res) => {
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id])) if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]))
return res.status(404).json({ error: '文章不存在' }); return res.status(404).json({ error: '文章不存在' });
db.run('DELETE FROM blog_comments WHERE post_id = ?', [req.params.id]); db.run('DELETE FROM blog_comments WHERE post_id = ?', [req.params.id]);
db.run('DELETE FROM post_likes WHERE post_id = ?', [req.params.id]);
db.run('DELETE FROM blog_posts WHERE id = ?', [req.params.id]); db.run('DELETE FROM blog_posts WHERE id = ?', [req.params.id]);
res.json({ message: '删除成功' }); res.json({ message: '删除成功' });
}); });
// Comments // Comments
// 待审核评论列表(管理接口,须在 /comments/:postId 之前注册,避免 "pending" 被当作 postId
router.get('/comments/pending', authMiddleware, adminOnly, (req, res) => {
const comments = db.all(
`SELECT bc.*, u.username as author_name, bp.title as post_title
FROM blog_comments bc
LEFT JOIN users u ON bc.author_id = u.id
LEFT JOIN blog_posts bp ON bc.post_id = bp.id
WHERE bc.status = 'pending' ORDER BY bc.created_at ASC`);
res.json(comments);
});
// 评论列表:仅返回已通过审核(approved)的评论
router.get('/comments/:postId', (req, res) => { router.get('/comments/:postId', (req, res) => {
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.postId])) if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.postId]))
return res.status(404).json({ error: '文章不存在' }); return res.status(404).json({ error: '文章不存在' });
const comments = db.all( const comments = db.all(
'SELECT bc.*, u.username as author_name FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.post_id = ? ORDER BY bc.created_at ASC', "SELECT bc.*, u.username as author_name FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.post_id = ? AND bc.status = 'approved' ORDER BY bc.created_at ASC",
[req.params.postId]); [req.params.postId]);
res.json(comments); res.json(comments);
}); });
router.post('/comments/:postId', authMiddleware, (req, res) => { // 创建评论:支持嵌套回复 parent_id;审核模式(comment_moderate='1')下新评论进待审
router.post('/comments/:postId', authMiddleware, commentLimiter, async (req, res) => {
const { content } = req.body; const { content } = req.body;
const parent_id = parseInt(req.body.parent_id) || 0;
if (!content) return res.status(400).json({ error: '评论内容不能为空' }); if (!content) return res.status(400).json({ error: '评论内容不能为空' });
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.postId])) const post = db.get('SELECT * FROM blog_posts WHERE id = ?', [req.params.postId]);
return res.status(404).json({ error: '文章不存在' }); if (!post) return res.status(404).json({ error: '文章不存在' });
const id = db.run('INSERT INTO blog_comments (post_id, content, author_id) VALUES (?, ?, ?)', if (parent_id) {
[req.params.postId, content, req.user.id]); const parent = db.get('SELECT * FROM blog_comments WHERE id = ?', [parent_id]);
if (!parent) return res.status(400).json({ error: '回复的评论不存在' });
if (parent.post_id !== post.id) return res.status(400).json({ error: '回复的评论不属于该文章' });
}
const status = db.getSetting('comment_moderate') === '1' ? 'pending' : 'approved';
const id = db.run('INSERT INTO blog_comments (post_id, content, author_id, parent_id, status) VALUES (?, ?, ?, ?, ?)',
[post.id, content, req.user.id, parent_id, status]);
const comment = db.get( const comment = db.get(
'SELECT bc.*, u.username as author_name FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.id = ?', [id]); 'SELECT bc.*, u.username as author_name FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.id = ?', [id]);
// 邮件通知(失败不影响评论创建)
notifyCommentToAuthor(post, req.user.id, comment);
res.json(comment); res.json(comment);
}); });
// 审核:通过
router.post('/comments/:id/approve', authMiddleware, adminOnly, (req, res) => {
const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]);
if (!comment) return res.status(404).json({ error: '评论不存在' });
db.run("UPDATE blog_comments SET status = 'approved' WHERE id = ?", [comment.id]);
res.json({ message: '已通过', comment: db.get('SELECT * FROM blog_comments WHERE id = ?', [comment.id]) });
});
// 审核:拒绝
router.post('/comments/:id/reject', authMiddleware, adminOnly, (req, res) => {
const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]);
if (!comment) return res.status(404).json({ error: '评论不存在' });
db.run("UPDATE blog_comments SET status = 'rejected' WHERE id = ?", [comment.id]);
res.json({ message: '已拒绝', comment: db.get('SELECT * FROM blog_comments WHERE id = ?', [comment.id]) });
});
router.delete('/comments/:id', authMiddleware, (req, res) => { router.delete('/comments/:id', authMiddleware, (req, res) => {
const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]); const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]);
if (!comment) return res.status(404).json({ error: '评论不存在' }); if (!comment) return res.status(404).json({ error: '评论不存在' });
+2
View File
@@ -122,3 +122,5 @@ router.post('/complete-register', async (req, res) => {
}); });
module.exports = router; module.exports = router;
module.exports.getTransporter = getTransporter;
module.exports.emailTemplate = emailTemplate;
+69
View File
@@ -0,0 +1,69 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
// XML 转义:& < > " '
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// created_at'YYYY-MM-DD HH:MM:SS'UTC)转 RFC822 格式
function toRfc822(createdAt) {
try {
const d = new Date(String(createdAt).replace(' ', 'T') + 'Z');
return isNaN(d.getTime()) ? String(createdAt) : d.toUTCString();
} catch {
return String(createdAt);
}
}
// RSS 2.0:最新 20 篇已发布博文
router.get('/feed.xml', (req, res) => {
const siteName = db.getSetting('site_name') || 'RainWeb';
const siteDesc = db.getSetting('site_description') || '个人云平台';
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
const base = siteUrl.replace(/\/$/, '');
const posts = db.all(
'SELECT id, title, excerpt, content, created_at FROM blog_posts WHERE published = 1 ORDER BY created_at DESC LIMIT 20');
let items = '';
posts.forEach(p => {
const link = base + '/blog/' + p.id;
let description;
if (p.excerpt) {
description = `<description>${escapeXml(p.excerpt)}</description>`;
} else {
description = `<description><![CDATA[${String(p.content || '').replace(/\]\]>/g, ']]]]><![CDATA[>')}]]></description>`;
}
items += ` <item>
<title>${escapeXml(p.title)}</title>
<link>${escapeXml(link)}</link>
<guid>${escapeXml(link)}</guid>
<pubDate>${escapeXml(toRfc822(p.created_at))}</pubDate>
${description}
</item>
`;
});
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${escapeXml(siteName)}</title>
<link>${escapeXml(base)}</link>
<description>${escapeXml(siteDesc)}</description>
${items} </channel>
</rss>
`;
res.header('Content-Type', 'application/rss+xml; charset=utf-8');
res.send(xml);
});
module.exports = router;
-38
View File
@@ -1,38 +0,0 @@
const express = require('express');
const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router();
router.get('/', (req, res) => {
const links = db.all('SELECT * FROM links ORDER BY sort_order ASC, id ASC');
res.json(links);
});
router.post('/', authMiddleware, adminOnly, (req, res) => {
const { title, url, description, icon, category, sort_order, embed_url, is_internal } = req.body;
if (!title || !url) return res.status(400).json({ error: '标题和链接不能为空' });
const id = db.run(
'INSERT INTO links (title, url, description, icon, category, sort_order, embed_url, is_internal) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[title, url, description || '', icon || '', category || '默认', sort_order || 0, embed_url || '', is_internal ? 1 : 0]
);
res.json(db.get('SELECT * FROM links WHERE id = ?', [id]));
});
router.put('/:id', authMiddleware, adminOnly, (req, res) => {
const { title, url, description, icon, category, sort_order, embed_url, is_internal } = req.body;
if (!db.get('SELECT id FROM links WHERE id = ?', [req.params.id])) return res.status(404).json({ error: '链接不存在' });
db.run(
'UPDATE links SET title=?, url=?, description=?, icon=?, category=?, sort_order=?, embed_url=?, is_internal=? WHERE id=?',
[title || '', url || '', description || '', icon || '', category || '默认', sort_order || 0, embed_url || '', is_internal ? 1 : 0, req.params.id]
);
res.json(db.get('SELECT * FROM links WHERE id = ?', [req.params.id]));
});
router.delete('/:id', authMiddleware, adminOnly, (req, res) => {
if (!db.get('SELECT id FROM links WHERE id = ?', [req.params.id])) return res.status(404).json({ error: '链接不存在' });
db.run('DELETE FROM links WHERE id = ?', [req.params.id]);
res.json({ message: '删除成功' });
});
module.exports = router;
+7 -4
View File
@@ -6,18 +6,21 @@ const router = express.Router();
const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color', const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
'recaptcha_site_key','turnstile_site_key', 'recaptcha_site_key','turnstile_site_key',
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style', 'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style',
'glass_blur','glass_opacity','theme_force_dark', 'glass_blur','glass_opacity','theme_force_dark',
'captcha_type','captcha_login','captcha_register','captcha_forum', 'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar', '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']; 'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
'footer_style','footer_copyright','footer_powered','footer_desc'];
const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key', 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', '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','theme_force_dark', 'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark',
'captcha_type','captcha_login','captcha_register','captcha_forum', 'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar', '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']; 'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
'footer_style','footer_copyright','footer_powered','footer_desc',
'comment_moderate','comment_notify'];
const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key']; const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key'];
+6 -2
View File
@@ -32,6 +32,7 @@ const uploadRoutes = require('./routes/upload');
const setupRoutes = require('./routes/setup'); const setupRoutes = require('./routes/setup');
const proxyRoutes = require('./routes/proxy'); const proxyRoutes = require('./routes/proxy');
const importRoutes = require('./routes/import'); const importRoutes = require('./routes/import');
const feedRoutes = require('./routes/feed');
const { blogSSR, forumSSR, sitemapXml } = require('./ssr'); const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
const app = express(); const app = express();
@@ -129,6 +130,8 @@ app.get('/forum/manage/:id', (req, res) => {
res.redirect('/forum-manage.html'); res.redirect('/forum-manage.html');
}); });
app.get('/sitemap.xml', sitemapXml); app.get('/sitemap.xml', sitemapXml);
// RSS 订阅:显式路由,须在 SPA catch-all 之前(挂在 SSR 区附近)
app.use(feedRoutes);
app.get('/robots.txt', (req, res) => { app.get('/robots.txt', (req, res) => {
const db = require('./db'); const db = require('./db');
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host')); const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
@@ -139,8 +142,9 @@ Allow: /
Sitemap: ${domain}/sitemap.xml`); Sitemap: ${domain}/sitemap.xml`);
}); });
// 管理后台独立应用(Phase 6):/admin* → dist/admin.html(必须注册在 app.get('*') SPA fallback 之前) // 管理后台独立应用(Phase 6):/admin 与 /admin/* → dist/admin.html(必须注册在 app.get('*') SPA fallback 之前)
app.get('/admin*', (req, res) => { // 注意:用路径数组 ['/admin', '/admin/*'] 精确匹配,避免 /adminfoo 等前缀误命中
app.get(['/admin', '/admin/*'], (req, res) => {
const adminPath = path.join(__dirname, 'public', 'dist', 'admin.html'); const adminPath = path.join(__dirname, 'public', 'dist', 'admin.html');
if (require('fs').existsSync(adminPath)) { if (require('fs').existsSync(adminPath)) {
res.sendFile(adminPath); res.sendFile(adminPath);
+2
View File
@@ -100,6 +100,8 @@ function renderSSR(content, useMarkdown) {
function blogSSR(req, res) { 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]); 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('文章不存在'); if (!post || !post.published) return res.status(404).send('文章不存在');
// 阅读量 +1(SEO 页访问也计入)
db.run('UPDATE blog_posts SET views = views + 1 WHERE id = ?', [post.id]);
const body = renderSSR(post.content, post.use_markdown); const body = renderSSR(post.content, post.use_markdown);
const excerpt = post.excerpt || post.content.slice(0, 150); const excerpt = post.excerpt || post.content.slice(0, 150);
const siteName = db.getSetting('site_name') || 'RainWeb'; const siteName = db.getSetting('site_name') || 'RainWeb';