15 Commits
Author SHA1 Message Date
miaomiao 5b166ef0e2 fix: 壁纸下内容区统一磨砂背景(可读性修复,浅深色自适应) 2026-08-07 15:26:03 +08:00
miaomiao 4f89c63b28 feat: 壁纸开关(后台主题设置可启用/禁用壁纸) 2026-08-07 15:24:09 +08:00
miaomiao 7bfeffaaa0 feat: 目录侧边独立卡片(正文 720 居中不重叠)+ 锚点根治(HTML 生成时注入 h2 id) 2026-08-07 04:50:34 +08:00
miaomiao aff5764b9b fix: 目录悬浮定位修正(≥1500px 才显示,避免与正文重叠) 2026-08-07 04:32:09 +08:00
miaomiao bb830f779c feat: 目录独立悬浮卡片(右侧留白区,正文不缩宽;<1280px 隐藏) 2026-08-07 04:30:52 +08:00
miaomiao 63d329db5f fix: 修复详情页 TDZ 白屏(wordCount 重复声明清理) 2026-08-07 04:22:42 +08:00
miaomiao e83a4e6ee1 feat: 详情页侧边目录(仅 h2/500 字阈值)+ meta 间距修正 2026-08-07 04:19:18 +08:00
miaomiao 0153f770f6 revert: 移除博客列表页磨砂背景(保留详情页) 2026-08-07 04:16:09 +08:00
miaomiao 789a2d9b33 feat: 博客文章详情页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:14:29 +08:00
miaomiao 5a878c1aab feat: 博客页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:13:50 +08:00
miaomiao 5e4cdf26b9 feat: 页脚三样式统一磨砂玻璃(半透明+blur,浅深色自适应) 2026-08-07 04:12:43 +08:00
miaomiao 2faace4810 fix: 登录/注册页改回完全透明(透壁纸) 2026-08-07 04:08:01 +08:00
miaomiao 45002b809a feat: 登录/注册页磨砂玻璃背景(半透明+blur,浅深色自适应) 2026-08-07 04:06:34 +08:00
miaomiao 1b62bf99ce fix: 登录/注册页去掉深色遮罩背景(透出壁纸,保留居中) 2026-08-07 04:05:45 +08:00
miaomiao 6fafa6eb00 fix: 注册弹窗与登录一致(flex 居中+背景遮罩);页脚背景透明(壁纸透出) 2026-08-07 04:00:04 +08:00
7 changed files with 196 additions and 52 deletions
+1
View File
@@ -186,6 +186,7 @@ function seedDefaults() {
smtp_from_name: 'RainWeb', smtp_from_name: 'RainWeb',
theme_wallpaper: '', theme_wallpaper: '',
theme_wallpaper_scale: 'cover', theme_wallpaper_scale: 'cover',
theme_wallpaper_enabled: '1',
nav_style: 'default', nav_style: 'default',
card_style: 'default', card_style: 'default',
glass_blur: '20', glass_blur: '20',
@@ -19,6 +19,7 @@ export default function ThemeSettings() {
primary_color: '#6750a4', primary_color: '#6750a4',
theme_wallpaper: '', theme_wallpaper: '',
theme_wallpaper_scale: 'cover', theme_wallpaper_scale: 'cover',
theme_wallpaper_enabled: true,
nav_style: 'default', nav_style: 'default',
card_style: 'default', card_style: 'default',
glass_blur: '20', glass_blur: '20',
@@ -34,6 +35,7 @@ export default function ThemeSettings() {
primary_color: s.primary_color || '#6750a4', primary_color: s.primary_color || '#6750a4',
theme_wallpaper: s.theme_wallpaper || '', theme_wallpaper: s.theme_wallpaper || '',
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover', theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
nav_style: s.nav_style || 'default', nav_style: s.nav_style || 'default',
card_style: s.card_style || 'default', card_style: s.card_style || 'default',
glass_blur: s.glass_blur || '20', glass_blur: s.glass_blur || '20',
@@ -65,6 +67,7 @@ export default function ThemeSettings() {
primary_color: form.primary_color, primary_color: form.primary_color,
theme_wallpaper: form.theme_wallpaper.trim(), theme_wallpaper: form.theme_wallpaper.trim(),
theme_wallpaper_scale: form.theme_wallpaper_scale, theme_wallpaper_scale: form.theme_wallpaper_scale,
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
theme_force_dark: form.theme_force_dark ? '1' : '0', theme_force_dark: form.theme_force_dark ? '1' : '0',
nav_style: form.nav_style, nav_style: form.nav_style,
card_style: form.card_style, card_style: form.card_style,
@@ -92,6 +95,11 @@ export default function ThemeSettings() {
<Paper sx={{ p: 3, mb: 2 }}> <Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography> <Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
<FormControlLabel
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
label="启用壁纸背景"
sx={{ mb: 1 }}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button> <Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} /> <input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
+1 -1
View File
@@ -76,7 +76,7 @@ export default function Layout() {
const wallpaper = settings.theme_wallpaper || ''; const wallpaper = settings.theme_wallpaper || '';
const scale = settings.theme_wallpaper_scale || 'cover'; const scale = settings.theme_wallpaper_scale || 'cover';
if (wallpaper) { if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
body.classList.add('has-wallpaper'); body.classList.add('has-wallpaper');
body.style.setProperty('--wallpaper', `url(${wallpaper})`); body.style.setProperty('--wallpaper', `url(${wallpaper})`);
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' }; const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
+7 -1
View File
@@ -43,7 +43,13 @@ export function renderContent(content, useMarkdown) {
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)])); html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)])); html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)]));
return DOMPurify.sanitize(html); html = DOMPurify.sanitize(html);
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
let h2Index = 0;
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
return html;
} }
export default function MarkdownRenderer({ content = '', useMarkdown = true }) { export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
+43 -44
View File
@@ -33,7 +33,6 @@ export default function BlogDetail() {
const [prevnext, setPrevnext] = useState(null); const [prevnext, setPrevnext] = useState(null);
const [like, setLike] = useState({ liked: false, count: 0 }); const [like, setLike] = useState({ liked: false, count: 0 });
const [toc, setToc] = useState([]); const [toc, setToc] = useState([]);
const [tocOpen, setTocOpen] = useState(true);
const [replyTo, setReplyTo] = useState(null); // {id, name} const [replyTo, setReplyTo] = useState(null); // {id, name}
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -67,20 +66,22 @@ export default function BlogDetail() {
} }
}, [load]); }, [load]);
// 目录:从已渲染的 .md-body 提取 h2/h3 并打锚点 id
useEffect(() => {
if (!post) return;
const body = document.querySelector('.blog-article .md-body');
if (!body) { setToc([]); return; }
const items = [...body.querySelectorAll('h2, h3')].map((h, i) => {
if (!h.id) h.id = 'toc-' + i;
return { id: h.id, text: (h.textContent || '').trim(), level: h.tagName === 'H2' ? 2 : 3 };
});
setToc(items);
}, [post]);
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id); const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]); 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 tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
// ── 点赞(乐观更新)── // ── 点赞(乐观更新)──
@@ -161,7 +162,8 @@ export default function BlogDetail() {
} }
return ( return (
<div className="blog-article"> <div className="article-layout">
<div className="blog-article article-main">
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}> <Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表 <span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
</Link> </Link>
@@ -194,36 +196,6 @@ export default function BlogDetail() {
)} )}
</div> </div>
{/* 目录(正文顶部,可折叠) */}
{toc.length > 0 && (
<div className="toc-block">
<button className="toc-toggle" onClick={() => setTocOpen((v) => !v)}>
<span className="material-icons" style={{ fontSize: 17 }}>format_list_bulleted</span>
目录
<span className="toc-count">{toc.length}</span>
<span className={'material-icons toc-arrow' + (tocOpen ? ' open' : '')} style={{ fontSize: 18 }}>expand_more</span>
</button>
{tocOpen && (
<ul className="toc-list">
{toc.map((t) => (
<li key={t.id} className={'toc-item toc-lv' + t.level}>
<a
href={'#' + t.id}
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(t.id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
>
{t.text}
</a>
</li>
))}
</ul>
)}
</div>
)}
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} /> <MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
{/* 上一篇 / 下一篇 */} {/* 上一篇 / 下一篇 */}
@@ -294,6 +266,33 @@ export default function BlogDetail() {
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论 <Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
</p> </p>
)} )}
</div>
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
{toc.length > 0 && (
<aside className="article-toc-side">
<div className="toc-title">
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
目录 <span className="toc-count">{toc.length}</span>
</div>
<ul className="toc-list">
{toc.map((t) => (
<li key={t.id} className="toc-item">
<a
href={'#' + t.id}
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(t.id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
>
{t.text}
</a>
</li>
))}
</ul>
</aside>
)}
</div> </div>
); );
} }
+134 -4
View File
@@ -779,13 +779,13 @@ table tr:hover td {
} }
/* ===== Login Page ===== */ /* ===== Login Page ===== */
.login-page { .login-page, .register-page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
background: var(--md-ref-background); background: transparent; /* 完全透明,透出壁纸 */
} }
.login-card { .login-card {
@@ -1559,6 +1559,18 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
} }
/* 壁纸下内容区统一磨砂背景:半透明底 + 模糊,保证文字清晰同时保留壁纸美感 */
body.has-wallpaper .page {
background: rgba(255,251,254,0.65);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
}
[data-theme="dark"] body.has-wallpaper .page {
background: rgba(28,27,31,0.6);
}
/* Forum sidebar glass with wallpaper */ /* Forum sidebar glass with wallpaper */
.has-wallpaper .forum-sidebar .forum-cat-item { .has-wallpaper .forum-sidebar .forum-cat-item {
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
@@ -1802,7 +1814,13 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
flex-wrap: wrap; flex-wrap: wrap;
padding: 22px 28px 28px; padding: 22px 28px 28px;
border-top: 1px solid var(--md-ref-outline-variant); border-top: 1px solid var(--md-ref-outline-variant);
background: var(--md-ref-background); /* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */
background: rgba(255, 251, 254, 0.55);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
[data-theme="dark"] .footer-classic {
background: rgba(28, 27, 31, 0.6);
} }
.footer-classic .fc-left { .footer-classic .fc-left {
font-size: 13px; font-size: 13px;
@@ -1840,9 +1858,15 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
/* ---- 分栏导航 ---- */ /* ---- 分栏导航 ---- */
.footer-columns { .footer-columns {
background: var(--md-ref-background); /* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */
background: rgba(255, 251, 254, 0.55);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--md-ref-outline-variant); border-top: 1px solid var(--md-ref-outline-variant);
} }
[data-theme="dark"] .footer-columns {
background: rgba(28, 27, 31, 0.6);
}
.footer-columns .fc-grid { .footer-columns .fc-grid {
display: grid; display: grid;
grid-template-columns: 2fr 1fr 1fr; grid-template-columns: 2fr 1fr 1fr;
@@ -2449,3 +2473,109 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
0%, 55% { left: -60%; } 0%, 55% { left: -60%; }
100% { left: 130%; } 100% { left: 130%; }
} }
/* ===== 博客文章详情页壁纸下磨砂背景(增强可读性) ===== */
.blog-article {
background: rgba(255, 251, 254, 0.5);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 16px;
padding: 24px;
}
[data-theme="dark"] .blog-article {
background: rgba(28, 27, 31, 0.55);
}
/* ===== 文章详情页布局:正文卡片(居中固定宽)+ 右侧悬浮目录 =====
* 注意:.blog-article 带 backdrop-filter,会成为 position:fixed 后代的包含块,
* 因此目录卡片必须放在 .article-layout 层(磨砂卡片的兄弟),
* 其 fixed 才相对视口定位。正文宽 720 居中,目录左缘 = 50% + 388px360+28)。 */
.article-layout {
display: flex;
justify-content: center;
align-items: flex-start;
}
.article-main {
flex: 0 0 auto;
width: 720px;
max-width: 100%;
}
.article-toc-side {
width: 220px;
/* 独立悬浮卡片:相对视口 fixed,锚定在正文右缘 + 28px 处,
不占文档流、不挤压正文,任何视口下都不会与阅读区重叠 */
position: fixed;
top: 88px;
left: calc(50% + 388px); /* 正文半宽 360px + 间距 28px */
max-height: calc(100vh - 120px);
overflow-y: auto;
background: rgba(255, 251, 254, 0.55);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--md-ref-outline-variant);
border-radius: 12px;
padding: 14px 16px;
box-shadow: 0 4px 20px var(--md-shadow);
}
[data-theme="dark"] .article-toc-side {
background: rgba(28, 27, 31, 0.6);
}
.article-toc-side .toc-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: var(--md-ref-on-surface);
}
.article-toc-side .toc-count {
font-size: 11px;
font-weight: 500;
opacity: 0.65;
}
.article-toc-side .toc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.article-toc-side .toc-item a {
display: block;
padding: 5px 10px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
color: var(--md-ref-on-surface-variant);
text-decoration: none;
transition: background 0.15s, color 0.15s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-toc-side .toc-item a:hover {
background: var(--md-ref-surface-container-high);
color: var(--md-ref-primary);
}
/* 小屏隐藏:视口容不下「正文 720 + 目录 220 + 间距 28 + 边距」时收起,
内容区不受影响(目录 fixed 不占文档流) */
@media (max-width: 1299px) {
.article-toc-side { display: none; }
}
/* ===== 文章 meta 间距(作者 · 时间 · 阅读 · 字数) ===== */
.article-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 14px;
font-size: 13px;
color: var(--md-ref-on-surface-variant);
}
.article-meta .meta-stat {
display: inline-flex;
align-items: center;
gap: 4px;
}
+2 -2
View File
@@ -6,7 +6,7 @@ const router = express.Router();
const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color', const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
'recaptcha_site_key','turnstile_site_key', 'recaptcha_site_key','turnstile_site_key',
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style', 'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style',
'glass_blur','glass_opacity','theme_force_dark', 'glass_blur','glass_opacity','theme_force_dark',
'captcha_type','captcha_login','captcha_register','captcha_forum', 'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar', 'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
@@ -15,7 +15,7 @@ const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key', const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key',
'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name', 'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name',
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark', 'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark',
'captcha_type','captcha_login','captcha_register','captcha_forum', 'captcha_type','captcha_login','captcha_register','captcha_forum',
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar', 'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout', 'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',