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