20 Commits
Author SHA1 Message Date
miaomiao 4af71185d5 feat: Markdown Indigo 定制主题(文本级渲染/背景透明走磨砂玻璃/主题色变量/深浅色/okaidia 代码块) 2026-08-07 19:50:29 +08:00
miaomiao 6d60b10c14 fix: 论坛板块按钮深色模式白底(button UA 默认样清除,基础态透明) 2026-08-07 19:24:58 +08:00
miaomiao cf4c7d4ca4 fix: UI/无障碍审查修复(焦点可见/键盘可达/dialog 语义/对比度/触控目标等 B1-B10) 2026-08-07 16:13:01 +08:00
miaomiao 5acc461e88 fix: 壁纸下玻璃表面统一抬升可读性(--glass-bg 0.82/0.74,逐表面应用,壁纸间隙可见) 2026-08-07 16:00:30 +08:00
miaomiao 391cd1bdae fix: 删除壁纸下 .page 遮罩(保留卡片/页脚磨砂) 2026-08-07 15:48:24 +08:00
miaomiao 5b166ef0e2 fix: 壁纸下内容区统一磨砂背景(可读性修复,浅深色自适应) 2026-08-07 15:26:03 +08:00
miaomiao 4f89c63b28 feat: 壁纸开关(后台主题设置可启用/禁用壁纸) 2026-08-07 15:24:09 +08:00
miaomiao 7bfeffaaa0 feat: 目录侧边独立卡片(正文 720 居中不重叠)+ 锚点根治(HTML 生成时注入 h2 id) 2026-08-07 04:50:34 +08:00
miaomiao aff5764b9b fix: 目录悬浮定位修正(≥1500px 才显示,避免与正文重叠) 2026-08-07 04:32:09 +08:00
miaomiao bb830f779c feat: 目录独立悬浮卡片(右侧留白区,正文不缩宽;<1280px 隐藏) 2026-08-07 04:30:52 +08:00
miaomiao 63d329db5f fix: 修复详情页 TDZ 白屏(wordCount 重复声明清理) 2026-08-07 04:22:42 +08:00
miaomiao e83a4e6ee1 feat: 详情页侧边目录(仅 h2/500 字阈值)+ meta 间距修正 2026-08-07 04:19:18 +08:00
miaomiao 0153f770f6 revert: 移除博客列表页磨砂背景(保留详情页) 2026-08-07 04:16:09 +08:00
miaomiao 789a2d9b33 feat: 博客文章详情页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:14:29 +08:00
miaomiao 5a878c1aab feat: 博客页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:13:50 +08:00
miaomiao 5e4cdf26b9 feat: 页脚三样式统一磨砂玻璃(半透明+blur,浅深色自适应) 2026-08-07 04:12:43 +08:00
miaomiao 2faace4810 fix: 登录/注册页改回完全透明(透壁纸) 2026-08-07 04:08:01 +08:00
miaomiao 45002b809a feat: 登录/注册页磨砂玻璃背景(半透明+blur,浅深色自适应) 2026-08-07 04:06:34 +08:00
miaomiao 1b62bf99ce fix: 登录/注册页去掉深色遮罩背景(透出壁纸,保留居中) 2026-08-07 04:05:45 +08:00
miaomiao 6fafa6eb00 fix: 注册弹窗与登录一致(flex 居中+背景遮罩);页脚背景透明(壁纸透出) 2026-08-07 04:00:04 +08:00
18 changed files with 568 additions and 107 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',
+1 -1
View File
@@ -12,7 +12,7 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<div id="musicEmbed"></div> <div id="musicEmbed"></div>
<div id="snackbar" class="snackbar"></div> <div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>
@@ -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} />
+12 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import * as captchaApi from '../api/captcha.js'; import * as captchaApi from '../api/captcha.js';
import { useDialog } from '../lib/utils.js';
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ── // ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
let pending = null; // { action, type, resolve } let pending = null; // { action, type, resolve }
@@ -140,15 +141,16 @@ function BuiltinCaptcha({ onFinish }) {
onChange={(e) => setAnswer(e.target.value)} onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
placeholder="输入验证码" placeholder="输入验证码"
aria-label="输入验证码"
maxLength={6} maxLength={6}
autoComplete="off" autoComplete="off"
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }} style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
/> />
<button type="button" className="btn btn-icon" title="刷新" onClick={loadImage} style={{ flexShrink: 0 }}> <button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
<span className="material-icons">refresh</span> <span className="material-icons">refresh</span>
</button> </button>
</div> </div>
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>} {error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
{powDone ? ( {powDone ? (
<> <>
@@ -264,13 +266,21 @@ export default function CaptchaModalHost() {
} }
}; };
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
if (!request) return null; if (!request) return null;
return ( return (
<div <div
ref={dialogRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="验证码"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }} onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
onKeyDown={onKeyDown}
> >
{request.type === 'recaptcha' ? ( {request.type === 'recaptcha' ? (
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} /> <ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
+2 -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%' };
@@ -167,6 +167,7 @@ export default function Layout() {
<button <button
className="btn-icon" className="btn-icon"
onClick={handleToggleTheme} onClick={handleToggleTheme}
aria-label="切换主题"
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'} title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
> >
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span> <span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
+7 -1
View File
@@ -43,7 +43,13 @@ export function renderContent(content, useMarkdown) {
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)])); html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)])); html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)]));
return DOMPurify.sanitize(html); html = DOMPurify.sanitize(html);
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
let h2Index = 0;
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
return html;
} }
export default function MarkdownRenderer({ content = '', useMarkdown = true }) { export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
+2 -2
View File
@@ -49,9 +49,9 @@ export default function MusicEmbed({ settings }) {
> >
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */} {/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} /> <div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
<div className="music-embed-icon" onClick={expand} title="展开播放器"> <button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
<span className="material-icons">music_note</span> <span className="material-icons">music_note</span>
</div> </button>
</div> </div>
); );
} }
+41
View File
@@ -1,3 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
/** HTML 转义:& < > " ' 全转义 */ /** HTML 转义:& < > " ' 全转义 */
export function escapeHtml(str) { export function escapeHtml(str) {
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => { return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
@@ -45,3 +47,42 @@ export function showSnackbar(msg) {
setTimeout(() => el.classList.remove('show', 'hide'), 300); setTimeout(() => el.classList.remove('show', 'hide'), 300);
}, 2500); }, 2500);
} }
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button */
export function focusDialog(container) {
if (!container) return;
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
if (el && typeof el.focus === 'function') el.focus();
}
/**
* 弹窗键盘/焦点管理(B3):
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
* - Esc 关闭弹窗
* - 关闭后焦点还给触发元素
* 返回 { dialogRef, onKeyDown }dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
*/
export function useDialog(open, onClose) {
const dialogRef = useRef(null);
const triggerRef = useRef(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
focusDialog(dialogRef.current);
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
triggerRef.current.focus();
triggerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
e.stopPropagation();
if (onClose) onClose();
}
}, [onClose]);
return { dialogRef, onKeyDown };
}
+1
View File
@@ -88,6 +88,7 @@ export default function Blog() {
return ( return (
<div className="homepage-layout blog-layout"> <div className="homepage-layout blog-layout">
<h1 className="sr-only">博客</h1>
<BlogSidebar settings={settings} /> <BlogSidebar settings={settings} />
<div className="homepage-content"> <div className="homepage-content">
{/* 工具条:搜索框 + 归档 */} {/* 工具条:搜索框 + 归档 */}
+45 -46
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>
@@ -185,7 +187,7 @@ export default function BlogDetail() {
)} )}
<div className="article-actions"> <div className="article-actions">
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞"> <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="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
<span className="like-count">{like.count}</span> <span className="like-count">{like.count}</span>
</button> </button>
@@ -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} />
{/* 上一篇 / 下一篇 */} {/* 上一篇 / 下一篇 */}
@@ -253,7 +225,7 @@ export default function BlogDetail() {
)} )}
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} /> <hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4> <h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
{comments.length === 0 ? ( {comments.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p> <p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
@@ -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>
); );
} }
+9 -1
View File
@@ -44,6 +44,7 @@ export default function Embed() {
return ( return (
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<h1 className="sr-only">嵌入页面</h1>
<div <div
className="embed-toolbar" className="embed-toolbar"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
@@ -67,7 +68,14 @@ export default function Embed() {
Ignore X-Frame-Headers Ignore X-Frame-Headers
</a>{' '} </a>{' '}
扩展或点击右上角原站在新标签页打开 扩展或点击右上角原站在新标签页打开
<span onClick={() => setShowHint(false)} style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600 }}></span> <button
type="button"
onClick={() => setShowHint(false)}
aria-label="关闭提示"
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
>
</button>
</div> </div>
)} )}
+18 -7
View File
@@ -6,7 +6,7 @@ import { getToken } from '../api/client.js';
import { uploadFile } from '../api/upload.js'; import { uploadFile } from '../api/upload.js';
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js'; import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
import { showCaptcha } from '../components/CaptchaModal.jsx'; import { showCaptcha } from '../components/CaptchaModal.jsx';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar, useDialog } from '../lib/utils.js';
/** 板块的子分类列表 */ /** 板块的子分类列表 */
function subCatsOf(cat) { function subCatsOf(cat) {
@@ -133,8 +133,12 @@ export default function Forum() {
const currentCat = categories.find((c) => c.id === currentCatId); const currentCat = categories.find((c) => c.id === currentCatId);
const subCats = subCatsOf(currentCat); const subCats = subCatsOf(currentCat);
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
return ( return (
<div className="forum-layout"> <div className="forum-layout">
<h1 className="sr-only">论坛</h1>
<aside className="forum-sidebar"> <aside className="forum-sidebar">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span> <span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
@@ -144,7 +148,8 @@ export default function Forum() {
</div> </div>
<div className="forum-cat-list"> <div className="forum-cat-list">
{categories.map((c) => ( {categories.map((c) => (
<div <button
type="button"
key={c.id} key={c.id}
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')} className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
onClick={() => selectCategory(c.id)} onClick={() => selectCategory(c.id)}
@@ -153,17 +158,18 @@ export default function Forum() {
{c.announcement ? ( {c.announcement ? (
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span> <span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
) : null} ) : null}
</div> </button>
))} ))}
</div> </div>
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} /> <hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
<div <button
type="button"
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')} className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
onClick={showAllPosts} onClick={showAllPosts}
style={{ fontWeight: 500 }} style={{ fontWeight: 500 }}
> >
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新 <span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
</div> </button>
</aside> </aside>
<div className="forum-content"> <div className="forum-content">
@@ -182,9 +188,9 @@ export default function Forum() {
)} )}
{subCats.length > 0 && ( {subCats.length > 0 && (
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}> <div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
<span className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</span> <button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
{subCats.map((s) => ( {subCats.map((s) => (
<span key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</span> <button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
))} ))}
</div> </div>
)} )}
@@ -223,9 +229,14 @@ export default function Forum() {
{/* 发帖弹窗 */} {/* 发帖弹窗 */}
{showNewPost && ( {showNewPost && (
<div <div
ref={npDialogRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="发布新帖"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
onKeyDown={npDialogKey}
> >
<div className="dialog"> <div className="dialog">
<h3>发布新帖</h3> <h3>发布新帖</h3>
+5 -3
View File
@@ -108,7 +108,7 @@ export default function ForumDetail() {
</button> </button>
)} )}
</div> </div>
<h3 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h3> <h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{post.author_name || '匿名'}</span> <span>{post.author_name || '匿名'}</span>
<span>{post.created_at}</span> <span>{post.created_at}</span>
@@ -158,12 +158,14 @@ export default function ForumDetail() {
<div className="reply-meta"> <div className="reply-meta">
<strong>{r.author_name || '匿名'}</strong> · {r.created_at} <strong>{r.author_name || '匿名'}</strong> · {r.created_at}
{canDeleteReply && ( {canDeleteReply && (
<span <button
type="button"
aria-label="删除回复"
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }} style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
onClick={() => deleteReply(r.id)} onClick={() => deleteReply(r.id)}
> >
删除 删除
</span> </button>
)} )}
</div> </div>
<div className="reply-body">{r.content}</div> <div className="reply-body">{r.content}</div>
+1
View File
@@ -19,6 +19,7 @@ export default function Home() {
return ( return (
<div className="homepage-layout"> <div className="homepage-layout">
<h1 className="sr-only">首页</h1>
<BlogSidebar settings={settings} showRecent forceShow /> <BlogSidebar settings={settings} showRecent forceShow />
<div className="homepage-content"> <div className="homepage-content">
<div className="card"> <div className="card">
+39 -12
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import * as pwApi from '../api/passwords.js'; import * as pwApi from '../api/passwords.js';
import { me } from '../api/auth.js'; import { me } from '../api/auth.js';
import { getToken } from '../api/client.js'; import { getToken } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar, useDialog } from '../lib/utils.js';
/** /**
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。 * 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
@@ -250,6 +250,14 @@ export default function Passwords() {
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null; const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
// 加载中 // 加载中
if (phase === 'loading') { if (phase === 'loading') {
return <div className="loading"><div className="spinner"></div></div>; return <div className="loading"><div className="spinner"></div></div>;
@@ -313,14 +321,18 @@ export default function Passwords() {
{entries && entries.length > 0 && ( {entries && entries.length > 0 && (
<div className="password-grid"> <div className="password-grid">
{entries.map((p) => ( {entries.map((p) => (
<div key={p.id} className="card password-card" onClick={() => setDetailId(p.id)}> <div key={p.id} className="password-card-wrap">
<div className="pw-title">{p.title}</div> <button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
<div className="pw-username">{p.username || '无用户名'}</div> <div className="pw-title">{p.title}</div>
<div className="pw-username">{p.username || '无用户名'}</div>
</button>
<div className="pw-actions"> <div className="pw-actions">
<button <button
type="button"
className="btn-icon" className="btn-icon"
style={{ width: 32, height: 32, fontSize: 16 }} style={{ width: 40, height: 40, fontSize: 16 }}
title="复制密码" title="复制密码"
aria-label={`复制 ${p.title} 的密码`}
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }} onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
> >
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> <span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
@@ -334,9 +346,14 @@ export default function Passwords() {
{/* 设置/修改 PIN 弹窗 */} {/* 设置/修改 PIN 弹窗 */}
{pinDialog && ( {pinDialog && (
<div <div
ref={pinDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="设置 PIN 码"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
onKeyDown={pinDlgKey}
> >
<div className="dialog" style={{ maxWidth: 360 }}> <div className="dialog" style={{ maxWidth: 360 }}>
<h3>设置 PIN </h3> <h3>设置 PIN </h3>
@@ -360,9 +377,14 @@ export default function Passwords() {
{/* 添加/编辑条目弹窗 */} {/* 添加/编辑条目弹窗 */}
{pwDialog && ( {pwDialog && (
<div <div
ref={pwDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label={editingId ? '编辑密码' : '添加密码'}
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
onKeyDown={pwDlgKey}
> >
<div className="dialog"> <div className="dialog">
<h3>{editingId ? '编辑密码' : '添加密码'}</h3> <h3>{editingId ? '编辑密码' : '添加密码'}</h3>
@@ -400,9 +422,14 @@ export default function Passwords() {
{/* 详情弹窗 */} {/* 详情弹窗 */}
{detailEntry && ( {detailEntry && (
<div <div
ref={detailDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label={detailEntry.title}
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
onKeyDown={detailDlgKey}
> >
<div className="dialog" style={{ maxWidth: 500 }}> <div className="dialog" style={{ maxWidth: 500 }}>
<h3>{detailEntry.title}</h3> <h3>{detailEntry.title}</h3>
@@ -410,16 +437,16 @@ export default function Passwords() {
<div className="pw-field"> <div className="pw-field">
<span className="pw-label">用户名</span> <span className="pw-label">用户名</span>
<span className="pw-value">{detailEntry.username || ''}</span> <span className="pw-value">{detailEntry.username || ''}</span>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}> <button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span> <span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span> </button>
</div> </div>
<div className="pw-field"> <div className="pw-field">
<span className="pw-label">密码</span> <span className="pw-label">密码</span>
<span className="pw-value">{detailEntry.password}</span> <span className="pw-value">{detailEntry.password}</span>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.password, '密码')}> <button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span> <span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span> </button>
</div> </div>
{detailEntry.url ? ( {detailEntry.url ? (
<div className="pw-field"> <div className="pw-field">
@@ -447,7 +474,7 @@ export default function Passwords() {
{/* 密码生成器弹窗 */} {/* 密码生成器弹窗 */}
{genDialog && ( {genDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}> <div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
<div className="dialog" style={{ maxWidth: 380 }}> <div className="dialog" style={{ maxWidth: 380 }}>
<h3>生成随机密码</h3> <h3>生成随机密码</h3>
<div className="form-group"> <div className="form-group">
@@ -476,7 +503,7 @@ export default function Passwords() {
{/* 修改 PIN 警告确认弹窗 */} {/* 修改 PIN 警告确认弹窗 */}
{pinChangeWarn && ( {pinChangeWarn && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}> <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"> <div className="dialog">
<h3>确认修改 PIN</h3> <h3>确认修改 PIN</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}> 修改 PIN 现有密码条目将无法解密需要重新添加确定继续</p> <p style={{ marginBottom: 24, fontSize: 16 }}> 修改 PIN 现有密码条目将无法解密需要重新添加确定继续</p>
@@ -490,7 +517,7 @@ export default function Passwords() {
{/* 删除确认弹窗 */} {/* 删除确认弹窗 */}
{confirmDialog && ( {confirmDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}> <div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
<div className="dialog"> <div className="dialog">
<h3>确认操作</h3> <h3>确认操作</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录</p> <p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录</p>
+13 -1
View File
@@ -4,7 +4,7 @@ import * as profileApi from '../api/profile.js';
import * as authApi from '../api/auth.js'; import * as authApi from '../api/auth.js';
import { avatarUrl, uploadAvatar } from '../api/upload.js'; import { avatarUrl, uploadAvatar } from '../api/upload.js';
import { getToken, notifyAuthChange } from '../api/client.js'; import { getToken, notifyAuthChange } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js'; import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
/** FileReader + Image 加载图片(头像裁剪前读取) */ /** FileReader + Image 加载图片(头像裁剪前读取) */
function loadImage(file) { function loadImage(file) {
@@ -38,6 +38,13 @@ export default function Profile() {
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [pwBusy, setPwBusy] = useState(false); const [pwBusy, setPwBusy] = useState(false);
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
useEffect(() => {
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
}, [pwDialog, pwStep]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -186,9 +193,14 @@ export default function Profile() {
{/* 修改密码弹窗 */} {/* 修改密码弹窗 */}
{pwDialog && ( {pwDialog && (
<div <div
ref={pwDlgRef}
className="dialog-overlay active" className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="修改密码"
style={{ display: 'flex', zIndex: 9999 }} style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }} onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
onKeyDown={pwDlgKey}
> >
<div className="dialog"> <div className="dialog">
<h3>修改密码</h3> <h3>修改密码</h3>
+361 -28
View File
@@ -188,6 +188,27 @@ button:active, .btn:active {
filter: brightness(0.9); filter: brightness(0.9);
} }
/* 键盘焦点可见(B1):所有可交互元素 focus-visible 时显示焦点环,补偿上方 outline: none */
button:focus-visible, .btn:focus-visible, a:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--md-ref-primary);
outline-offset: 2px;
border-radius: 100px;
}
/* 屏幕阅读器专用(B6):只读不出、不占布局的辅助标题 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
margin: -1px;
padding: 0;
border: 0;
}
.btn-filled { .btn-filled {
background: var(--md-ref-primary); background: var(--md-ref-primary);
color: var(--md-ref-on-primary); color: var(--md-ref-on-primary);
@@ -522,6 +543,10 @@ table tr:hover td {
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
border: 1px solid transparent; border: 1px solid transparent;
/* div/span → button 后补偿:不被 button 全局样式的固定高度/字体/对齐影响 */
font-family: inherit;
height: auto;
text-align: left;
} }
.chip.active { .chip.active {
@@ -779,13 +804,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 {
@@ -979,6 +1004,15 @@ table tr:hover td {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
/* div/span → button 后补偿:保持块级填充、左对齐与自然高度 */
font-family: inherit;
height: auto;
justify-content: flex-start;
text-align: left;
/* B2 div→button 后补偿:清除 UA 默认浅色按钮底/描边——否则深色模式下
非选中态按钮仍是白色 buttonface(点击切换分类时尤明显),背景随主题变体 */
background: transparent;
border: none;
} }
.forum-cat-item:hover { background: var(--md-ref-surface-variant); } .forum-cat-item:hover { background: var(--md-ref-surface-variant); }
@@ -1135,10 +1169,25 @@ table tr:hover td {
gap: 16px; gap: 16px;
} }
/* 密码卡片的外层定位容器:复制按钮与卡片同级(避免 button 嵌套 button),
用绝对定位贴在卡片右上角 */
.password-card-wrap {
position: relative;
}
.password-card { .password-card {
padding: 16px; padding: 16px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
/* div/span → button 后补偿:卡片保留块级布局、继承文字色与左对齐 */
font-family: inherit;
color: inherit;
height: auto;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
text-align: left;
width: 100%;
} }
.password-card .pw-title { .password-card .pw-title {
@@ -1192,6 +1241,12 @@ table tr:hover td {
color: var(--md-ref-primary); color: var(--md-ref-primary);
padding: 4px; padding: 4px;
border-radius: 50%; border-radius: 50%;
/* span → button 后补偿:保持小图标按钮外观(不被 button 全局样式撑高) */
display: inline-flex;
align-items: center;
justify-content: center;
height: auto;
font-family: inherit;
} }
.password-detail .pw-field .pw-copy:hover { background: var(--md-ref-primary-container); } .password-detail .pw-field .pw-copy:hover { background: var(--md-ref-primary-container); }
@@ -1266,7 +1321,7 @@ table tr:hover td {
.nav-version { .nav-version {
font-size: 11px; font-size: 11px;
color: var(--md-ref-outline); color: var(--md-ref-on-surface-variant);
margin-right: 8px; margin-right: 8px;
white-space: nowrap; white-space: nowrap;
align-self: flex-end; align-self: flex-end;
@@ -1505,22 +1560,23 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
.has-wallpaper .forum-post-card, .has-wallpaper .forum-post-card,
.has-wallpaper .blog-card, .has-wallpaper .blog-card,
.has-wallpaper .panel-card, .has-wallpaper .panel-card,
.has-wallpaper .password-card { .has-wallpaper .password-card,
background: var(--wallpaper-overlay, rgba(0,0,0,0.25)); .has-wallpaper .link-card {
background: var(--glass-bg, rgba(255,251,254,0.75));
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
} }
.has-wallpaper .nav-bar { .has-wallpaper .nav-bar {
background: var(--wallpaper-overlay, rgba(0,0,0,0.2)); background: var(--glass-bg, rgba(255,251,254,0.8));
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255,255,255,0.08); border-bottom: 1px solid rgba(255,255,255,0.1);
} }
.has-wallpaper .dialog { .has-wallpaper .dialog {
background: var(--wallpaper-overlay, rgba(0,0,0,0.4)); background: var(--glass-bg-heavy, rgba(255,251,254,0.9));
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
} }
@@ -1528,14 +1584,27 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
.has-wallpaper input, .has-wallpaper input,
.has-wallpaper select, .has-wallpaper select,
.has-wallpaper textarea { .has-wallpaper textarea {
background: var(--wallpaper-overlay, rgba(0,0,0,0.15)); background: var(--glass-bg, rgba(255,251,254,0.6));
backdrop-filter: blur(4px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
} }
[data-theme="dark"] .has-wallpaper::before { [data-theme="dark"] .has-wallpaper::before {
background: var(--wallpaper-overlay, rgba(0,0,0,0.4)); background: var(--wallpaper-overlay, rgba(0,0,0,0.4));
} }
/* 壁纸开启时整体提高玻璃不透明度(内容可读性):
--glass-bg / --glass-bg-heavy 的所有使用者(玻璃卡/导航/表格/弹窗)自动加强;
无壁纸时保持原有半透明质感不受影响 */
body.has-wallpaper {
--glass-bg: rgba(255,251,254,0.82);
--glass-bg-heavy: rgba(255,251,254,0.92);
}
[data-theme="dark"] body.has-wallpaper {
--glass-bg: rgba(28,27,31,0.74);
--glass-bg-heavy: rgba(28,27,31,0.84);
}
/* Glass card style */ /* Glass card style */
.card.glass-card, .card.glass-card,
.glass-card { .glass-card {
@@ -1559,9 +1628,42 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
} }
/* Forum sidebar glass with wallpaper */ /* 壁纸下内容区统一磨砂背景(可读性核心):
.has-wallpaper .forum-sidebar .forum-cat-item { 用 .page::before 实现而不是直接在 .page 上 backdrop-filter——
backdrop-filter: blur(4px); 直接加会使 .page 成为 position:fixed 后代的包含块,
破坏详情页悬浮目录(.article-toc-side)的视口定位。
半透明 + 模糊给文字稳固底色,壁纸在面板边缘仍清晰可见 */
/* 壁纸下页脚同源加强(页脚在 .page 之外,单独覆盖) */
body.has-wallpaper .footer-classic {
background: rgba(255, 251, 254, 0.82);
}
[data-theme="dark"] body.has-wallpaper .footer-classic {
background: rgba(28, 27, 31, 0.75);
}
/* 壁纸下详情页正文卡 / 目录卡加强:阅读面需要更高不透明度(其余卡片由
body.has-wallpaper 的 --glass-bg 变量统一加强) */
body.has-wallpaper .blog-article,
body.has-wallpaper .article-toc-side {
background: rgba(255, 251, 254, 0.78);
}
[data-theme="dark"] body.has-wallpaper .blog-article,
[data-theme="dark"] body.has-wallpaper .article-toc-side {
background: rgba(28, 27, 31, 0.74);
}
/* 壁纸下论坛侧栏玻璃底(B9):仿 .blog-article 的半透明 + 模糊 + 圆角,
保证壁纸下分类文字可读(深色主题用深色底) */
.has-wallpaper .forum-sidebar {
background: rgba(255, 251, 254, 0.5);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 16px;
padding: 12px;
}
[data-theme="dark"] .has-wallpaper .forum-sidebar {
background: rgba(28, 27, 31, 0.55);
} }
/* Blog waterfall cards with wallpaper */ /* Blog waterfall cards with wallpaper */
@@ -1602,7 +1704,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.blog-waterfall .blog-card { padding: 16px; } .blog-waterfall .blog-card { padding: 16px; }
.nav-tab { font-size: 13px; padding: 4px 10px; } .nav-tab { font-size: 13px; padding: 8px 12px; }
} }
/* ===== Homepage Layout ===== */ /* ===== Homepage Layout ===== */
@@ -1763,6 +1865,18 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
line-height: 0; line-height: 0;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
/* 折叠触发按钮:div → button 后补偿,保持 48×48 圆角矩形图标样式 */
.music-embed-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
font-family: inherit;
cursor: pointer;
color: var(--md-ref-on-surface-variant);
}
.music-embed.right { right: 24px; } .music-embed.right { right: 24px; }
.music-embed.left { left: 24px; } .music-embed.left { left: 24px; }
.music-embed iframe { .music-embed iframe {
@@ -1802,7 +1916,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;
@@ -1835,14 +1955,21 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
.footer-classic .fc-version { .footer-classic .fc-version {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--md-ref-on-surface-variant);
opacity: 0.7; opacity: 0.7;
} }
/* ---- 分栏导航 ---- */ /* ---- 分栏导航 ---- */
.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;
@@ -1883,7 +2010,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
font-weight: 600; font-weight: 600;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--md-ref-outline); color: var(--md-ref-on-surface-variant);
margin-bottom: 12px; margin-bottom: 12px;
} }
.fc-col .col-links { display: flex; flex-direction: column; gap: 8px; } .fc-col .col-links { display: flex; flex-direction: column; gap: 8px; }
@@ -2036,8 +2163,8 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
box-shadow: none !important; box-shadow: none !important;
} }
.search-clear { .search-clear {
width: 28px; width: 40px;
height: 28px; height: 40px;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
background: transparent; background: transparent;
@@ -2091,7 +2218,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
overflow: hidden; overflow: hidden;
margin-bottom: 6px; margin-bottom: 6px;
} }
.sri-meta { font-size: 12px; color: var(--md-ref-outline); display: flex; gap: 6px; flex-wrap: wrap; } .sri-meta { font-size: 12px; color: var(--md-ref-on-surface-variant); display: flex; gap: 6px; flex-wrap: wrap; }
.sri-meta .sep { color: var(--md-ref-outline-variant); } .sri-meta .sep { color: var(--md-ref-outline-variant); }
.sri-tags { color: var(--md-ref-primary); } .sri-tags { color: var(--md-ref-primary); }
@@ -2147,12 +2274,12 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
cursor: pointer; cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0.0, 0.2, 1); transition: all 0.25s cubic-bezier(0.4, 0.0, 0.2, 1);
} }
.like-btn:hover { border-color: #d32f2f; color: #d32f2f; background: rgba(211, 47, 47, 0.06); } .like-btn:hover { border-color: var(--md-ref-error); color: var(--md-ref-error); background: color-mix(in srgb, var(--md-ref-error) 10%, transparent); }
.like-btn .material-icons { font-size: 18px; } .like-btn .material-icons { font-size: 18px; }
.like-btn.liked { .like-btn.liked {
border-color: #d32f2f; border-color: var(--md-ref-error);
color: #d32f2f; color: var(--md-ref-error);
background: rgba(211, 47, 47, 0.1); background: color-mix(in srgb, var(--md-ref-error) 10%, transparent);
} }
.like-btn:active { transform: scale(0.94); } .like-btn:active { transform: scale(0.94); }
@@ -2237,7 +2364,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
border-color: var(--md-ref-primary); border-color: var(--md-ref-primary);
box-shadow: 0 4px 16px var(--md-shadow); box-shadow: 0 4px 16px var(--md-shadow);
} }
.pn-label { font-size: 12px; font-weight: 500; color: var(--md-ref-outline); } .pn-label { font-size: 12px; font-weight: 500; color: var(--md-ref-on-surface-variant); }
.pn-title { .pn-title {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@@ -2272,7 +2399,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
} }
.reply-btn { .reply-btn {
margin-top: 8px; margin-top: 8px;
height: 28px; height: 40px;
padding: 0 10px; padding: 0 10px;
font-size: 12px; font-size: 12px;
color: var(--md-ref-on-surface-variant); color: var(--md-ref-on-surface-variant);
@@ -2334,7 +2461,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
white-space: nowrap; white-space: nowrap;
} }
.archive-item:hover .archive-item-title { color: var(--md-ref-primary); } .archive-item:hover .archive-item-title { color: var(--md-ref-primary); }
.archive-item-date { font-size: 12px; color: var(--md-ref-outline); flex-shrink: 0; } .archive-item-date { font-size: 12px; color: var(--md-ref-on-surface-variant); flex-shrink: 0; }
/* ===== 博客增强响应式 ===== */ /* ===== 博客增强响应式 ===== */
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -2449,3 +2576,209 @@ 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;
}
/* ============================================================
Markdown Indigo 定制(.md-body
—— 只做文本级渲染;除代码块(okaidia 暗底)外全部背景透明,
由卡片磨砂玻璃接管;颜色全部走主题 CSS 变量,
浅/深色随 data-theme 自动切换。
============================================================ */
/* 正文基调:透明背景,文字色走主题变量 */
.md-body {
color: var(--md-ref-on-surface);
line-height: 1.75;
}
/* 标题:Indigo 排版(字重 500 / 行高 1.3),h1/h2 带左侧竖线 + 下边框 */
.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 {
color: var(--md-ref-on-surface);
font-weight: 500;
line-height: 1.3;
margin: 1.5em 0 .6em;
}
.md-body h1, .md-body h2 {
padding: .25em 0 .25em .75em;
border-left: 6px solid var(--md-ref-primary);
border-bottom: 1px solid var(--md-ref-outline-variant);
}
/* 加粗 / 删除线:不加 Indigo 的「」引号,仅保留字重;删除线走主题灰 */
.md-body strong { font-weight: 700; }
.md-body del, .md-body s { color: var(--md-ref-outline); }
/* 链接:主题主色 + Indigo 下边框式下划线 */
.md-body a {
color: var(--md-ref-primary);
text-decoration: none;
border-bottom: 1px solid color-mix(in srgb, var(--md-ref-primary) 40%, transparent);
}
.md-body a:hover { border-bottom-color: var(--md-ref-primary); }
/* 行内代码:主题容器色背景(可换肤),透明背景不出现 */
.md-body code {
background: var(--md-ref-surface-container);
color: var(--md-ref-on-surface);
padding: 2px 6px;
border-radius: 4px;
font-size: .9em;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
/* 引用块:Indigo 左粗线(主题主色)+ 主题浅底 */
.md-body blockquote {
margin: 1em 0;
padding: .5em 1em;
border-left: 4px solid var(--md-ref-primary);
background: var(--md-ref-surface-container-low);
border-radius: 0 8px 8px 0;
color: color-mix(in srgb, var(--md-ref-primary) 70%, var(--md-ref-on-surface));
}
.md-body blockquote > :last-child { margin-bottom: 0; }
.md-body blockquote > :first-child { margin-top: 0; }
/* 代码块:唯一保留背景的元素 —— okaidia 暗色主题 */
.md-body pre {
background: #272822;
color: #f8f8f2;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
margin: 1em 0;
font-size: 13px;
line-height: 1.6;
text-shadow: 0 1px rgba(0, 0, 0, .3);
tab-size: 4;
}
.md-body pre code {
background: none;
color: #f8f8f2;
padding: 0;
font-size: inherit;
text-shadow: 0 1px rgba(0, 0, 0, .3);
}
.md-body pre::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .18); border-radius: 2px; }
/* okaidia 语法高亮(token 出现时生效,如后续接入 highlight.js/prism */
.md-body .token.cdata, .md-body .token.comment, .md-body .token.doctype, .md-body .token.prolog { color: #8292a2; }
.md-body .token.punctuation { color: #f8f8f2; }
.md-body .token.namespace { opacity: .7; }
.md-body .token.constant, .md-body .token.deleted, .md-body .token.property, .md-body .token.symbol, .md-body .token.tag { color: #f92672; }
.md-body .token.boolean, .md-body .token.number { color: #ae81ff; }
.md-body .token.attr-name, .md-body .token.builtin, .md-body .token.char, .md-body .token.inserted, .md-body .token.selector, .md-body .token.string { color: #a6e22e; }
.md-body .token.entity, .md-body .token.operator, .md-body .token.url, .md-body .token.variable { color: #f8f8f2; }
.md-body .token.atrule, .md-body .token.attr-value, .md-body .token.class-name, .md-body .token.function { color: #e6db74; }
.md-body .token.keyword { color: #66d9ef; }
.md-body .token.important, .md-body .token.regex { color: #fd971f; }
.md-body .token.bold, .md-body .token.important { font-weight: 700; }
.md-body .token.italic { font-style: italic; }
/* 深色模式:文本色跟随 on-surface 浅色变体;代码块保持 okaidia 暗底不变 */
[data-theme="dark"] .md-body { color: var(--md-ref-on-surface); }
[data-theme="dark"] .md-body blockquote { background: var(--md-ref-surface-container); }
+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',