Files
rainblogweb/frontend/src/pages/BlogDetail.jsx
T

299 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}