14 Commits
Author SHA1 Message Date
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
31 changed files with 1979 additions and 133 deletions
+1
View File
@@ -7,3 +7,4 @@ uploads/*
server.pid
releases/
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。
- **挂载顺序关键**`serveIndex('/')``/assets`dist/assetsimmutable)→ static(public) → `/uploads``/api/*``/admin*`dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。
- `routes/links.js` 未被挂载,是死代码(勿依赖)。
- `middleware/auth.js`Bearer JWT`SECRET` = `process.env.JWT_SECRET`**无硬编码回退**——server.js 启动时若 `.env.json``jwt_secret` 则随机生成写入并设置环境变量);`adminOnly` 会查库复查角色(用户被删/降权立即失效)。
- `routes/setup.js``/complete``setup_complete` 门禁('1' 后 403),新密码禁止等于默认 `admin123`
- `routes/proxy.js`:面板嵌入代理——支持 `Authorization` header 或 `?token=` queryiframe 无法带 header);有内网地址拦截(SSRF)。
+1 -1
View File
@@ -1 +1 @@
2.0.0
2.0.3
+56
View File
@@ -23,6 +23,7 @@ Commands:
password [new-pass] Change admin password (leave empty for prompt)
captcha Interactive captcha rule configuration
config Show all current settings
backup Backup database to backups/ (readonly, safe to run while running)
upgrade Git pull + npm install + restart (one-click upgrade)
help Show this help
@@ -31,6 +32,7 @@ Examples:
node cli.js port 8080
node cli.js password MyNewP@ss123
node cli.js captcha
node cli.js backup
node cli.js upgrade
`;
@@ -46,6 +48,7 @@ async function main() {
case 'password': return cmdPassword();
case 'captcha': return cmdCaptcha();
case 'config': return cmdConfig();
case 'backup': return cmdBackup();
case 'upgrade': return cmdUpgrade();
case 'help':
default:
@@ -60,6 +63,10 @@ async function cmdStatus() {
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'}`);
// 最近一次备份时间
const latestBackup = getLatestBackup();
console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份'));
// Check if server is running
try {
await httpGet('http://localhost:' + (getConfigPort()));
@@ -223,6 +230,55 @@ async function cmdRestart() {
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 ===
async function cmdUpgrade() {
console.log('=== RainWeb Upgrade ===\n');
+24 -1
View File
@@ -72,7 +72,9 @@ function initTables() {
db.exec(`CREATE TABLE IF NOT EXISTS blog_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
content TEXT NOT NULL, author_id INTEGER,
author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')),
author_name TEXT DEFAULT '', parent_id INTEGER DEFAULT 0,
status TEXT DEFAULT 'approved',
created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (post_id) REFERENCES blog_posts(id))`);
db.exec(`CREATE TABLE IF NOT EXISTS forum_replies (
@@ -86,10 +88,16 @@ function initTables() {
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL,
published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1,
tags TEXT DEFAULT '', views INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (author_id) REFERENCES users(id))`);
db.exec(`CREATE TABLE IF NOT EXISTS post_likes (
post_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
PRIMARY KEY (post_id, user_id))`);
db.exec(`CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL,
pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '',
@@ -116,6 +124,7 @@ function initTables() {
// 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_comments_post ON blog_comments(post_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
@@ -137,6 +146,14 @@ function migrateSchema() {
// v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑,
// 默认 100000:存量用户保持旧迭代数,旧密文仍可解密)
{ version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } },
// v2: 博客增强——博文标签/阅读量、评论嵌套与审核、点赞表
{ version: 2, up: () => {
try { db.exec("ALTER TABLE blog_posts ADD COLUMN tags TEXT DEFAULT ''"); } catch {}
try { db.exec("ALTER TABLE blog_posts ADD COLUMN views INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE blog_comments ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
} },
];
for (const m of migrations) {
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
@@ -183,6 +200,12 @@ function seedDefaults() {
music_embed_position: 'right',
music_embed_autohide: '0',
music_embed_idle_timeout: '10',
footer_style: 'classic',
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
footer_desc: '',
comment_moderate: '0',
comment_notify: '0',
};
for (const [k, v] of Object.entries(defaults)) {
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
-1
View File
@@ -7,7 +7,6 @@
<meta name="description" content="${site_description}">
<meta property="og:type" content="website">
<link rel="icon" href="${site_favicon}">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
+4
View File
@@ -5,6 +5,8 @@ import Layout from './components/Layout.jsx';
import Home from './pages/Home.jsx';
import Blog from './pages/Blog.jsx';
import BlogDetail from './pages/BlogDetail.jsx';
import Tag from './pages/Tag.jsx';
import Archive from './pages/Archive.jsx';
import Forum from './pages/Forum.jsx';
import ForumDetail from './pages/ForumDetail.jsx';
import Passwords from './pages/Passwords.jsx';
@@ -25,6 +27,8 @@ export default function App() {
<Route path="/" element={<Home />} />
<Route path="/blog.html" element={<Blog />} />
<Route path="/blog/:id" element={<BlogDetail />} />
<Route path="/tag/:name" element={<Tag />} />
<Route path="/archive.html" element={<Archive />} />
<Route path="/forum.html" element={<Forum />} />
<Route path="/forum/:id" element={<ForumDetail />} />
<Route path="/passwords.html" element={<Passwords />} />
+2
View File
@@ -23,6 +23,7 @@ import PaletteIcon from '@mui/icons-material/Palette';
import EmailIcon from '@mui/icons-material/Email';
import ArticleIcon from '@mui/icons-material/Article';
import ForumIcon from '@mui/icons-material/Forum';
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
import PeopleIcon from '@mui/icons-material/People';
import CampaignIcon from '@mui/icons-material/Campaign';
import LinkIcon from '@mui/icons-material/Link';
@@ -39,6 +40,7 @@ const NAV_ITEMS = [
{ path: '/homepage', label: '首页设置', icon: <HomeIcon /> },
{ path: '/email', label: '邮件配置', icon: <EmailIcon /> },
{ path: '/blog', label: '博客管理', icon: <ArticleIcon /> },
{ path: '/comments', label: '评论管理', icon: <ChatBubbleOutlineOutlinedIcon /> },
{ path: '/forum', label: '论坛管理', icon: <ForumIcon /> },
{ path: '/users', label: '用户管理', icon: <PeopleIcon /> },
{ path: '/announcements', label: '公告管理', icon: <CampaignIcon /> },
+2
View File
@@ -18,6 +18,7 @@ import ThemeSettings from './pages/ThemeSettings.jsx';
import Homepage from './pages/Homepage.jsx';
import EmailSettings from './pages/EmailSettings.jsx';
import BlogManage from './pages/BlogManage.jsx';
import CommentManage from './pages/CommentManage.jsx';
import ForumManage from './pages/ForumManage.jsx';
import Users from './pages/Users.jsx';
import Announcements from './pages/Announcements.jsx';
@@ -84,6 +85,7 @@ function AdminApp() {
<Route path="/homepage" element={<Homepage />} />
<Route path="/email" element={<EmailSettings />} />
<Route path="/blog" element={<BlogManage />} />
<Route path="/comments" element={<CommentManage />} />
<Route path="/forum" element={<ForumManage />} />
<Route path="/users" element={<Users />} />
<Route path="/announcements" element={<Announcements />} />
+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 Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import Switch from '@mui/material/Switch';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
/** 基本设置:站名/描述/站点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() {
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);
useEffect(() => {
@@ -19,11 +43,18 @@ export default function Settings() {
site_description: s.site_description || '',
site_url: s.site_url || '',
site_favicon: s.site_favicon || '',
footer_style: s.footer_style || 'classic',
footer_copyright: s.footer_copyright || '',
footer_powered: s.footer_powered || '',
footer_desc: s.footer_desc || '',
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
comment_notify: s.comment_notify === '1' ? '1' : '0',
}))
.catch((e) => showSnack(e.message, 'error'));
}, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const toggle = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked ? '1' : '0' }));
const save = async () => {
setSaving(true);
@@ -36,18 +67,84 @@ export default function Settings() {
setSaving(false);
};
const footStyle = form.footer_style;
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>基本设置</Typography>
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
<Paper sx={{ p: 3 }}>
<TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" />
<TextField fullWidth label="网站描述(SEO" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" />
<TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" />
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={save} disabled={saving}>保存基本设置</Button>
</Box>
</Paper>
<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>
);
}
+40 -2
View File
@@ -18,13 +18,51 @@ export function deletePost(id) {
return request('/blog/posts/' + id, { method: 'DELETE' });
}
// ── 搜索 / 标签 / 归档 / 上下篇 / 点赞(博客增强 B2)──────────
export function searchPosts(q) {
return request('/blog/search?q=' + encodeURIComponent(q || ''));
}
export function getTags() {
return request('/blog/tags');
}
export function getTagPosts(name) {
return request('/blog/tag/' + encodeURIComponent(name));
}
export function getArchive() {
return request('/blog/archive');
}
export function getPrevNext(id) {
return request('/blog/posts/' + id + '/prevnext');
}
export function getLikeState(id) {
return request('/blog/posts/' + id + '/like');
}
export function likePost(id) {
return request('/blog/posts/' + id + '/like', { method: 'POST' });
}
export function unlikePost(id) {
return request('/blog/posts/' + id + '/like', { method: 'DELETE' });
}
// ── 评论 ────────────────────────────────────────
export function listComments(postId) {
return request('/blog/comments/' + postId);
}
export function createComment(postId, content) {
return request('/blog/comments/' + postId, { method: 'POST', body: { content } });
/** content 评论正文;parentId>0 表示回复某条评论(嵌套) */
export function createComment(postId, content, parentId = 0) {
return request('/blog/comments/' + postId, { method: 'POST', body: { content, parent_id: parentId || 0 } });
}
export function deleteComment(id) {
return request('/blog/comments/' + id, { method: 'DELETE' });
}
// ── 评论审核(管理员)──────────────────────────
export function listPendingComments() {
return request('/blog/comments/pending');
}
export function approveComment(id) {
return request('/blog/comments/' + id + '/approve', { method: 'POST' });
}
export function rejectComment(id) {
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
}
+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>
);
}
+2 -10
View File
@@ -7,6 +7,7 @@ import { getToken, setToken, notifyAuthChange } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js';
import MusicEmbed from './MusicEmbed.jsx';
import CaptchaModalHost from './CaptchaModal.jsx';
import Footer from './Footer.jsx';
/** QQ 邮箱自动头像(迁移自 nav.js) */
function qqAvatar(user) {
@@ -193,16 +194,7 @@ export default function Layout() {
<Outlet />
</main>
<footer
style={{
textAlign: 'center',
padding: '20px 16px 64px',
fontSize: 13,
color: 'var(--md-ref-outline)',
}}
>
{siteName} v{version}
</footer>
<Footer settings={settings} version={version} />
<MusicEmbed settings={settings} />
<CaptchaModalHost />
+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>
);
}
+141 -22
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import BlogSidebar from '../components/BlogSidebar.jsx';
import { listPosts } from '../api/blog.js';
import { listPosts, searchPosts, getTags } from '../api/blog.js';
import { getPublicSettings } from '../api/settings.js';
/** 摘要:优先取 excerpt,否则从内容中剥离 markdown 符号截取(照搬 blog.js */
@@ -10,45 +10,164 @@ function excerptOf(p) {
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() {
const [settings, setSettings] = useState({});
const [posts, setPosts] = useState(null); // null=加载中
const [error, setError] = useState('');
// 搜索状态
const [query, setQuery] = useState('');
const [results, setResults] = useState(null); // null=未搜索
const [searching, setSearching] = useState(false);
// 标签云
const [tags, setTags] = useState([]);
useEffect(() => {
getPublicSettings().then(setSettings).catch(() => {});
listPosts()
.then((ps) => setPosts(ps || []))
.catch((e) => setError(e.message || '加载失败'));
getTags()
.then((ts) => setTags(ts || []))
.catch(() => setTags([]));
}, []);
const doSearch = async (e) => {
e && e.preventDefault();
const q = query.trim();
if (!q) { setResults(null); return; }
setSearching(true);
try {
const rs = await searchPosts(q);
setResults(rs || []);
} catch (err) {
setResults([]);
setError(err.message || '搜索失败');
}
setSearching(false);
};
const clearSearch = () => {
setQuery('');
setResults(null);
setError('');
};
// 搜索结果以列表呈现(区别于瀑布流卡片)
const renderResults = () => (
<div className="search-results">
<div className="search-results-head">
<span>搜索结果{results.length}</span>
<button className="btn btn-text btn-sm" onClick={clearSearch}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span> 清空
</button>
</div>
{results.length === 0 ? (
<div className="empty-state"><div className="empty-icon">🔍</div><p>没有找到相关内容</p></div>
) : (
<div className="search-results-list">
{results.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="search-result-item">
<div className="sri-title">{p.title}</div>
{p.excerpt && <div className="sri-excerpt">{p.excerpt}</div>}
<div className="sri-meta">
<span>{p.author_name || '管理员'}</span>
<span className="sep">·</span>
<span>{p.created_at}</span>
{p.tags && <span className="sri-tags">{p.tags}</span>}
</div>
</Link>
))}
</div>
)}
</div>
);
return (
<div className="homepage-layout">
<div className="homepage-layout blog-layout">
<BlogSidebar settings={settings} />
<div className="homepage-content">
{!posts && !error && (
<div className="loading"><div className="spinner"></div></div>
)}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>加载失败</p></div>
)}
{posts && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div>
)}
{posts && posts.length > 0 && (
<div className="blog-waterfall">
{posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
<div className="blog-featured"></div>
<div className="blog-title">{p.title}</div>
<div className="blog-excerpt">{excerptOf(p)}</div>
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
</Link>
))}
{/* 工具条:搜索框 + 归档 */}
<form className="blog-toolbar" onSubmit={doSearch}>
<div className="search-box">
<span className="material-icons">search</span>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索文章标题 / 内容..."
aria-label="搜索文章"
/>
{query && (
<button type="button" className="search-clear" onClick={() => setQuery('')} aria-label="清空输入">
<span className="material-icons">close</span>
</button>
)}
</div>
<button type="submit" className="btn btn-tonal btn-sm" disabled={searching}>
{searching ? '搜索中...' : '搜索'}
</button>
<Link to="/archive.html" className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
</Link>
</form>
{/* 标签云 */}
{tags.length > 0 && (
<div className="tag-cloud card">
<div className="tag-cloud-title">
<span className="material-icons">local_offer</span> 标签
</div>
<div className="tag-cloud-body">
{tags.map((t) => (
<Link key={t.name} to={`/tag/${encodeURIComponent(t.name)}`} className="tag-chip" style={{ fontSize: tagSize(t.count) }}>
{t.name}
<span className="tag-count">{t.count}</span>
</Link>
))}
</div>
</div>
)}
{/* 搜索结果 / 瀑布流 */}
{results !== null ? (
renderResults()
) : (
<>
{!posts && !error && (
<div className="loading"><div className="spinner"></div></div>
)}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>{error}</p></div>
)}
{posts && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div>
)}
{posts && posts.length > 0 && (
<div className="blog-waterfall">
{posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
<div className="blog-featured"></div>
<div className="blog-title">{p.title}</div>
<div className="blog-excerpt">{excerptOf(p)}</div>
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
</Link>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
/** 标签字号随文章数渐变(经典标签云效果):1 篇 ~12px,8 篇及以上 ~18px */
function tagSize(count) {
const c = Number(count) || 1;
const size = 12 + Math.min(c, 8) * 0.75;
return size.toFixed(1) + 'px';
}
+196 -23
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 * as blogApi from '../api/blog.js';
import { me } from '../api/auth.js';
@@ -6,9 +6,20 @@ import { getToken } from '../api/client.js';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import { showSnackbar } from '../lib/utils.js';
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
function countWords(content) {
const plain = String(content || '')
.replace(/\[image:[^\]]*\]|\[file:[^\]]*\]/g, '')
.replace(/[#*`\[\]()>|~_!-]/g, '');
const cjk = (plain.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
const words = plain.replace(/[\u4e00-\u9fff\u3400-\u4dbf]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
return cjk + words;
}
/**
* 博客详情页(路由 /blog/:id,SPA 内走前端路由,SSR 直链由后端处理):
* 文章正文(MarkdownRenderer+ 评论列表/发表 + 编辑按钮(作者/管理员可见)。
* 博客详情页(路由 /blog/:id):
* 正文(MarkdownRenderer+ 阅读量/标签/点赞 + 目录/字数 + 上一篇/下一篇 +
* 嵌套评论(parent_id 回复)。
*/
export default function BlogDetail() {
const { id } = useParams();
@@ -19,15 +30,27 @@ export default function BlogDetail() {
const [error, setError] = useState('');
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [prevnext, setPrevnext] = useState(null);
const [like, setLike] = useState({ liked: false, count: 0 });
const [toc, setToc] = useState([]);
const [tocOpen, setTocOpen] = useState(true);
const [replyTo, setReplyTo] = useState(null); // {id, name}
const load = useCallback(async () => {
setLoading(true);
setError('');
setReplyTo(null);
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);
const cs = await blogApi.listComments(id);
setComments(cs || []);
setPrevnext(pn);
setLike(lk || { liked: false, count: 0 });
} catch (e) {
setError(e.message || '加载失败');
} finally {
@@ -44,16 +67,78 @@ export default function BlogDetail() {
}
}, [load]);
// 目录:从已渲染的 .md-body 提取 h2/h3 并打锚点 id
useEffect(() => {
if (!post) return;
const body = document.querySelector('.blog-article .md-body');
if (!body) { setToc([]); return; }
const items = [...body.querySelectorAll('h2, h3')].map((h, i) => {
if (!h.id) h.id = 'toc-' + i;
return { id: h.id, text: (h.textContent || '').trim(), level: h.tagName === 'H2' ? 2 : 3 };
});
setToc(items);
}, [post]);
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
// ── 点赞(乐观更新)──
const toggleLike = async () => {
if (!user) { showSnackbar('登录后可以点赞'); return; }
const next = !like.liked;
setLike((s) => ({ liked: next, count: Math.max(0, s.count + (next ? 1 : -1)) }));
try {
const r = next ? await blogApi.likePost(id) : await blogApi.unlikePost(id);
setLike({ liked: r.liked, count: r.count });
} catch (e) {
setLike((s) => ({ liked: !s.liked, count: Math.max(0, s.count + (s.liked ? 1 : -1)) }));
showSnackbar(e.message);
}
};
// ── 嵌套评论 ──
const childrenOf = useCallback((pid) => comments.filter((c) => c.parent_id === pid), [comments]);
const rootComments = comments.filter((c) => !c.parent_id);
const renderComment = (c, depth = 0) => {
const kids = childrenOf(c.id);
return (
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
<div className="reply-meta">
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
</div>
<div className="reply-body">{c.content}</div>
{user && (
<button
className="btn btn-text btn-sm reply-btn"
onClick={() => {
setReplyTo({ id: c.id, name: c.author_name || '游客' });
setCommentText(`@${c.author_name || '游客'} `);
}}
>
<span className="material-icons" style={{ fontSize: 14 }}>reply</span> 回复
</button>
)}
{kids.length > 0 && (
<div className="reply-children">
{kids.map((k) => renderComment(k, depth + 1))}
</div>
)}
</div>
);
};
const submitComment = async () => {
const content = commentText.trim();
if (!content) { showSnackbar('评论不能为空'); return; }
setSubmitting(true);
try {
await blogApi.createComment(id, content);
await blogApi.createComment(id, content, replyTo ? replyTo.id : 0);
showSnackbar('评论已发表');
setCommentText('');
setReplyTo(null);
await load();
} catch (e) {
showSnackbar(e.message);
@@ -80,41 +165,129 @@ export default function BlogDetail() {
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
</Link>
<h1 className="article-title">{post.title}</h1>
<div className="article-meta">
{post.author_name || '管理员'} · {post.created_at}
<span className="meta-stat" title="阅读量">
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
</span>
<span className="meta-stat" title="字数"> {wordCount} </span>
</div>
{tags.length > 0 && (
<div className="article-tags">
{tags.map((t) => (
<Link key={t} to={`/tag/${encodeURIComponent(t)}`} className="chip article-tag-chip">{t}</Link>
))}
</div>
)}
<div className="article-actions">
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞">
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
<span className="like-count">{like.count}</span>
</button>
{canEdit && (
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm" style={{ marginLeft: 12 }}>编辑</Link>
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
)}
</div>
{/* 目录(正文顶部,可折叠) */}
{toc.length > 0 && (
<div className="toc-block">
<button className="toc-toggle" onClick={() => setTocOpen((v) => !v)}>
<span className="material-icons" style={{ fontSize: 17 }}>format_list_bulleted</span>
目录
<span className="toc-count">{toc.length}</span>
<span className={'material-icons toc-arrow' + (tocOpen ? ' open' : '')} style={{ fontSize: 18 }}>expand_more</span>
</button>
{tocOpen && (
<ul className="toc-list">
{toc.map((t) => (
<li key={t.id} className={'toc-item toc-lv' + t.level}>
<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>
)}
</div>
)}
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
{/* 上一篇 / 下一篇 */}
{prevnext && (prevnext.prev || prevnext.next) && (
<nav className="prevnext-nav">
<div className="pn-col pn-prev">
{prevnext.prev ? (
<Link to={`/blog/${prevnext.prev.id}`}>
<span className="pn-label">上一篇</span>
<span className="pn-title">{prevnext.prev.title}</span>
</Link>
) : (
<span className="pn-disabled"><span className="pn-label">上一篇</span><span className="pn-title">没有了</span></span>
)}
</div>
<div className="pn-col pn-next">
{prevnext.next ? (
<Link to={`/blog/${prevnext.next.id}`}>
<span className="pn-label">下一篇</span>
<span className="pn-title">{prevnext.next.title}</span>
</Link>
) : (
<span className="pn-disabled"><span className="pn-label">下一篇</span><span className="pn-title">没有了</span></span>
)}
</div>
</nav>
)}
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4>
{comments.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
) : (
comments.map((c) => (
<div className="reply-item" key={c.id}>
<div className="reply-meta"><strong>{c.author_name || '游客'}</strong> · {c.created_at}</div>
<div className="reply-body">{c.content}</div>
</div>
))
<div className="comment-list">
{rootComments.map((c) => renderComment(c))}
</div>
)}
{user ? (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="写下你的评论..."
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
/>
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
发表评论
</button>
<div className="comment-form">
{replyTo && (
<div className="reply-to-hint">
<span className="material-icons" style={{ fontSize: 15 }}>reply</span>
回复 <strong>@{replyTo.name}</strong>
<button
className="btn btn-text btn-sm"
onClick={() => { setReplyTo(null); setCommentText(''); }}
>
<span className="material-icons" style={{ fontSize: 15 }}>close</span> 取消
</button>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: replyTo ? 8 : 0 }}>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={replyTo ? `回复 @${replyTo.name}...` : '写下你的评论...'}
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
/>
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
发表评论
</button>
</div>
</div>
) : (
<p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}>
+29 -2
View File
@@ -56,6 +56,9 @@ export default function Passwords() {
const [confirmPin, setConfirmPin] = useState('');
const [pinSaving, setPinSaving] = useState(false);
// 修改 PIN 警告确认(修改后旧密文无法解密,需重新添加条目)
const [pinChangeWarn, setPinChangeWarn] = useState(false);
// 添加/编辑条目弹窗
const [pwDialog, setPwDialog] = useState(false);
const [editingId, setEditingId] = useState(null);
@@ -154,7 +157,7 @@ export default function Passwords() {
setPhase('locked');
setPinInput('');
setPinError('');
setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false);
setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false); setPinChangeWarn(false);
showSnackbar('已锁定');
} catch (e) {
showSnackbar(e.message);
@@ -167,6 +170,16 @@ export default function Passwords() {
setPinDialog(true);
};
// 修改 PIN:先弹警告确认,确认后再打开设置弹窗
const openPinChange = () => {
setPinChangeWarn(true);
};
const proceedPinChange = () => {
setPinChangeWarn(false);
openPinDialog();
};
const openAddDialog = () => {
setEditingId(null);
setFTitle(''); setFUsername(''); setFPassword(''); setFUrl(''); setFNotes('');
@@ -281,7 +294,7 @@ export default function Passwords() {
<button className="btn btn-tonal" onClick={lockVault}>
<span className="material-icons">lock</span> 锁定
</button>
<button className="btn btn-text" onClick={openPinDialog}>
<button className="btn btn-text" onClick={openPinChange}>
<span className="material-icons">edit</span> 修改 PIN
</button>
<button className="btn btn-filled" onClick={openAddDialog}>
@@ -461,6 +474,20 @@ export default function Passwords() {
</div>
)}
{/* 修改 PIN 警告确认弹窗 */}
{pinChangeWarn && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
<div className="dialog">
<h3>确认修改 PIN</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}> 修改 PIN 现有密码条目将无法解密需要重新添加确定继续</p>
<div className="actions">
<button className="btn btn-text" onClick={() => setPinChangeWarn(false)}>取消</button>
<button className="btn btn-danger" onClick={proceedPinChange}>确定继续</button>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{confirmDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
+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 [excerpt, setExcerpt] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState('');
const [published, setPublished] = useState(true);
const [preview, setPreview] = useState(false);
@@ -34,6 +35,7 @@ export default function Write() {
.then((p) => {
setTitle(p.title);
setExcerpt(p.excerpt || '');
setTags(p.tags || '');
setContent(p.content);
setPublished(!!p.published);
})
@@ -76,6 +78,7 @@ export default function Write() {
title: title.trim(),
content: content.trim(),
excerpt: excerpt.trim(),
tags: tags.trim(),
published,
use_markdown: 1,
};
@@ -123,6 +126,11 @@ export default function Write() {
<label>摘要</label>
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
</div>
<div className="form-group">
<label>标签</label>
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="多个标签用英文逗号分隔,如:技术,生活,随笔" />
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>多个标签用英文逗号分隔用于标签云与标签页</div>
</div>
<div className="form-group">
<label>内容 *</label>
<textarea
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "rainweb-links",
"version": "2.0.0",
"version": "2.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rainweb-links",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
@@ -506,7 +506,7 @@
}
},
"node_modules/@emotion/utils": {
"version": "2.0.0",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
@@ -1320,7 +1320,7 @@
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
@@ -1480,7 +1480,7 @@
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
@@ -1557,7 +1557,7 @@
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
@@ -2557,7 +2557,7 @@
}
},
"node_modules/ms": {
"version": "2.0.0",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "rainweb-links",
"version": "2.0.0",
"version": "2.0.3",
"description": "链接聚合管理平台",
"main": "server.js",
"scripts": {
+683 -2
View File
@@ -225,12 +225,33 @@ button:active, .btn:active {
background: var(--md-ref-primary-container);
}
/* Material Icons - 自托管(本地 @font-face,替代 Google Fonts,国内访问稳定) */
@font-face {
font-family: 'Material Icons';
src: url('/fonts/MaterialIcons-Regular.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Material Icons - ensure perfect centering */
.material-icons {
vertical-align: middle;
line-height: 1;
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
vertical-align: middle;
}
.btn-icon {
@@ -466,6 +487,9 @@ table tr:hover td {
max-width: 960px;
margin: 0 auto;
padding: 24px 16px;
/* 内容区撑满视口剩余高度(导航 56px),页脚落在视口之外需滚动可见;
内容超出一屏时页脚自然跟随文档末尾 */
min-height: calc(100vh - 56px);
}
.page-wide {
@@ -1768,3 +1792,660 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
color: var(--md-ref-on-primary);
}
/* ===== 页脚(footer_style: classic / columns / glass,视觉对齐 footer-demo 02/03/04 ===== */
/* ---- 经典左右 ---- */
.footer-classic {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 22px 28px 28px;
border-top: 1px solid var(--md-ref-outline-variant);
background: var(--md-ref-background);
}
.footer-classic .fc-left {
font-size: 13px;
letter-spacing: 0.3px;
color: var(--md-ref-on-surface-variant);
}
.footer-classic .fc-left .sep {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
margin: 0 10px;
vertical-align: middle;
background: linear-gradient(135deg, var(--md-ref-primary), #d0bcff);
box-shadow: 0 0 8px rgba(103, 80, 164, 0.7);
}
.footer-classic .fc-right {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
color: var(--md-ref-on-primary-container);
background: var(--md-ref-primary-container);
border-radius: 100px;
padding: 5px 14px;
box-shadow: 0 1px 3px var(--md-shadow);
}
.footer-classic .fc-version {
font-size: 11px;
font-weight: 500;
opacity: 0.7;
}
/* ---- 分栏导航 ---- */
.footer-columns {
background: var(--md-ref-background);
border-top: 1px solid var(--md-ref-outline-variant);
}
.footer-columns .fc-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 32px;
padding: 36px 28px 28px;
max-width: 1080px;
margin: 0 auto;
}
.fc-brand .brand-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.fc-brand .brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--md-ref-primary);
color: var(--md-ref-on-primary);
font-size: 17px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.fc-brand .brand-name { font-size: 17px; font-weight: 600; letter-spacing: 0.3px; }
.fc-brand .brand-version { font-size: 12px; font-weight: 500; color: var(--md-ref-outline); }
.fc-brand .brand-desc {
font-size: 13px;
line-height: 1.7;
color: var(--md-ref-on-surface-variant);
max-width: 300px;
}
.fc-col .col-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--md-ref-outline);
margin-bottom: 12px;
}
.fc-col .col-links { display: flex; flex-direction: column; gap: 8px; }
.fc-col .col-links a {
font-size: 14px;
color: var(--md-ref-on-surface-variant);
text-decoration: none;
transition: color 0.2s;
}
.fc-col .col-links a:hover { color: var(--md-ref-primary); }
.footer-columns .fc-bottom {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border-top: 1px solid var(--md-ref-outline-variant);
padding: 16px 28px;
font-size: 12.5px;
letter-spacing: 0.3px;
color: var(--md-ref-on-surface-variant);
background: var(--md-ref-surface-container-low);
}
.footer-columns .fc-bottom .sep {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
margin: 0 10px;
vertical-align: middle;
background: linear-gradient(135deg, var(--md-ref-primary), #d0bcff);
box-shadow: 0 0 8px rgba(103, 80, 164, 0.7);
}
/* ---- 玻璃卡片 ---- */
.footer-glass-wrap {
position: relative;
overflow: hidden;
padding: 28px;
background: linear-gradient(135deg,
var(--md-ref-primary-container) 0%,
var(--md-ref-secondary-container) 45%,
var(--md-ref-background) 100%);
}
.footer-glass-wrap .blob {
position: absolute;
border-radius: 50%;
filter: blur(12px);
opacity: 0.5;
pointer-events: none;
}
.footer-glass-wrap .blob-1 { width: 240px; height: 240px; top: -80px; left: -40px; background: var(--md-ref-primary); }
.footer-glass-wrap .blob-2 { width: 280px; height: 280px; top: -60px; right: 6%; background: var(--md-ref-secondary); }
.footer-glass-wrap .blob-3 { width: 200px; height: 200px; bottom: -90px; left: 40%; background: var(--md-ref-primary); opacity: 0.35; }
.footer-glass {
position: relative;
z-index: 2;
max-width: 1080px;
margin: 0 auto;
padding: 22px 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
border-radius: 20px;
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
box-shadow: 0 8px 32px var(--md-shadow);
transition: background 0.3s, border-color 0.3s;
}
.footer-glass:hover {
background: var(--glass-bg-heavy);
border-color: var(--md-ref-primary);
}
.fg-left { display: flex; align-items: center; gap: 12px; }
.fg-logo {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--md-ref-primary);
color: var(--md-ref-on-primary);
font-size: 19px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px var(--md-shadow);
flex-shrink: 0;
}
.fg-name { font-size: 15px; font-weight: 600; letter-spacing: 0.2px; }
.fg-tag { font-size: 12px; color: var(--md-ref-on-surface-variant); margin-top: 2px; }
.fg-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
font-size: 12.5px;
line-height: 1.6;
letter-spacing: 0.3px;
color: var(--md-ref-on-surface-variant);
text-align: right;
}
.fg-right .fg-powered { color: var(--md-ref-primary); font-weight: 500; }
/* ===== 博客列表页移动端首屏修复:内容优先,侧栏移到底部 ===== */
@media (max-width: 768px) {
.blog-layout .homepage-content { order: 1; width: 100%; }
.blog-layout .homepage-sidebar { order: 2; width: 100%; }
}
/* ===== 博客增强:搜索 / 标签云 / 归档 / 点赞 / 目录 / 嵌套评论(B2 ===== */
/* ---- 列表页工具条 + 搜索框 ---- */
.blog-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 8px 0 14px;
border-radius: 100px;
border: 1px solid var(--md-ref-outline-variant);
background: var(--md-ref-surface-container-low);
color: var(--md-ref-on-surface-variant);
transition: border 0.2s, box-shadow 0.2s;
}
.search-box:focus-within {
border-color: var(--md-ref-primary);
box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.15);
}
.search-box .material-icons { font-size: 20px; flex-shrink: 0; }
.search-box input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0;
font-size: 14px;
color: var(--md-ref-on-surface);
outline: none;
box-shadow: none !important;
}
.search-clear {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--md-ref-on-surface-variant);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.search-clear:hover { background: var(--md-ref-surface-variant); }
.search-clear .material-icons { font-size: 18px; }
/* ---- 搜索结果 ---- */
.search-results-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
color: var(--md-ref-on-surface);
margin-bottom: 12px;
}
.search-results-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.search-result-item {
display: block;
text-decoration: none;
padding: 16px 18px;
border-radius: 16px;
border: 1px solid var(--md-ref-outline-variant);
background: var(--md-card-bg);
transition: all 0.25s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.search-result-item:hover {
border-color: var(--md-ref-primary);
box-shadow: 0 4px 16px var(--md-shadow);
transform: translateY(-2px);
}
.sri-title { font-size: 16px; font-weight: 600; color: var(--md-ref-on-surface); margin-bottom: 4px; }
.sri-excerpt {
font-size: 13px;
line-height: 1.6;
color: var(--md-ref-on-surface-variant);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 6px;
}
.sri-meta { font-size: 12px; color: var(--md-ref-outline); display: flex; gap: 6px; flex-wrap: wrap; }
.sri-meta .sep { color: var(--md-ref-outline-variant); }
.sri-tags { color: var(--md-ref-primary); }
/* ---- 标签云 ---- */
.tag-cloud { padding: 14px 18px; margin-bottom: 16px; }
.tag-cloud-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--md-ref-on-surface-variant);
margin-bottom: 10px;
}
.tag-cloud-title .material-icons { font-size: 16px; color: var(--md-ref-primary); }
.tag-cloud-body { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: baseline; }
.tag-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
color: var(--md-ref-primary);
text-decoration: none;
line-height: 1.6;
transition: color 0.2s, transform 0.2s;
}
.tag-chip:hover { color: var(--md-ref-on-primary-container); text-decoration: none; transform: translateY(-1px); }
.tag-chip .tag-count { font-size: 10px; color: var(--md-ref-outline); }
.tag-title-count { font-size: 14px; font-weight: 400; color: var(--md-ref-on-surface-variant); margin-left: 8px; }
/* ---- 详情页 meta / 标签 / 点赞 ---- */
.meta-stat {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--md-ref-on-surface-variant);
}
.article-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.article-tag-chip { text-decoration: none; transition: all 0.2s; }
.article-tag-chip:hover { border-color: var(--md-ref-primary); background: var(--md-ref-primary-container); }
.article-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.like-btn {
height: 34px;
padding: 0 16px;
border-radius: 100px;
border: 1px solid var(--md-ref-outline-variant);
background: transparent;
color: var(--md-ref-on-surface-variant);
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.like-btn:hover { border-color: #d32f2f; color: #d32f2f; background: rgba(211, 47, 47, 0.06); }
.like-btn .material-icons { font-size: 18px; }
.like-btn.liked {
border-color: #d32f2f;
color: #d32f2f;
background: rgba(211, 47, 47, 0.1);
}
.like-btn:active { transform: scale(0.94); }
/* ---- 目录 TOC ---- */
.toc-block {
margin: 16px 0 20px;
border: 1px solid var(--md-ref-outline-variant);
border-radius: 16px;
background: var(--md-ref-surface-container-low);
overflow: hidden;
}
.toc-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: none;
background: transparent;
font-family: inherit;
font-size: 14px;
font-weight: 600;
color: var(--md-ref-on-surface);
cursor: pointer;
text-align: left;
}
.toc-toggle:hover { background: var(--md-ref-surface-variant); }
.toc-count {
font-size: 11px;
font-weight: 500;
color: var(--md-ref-on-primary-container);
background: var(--md-ref-primary-container);
border-radius: 100px;
padding: 2px 8px;
}
.toc-arrow { margin-left: auto; transition: transform 0.25s cubic-bezier(0.4, 0.0, 0.2, 1); }
.toc-arrow.open { transform: rotate(180deg); }
.toc-list {
list-style: none;
padding: 4px 16px 14px;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.toc-item a {
display: block;
padding: 6px 10px;
border-radius: 100px;
font-size: 13.5px;
line-height: 1.5;
color: var(--md-ref-on-surface-variant);
text-decoration: none;
transition: all 0.2s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toc-item a:hover { background: var(--md-ref-surface-variant); color: var(--md-ref-primary); }
.toc-lv3 a { padding-left: 26px; font-size: 13px; }
.md-body h2, .md-body h3 { scroll-margin-top: 76px; }
/* ---- 上一篇 / 下一篇 ---- */
.prevnext-nav {
display: flex;
gap: 12px;
margin: 28px 0 8px;
}
.pn-col { flex: 1; min-width: 0; }
.pn-col a, .pn-col .pn-disabled {
display: flex;
flex-direction: column;
gap: 3px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--md-ref-outline-variant);
background: var(--md-ref-surface-container-low);
text-decoration: none;
transition: all 0.25s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.pn-col a:hover {
border-color: var(--md-ref-primary);
box-shadow: 0 4px 16px var(--md-shadow);
}
.pn-label { font-size: 12px; font-weight: 500; color: var(--md-ref-outline); }
.pn-title {
font-size: 14px;
font-weight: 500;
color: var(--md-ref-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pn-col a:hover .pn-title { color: var(--md-ref-primary); }
.pn-col .pn-disabled { opacity: 0.5; cursor: default; }
.pn-next a, .pn-next .pn-disabled { align-items: flex-end; text-align: right; }
/* ---- 嵌套评论 ---- */
.comment-list { display: flex; flex-direction: column; gap: 12px; }
.reply-children {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
margin-left: 26px;
padding-left: 14px;
border-left: 2px solid var(--md-ref-primary-container);
}
.reply-count {
font-size: 11px;
font-weight: 500;
color: var(--md-ref-on-primary-container);
background: var(--md-ref-primary-container);
border-radius: 100px;
padding: 1px 8px;
margin-left: 8px;
}
.reply-btn {
margin-top: 8px;
height: 28px;
padding: 0 10px;
font-size: 12px;
color: var(--md-ref-on-surface-variant);
}
.reply-btn:hover { color: var(--md-ref-primary); background: var(--md-ref-primary-container); }
.comment-form { margin-top: 12px; }
.reply-to-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--md-ref-on-surface-variant);
background: var(--md-ref-primary-container);
color: var(--md-ref-on-primary-container);
border-radius: 12px;
padding: 8px 12px;
}
.reply-to-hint .btn { color: inherit; font-size: 12px; margin-left: auto; height: 26px; padding: 0 8px; }
.reply-to-hint strong { color: var(--md-ref-primary); }
/* ---- 归档页 ---- */
.archive-list { display: flex; flex-direction: column; }
.archive-month { padding: 18px 0 6px; border-bottom: 1px solid var(--md-ref-outline-variant); }
.archive-month:first-child { padding-top: 8px; }
.archive-month-title {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 17px;
font-weight: 600;
color: var(--md-ref-on-surface);
margin-bottom: 12px;
}
.archive-month-count {
font-size: 12px;
font-weight: 500;
color: var(--md-ref-on-primary-container);
background: var(--md-ref-primary-container);
border-radius: 100px;
padding: 2px 10px;
}
.archive-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 10px 8px;
border-radius: 12px;
text-decoration: none;
transition: background 0.2s;
}
.archive-item:hover { background: var(--md-ref-surface-container); }
.archive-item-title {
font-size: 14.5px;
font-weight: 500;
color: var(--md-ref-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-item:hover .archive-item-title { color: var(--md-ref-primary); }
.archive-item-date { font-size: 12px; color: var(--md-ref-outline); flex-shrink: 0; }
/* ===== 博客增强响应式 ===== */
@media (max-width: 720px) {
.reply-children { margin-left: 12px; padding-left: 10px; }
.prevnext-nav { flex-direction: column; }
.pn-next a, .pn-next .pn-disabled { align-items: flex-start; text-align: left; }
.archive-item-title { white-space: normal; }
}
/* ===== 页脚响应式 ===== */
@media (max-width: 720px) {
.footer-columns .fc-grid { grid-template-columns: 1fr; gap: 22px; }
.footer-glass-wrap { padding: 20px; }
.fg-right { align-items: flex-start; text-align: left; }
.footer-classic { padding: 20px 20px 24px; }
.footer-columns .fc-bottom { padding: 14px 20px; }
}
/* ===== 炫酷版权发光(footer_copyright 用 <span class="copyright-glow">…</span> ===== */
.copyright-glow {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 10px;
padding: 8px 22px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(103, 80, 164, 0.16), rgba(224, 163, 255, 0.09) 50%, rgba(255, 138, 171, 0.10));
border: 1px solid var(--md-ref-outline-variant);
font-weight: 600;
letter-spacing: 0.6px;
color: var(--md-ref-on-surface);
backdrop-filter: blur(8px);
animation: copyrightGlow 3s ease-in-out infinite;
}
.copyright-glow b,
.copyright-glow .grad {
background: linear-gradient(90deg, var(--md-ref-primary), #d0bcff, #ff8fab);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
@keyframes copyrightGlow {
0%, 100% {
box-shadow: 0 0 14px rgba(103, 80, 164, 0.30), 0 0 32px rgba(208, 188, 255, 0.12), inset 0 0 10px rgba(255, 255, 255, 0.05);
border-color: var(--md-ref-outline-variant);
}
50% {
box-shadow: 0 0 22px rgba(103, 80, 164, 0.55), 0 0 48px rgba(208, 188, 255, 0.25), inset 0 0 14px rgba(255, 255, 255, 0.08);
border-color: rgba(208, 188, 255, 0.5);
}
}
/* ===== 炫酷 Powered by 徽章(footer_powered 用 <span class="powered-glow">…</span> ===== */
.powered-glow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 18px;
border-radius: 999px;
position: relative;
overflow: hidden;
font-weight: 700;
letter-spacing: 1.2px;
font-size: 13px;
color: #fff;
text-shadow: 0 0 10px rgba(208, 188, 255, 0.8), 0 0 20px rgba(103, 80, 164, 0.5);
background: linear-gradient(135deg, #6750a4, #7c4dff 40%, #b388ff 70%, #ff8fab);
background-size: 200% 200%;
animation: poweredFlow 4s ease infinite, poweredPulse 2.4s ease-in-out infinite;
box-shadow: 0 0 16px rgba(124, 77, 255, 0.45), 0 0 36px rgba(103, 80, 164, 0.25);
}
.powered-glow::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1.5px;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.15) 40%, rgba(255, 138, 171, 0.8));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: poweredBorder 3.2s linear infinite;
}
.powered-glow::after {
content: '';
position: absolute;
top: -50%;
left: -60%;
width: 40%;
height: 200%;
transform: rotate(20deg);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent);
animation: poweredShine 3.2s ease-in-out infinite;
}
@keyframes poweredFlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes poweredPulse {
0%, 100% { box-shadow: 0 0 16px rgba(124, 77, 255, 0.45), 0 0 36px rgba(103, 80, 164, 0.25); }
50% { box-shadow: 0 0 24px rgba(124, 77, 255, 0.7), 0 0 56px rgba(103, 80, 164, 0.4); }
}
@keyframes poweredBorder {
0% { filter: hue-rotate(0deg); opacity: 0.7; }
50% { filter: hue-rotate(40deg); opacity: 1; }
100% { filter: hue-rotate(0deg); opacity: 0.7; }
}
@keyframes poweredShine {
0%, 55% { left: -60%; }
100% { left: 130%; }
}
Binary file not shown.
+200 -12
View File
@@ -1,10 +1,20 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const db = require('../db');
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router();
// 评论限流:10 分钟窗口内最多 15 次
const commentLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 15,
standardHeaders: true,
legacyHeaders: false,
message: { error: '评论过于频繁,请稍后再试' },
});
// 可选鉴权:有效 token 时解析出 req.user,匿名请求直接放行(用于草稿的"作者可见"判断)
function optionalAuth(req, res, next) {
const header = req.headers.authorization;
@@ -16,6 +26,47 @@ function optionalAuth(req, res, 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) => {
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'));
});
// 搜索:标题/正文/摘要模糊匹配(参数化 + 通配符转义),仅已发布,最多 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) => {
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 = ?',
@@ -39,26 +137,77 @@ router.get('/posts/:id', optionalAuth, (req, res) => {
if (post.published !== 1) {
if (!req.user || !(req.user.role === 'admin' || req.user.id === post.author_id))
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);
});
// 上一篇 / 下一篇(按 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) => {
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: '标题和内容不能为空' });
const id = db.run(
'INSERT INTO blog_posts (title, content, excerpt, author_id, published, use_markdown) VALUES (?, ?, ?, ?, ?, ?)',
[title, content, excerpt || '', req.user.id, published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1]);
'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, normalizeTags(tags)]);
res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [id]));
});
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]))
return res.status(404).json({ error: '文章不存在' });
db.run(
"UPDATE blog_posts SET title=?, content=?, excerpt=?, published=?, use_markdown=?, 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]);
"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, normalizeTags(tags), 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]))
return res.status(404).json({ error: '文章不存在' });
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]);
res.json({ message: '删除成功' });
});
// 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) => {
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.postId]))
return res.status(404).json({ error: '文章不存在' });
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]);
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 parent_id = parseInt(req.body.parent_id) || 0;
if (!content) return res.status(400).json({ error: '评论内容不能为空' });
if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.postId]))
return res.status(404).json({ error: '文章不存在' });
const id = db.run('INSERT INTO blog_comments (post_id, content, author_id) VALUES (?, ?, ?)',
[req.params.postId, content, req.user.id]);
const post = db.get('SELECT * FROM blog_posts WHERE id = ?', [req.params.postId]);
if (!post) return res.status(404).json({ error: '文章不存在' });
if (parent_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(
'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);
});
// 审核:通过
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) => {
const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]);
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.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;
+5 -2
View File
@@ -10,14 +10,17 @@ const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
'glass_blur','glass_opacity','theme_force_dark',
'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout'];
'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',
'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',
'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout'];
'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'];
+6 -2
View File
@@ -32,6 +32,7 @@ const uploadRoutes = require('./routes/upload');
const setupRoutes = require('./routes/setup');
const proxyRoutes = require('./routes/proxy');
const importRoutes = require('./routes/import');
const feedRoutes = require('./routes/feed');
const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
const app = express();
@@ -129,6 +130,8 @@ app.get('/forum/manage/:id', (req, res) => {
res.redirect('/forum-manage.html');
});
app.get('/sitemap.xml', sitemapXml);
// RSS 订阅:显式路由,须在 SPA catch-all 之前(挂在 SSR 区附近)
app.use(feedRoutes);
app.get('/robots.txt', (req, res) => {
const db = require('./db');
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
@@ -139,8 +142,9 @@ Allow: /
Sitemap: ${domain}/sitemap.xml`);
});
// 管理后台独立应用(Phase 6):/admin* → dist/admin.html(必须注册在 app.get('*') SPA fallback 之前)
app.get('/admin*', (req, res) => {
// 管理后台独立应用(Phase 6):/admin 与 /admin/* → dist/admin.html(必须注册在 app.get('*') SPA fallback 之前)
// 注意:用路径数组 ['/admin', '/admin/*'] 精确匹配,避免 /adminfoo 等前缀误命中
app.get(['/admin', '/admin/*'], (req, res) => {
const adminPath = path.join(__dirname, 'public', 'dist', 'admin.html');
if (require('fs').existsSync(adminPath)) {
res.sendFile(adminPath);
+2
View File
@@ -100,6 +100,8 @@ function renderSSR(content, useMarkdown) {
function blogSSR(req, res) {
const post = db.get('SELECT bp.*, u.username as author_name FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?', [req.params.id]);
if (!post || !post.published) return res.status(404).send('文章不存在');
// 阅读量 +1(SEO 页访问也计入)
db.run('UPDATE blog_posts SET views = views + 1 WHERE id = ?', [post.id]);
const body = renderSSR(post.content, post.use_markdown);
const excerpt = post.excerpt || post.content.slice(0, 150);
const siteName = db.getSetting('site_name') || 'RainWeb';