xhs_get_comments → xhs_get_sub_comments:针对单条评论加载子评论

getFeedDetail 已返回首屏一级评论(含 1-2 条子评论预览),不再需要独立的
评论加载工具。新增 xhs_get_sub_comments 针对指定一级评论加载完整子评论,
支持 max_count 参数(默认 20)控制加载量,避免超时和上下文溢出。

后端:
- schemas: GetFeedCommentsSchema → GetSubCommentsSchema (feed_id, xsec_token, comment_id, max_count)
- types: 删除 CommentsResult
- feed-detail: 删除 getFeedComments/scrapeComments/CommentSort/parseCommentElement,
  新增 getSubComments(导航→store就绪→定位评论→点击展开→读store)
- selectors: 删除 commentSort* 选择器
- index/routes: 注册新工具和路由,超时改用 feed_detail(60s)

前端:
- types/endpoints: 删除 CommentsResult,新增 getSubComments API
- FeedDetail: 删除独立评论加载逻辑,评论随详情显示,新增 handleLoadSubComments
- CommentTree: "还有 X 条回复" 改为可点击按钮,带加载状态
This commit is contained in:
2026-03-02 17:52:35 +08:00
parent a0f3a3cbac
commit 54a3d9708a
10 changed files with 273 additions and 375 deletions
+5 -5
View File
@@ -4,7 +4,7 @@ import type {
QRCodeResult,
Feed,
FeedDetail,
CommentsResult,
Comment,
UserProfile,
SearchFilters,
HealthResponse,
@@ -47,15 +47,15 @@ export const getFeedDetail = (feedId: string, xsecToken: string) =>
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }),
});
export const getFeedComments = (
export const getSubComments = (
feedId: string,
xsecToken: string,
sort: 'default' | 'newest' | 'most_liked' = 'default',
commentId: string,
maxCount = 20,
) =>
apiFetch<ApiResponse<CommentsResult>>('/api/xhs/feeds/comments', {
apiFetch<ApiResponse<Comment[]>>('/api/xhs/feeds/sub-comments', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, sort, max_count: maxCount }),
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, comment_id: commentId, max_count: maxCount }),
});
// User
-6
View File
@@ -64,12 +64,6 @@ export interface Comment {
subComments: Comment[];
}
export interface CommentsResult {
comments: Comment[];
hasMore: boolean;
totalCount: number;
}
export interface UserProfile {
id: string;
nickname: string;
+54 -35
View File
@@ -1,53 +1,72 @@
import type { Comment } from '@/api/types';
import { Spinner } from '@/components/ui/Spinner';
import { formatTime } from '@/lib/formatters';
interface Props {
comments: Comment[];
depth?: number;
onReply?: (commentId: string, userId: string, nickname: string) => void;
onLoadSubComments?: (commentId: string) => void;
subCommentsLoadingId?: string | null;
}
export function CommentTree({ comments, depth = 0, onReply }: Props) {
export function CommentTree({ comments, depth = 0, onReply, onLoadSubComments, subCommentsLoadingId }: Props) {
return (
<div className={depth > 0 ? 'ml-6 border-l border-dark-border pl-4' : ''}>
{comments.map((comment) => (
<div key={comment.id} className="py-3 border-b border-dark-border/50 last:border-0">
<div className="flex items-start gap-2">
{comment.avatar && (
<img src={comment.avatar} alt="" className="w-6 h-6 rounded-full shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-dark-accent">{comment.nickname}</span>
<span className="text-xs text-dark-muted">{formatTime(comment.createTime)}</span>
{comment.ipLocation && (
<span className="text-xs text-dark-muted">{comment.ipLocation}</span>
)}
{onReply && (
<button
onClick={() => onReply(comment.id, comment.userId, comment.nickname)}
className="text-xs text-dark-muted hover:text-dark-accent ml-2"
>
</button>
{comments.map((comment) => {
const remainingCount = comment.subCommentCount - comment.subComments.length;
const isLoadingSubs = subCommentsLoadingId === comment.id;
return (
<div key={comment.id} className="py-3 border-b border-dark-border/50 last:border-0">
<div className="flex items-start gap-2">
{comment.avatar && (
<img src={comment.avatar} alt="" className="w-6 h-6 rounded-full shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-dark-accent">{comment.nickname}</span>
<span className="text-xs text-dark-muted">{formatTime(comment.createTime)}</span>
{comment.ipLocation && (
<span className="text-xs text-dark-muted">{comment.ipLocation}</span>
)}
{onReply && (
<button
onClick={() => onReply(comment.id, comment.userId, comment.nickname)}
className="text-xs text-dark-muted hover:text-dark-accent ml-2"
>
</button>
)}
</div>
<p className="text-sm text-dark-text/90">{comment.content}</p>
{comment.likeCount > 0 && (
<span className="text-xs text-dark-muted mt-1 block">{comment.likeCount} </span>
)}
</div>
<p className="text-sm text-dark-text/90">{comment.content}</p>
{comment.likeCount > 0 && (
<span className="text-xs text-dark-muted mt-1 block">{comment.likeCount} </span>
)}
</div>
{comment.subComments.length > 0 && (
<CommentTree comments={comment.subComments} depth={depth + 1} onReply={onReply} />
)}
{remainingCount > 0 && depth === 0 && (
<button
onClick={() => onLoadSubComments?.(comment.id)}
disabled={isLoadingSubs}
className="ml-8 mt-1 text-xs text-dark-accent hover:text-dark-accent/80 disabled:opacity-50 flex items-center gap-1"
>
{isLoadingSubs ? (
<>
<Spinner size="sm" />
<span>...</span>
</>
) : (
<span> {remainingCount} </span>
)}
</button>
)}
</div>
{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>
);
}
+17 -47
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import type { FeedDetail as FeedDetailType, Comment } from '@/api/types';
import { getFeedDetail, getFeedComments, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
import { getFeedDetail, getSubComments, 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';
@@ -24,10 +24,7 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
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 [subCommentsLoading, setSubCommentsLoading] = useState<string | null>(null);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
@@ -49,8 +46,6 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
// 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');
@@ -61,28 +56,26 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
}, [feedId, xsecToken]);
const handleLoadComments = async (maxCount = commentsMaxCount) => {
setCommentsLoading(true);
const handleLoadSubComments = async (commentId: string) => {
setSubCommentsLoading(commentId);
try {
const res = await getFeedComments(feedId, xsecToken, 'default', maxCount);
const res = await getSubComments(feedId, xsecToken, commentId);
if (res.success && res.data) {
setComments(res.data.comments);
setCommentsHasMore(res.data.hasMore);
setCommentsTotalCount(res.data.totalCount);
setComments((prev) =>
prev.map((c) =>
c.id === commentId
? { ...c, subComments: res.data!, subCommentCount: res.data!.length }
: c,
),
);
}
} catch {
toast('error', '加载评论失败');
toast('error', '加载评论失败');
} finally {
setCommentsLoading(false);
setSubCommentsLoading(null);
}
};
const handleLoadMore = () => {
const nextCount = commentsMaxCount * 2;
setCommentsMaxCount(nextCount);
void handleLoadComments(nextCount);
};
const handleToggleLike = async () => {
setActionLoading('like');
try {
@@ -323,25 +316,10 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
</div>
{/* Comments */}
{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">
({commentsTotalCount > 0 ? commentsTotalCount : comments.length})
({detail.commentCount > 0 ? detail.commentCount : comments.length})
</h3>
<CommentTree
comments={comments}
@@ -349,17 +327,9 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
setReplyTarget({ commentId, userId, nickname });
setReplyText('');
}}
onLoadSubComments={(commentId) => void handleLoadSubComments(commentId)}
subCommentsLoadingId={subCommentsLoading}
/>
{commentsHasMore && !commentsLoading && (
<Button
size="sm"
variant="secondary"
onClick={handleLoadMore}
className="mt-3 w-full"
>
</Button>
)}
</div>
)}
</div>