集成互动功能到帖子详情,删除独立互动页面
- 点赞/收藏改为纯 toggle,移除 unlike/unfavorite 参数 - 帖子详情 API 返回 isLiked/isFavorited 状态(SVG xlink:href 检测) - 前端两个切换按钮替代原四个独立按钮 - 修复 __INITIAL_STATE__ Vue 响应式代理序列化(structuredClone + fallback) - 修复 overlay 场景下点赞按钮误点 feed 列表元素(.last() 定位) - 删除 InteractionsPage 及相关路由/导航
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { FeedDetail as FeedDetailType } from '@/api/types';
|
||||
import { getFeedDetail } from '@/api/endpoints';
|
||||
import { getFeedDetail, 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;
|
||||
@@ -15,11 +17,19 @@ interface Props {
|
||||
}
|
||||
|
||||
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 [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);
|
||||
@@ -27,6 +37,8 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setDetail(res.data);
|
||||
setLiked(res.data.isLiked);
|
||||
setFavorited(res.data.isFavorited);
|
||||
} else {
|
||||
setError(res.error?.message || 'Failed to load detail');
|
||||
}
|
||||
@@ -35,6 +47,77 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
||||
.finally(() => setLoading(false));
|
||||
}, [feedId, xsecToken]);
|
||||
|
||||
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} />
|
||||
@@ -120,6 +203,26 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
||||
))}
|
||||
</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"
|
||||
@@ -157,13 +260,46 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
||||
</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 */}
|
||||
{detail.comments.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
|
||||
评论 ({detail.comments.length})
|
||||
</h3>
|
||||
<CommentTree comments={detail.comments} />
|
||||
<CommentTree
|
||||
comments={detail.comments}
|
||||
onReply={(commentId, userId, nickname) => {
|
||||
setReplyTarget({ commentId, userId, nickname });
|
||||
setReplyText('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user