sync from local backup

This commit is contained in:
chunyu
2026-08-05 23:59:22 +08:00
commit a59f46b894
409 changed files with 166027 additions and 0 deletions
+658
View File
@@ -0,0 +1,658 @@
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";
const ArticleDetail: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams();
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: `/article/${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/${article.author_user_id}`)}
/>
<span
className="author-name"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/user/${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: 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(`/article/${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/${article.author_user_id}`)}
/>
</div>
<div className="author-card-info">
<div
className="author-card-name"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/user/${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/${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/${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/${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/${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;