Compare commits
37
Commits
249ed8cd2c
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb9592d56 | ||
|
|
8a08932d78 | ||
|
|
b152a2a9c9 | ||
|
|
4af71185d5 | ||
|
|
6d60b10c14 | ||
|
|
cf4c7d4ca4 | ||
|
|
5acc461e88 | ||
|
|
391cd1bdae | ||
|
|
5b166ef0e2 | ||
|
|
4f89c63b28 | ||
|
|
7bfeffaaa0 | ||
|
|
aff5764b9b | ||
|
|
bb830f779c | ||
|
|
63d329db5f | ||
|
|
e83a4e6ee1 | ||
|
|
0153f770f6 | ||
|
|
789a2d9b33 | ||
|
|
5a878c1aab | ||
|
|
5e4cdf26b9 | ||
|
|
2faace4810 | ||
|
|
45002b809a | ||
|
|
1b62bf99ce | ||
|
|
6fafa6eb00 | ||
|
|
6649eba777 | ||
|
|
6aedc5f632 | ||
|
|
ed82908a0d | ||
|
|
274291f8fe | ||
|
|
588df32306 | ||
|
|
3c98084909 | ||
|
|
1c0339bae1 | ||
|
|
651bbbb22e | ||
|
|
36be795af5 | ||
|
|
632d19f49e | ||
|
|
3cf560f932 | ||
|
|
ad8883cc61 | ||
|
|
f8d495b128 | ||
|
|
c2ca0f2327 |
@@ -7,3 +7,4 @@ uploads/*
|
||||
server.pid
|
||||
releases/
|
||||
public/dist/
|
||||
backups/
|
||||
|
||||
@@ -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/assets,immutable)→ 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=` query(iframe 无法带 header);有内网地址拦截(SSRF)。
|
||||
|
||||
@@ -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 v13:src.backup(destPath) 接受目标文件路径(string),返回 Promise
|
||||
const Database = require('better-sqlite3');
|
||||
const src = new Database(srcPath, { readonly: true });
|
||||
try {
|
||||
await src.backup(destPath);
|
||||
} finally {
|
||||
src.close();
|
||||
}
|
||||
|
||||
const size = fs.statSync(destPath).size;
|
||||
console.log(`备份成功:${destPath}(${size} bytes)`);
|
||||
}
|
||||
|
||||
// 读取 backups/ 目录下最新的备份文件(按 mtime),无备份返回 null
|
||||
function getLatestBackup() {
|
||||
const backupDir = path.join(__dirname, 'backups');
|
||||
if (!fs.existsSync(backupDir)) return null;
|
||||
let latest = null;
|
||||
for (const f of fs.readdirSync(backupDir)) {
|
||||
if (!f.startsWith('rainweb-') || !f.endsWith('.db')) continue;
|
||||
const filePath = path.join(backupDir, f);
|
||||
const mtime = fs.statSync(filePath).mtime;
|
||||
if (!latest || mtime > latest.mtime) latest = { file: f, mtime };
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function formatMtime(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
async function cmdUpgrade() {
|
||||
console.log('=== RainWeb Upgrade ===\n');
|
||||
|
||||
@@ -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); }
|
||||
@@ -169,6 +186,7 @@ function seedDefaults() {
|
||||
smtp_from_name: 'RainWeb',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: '1',
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
@@ -183,6 +201,12 @@ function seedDefaults() {
|
||||
music_embed_position: 'right',
|
||||
music_embed_autohide: '0',
|
||||
music_embed_idle_timeout: '10',
|
||||
footer_style: 'classic',
|
||||
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
|
||||
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
|
||||
footer_desc: '',
|
||||
comment_moderate: '0',
|
||||
comment_notify: '0',
|
||||
};
|
||||
for (const [k, v] of Object.entries(defaults)) {
|
||||
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
|
||||
|
||||
+1
-2
@@ -7,13 +7,12 @@
|
||||
<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>
|
||||
<div id="root"></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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function ThemeSettings() {
|
||||
primary_color: '#6750a4',
|
||||
theme_wallpaper: '',
|
||||
theme_wallpaper_scale: 'cover',
|
||||
theme_wallpaper_enabled: true,
|
||||
nav_style: 'default',
|
||||
card_style: 'default',
|
||||
glass_blur: '20',
|
||||
@@ -34,6 +35,7 @@ export default function ThemeSettings() {
|
||||
primary_color: s.primary_color || '#6750a4',
|
||||
theme_wallpaper: s.theme_wallpaper || '',
|
||||
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
|
||||
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
|
||||
nav_style: s.nav_style || 'default',
|
||||
card_style: s.card_style || 'default',
|
||||
glass_blur: s.glass_blur || '20',
|
||||
@@ -65,6 +67,7 @@ export default function ThemeSettings() {
|
||||
primary_color: form.primary_color,
|
||||
theme_wallpaper: form.theme_wallpaper.trim(),
|
||||
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
||||
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
|
||||
theme_force_dark: form.theme_force_dark ? '1' : '0',
|
||||
nav_style: form.nav_style,
|
||||
card_style: form.card_style,
|
||||
@@ -92,6 +95,11 @@ export default function ThemeSettings() {
|
||||
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
||||
label="启用壁纸背景"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as captchaApi from '../api/captcha.js';
|
||||
import { useDialog } from '../lib/utils.js';
|
||||
|
||||
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
|
||||
let pending = null; // { action, type, resolve }
|
||||
@@ -140,15 +141,16 @@ function BuiltinCaptcha({ onFinish }) {
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
placeholder="输入验证码"
|
||||
aria-label="输入验证码"
|
||||
maxLength={6}
|
||||
autoComplete="off"
|
||||
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-icon" title="刷新" 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>
|
||||
</button>
|
||||
</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)' }}>
|
||||
{powDone ? (
|
||||
<>
|
||||
@@ -264,13 +266,21 @@ export default function CaptchaModalHost() {
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
|
||||
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="验证码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{request.type === 'recaptcha' ? (
|
||||
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -75,7 +76,7 @@ export default function Layout() {
|
||||
const wallpaper = settings.theme_wallpaper || '';
|
||||
const scale = settings.theme_wallpaper_scale || 'cover';
|
||||
|
||||
if (wallpaper) {
|
||||
if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
|
||||
body.classList.add('has-wallpaper');
|
||||
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
|
||||
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
|
||||
@@ -166,6 +167,7 @@ export default function Layout() {
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleToggleTheme}
|
||||
aria-label="切换主题"
|
||||
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
|
||||
>
|
||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||
@@ -193,16 +195,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 />
|
||||
|
||||
@@ -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(/\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 }) {
|
||||
|
||||
@@ -49,9 +49,9 @@ export default function MusicEmbed({ settings }) {
|
||||
>
|
||||
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/** HTML 转义:& < > " ' 全转义 */
|
||||
export function escapeHtml(str) {
|
||||
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
||||
@@ -45,3 +47,42 @@ export function showSnackbar(msg) {
|
||||
setTimeout(() => el.classList.remove('show', 'hide'), 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button) */
|
||||
export function focusDialog(container) {
|
||||
if (!container) return;
|
||||
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
|
||||
if (el && typeof el.focus === 'function') el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗键盘/焦点管理(B3):
|
||||
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
|
||||
* - Esc 关闭弹窗
|
||||
* - 关闭后焦点还给触发元素
|
||||
* 返回 { dialogRef, onKeyDown }:dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
|
||||
*/
|
||||
export function useDialog(open, onClose) {
|
||||
const dialogRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement;
|
||||
focusDialog(dialogRef.current);
|
||||
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
if (onClose) onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return { dialogRef, onKeyDown };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
@@ -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,165 @@ 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">
|
||||
<h1 className="sr-only">博客</h1>
|
||||
<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';
|
||||
}
|
||||
|
||||
@@ -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,26 @@ 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 [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 {
|
||||
@@ -45,15 +67,79 @@ export default function BlogDetail() {
|
||||
}, [load]);
|
||||
|
||||
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
||||
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
||||
|
||||
// 目录:从已渲染的 .md-body 提取 h2(仅一级章节)打锚点;短文章(<500 字)不提取
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
if (wordCount < 500) { setToc([]); return; }
|
||||
const body = document.querySelector('.blog-article .md-body');
|
||||
if (!body) { setToc([]); return; }
|
||||
const items = [...body.querySelectorAll('h2')].map((h, i) => {
|
||||
if (!h.id) h.id = 'toc-' + i;
|
||||
return { id: h.id, text: (h.textContent || '').trim(), level: 2 };
|
||||
});
|
||||
setToc(items);
|
||||
}, [post, wordCount]);
|
||||
|
||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
||||
|
||||
// ── 点赞(乐观更新)──
|
||||
const toggleLike = async () => {
|
||||
if (!user) { showSnackbar('登录后可以点赞'); return; }
|
||||
const next = !like.liked;
|
||||
setLike((s) => ({ liked: next, count: Math.max(0, s.count + (next ? 1 : -1)) }));
|
||||
try {
|
||||
const r = next ? await blogApi.likePost(id) : await blogApi.unlikePost(id);
|
||||
setLike({ liked: r.liked, count: r.count });
|
||||
} catch (e) {
|
||||
setLike((s) => ({ liked: !s.liked, count: Math.max(0, s.count + (s.liked ? 1 : -1)) }));
|
||||
showSnackbar(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 嵌套评论 ──
|
||||
const childrenOf = useCallback((pid) => comments.filter((c) => c.parent_id === pid), [comments]);
|
||||
const rootComments = comments.filter((c) => !c.parent_id);
|
||||
|
||||
const renderComment = (c, depth = 0) => {
|
||||
const kids = childrenOf(c.id);
|
||||
return (
|
||||
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
|
||||
<div className="reply-meta">
|
||||
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
|
||||
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
|
||||
</div>
|
||||
<div className="reply-body">{c.content}</div>
|
||||
{user && (
|
||||
<button
|
||||
className="btn btn-text btn-sm reply-btn"
|
||||
onClick={() => {
|
||||
setReplyTo({ id: c.id, name: c.author_name || '游客' });
|
||||
setCommentText(`@${c.author_name || '游客'} `);
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 14 }}>reply</span> 回复
|
||||
</button>
|
||||
)}
|
||||
{kids.length > 0 && (
|
||||
<div className="reply-children">
|
||||
{kids.map((k) => renderComment(k, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
const content = commentText.trim();
|
||||
if (!content) { showSnackbar('评论不能为空'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await blogApi.createComment(id, content);
|
||||
await blogApi.createComment(id, content, replyTo ? replyTo.id : 0);
|
||||
showSnackbar('评论已发表');
|
||||
setCommentText('');
|
||||
setReplyTo(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showSnackbar(e.message);
|
||||
@@ -76,51 +162,137 @@ export default function BlogDetail() {
|
||||
}
|
||||
|
||||
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 }}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
|
||||
</Link>
|
||||
|
||||
<h1 className="article-title">{post.title}</h1>
|
||||
|
||||
<div className="article-meta">
|
||||
{post.author_name || '管理员'} · {post.created_at}
|
||||
<span className="meta-stat" title="阅读量">
|
||||
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
|
||||
</span>
|
||||
<span className="meta-stat" title="字数">约 {wordCount} 字</span>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="article-tags">
|
||||
{tags.map((t) => (
|
||||
<Link key={t} to={`/tag/${encodeURIComponent(t)}`} className="chip article-tag-chip">{t}</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-actions">
|
||||
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||
<span className="like-count">{like.count}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm" style={{ marginLeft: 12 }}>编辑</Link>
|
||||
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
||||
|
||||
{/* 上一篇 / 下一篇 */}
|
||||
{prevnext && (prevnext.prev || prevnext.next) && (
|
||||
<nav className="prevnext-nav">
|
||||
<div className="pn-col pn-prev">
|
||||
{prevnext.prev ? (
|
||||
<Link to={`/blog/${prevnext.prev.id}`}>
|
||||
<span className="pn-label">上一篇</span>
|
||||
<span className="pn-title">{prevnext.prev.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">上一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pn-col pn-next">
|
||||
{prevnext.next ? (
|
||||
<Link to={`/blog/${prevnext.next.id}`}>
|
||||
<span className="pn-label">下一篇</span>
|
||||
<span className="pn-title">{prevnext.next.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="pn-disabled"><span className="pn-label">下一篇</span><span className="pn-title">没有了</span></span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4>
|
||||
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
|
||||
|
||||
{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 }}>
|
||||
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="article-toc-side">
|
||||
<div className="toc-title">
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
|
||||
目录 <span className="toc-count">{toc.length}</span>
|
||||
</div>
|
||||
<ul className="toc-list">
|
||||
{toc.map((t) => (
|
||||
<li key={t.id} className="toc-item">
|
||||
<a
|
||||
href={'#' + t.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(t.id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function Embed() {
|
||||
|
||||
return (
|
||||
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<h1 className="sr-only">嵌入页面</h1>
|
||||
<div
|
||||
className="embed-toolbar"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
|
||||
@@ -67,7 +68,14 @@ export default function Embed() {
|
||||
Ignore X-Frame-Headers
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getToken } from '../api/client.js';
|
||||
import { uploadFile } from '../api/upload.js';
|
||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/** 板块的子分类列表 */
|
||||
function subCatsOf(cat) {
|
||||
@@ -133,8 +133,12 @@ export default function Forum() {
|
||||
const currentCat = categories.find((c) => c.id === currentCatId);
|
||||
const subCats = subCatsOf(currentCat);
|
||||
|
||||
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
|
||||
|
||||
return (
|
||||
<div className="forum-layout">
|
||||
<h1 className="sr-only">论坛</h1>
|
||||
<aside className="forum-sidebar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
||||
@@ -144,7 +148,8 @@ export default function Forum() {
|
||||
</div>
|
||||
<div className="forum-cat-list">
|
||||
{categories.map((c) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={c.id}
|
||||
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
|
||||
onClick={() => selectCategory(c.id)}
|
||||
@@ -153,17 +158,18 @@ export default function Forum() {
|
||||
{c.announcement ? (
|
||||
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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' : '')}
|
||||
onClick={showAllPosts}
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
||||
</div>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div className="forum-content">
|
||||
@@ -171,7 +177,7 @@ export default function Forum() {
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
|
||||
<span className="chip" 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>
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
|
||||
{currentCat.announcement && (
|
||||
@@ -182,9 +188,9 @@ export default function Forum() {
|
||||
)}
|
||||
{subCats.length > 0 && (
|
||||
<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) => (
|
||||
<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>
|
||||
)}
|
||||
@@ -208,9 +214,9 @@ export default function Forum() {
|
||||
<span>{p.created_at}</span>
|
||||
<span>{p.reply_count || 0} 回复</span>
|
||||
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
|
||||
<span className="chip" 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 ? (
|
||||
<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}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -223,9 +229,14 @@ export default function Forum() {
|
||||
{/* 发帖弹窗 */}
|
||||
{showNewPost && (
|
||||
<div
|
||||
ref={npDialogRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="发布新帖"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
||||
onKeyDown={npDialogKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>发布新帖</h3>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function ForumDetail() {
|
||||
</button>
|
||||
)}
|
||||
</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' }}>
|
||||
<span>{post.author_name || '匿名'}</span>
|
||||
<span>{post.created_at}</span>
|
||||
@@ -158,12 +158,14 @@ export default function ForumDetail() {
|
||||
<div className="reply-meta">
|
||||
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
|
||||
{canDeleteReply && (
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除回复"
|
||||
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
||||
onClick={() => deleteReply(r.id)}
|
||||
>
|
||||
删除
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="reply-body">{r.content}</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="homepage-layout">
|
||||
<h1 className="sr-only">首页</h1>
|
||||
<BlogSidebar settings={settings} showRecent forceShow />
|
||||
<div className="homepage-content">
|
||||
<div className="card">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import * as pwApi from '../api/passwords.js';
|
||||
import { me } from '../api/auth.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||
|
||||
/**
|
||||
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
|
||||
@@ -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('');
|
||||
@@ -237,6 +250,14 @@ export default function Passwords() {
|
||||
|
||||
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
|
||||
|
||||
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
|
||||
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
|
||||
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
|
||||
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
|
||||
|
||||
// 加载中
|
||||
if (phase === 'loading') {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
@@ -281,7 +302,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}>
|
||||
@@ -300,14 +321,18 @@ export default function Passwords() {
|
||||
{entries && entries.length > 0 && (
|
||||
<div className="password-grid">
|
||||
{entries.map((p) => (
|
||||
<div key={p.id} className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||
<div className="pw-title">{p.title}</div>
|
||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||
<div key={p.id} className="password-card-wrap">
|
||||
<button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||
<div className="pw-title">{p.title}</div>
|
||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||
</button>
|
||||
<div className="pw-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ width: 32, height: 32, fontSize: 16 }}
|
||||
style={{ width: 40, height: 40, fontSize: 16 }}
|
||||
title="复制密码"
|
||||
aria-label={`复制 ${p.title} 的密码`}
|
||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
||||
@@ -321,9 +346,14 @@ export default function Passwords() {
|
||||
{/* 设置/修改 PIN 弹窗 */}
|
||||
{pinDialog && (
|
||||
<div
|
||||
ref={pinDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置 PIN 码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
|
||||
onKeyDown={pinDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 360 }}>
|
||||
<h3>设置 PIN 码</h3>
|
||||
@@ -347,9 +377,14 @@ export default function Passwords() {
|
||||
{/* 添加/编辑条目弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editingId ? '编辑密码' : '添加密码'}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
|
||||
@@ -387,9 +422,14 @@ export default function Passwords() {
|
||||
{/* 详情弹窗 */}
|
||||
{detailEntry && (
|
||||
<div
|
||||
ref={detailDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={detailEntry.title}
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
|
||||
onKeyDown={detailDlgKey}
|
||||
>
|
||||
<div className="dialog" style={{ maxWidth: 500 }}>
|
||||
<h3>{detailEntry.title}</h3>
|
||||
@@ -397,16 +437,16 @@ export default function Passwords() {
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">用户名</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>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pw-field">
|
||||
<span className="pw-label">密码</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>
|
||||
</button>
|
||||
</div>
|
||||
{detailEntry.url ? (
|
||||
<div className="pw-field">
|
||||
@@ -434,7 +474,7 @@ export default function Passwords() {
|
||||
|
||||
{/* 密码生成器弹窗 */}
|
||||
{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 }}>
|
||||
<h3>生成随机密码</h3>
|
||||
<div className="form-group">
|
||||
@@ -461,9 +501,23 @@ export default function Passwords() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改 PIN 警告确认弹窗 */}
|
||||
{pinChangeWarn && (
|
||||
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
|
||||
<div className="dialog">
|
||||
<h3>确认修改 PIN</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>⚠️ 修改 PIN 后,现有密码条目将无法解密,需要重新添加。确定继续?</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-text" onClick={() => setPinChangeWarn(false)}>取消</button>
|
||||
<button className="btn btn-danger" onClick={proceedPinChange}>确定继续</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDialog && (
|
||||
<div 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">
|
||||
<h3>确认操作</h3>
|
||||
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录?</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as profileApi from '../api/profile.js';
|
||||
import * as authApi from '../api/auth.js';
|
||||
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
||||
import { getToken, notifyAuthChange } from '../api/client.js';
|
||||
import { showSnackbar } from '../lib/utils.js';
|
||||
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||
|
||||
/** FileReader + Image 加载图片(头像裁剪前读取) */
|
||||
function loadImage(file) {
|
||||
@@ -38,6 +38,13 @@ export default function Profile() {
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwBusy, setPwBusy] = useState(false);
|
||||
|
||||
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
|
||||
useEffect(() => {
|
||||
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
|
||||
}, [pwDialog, pwStep]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@@ -186,9 +193,14 @@ export default function Profile() {
|
||||
{/* 修改密码弹窗 */}
|
||||
{pwDialog && (
|
||||
<div
|
||||
ref={pwDlgRef}
|
||||
className="dialog-overlay active"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="修改密码"
|
||||
style={{ display: 'flex', zIndex: 9999 }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||
onKeyDown={pwDlgKey}
|
||||
>
|
||||
<div className="dialog">
|
||||
<h3>修改密码</h3>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rainweb-links",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"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.1.0",
|
||||
"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.1.0",
|
||||
"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.1.0",
|
||||
"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.1.0",
|
||||
"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.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "链接聚合管理平台",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+1061
-17
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+200
-12
@@ -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, '<')}</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: '评论不存在' });
|
||||
|
||||
@@ -122,3 +122,5 @@ router.post('/complete-register', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.getTransporter = getTransporter;
|
||||
module.exports.emailTemplate = emailTemplate;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -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
@@ -6,18 +6,21 @@ const router = express.Router();
|
||||
|
||||
const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
|
||||
'recaptcha_site_key','turnstile_site_key',
|
||||
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'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'];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user