Files
chunyu_prject_react/src/pages/ArticleDetail/ArticleDetail.tsx
T
chunyu 24f200bfa6 feat(C-01):路由/代理前缀冲突修复
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全绿)。
2026-09-15 15:25:59 +08:00

661 lines
25 KiB
TypeScript
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 "./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;