import "./ArticleDetail.css"; import React, { useState, useEffect, useCallback } from "react"; import { Tag, Avatar, Button, Tooltip, Divider } from "antd"; import { message } from "@/utils/message"; import { useTranslation } from "react-i18next"; import { EyeOutlined, LikeOutlined, LikeFilled, CommentOutlined, StarOutlined, ShareAltOutlined, BookOutlined, ClockCircleOutlined, ArrowLeftOutlined, HeartOutlined, HeartFilled, StarFilled, } from "@ant-design/icons"; import { useNavigate, useParams } from "react-router-dom"; import { api_request } from "@/utils/request"; import { useRecordHistory } from "@/hooks/useRecordHistory"; import { formatDateTime } from "@/utils/time"; import { sanitizeHtml } from "@/utils/htmlSanitizer"; const ArticleDetail: React.FC = () => { const navigate = useNavigate(); const { id } = useParams(); const { t, i18n } = useTranslation(); const [article, setArticle] = useState(null); const [comments, setComments] = useState([]); const [liked, setLiked] = useState(false); const [favorited, setFavorited] = useState(false); const [likeCount, setLikeCount] = useState(0); const [favoriteCount, setFavoriteCount] = useState(0); const [following, setFollowing] = useState(false); const [followLoading, setFollowLoading] = useState(false); const [readProgress, setReadProgress] = useState(0); const [loading, setLoading] = useState(true); const [showBackToTop, setShowBackToTop] = useState(false); const [commentText, setCommentText] = useState(""); const [replyText, setReplyText] = useState(""); const [commentPage, setCommentPage] = useState(1); const [commentsPagination, setCommentsPagination] = useState(null); const [commentsLoading, setCommentsLoading] = useState(false); const [replyTarget, setReplyTarget] = useState<{ commentId: number; replyTo: string } | null>(null); const [showMentionList, setShowMentionList] = useState(false); const [showReplyMentionList, setShowReplyMentionList] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [mentionTarget, setMentionTarget] = useState<"comment" | "reply">("comment"); const mentionUsers = React.useMemo(() => { const users: { id: number; name: string; avatar: string; isAuthor?: boolean }[] = []; if (article) { users.push({ id: article.author_user_id, name: article.author_name, avatar: article.author_avatar, isAuthor: true }); } const seen = new Set(users.map(u => u.id)); const extractFromComments = (list: any[]) => { for (const c of list) { if (c.user_id && !seen.has(c.user_id)) { seen.add(c.user_id); users.push({ id: c.user_id, name: c.user_name, avatar: c.user_avatar }); } if (c.replies?.length) extractFromComments(c.replies); } }; extractFromComments(comments); return users; }, [article, comments]); const filteredMentionUsers = mentionUsers.filter(u => u.name.toLowerCase().includes(mentionQuery.toLowerCase()) ); useRecordHistory(article ? { type: "article", title: article.title || "", description: article.excerpt || "", image: article.image || "", category: article.category || "", link: `/post/${id}`, } : null); useEffect(() => { const loadArticle = async () => { if (!id) return; setLoading(true); try { const res = await api_request.article.get_detail(Number(id)); const data = (res as any)?.data; if (data) { setArticle(data); setLiked(data.is_liked || false); setFavorited(data.is_favorited || false); setLikeCount(data.likes || 0); setFavoriteCount(data.favorites_count || 0); setComments(data.comments || []); setCommentsPagination(data.comments_pagination || null); setCommentPage(1); setFollowing(data.is_following || false); } } catch {} finally { setLoading(false); } }; loadArticle(); }, [id]); const detectMention = (text: string, cursorPos: number | null): boolean => { if (cursorPos === null) return false; const beforeCursor = text.slice(0, cursorPos); const atIndex = beforeCursor.lastIndexOf("@"); if (atIndex === -1) return false; const afterAt = beforeCursor.slice(atIndex + 1); if (afterAt.includes(" ") || afterAt.length > 10) return false; setMentionQuery(afterAt); return true; }; const handleCommentChange = (e: React.ChangeEvent) => { const val = e.target.value; setCommentText(val); const pos = e.target.selectionStart; const isMention = detectMention(val, pos); setShowMentionList(isMention); setMentionTarget("comment"); }; const handleReplyChange = (e: React.ChangeEvent) => { const val = e.target.value; setReplyText(val); const pos = e.target.selectionStart; const isMention = detectMention(val, pos); setShowReplyMentionList(isMention); setMentionTarget("reply"); }; const insertMention = (user: { id: number; name: string; avatar: string; isAuthor?: boolean }) => { const tag = `@${user.name} `; if (mentionTarget === "comment") { const pos = commentText.lastIndexOf("@"); const newText = commentText.slice(0, pos) + tag; setCommentText(newText); setShowMentionList(false); } else { const pos = replyText.lastIndexOf("@"); const newText = replyText.slice(0, pos) + tag; setReplyText(newText); setShowReplyMentionList(false); } setMentionQuery(""); }; const handleScroll = useCallback(() => { const scrollTop = window.scrollY; const docHeight = document.documentElement.scrollHeight - window.innerHeight; const progress = docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0; setReadProgress(progress); setShowBackToTop(scrollTop > 400); }, []); useEffect(() => { window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); }, [handleScroll]); useEffect(() => { const hash = window.location.hash; if (hash && hash.startsWith("#comment-")) { const timer = setTimeout(() => { const el = document.getElementById(hash.slice(1)); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); el.style.transition = "box-shadow 0.3s"; el.style.boxShadow = "0 0 0 2px #667eea, 0 4px 16px rgba(102, 126, 234, 0.25)"; setTimeout(() => { el.style.boxShadow = ""; }, 2500); } }, 500); return () => clearTimeout(timer); } }, []); const handleLike = async () => { if (!id) return; try { const res = await api_request.article.toggle_like(Number(id)); const data = (res as any)?.data; if (data) { setLiked(data.liked); setLikeCount(data.likes_count); } } catch {} }; const handleFavorite = async () => { if (!id) return; try { const res = await api_request.article.toggle_favorite(Number(id)); const data = (res as any)?.data; if (data) { setFavorited(data.favorited); setFavoriteCount(data.favorites_count); } } catch {} }; const handleFollow = async () => { if (!article || !article.author_user_id) return; setFollowLoading(true); try { const res = await api_request.user.follow(article.author_user_id); const data = (res as any)?.data; if (data) { setFollowing(data.is_following); } } catch {} finally { setFollowLoading(false); } }; const handleCommentLike = async (commentId: number) => { try { const res = await api_request.article.toggle_comment_like(commentId); const data = (res as any)?.data; if (data) { const updateCommentLikes = (list: any[]): any[] => list.map((c: any) => { if (c.id === commentId) { return { ...c, is_liked: data.liked, likes: data.likes_count }; } if (c.replies?.length) { return { ...c, replies: updateCommentLikes(c.replies) }; } return c; }); setComments(updateCommentLikes(comments)); } } catch {} }; const handleShare = () => { navigator.clipboard.writeText(window.location.href); message.success(t('articles.comments.copied')); }; const handleComment = async () => { if (!commentText.trim() || !id) return; try { const res = await api_request.article.create_comment(Number(id), { content: commentText }); if ((res as any)?.code === 10000) { setCommentText(''); const newComment = (res as any)?.data; if (newComment) { setComments(prev => [newComment, ...prev]); } } } catch {} }; const loadMoreComments = async () => { if (!id || !commentsPagination?.next || commentsLoading) return; setCommentsLoading(true); try { const nextPage = commentPage + 1; const res = await api_request.article.get_comments(Number(id), { page: nextPage }); const data = (res as any)?.data; if (data?.results) { setComments(prev => [...prev, ...data.results]); setCommentsPagination({ next: data.next, count: data.count }); setCommentPage(nextPage); } } catch {} finally { setCommentsLoading(false); } }; const handleReply = async (commentId: number) => { if (!replyText.trim()) { message.warning(t('articles.comments.emptyReply')); return; } if (!id) return; try { const res = await api_request.article.create_comment(Number(id), { content: replyText, parent: commentId, }); if ((res as any)?.code === 10000) { message.success(t('articles.comments.replySuccess')); setReplyText(""); setReplyTarget(null); const newReply = (res as any)?.data; if (newReply) { setComments(prev => prev.map(c => { if (c.id === commentId) { return { ...c, replies: [...(c.replies || []), newReply] }; } return c; })); } } } catch {} }; const scrollToTop = () => { window.scrollTo({ top: 0, behavior: "smooth" }); }; if (loading || !article) { return
{t('common.loading')}
; } return (
{article.title}

{article.title}

navigate(`/user-home/${article.author_user_id}`)} /> navigate(`/user-home/${article.author_user_id}`)} > {article.author_name}
{formatDateTime(article.publish_date, i18n.language)}
{article.update_date !== article.publish_date && (
{t('articles.detail.updated')}: {formatDateTime(article.update_date, i18n.language)}
)}
{article.read_time}
{article.views?.toLocaleString()} {t('articles.detail.read')}
{article.category}
{article.related_articles && article.related_articles.length > 0 && (

{t('articles.detail.relatedArticles')}

{article.related_articles.map((related: any) => (
navigate(`/post/${related.id}`)} >
{related.title}

{related.title}

{related.views || 0}
))}
)}
navigate(`/user-home/${article.author_user_id}`)} />
navigate(`/user-home/${article.author_user_id}`)} > {article.author_name}
{article.author_bio}
{t('articles.comments.title')} ({article.comments_count})
{t('articles.comments.mentionHint')}