xhs_get_comments 增加 sort + max_count 控制,评论随详情一起返回

- Comment 新增 subCommentCount,新增 CommentsResult 接口
- GetFeedCommentsSchema 替换 load_all 为 sort (default/newest/most_liked) + max_count (1-100)
- getFeedDetail 不再清空评论,从 Vue store 异步提取首屏评论(轮询 firstRequestFinish)
- getFeedComments 重写:支持排序切换、按 maxCount 加载、返回 hasMore/totalCount
- 前端详情加载后直接显示评论,无需单独请求;底部显示"加载更多评论"按钮
- CommentTree 显示"还有 X 条回复"提示
- 修复 formatTime 对空字符串和无效日期的处理
This commit is contained in:
2026-03-02 17:30:11 +08:00
parent e252310f23
commit a0f3a3cbac
11 changed files with 417 additions and 79 deletions
+67 -6
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import type { FeedDetail as FeedDetailType } from '@/api/types';
import { getFeedDetail, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
import type { FeedDetail as FeedDetailType, Comment } from '@/api/types';
import { getFeedDetail, getFeedComments, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
import { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner';
import { Button } from '@/components/ui/Button';
@@ -23,6 +23,12 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
const [error, setError] = useState<string | null>(null);
const [currentImage, setCurrentImage] = useState(0);
const [comments, setComments] = useState<Comment[]>([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentsHasMore, setCommentsHasMore] = useState(false);
const [commentsTotalCount, setCommentsTotalCount] = useState(0);
const [commentsMaxCount, setCommentsMaxCount] = useState(20);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
@@ -33,20 +39,50 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
useEffect(() => {
setLoading(true);
setError(null);
setComments([]);
void getFeedDetail(feedId, xsecToken)
.then((res) => {
if (res.success && res.data) {
setDetail(res.data);
setLiked(res.data.isLiked);
setFavorited(res.data.isFavorited);
// Use comments from __INITIAL_STATE__ (first page, ~10-20).
if (res.data.comments.length > 0) {
setComments(res.data.comments);
setCommentsTotalCount(res.data.commentCount);
setCommentsHasMore(res.data.commentCount > res.data.comments.length);
}
} else {
setError(res.error?.message || 'Failed to load detail');
}
})
.catch((err) => setError(err instanceof Error ? err.message : 'Error'))
.finally(() => setLoading(false));
}, [feedId, xsecToken]);
const handleLoadComments = async (maxCount = commentsMaxCount) => {
setCommentsLoading(true);
try {
const res = await getFeedComments(feedId, xsecToken, 'default', maxCount);
if (res.success && res.data) {
setComments(res.data.comments);
setCommentsHasMore(res.data.hasMore);
setCommentsTotalCount(res.data.totalCount);
}
} catch {
toast('error', '加载评论失败');
} finally {
setCommentsLoading(false);
}
};
const handleLoadMore = () => {
const nextCount = commentsMaxCount * 2;
setCommentsMaxCount(nextCount);
void handleLoadComments(nextCount);
};
const handleToggleLike = async () => {
setActionLoading('like');
try {
@@ -232,7 +268,7 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
)}
<div>
<p className="text-sm font-medium">{detail.user.nickname}</p>
<p className="text-xs text-dark-muted">{detail.ipLocation} · {formatTime(detail.createTime)}</p>
<p className="text-xs text-dark-muted">{[detail.ipLocation, formatTime(detail.createTime)].filter(Boolean).join(' · ') || '暂无信息'}</p>
</div>
</div>
@@ -287,18 +323,43 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
</div>
{/* Comments */}
{detail.comments.length > 0 && (
{comments.length === 0 && !commentsLoading && (
<Button
size="sm"
variant="secondary"
onClick={() => void handleLoadComments()}
>
</Button>
)}
{commentsLoading && (
<div className="flex items-center gap-2 py-4 text-dark-muted text-sm">
<Spinner size="sm" />
<span>...</span>
</div>
)}
{comments.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
({detail.comments.length})
({commentsTotalCount > 0 ? commentsTotalCount : comments.length})
</h3>
<CommentTree
comments={detail.comments}
comments={comments}
onReply={(commentId, userId, nickname) => {
setReplyTarget({ commentId, userId, nickname });
setReplyText('');
}}
/>
{commentsHasMore && !commentsLoading && (
<Button
size="sm"
variant="secondary"
onClick={handleLoadMore}
className="mt-3 w-full"
>
</Button>
)}
</div>
)}
</div>