集成互动功能到帖子详情,删除独立互动页面

- 点赞/收藏改为纯 toggle,移除 unlike/unfavorite 参数
- 帖子详情 API 返回 isLiked/isFavorited 状态(SVG xlink:href 检测)
- 前端两个切换按钮替代原四个独立按钮
- 修复 __INITIAL_STATE__ Vue 响应式代理序列化(structuredClone + fallback)
- 修复 overlay 场景下点赞按钮误点 feed 列表元素(.last() 定位)
- 删除 InteractionsPage 及相关路由/导航
This commit is contained in:
2026-03-02 14:39:15 +08:00
parent def0828815
commit 5a1f88de95
15 changed files with 308 additions and 442 deletions
+138 -2
View File
@@ -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>