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 (
{error || '文章不存在'}
返回博客暂无评论
) : (登录后可以评论
)}