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 (
0 ? ' reply-child' : '')}>
{c.author_name || '游客'} · {c.created_at} {kids.length > 0 && 回复 {kids.length}}
{c.content}
{user && ( )} {kids.length > 0 && (
{kids.map((k) => renderComment(k, depth + 1))}
)}
); }; 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
; } if (error || !post) { return (
⚠️

{error || '文章不存在'}

返回博客
); } return (
arrow_back 返回列表

{post.title}

{post.author_name || '管理员'} · {post.created_at} visibility {post.views || 0} 约 {wordCount} 字
{tags.length > 0 && (
{tags.map((t) => ( {t} ))}
)}
{canEdit && ( 编辑 )}
{/* 上一篇 / 下一篇 */} {prevnext && (prevnext.prev || prevnext.next) && ( )}

评论 ({comments.length})

{comments.length === 0 ? (

暂无评论

) : (
{rootComments.map((c) => renderComment(c))}
)} {user ? (
{replyTo && (
reply 回复 @{replyTo.name}
)}