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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user