Compare commits
13
Commits
v2.0.3
...
7bfeffaaa0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bfeffaaa0 | ||
|
|
aff5764b9b | ||
|
|
bb830f779c | ||
|
|
63d329db5f | ||
|
|
e83a4e6ee1 | ||
|
|
0153f770f6 | ||
|
|
789a2d9b33 | ||
|
|
5a878c1aab | ||
|
|
5e4cdf26b9 | ||
|
|
2faace4810 | ||
|
|
45002b809a | ||
|
|
1b62bf99ce | ||
|
|
6fafa6eb00 |
@@ -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 }) {
|
||||
|
||||
@@ -33,7 +33,6 @@ export default function BlogDetail() {
|
||||
const [prevnext, setPrevnext] = useState(null);
|
||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||
const [toc, setToc] = useState([]);
|
||||
const [tocOpen, setTocOpen] = useState(true);
|
||||
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -67,20 +66,22 @@ export default function BlogDetail() {
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
// 目录:从已渲染的 .md-body 提取 h2/h3 并打锚点 id
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
const body = document.querySelector('.blog-article .md-body');
|
||||
if (!body) { setToc([]); return; }
|
||||
const items = [...body.querySelectorAll('h2, h3')].map((h, i) => {
|
||||
if (!h.id) h.id = 'toc-' + i;
|
||||
return { id: h.id, text: (h.textContent || '').trim(), level: h.tagName === 'H2' ? 2 : 3 };
|
||||
});
|
||||
setToc(items);
|
||||
}, [post]);
|
||||
|
||||
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
||||
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
||||
|
||||
// 目录:从已渲染的 .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]);
|
||||
|
||||
// ── 点赞(乐观更新)──
|
||||
@@ -161,7 +162,8 @@ 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>
|
||||
@@ -194,36 +196,6 @@ export default function BlogDetail() {
|
||||
)}
|
||||
</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} />
|
||||
|
||||
{/* 上一篇 / 下一篇 */}
|
||||
@@ -294,6 +266,33 @@ export default function BlogDetail() {
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+122
-4
@@ -779,13 +779,13 @@ table tr:hover td {
|
||||
}
|
||||
|
||||
/* ===== Login Page ===== */
|
||||
.login-page {
|
||||
.login-page, .register-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--md-ref-background);
|
||||
background: transparent; /* 完全透明,透出壁纸 */
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@@ -1802,7 +1802,13 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
||||
flex-wrap: wrap;
|
||||
padding: 22px 28px 28px;
|
||||
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 {
|
||||
font-size: 13px;
|
||||
@@ -1840,9 +1846,15 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
||||
|
||||
/* ---- 分栏导航 ---- */
|
||||
.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);
|
||||
}
|
||||
[data-theme="dark"] .footer-columns {
|
||||
background: rgba(28, 27, 31, 0.6);
|
||||
}
|
||||
.footer-columns .fc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
@@ -2449,3 +2461,109 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
||||
0%, 55% { left: -60%; }
|
||||
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% + 388px(360+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user