54a3d9708a
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 条回复" 改为可点击按钮,带加载状态
341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { FeedDetail as FeedDetailType, Comment } from '@/api/types';
|
|
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';
|
|
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 [subCommentsLoading, setSubCommentsLoading] = useState<string | null>(null);
|
|
|
|
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);
|
|
}
|
|
} else {
|
|
setError(res.error?.message || 'Failed to load detail');
|
|
}
|
|
})
|
|
.catch((err) => setError(err instanceof Error ? err.message : 'Error'))
|
|
.finally(() => setLoading(false));
|
|
|
|
}, [feedId, xsecToken]);
|
|
|
|
const handleLoadSubComments = async (commentId: string) => {
|
|
setSubCommentsLoading(commentId);
|
|
try {
|
|
const res = await getSubComments(feedId, xsecToken, commentId);
|
|
if (res.success && res.data) {
|
|
setComments((prev) =>
|
|
prev.map((c) =>
|
|
c.id === commentId
|
|
? { ...c, subComments: res.data!, subCommentCount: res.data!.length }
|
|
: c,
|
|
),
|
|
);
|
|
}
|
|
} catch {
|
|
toast('error', '加载子评论失败');
|
|
} finally {
|
|
setSubCommentsLoading(null);
|
|
}
|
|
};
|
|
|
|
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">×</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 && (
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
|
|
评论 ({detail.commentCount > 0 ? detail.commentCount : comments.length})
|
|
</h3>
|
|
<CommentTree
|
|
comments={comments}
|
|
onReply={(commentId, userId, nickname) => {
|
|
setReplyTarget({ commentId, userId, nickname });
|
|
setReplyText('');
|
|
}}
|
|
onLoadSubComments={(commentId) => void handleLoadSubComments(commentId)}
|
|
subCommentsLoadingId={subCommentsLoading}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|