a0f3a3cbac
- 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 对空字符串和无效日期的处理
371 lines
14 KiB
TypeScript
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">×</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>
|
|
);
|
|
}
|