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
+14 -2
View File
@@ -4,6 +4,7 @@ import type {
QRCodeResult,
Feed,
FeedDetail,
CommentsResult,
UserProfile,
SearchFilters,
HealthResponse,
@@ -40,10 +41,21 @@ export const searchFeeds = (keyword: string, filters?: SearchFilters) =>
body: JSON.stringify({ keyword, filters }),
});
export const getFeedDetail = (feedId: string, xsecToken: string, loadAllComments = false) =>
export const getFeedDetail = (feedId: string, xsecToken: string) =>
apiFetch<ApiResponse<FeedDetail>>('/api/xhs/feeds/detail', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, load_all_comments: loadAllComments }),
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }),
});
export const getFeedComments = (
feedId: string,
xsecToken: string,
sort: 'default' | 'newest' | 'most_liked' = 'default',
maxCount = 20,
) =>
apiFetch<ApiResponse<CommentsResult>>('/api/xhs/feeds/comments', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, sort, max_count: maxCount }),
});
// User
+7
View File
@@ -60,9 +60,16 @@ export interface Comment {
likeCount: number;
createTime: string;
ipLocation: string;
subCommentCount: number;
subComments: Comment[];
}
export interface CommentsResult {
comments: Comment[];
hasMore: boolean;
totalCount: number;
}
export interface UserProfile {
id: string;
nickname: string;
+5
View File
@@ -41,6 +41,11 @@ export function CommentTree({ comments, depth = 0, onReply }: Props) {
{comment.subComments.length > 0 && (
<CommentTree comments={comment.subComments} depth={depth + 1} onReply={onReply} />
)}
{comment.subCommentCount > comment.subComments.length && (
<p className="ml-8 mt-1 text-xs text-dark-muted">
{comment.subCommentCount - comment.subComments.length}
</p>
)}
</div>
))}
</div>
+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>
+4 -1
View File
@@ -18,8 +18,11 @@ export function formatNumber(n: number): string {
}
export function formatTime(iso: string): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleString();
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString();
} catch {
return iso;
}