299 lines
11 KiB
React
299 lines
11 KiB
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';
|
||
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):
|
||
* 正文(MarkdownRenderer)+ 阅读量/标签/点赞 + 目录/字数 + 上一篇/下一篇 +
|
||
* 嵌套评论(parent_id 回复)。
|
||
*/
|
||
export default function BlogDetail() {
|
||
const { id } = useParams();
|
||
const [post, setPost] = useState(null);
|
||
const [comments, setComments] = useState([]);
|
||
const [user, setUser] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
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, cs, pn, lk] = await Promise.all([
|
||
blogApi.getPost(id),
|
||
blogApi.listComments(id),
|
||
blogApi.getPrevNext(id),
|
||
blogApi.getLikeState(id),
|
||
]);
|
||
setPost(p);
|
||
setComments(cs || []);
|
||
setPrevnext(pn);
|
||
setLike(lk || { liked: false, count: 0 });
|
||
} catch (e) {
|
||
setError(e.message || '加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [id]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
if (getToken()) {
|
||
me().then(setUser).catch(() => setUser(null));
|
||
} else {
|
||
setUser(null);
|
||
}
|
||
}, [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, replyTo ? replyTo.id : 0);
|
||
showSnackbar('评论已发表');
|
||
setCommentText('');
|
||
setReplyTo(null);
|
||
await load();
|
||
} catch (e) {
|
||
showSnackbar(e.message);
|
||
}
|
||
setSubmitting(false);
|
||
};
|
||
|
||
if (loading) {
|
||
return <div className="loading"><div className="spinner"></div></div>;
|
||
}
|
||
|
||
if (error || !post) {
|
||
return (
|
||
<div className="empty-state">
|
||
<div className="empty-icon">⚠️</div>
|
||
<p>{error || '文章不存在'}</p>
|
||
<Link to="/blog.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回博客</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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="点赞">
|
||
<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">编辑</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>
|
||
|
||
{comments.length === 0 ? (
|
||
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
|
||
) : (
|
||
<div className="comment-list">
|
||
{rootComments.map((c) => renderComment(c))}
|
||
</div>
|
||
)}
|
||
|
||
{user ? (
|
||
<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>
|
||
);
|
||
}
|