Files
social-mcp/web/src/components/feed/FeedDetail.tsx
T
kurihada a0f3a3cbac 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 对空字符串和无效日期的处理
2026-03-02 17:30:11 +08:00

371 lines
14 KiB
TypeScript

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 { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Textarea';
import { CommentTree } from './CommentTree';
import { formatNumber, formatTime } from '@/lib/formatters';
import { useToast } from '@/context/ToastContext';
interface Props {
feedId: string;
xsecToken: string;
onClose: () => void;
onUserClick?: (userId: string, xsecToken: string) => void;
}
export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
const { toast } = useToast();
const [detail, setDetail] = useState<FeedDetailType | null>(null);
const [loading, setLoading] = useState(true);
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);
const [commentText, setCommentText] = useState('');
const [replyTarget, setReplyTarget] = useState<{ commentId: string; userId: string; nickname: string } | null>(null);
const [replyText, setReplyText] = useState('');
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 {
const res = await toggleLike(feedId, xsecToken);
if (res.success && res.data) {
setLiked(res.data.liked);
toast('success', res.data.liked ? '已点赞' : '已取消点赞');
}
} catch (err) {
toast('error', err instanceof Error ? err.message : '操作失败');
} finally {
setActionLoading(null);
}
};
const handleToggleFavorite = async () => {
setActionLoading('favorite');
try {
const res = await toggleFavorite(feedId, xsecToken);
if (res.success && res.data) {
setFavorited(res.data.favorited);
toast('success', res.data.favorited ? '已收藏' : '已取消收藏');
}
} catch (err) {
toast('error', err instanceof Error ? err.message : '操作失败');
} finally {
setActionLoading(null);
}
};
const handleComment = async () => {
if (!commentText.trim()) {
toast('warning', '评论内容不能为空');
return;
}
setActionLoading('comment');
try {
await postComment(feedId, xsecToken, commentText);
toast('success', '评论已发布');
setCommentText('');
} catch (err) {
toast('error', err instanceof Error ? err.message : '操作失败');
} finally {
setActionLoading(null);
}
};
const handleReply = async () => {
if (!replyText.trim()) {
toast('warning', '回复内容不能为空');
return;
}
setActionLoading('reply');
try {
await replyComment({
feed_id: feedId,
xsec_token: xsecToken,
content: replyText,
comment_id: replyTarget?.commentId,
user_id: replyTarget?.userId,
});
toast('success', '回复已发布');
setReplyText('');
setReplyTarget(null);
} catch (err) {
toast('error', err instanceof Error ? err.message : '操作失败');
} finally {
setActionLoading(null);
}
};
return (
<div className="fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative ml-auto w-full max-w-2xl bg-dark-card border-l border-dark-border overflow-y-auto">
<div className="sticky top-0 bg-dark-card border-b border-dark-border px-5 py-3 flex items-center justify-between z-10">
<h3 className="font-semibold truncate"></h3>
<button onClick={onClose} className="text-dark-muted hover:text-dark-text text-xl">&times;</button>
</div>
{loading && (
<div className="flex justify-center py-20"><Spinner size="lg" /></div>
)}
{error && (
<div className="p-5 text-dark-danger">{error}</div>
)}
{detail && (
<div className="p-5 space-y-5">
{/* Images */}
{detail.images.length > 0 && (
<div>
<div className="rounded-xl overflow-hidden bg-dark-bg">
<img
src={detail.images[currentImage]}
alt=""
className="w-full max-h-96 object-contain"
/>
</div>
{detail.images.length > 1 && (
<div className="flex gap-2 mt-2 overflow-x-auto pb-1">
{detail.images.map((img, i) => (
<button
key={i}
onClick={() => setCurrentImage(i)}
className={`w-14 h-14 rounded-lg overflow-hidden shrink-0 border-2 ${
i === currentImage ? 'border-dark-accent' : 'border-transparent'
}`}
>
<img src={img} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
)}
{/* Video */}
{detail.videoUrl && (
<div className="rounded-xl overflow-hidden bg-dark-bg p-4">
<Badge variant="info"></Badge>
<p className="text-xs text-dark-muted mt-2 break-all">{detail.videoUrl}</p>
</div>
)}
{/* Title & Content */}
<div>
<h2 className="text-xl font-bold mb-2">{detail.title}</h2>
<p className="text-sm text-dark-text/80 whitespace-pre-wrap">{detail.description}</p>
</div>
{/* Tags */}
{detail.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{detail.tags.map((tag) => (
<Badge key={tag} variant="info">#{tag}</Badge>
))}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: '点赞', value: detail.likeCount },
{ label: '收藏', value: detail.collectCount },
{ label: '评论', value: detail.commentCount },
].map((s) => (
<div key={s.label} className="bg-dark-bg rounded-lg p-3 text-center">
<p className="text-lg font-bold">{formatNumber(s.value)}</p>
<p className="text-xs text-dark-muted">{s.label}</p>
</div>
))}
</div>
{/* Interaction buttons */}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={liked ? 'secondary' : 'primary'}
onClick={() => void handleToggleLike()}
loading={actionLoading === 'like'}
>
{liked ? '已点赞' : '点赞'}
</Button>
<Button
size="sm"
variant={favorited ? 'secondary' : 'primary'}
onClick={() => void handleToggleFavorite()}
loading={actionLoading === 'favorite'}
>
{favorited ? '已收藏' : '收藏'}
</Button>
</div>
{/* Author */}
<div
className="flex items-center gap-3 p-3 bg-dark-bg rounded-lg cursor-pointer hover:bg-dark-hover"
onClick={() => onUserClick?.(detail.user.id, detail.xsecToken)}
>
{detail.user.avatar && (
<img src={detail.user.avatar} alt="" className="w-10 h-10 rounded-full" />
)}
<div>
<p className="text-sm font-medium">{detail.user.nickname}</p>
<p className="text-xs text-dark-muted">{[detail.ipLocation, formatTime(detail.createTime)].filter(Boolean).join(' · ') || '暂无信息'}</p>
</div>
</div>
{/* IDs for interaction */}
<div className="bg-dark-bg rounded-lg p-3 text-xs space-y-1">
<p><span className="text-dark-muted">Feed ID:</span> <code className="text-dark-accent">{detail.id}</code></p>
<p><span className="text-dark-muted">xsec_token:</span> <code className="text-dark-accent">{detail.xsecToken}</code></p>
<p><span className="text-dark-muted">User ID:</span> <code className="text-dark-accent">{detail.user.id}</code></p>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant="ghost"
onClick={() => void navigator.clipboard.writeText(detail.id)}
>
Feed ID
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void navigator.clipboard.writeText(detail.xsecToken)}
>
Token
</Button>
</div>
</div>
{/* Comment input */}
<div className="space-y-2">
{replyTarget && (
<div className="flex items-center gap-2 px-3 py-2 bg-dark-accent/10 border border-dark-accent/30 rounded-lg text-sm">
<span className="text-dark-accent flex-1"> @{replyTarget.nickname}</span>
<button
onClick={() => { setReplyTarget(null); setReplyText(''); }}
className="text-dark-muted hover:text-dark-text text-xs"
>
</button>
</div>
)}
<Textarea
value={replyTarget ? replyText : commentText}
onChange={(e) => replyTarget ? setReplyText(e.target.value) : setCommentText(e.target.value)}
placeholder={replyTarget ? `回复 @${replyTarget.nickname}...` : '发表评论...'}
/>
<Button
size="sm"
onClick={() => void (replyTarget ? handleReply() : handleComment())}
loading={actionLoading === 'comment' || actionLoading === 'reply'}
>
{replyTarget ? '发送回复' : '发表评论'}
</Button>
</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})
</h3>
<CommentTree
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>
)}
</div>
</div>
);
}