SPA冲突路由改名+旧路径Navigate重定向(/api-docs→/open-api-docs、/api-detail→/open-api-detail、/article→/post、/user→/user-home);vite代理宽前缀改精确正则白名单;nginx.conf同步同口径;新增86路由回归脚本+Playwright 3项(86/86全绿)。
661 lines
25 KiB
TypeScript
661 lines
25 KiB
TypeScript
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<any>(null);
|
||
const [comments, setComments] = useState<any[]>([]);
|
||
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<any>(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<HTMLTextAreaElement>) => {
|
||
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<HTMLTextAreaElement>) => {
|
||
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 <div className="article-detail-page" style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>{t('common.loading')}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="article-detail-page">
|
||
<div className="read-progress-bar" style={{ width: `${readProgress}%` }} />
|
||
<div className="article-cover-wrapper">
|
||
<img
|
||
className="article-cover-img"
|
||
src={article.image}
|
||
alt={article.title}
|
||
/>
|
||
<div className="article-cover-overlay">
|
||
<div className="article-cover-content">
|
||
<h1 className="article-cover-title">{article.title}</h1>
|
||
<div className="article-cover-meta">
|
||
<div className="author-info">
|
||
<Avatar
|
||
src={article.author_avatar}
|
||
size={32}
|
||
style={{ cursor: "pointer" }}
|
||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||
/>
|
||
<span
|
||
className="author-name"
|
||
style={{ cursor: "pointer" }}
|
||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||
>
|
||
{article.author_name}
|
||
</span>
|
||
</div>
|
||
<div className="meta-item">
|
||
<ClockCircleOutlined />
|
||
<span>{formatDateTime(article.publish_date, i18n.language)}</span>
|
||
</div>
|
||
{article.update_date !== article.publish_date && (
|
||
<div className="meta-item">
|
||
<ClockCircleOutlined />
|
||
<span>{t('articles.detail.updated')}: {formatDateTime(article.update_date, i18n.language)}</span>
|
||
</div>
|
||
)}
|
||
<div className="meta-item">
|
||
<BookOutlined />
|
||
<span>{article.read_time}</span>
|
||
</div>
|
||
<div className="meta-item">
|
||
<EyeOutlined />
|
||
<span>{article.views?.toLocaleString()} {t('articles.detail.read')}</span>
|
||
</div>
|
||
<Tag color="purple">{article.category}</Tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="article-action-bar">
|
||
<div className="article-action-bar-inner">
|
||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||
<ArrowLeftOutlined />
|
||
<span>{t('articles.actions.back')}</span>
|
||
</button>
|
||
<div className="article-action-buttons">
|
||
<Tooltip title={liked ? t('articles.actions.unlike') : t('articles.actions.like')}>
|
||
<button
|
||
className={`action-btn ${liked ? "liked" : ""}`}
|
||
onClick={handleLike}
|
||
>
|
||
{liked ? <HeartFilled /> : <HeartOutlined />}
|
||
<span className="count">{likeCount}</span>
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title={favorited ? t('articles.actions.unfavorite') : t('articles.actions.favorite')}>
|
||
<button
|
||
className={`action-btn ${favorited ? "favorited" : ""}`}
|
||
onClick={handleFavorite}
|
||
>
|
||
{favorited ? <StarFilled /> : <StarOutlined />}
|
||
<span className="count">{favoriteCount}</span>
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title={t('articles.actions.share')}>
|
||
<button className="action-btn" onClick={handleShare}>
|
||
<ShareAltOutlined />
|
||
<span className="count">{t('articles.actions.share')}</span>
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title={t('articles.actions.comment')}>
|
||
<button className="action-btn">
|
||
<CommentOutlined />
|
||
<span className="count">{article.comments_count}</span>
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="article-body-card">
|
||
<div className="article-body" dangerouslySetInnerHTML={{ __html: sanitizeHtml(article.content || "") }} />
|
||
|
||
{article.related_articles && article.related_articles.length > 0 && (
|
||
<div className="related-articles-section">
|
||
<h3 className="related-articles-title">{t('articles.detail.relatedArticles')}</h3>
|
||
<div className="related-articles-grid">
|
||
{article.related_articles.map((related: any) => (
|
||
<div
|
||
key={related.id}
|
||
className="related-article-card"
|
||
onClick={() => navigate(`/post/${related.id}`)}
|
||
>
|
||
<div className="related-article-cover">
|
||
<img src={related.image} alt={related.title} />
|
||
</div>
|
||
<div className="related-article-info">
|
||
<h4 className="related-article-title">{related.title}</h4>
|
||
<span className="related-article-views">
|
||
<EyeOutlined /> {related.views || 0}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Divider className="article-divider" />
|
||
|
||
<div className="author-card">
|
||
<div className="author-card-avatar">
|
||
<Avatar
|
||
src={article.author_avatar}
|
||
size={64}
|
||
style={{ cursor: "pointer" }}
|
||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||
/>
|
||
</div>
|
||
<div className="author-card-info">
|
||
<div
|
||
className="author-card-name"
|
||
style={{ cursor: "pointer" }}
|
||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||
>
|
||
{article.author_name}
|
||
</div>
|
||
<div className="author-card-bio">
|
||
{article.author_bio}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
className="follow-btn"
|
||
ghost={following}
|
||
loading={followLoading}
|
||
onClick={handleFollow}
|
||
>
|
||
{following ? t('articles.detail.following') : t('articles.detail.follow')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="comment-section">
|
||
<div className="section-title">
|
||
{t('articles.comments.title')} ({article.comments_count})
|
||
</div>
|
||
<div className="comment-input-area">
|
||
<div className="comment-input-hint">
|
||
{t('articles.comments.mentionHint')}
|
||
</div>
|
||
<div className="mention-wrapper">
|
||
<textarea
|
||
placeholder={t('articles.comments.placeholder')}
|
||
value={commentText}
|
||
onChange={handleCommentChange}
|
||
/>
|
||
{showMentionList && filteredMentionUsers.length > 0 && (
|
||
<div className="mention-dropdown">
|
||
{filteredMentionUsers.map((user) => (
|
||
<div
|
||
key={user.id}
|
||
className="mention-dropdown-item"
|
||
onClick={() => insertMention(user)}
|
||
>
|
||
<Avatar src={user.avatar} size={28} />
|
||
<span className="mention-user-name">{user.name}</span>
|
||
{user.isAuthor && <span className="mention-author-tag">{t('articles.comments.author')}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="comment-input-actions">
|
||
<Button type="primary" onClick={handleComment}>
|
||
{t('articles.comments.submit')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="comment-list">
|
||
{comments.map((comment: any) => (
|
||
<div className="comment-item" key={comment.id} id={`comment-${comment.id}`}>
|
||
<Avatar
|
||
src={comment.user_avatar}
|
||
size={40}
|
||
style={{ cursor: "pointer" }}
|
||
onClick={() => comment.user_id && navigate(`/user-home/${comment.user_id}`)}
|
||
/>
|
||
<div className="comment-content">
|
||
<div className="comment-header">
|
||
<span
|
||
className="comment-author"
|
||
style={{ cursor: comment.user_id ? "pointer" : "default" }}
|
||
onClick={() => comment.user_id && navigate(`/user-home/${comment.user_id}`)}
|
||
>
|
||
{comment.user_name}
|
||
</span>
|
||
{comment.user_location && (
|
||
<span className="comment-location">{comment.user_location}</span>
|
||
)}
|
||
<span className="comment-time">{formatDateTime(comment.created_at, i18n.language)}</span>
|
||
</div>
|
||
<div className="comment-text">{comment.content}</div>
|
||
<div className="comment-actions">
|
||
<span
|
||
className={`comment-action ${comment.is_liked ? "liked" : ""}`}
|
||
onClick={() => handleCommentLike(comment.id)}
|
||
>
|
||
{comment.is_liked ? <LikeFilled /> : <LikeOutlined />} {comment.likes}
|
||
</span>
|
||
<span
|
||
className="comment-action"
|
||
onClick={() => setReplyTarget({ commentId: comment.id, replyTo: comment.user_name })}
|
||
>
|
||
<CommentOutlined /> {t('articles.comments.reply')}
|
||
</span>
|
||
</div>
|
||
|
||
{comment.replies?.length > 0 && (
|
||
<div className="comment-replies">
|
||
{comment.replies.map((reply: any) => (
|
||
<div className="reply-item" key={reply.id}>
|
||
<Avatar
|
||
src={reply.user_avatar}
|
||
size={32}
|
||
style={{ cursor: "pointer" }}
|
||
onClick={() => reply.user_id && navigate(`/user-home/${reply.user_id}`)}
|
||
/>
|
||
<div className="reply-content">
|
||
<div className="reply-header">
|
||
<span
|
||
className="reply-author"
|
||
style={{ cursor: reply.user_id ? "pointer" : "default" }}
|
||
onClick={() => reply.user_id && navigate(`/user-home/${reply.user_id}`)}
|
||
>
|
||
{reply.user_name}
|
||
</span>
|
||
{reply.user_location && (
|
||
<span className="reply-location">{reply.user_location}</span>
|
||
)}
|
||
{reply.parent && (
|
||
<span className="reply-to">{t('articles.comments.replyTo')} <strong>{reply.parent_user_name || comment.user_name}</strong></span>
|
||
)}
|
||
<span className="reply-time">{formatDateTime(reply.created_at, i18n.language)}</span>
|
||
</div>
|
||
<div className="reply-text">{reply.content}</div>
|
||
<div className="reply-actions">
|
||
<span
|
||
className={`comment-action ${reply.is_liked ? "liked" : ""}`}
|
||
onClick={() => handleCommentLike(reply.id)}
|
||
>
|
||
{reply.is_liked ? <LikeFilled /> : <LikeOutlined />} {reply.likes}
|
||
</span>
|
||
<span
|
||
className="comment-action"
|
||
onClick={() => setReplyTarget({ commentId: comment.id, replyTo: reply.user_name })}
|
||
>
|
||
<CommentOutlined /> {t('articles.comments.reply')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{replyTarget?.commentId === comment.id && (
|
||
<div className="reply-input-area">
|
||
<div className="reply-input-header">
|
||
{t('articles.comments.reply')} <strong>{replyTarget?.replyTo}</strong>
|
||
<span className="reply-input-close" onClick={() => { setReplyTarget(null); setReplyText(""); }}>×</span>
|
||
</div>
|
||
<div className="mention-wrapper">
|
||
<textarea
|
||
className="reply-input-textarea"
|
||
placeholder={`${t('articles.comments.replyPlaceholder')} ${replyTarget?.replyTo}...`}
|
||
value={replyText}
|
||
onChange={handleReplyChange}
|
||
autoFocus
|
||
/>
|
||
{showReplyMentionList && filteredMentionUsers.length > 0 && (
|
||
<div className="mention-dropdown">
|
||
{filteredMentionUsers.map((user) => (
|
||
<div
|
||
key={user.id}
|
||
className="mention-dropdown-item"
|
||
onClick={() => insertMention(user)}
|
||
>
|
||
<Avatar src={user.avatar} size={24} />
|
||
<span className="mention-user-name">{user.name}</span>
|
||
{user.isAuthor && <span className="mention-author-tag">{t('articles.comments.author')}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="reply-input-actions">
|
||
<Button size="small" onClick={() => { setReplyTarget(null); setReplyText(""); }}>{t('articles.comments.cancel')}</Button>
|
||
<Button type="primary" size="small" onClick={() => handleReply(comment.id)}>{t('articles.comments.reply')}</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{commentsPagination && commentsPagination.next && (
|
||
<div className="comment-load-more">
|
||
<Button
|
||
type="text"
|
||
onClick={() => loadMoreComments()}
|
||
loading={commentsLoading}
|
||
>
|
||
{t('articles.comments.loadMore')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className={`back-to-top ${showBackToTop ? "visible" : ""}`}
|
||
onClick={scrollToTop}
|
||
>
|
||
↑
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ArticleDetail;
|