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

- 点赞/收藏改为纯 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
-2
View File
@@ -4,7 +4,6 @@ import { ToastProvider } from '@/context/ToastContext';
import { Layout } from '@/components/layout/Layout';
import { DashboardPage } from '@/pages/DashboardPage';
import { XiaohongshuPage } from '@/pages/XiaohongshuPage';
import { InteractionsPage } from '@/pages/InteractionsPage';
import { ApiTesterPage } from '@/pages/ApiTesterPage';
import { SettingsPage } from '@/pages/SettingsPage';
@@ -17,7 +16,6 @@ export default function App() {
<Route element={<Layout />}>
<Route index element={<DashboardPage />} />
<Route path="xhs" element={<XiaohongshuPage />} />
<Route path="interactions" element={<InteractionsPage />} />
<Route path="api-tester" element={<ApiTesterPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
+6 -7
View File
@@ -9,7 +9,6 @@ import type {
HealthResponse,
ApiResponse,
PublishResult,
InteractionResult,
CommentResult,
} from './types';
@@ -101,14 +100,14 @@ export const replyComment = (data: {
body: JSON.stringify(data),
});
export const toggleLike = (feedId: string, xsecToken: string, unlike = false) =>
apiFetch<ApiResponse<InteractionResult>>('/api/xhs/like', {
export const toggleLike = (feedId: string, xsecToken: string) =>
apiFetch<ApiResponse<{ success: boolean; liked: boolean }>>('/api/xhs/like', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, unlike }),
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }),
});
export const toggleFavorite = (feedId: string, xsecToken: string, unfavorite = false) =>
apiFetch<ApiResponse<InteractionResult>>('/api/xhs/favorite', {
export const toggleFavorite = (feedId: string, xsecToken: string) =>
apiFetch<ApiResponse<{ success: boolean; favorited: boolean }>>('/api/xhs/favorite', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, unfavorite }),
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }),
});
+2
View File
@@ -43,6 +43,8 @@ export interface FeedDetail {
collectCount: number;
commentCount: number;
shareCount: number;
isLiked: boolean;
isFavorited: boolean;
createTime: string;
lastUpdateTime: string;
ipLocation: string;
+11 -2
View File
@@ -4,9 +4,10 @@ import { formatTime } from '@/lib/formatters';
interface Props {
comments: Comment[];
depth?: number;
onReply?: (commentId: string, userId: string, nickname: string) => void;
}
export function CommentTree({ comments, depth = 0 }: Props) {
export function CommentTree({ comments, depth = 0, onReply }: Props) {
return (
<div className={depth > 0 ? 'ml-6 border-l border-dark-border pl-4' : ''}>
{comments.map((comment) => (
@@ -22,6 +23,14 @@ export function CommentTree({ comments, depth = 0 }: Props) {
{comment.ipLocation && (
<span className="text-xs text-dark-muted">{comment.ipLocation}</span>
)}
{onReply && (
<button
onClick={() => onReply(comment.id, comment.userId, comment.nickname)}
className="text-xs text-dark-muted hover:text-dark-accent ml-2"
>
</button>
)}
</div>
<p className="text-sm text-dark-text/90">{comment.content}</p>
{comment.likeCount > 0 && (
@@ -30,7 +39,7 @@ export function CommentTree({ comments, depth = 0 }: Props) {
</div>
</div>
{comment.subComments.length > 0 && (
<CommentTree comments={comment.subComments} depth={depth + 1} />
<CommentTree comments={comment.subComments} depth={depth + 1} onReply={onReply} />
)}
</div>
))}
+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>
+2 -3
View File
@@ -1,7 +1,6 @@
export const NAV_ITEMS = [
{ path: '/', label: '仪表盘', icon: 'dashboard' },
{ path: '/xhs', label: '小红书', icon: 'xhs' },
{ path: '/interactions', label: '互动', icon: 'interactions' },
{ path: '/api-tester', label: 'API 测试', icon: 'api' },
{ path: '/settings', label: '设置', icon: 'settings' },
] as const;
@@ -18,6 +17,6 @@ export const API_ENDPOINTS = [
{ key: 'publish_video', method: 'POST', path: '/api/xhs/publish/video', label: '发布视频笔记', category: '发布', body: { title: '', content: '', video: '', tags: [], visibility: 'public' } },
{ key: 'comment', method: 'POST', path: '/api/xhs/comment', label: '发表评论', category: '互动', body: { feed_id: '', xsec_token: '', content: '' } },
{ key: 'comment_reply', method: 'POST', path: '/api/xhs/comment/reply', label: '回复评论', category: '互动', body: { feed_id: '', xsec_token: '', content: '', comment_id: '', user_id: '' } },
{ key: 'like', method: 'POST', path: '/api/xhs/like', label: '点赞/取消', category: '互动', body: { feed_id: '', xsec_token: '', unlike: false } },
{ key: 'favorite', method: 'POST', path: '/api/xhs/favorite', label: '收藏/取消', category: '互动', body: { feed_id: '', xsec_token: '', unfavorite: false } },
{ key: 'like', method: 'POST', path: '/api/xhs/like', label: '点赞/取消', category: '互动', body: { feed_id: '', xsec_token: '' } },
{ key: 'favorite', method: 'POST', path: '/api/xhs/favorite', label: '收藏/取消', category: '互动', body: { feed_id: '', xsec_token: '' } },
] as const;
-204
View File
@@ -1,204 +0,0 @@
import { useState, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { JsonViewer } from '@/components/ui/JsonViewer';
import { useToast } from '@/context/ToastContext';
import { toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
interface LogEntry {
id: number;
action: string;
time: string;
result: unknown;
}
let logId = 0;
export function InteractionsPage() {
const { toast } = useToast();
const [feedId, setFeedId] = useState('');
const [xsecToken, setXsecToken] = useState('');
const [loading, setLoading] = useState<string | null>(null);
const [log, setLog] = useState<LogEntry[]>([]);
const addLog = (action: string, result: unknown) => {
setLog((prev) => [{ id: logId++, action, time: new Date().toLocaleTimeString(), result }, ...prev].slice(0, 50));
};
// Comment state
const [commentText, setCommentText] = useState('');
// Reply state
const [replyText, setReplyText] = useState('');
const [replyCommentId, setReplyCommentId] = useState('');
const [replyUserId, setReplyUserId] = useState('');
const checkIds = () => {
if (!feedId.trim() || !xsecToken.trim()) {
toast('warning', 'Feed ID 和 xsec_token 为必填项');
return false;
}
return true;
};
const handleLike = useCallback(async (unlike: boolean) => {
if (!checkIds()) return;
setLoading(unlike ? 'unlike' : 'like');
try {
const res = await toggleLike(feedId, xsecToken, unlike);
addLog(unlike ? '取消点赞' : '点赞', res);
toast('success', unlike ? '已取消点赞' : '已点赞');
} catch (err) {
const msg = err instanceof Error ? err.message : '操作失败';
addLog(unlike ? '取消点赞' : '点赞', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, toast]);
const handleFavorite = useCallback(async (unfavorite: boolean) => {
if (!checkIds()) return;
setLoading(unfavorite ? 'unfavorite' : 'favorite');
try {
const res = await toggleFavorite(feedId, xsecToken, unfavorite);
addLog(unfavorite ? '取消收藏' : '收藏', res);
toast('success', unfavorite ? '已取消收藏' : '已收藏');
} catch (err) {
const msg = err instanceof Error ? err.message : '操作失败';
addLog(unfavorite ? '取消收藏' : '收藏', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, toast]);
const handleComment = useCallback(async () => {
if (!checkIds() || !commentText.trim()) {
toast('warning', '评论内容为必填项');
return;
}
setLoading('comment');
try {
const res = await postComment(feedId, xsecToken, commentText);
addLog('评论', res);
toast('success', '评论已发布');
setCommentText('');
} catch (err) {
const msg = err instanceof Error ? err.message : '操作失败';
addLog('评论', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, commentText, toast]);
const handleReply = useCallback(async () => {
if (!checkIds() || !replyText.trim()) {
toast('warning', '回复内容为必填项');
return;
}
setLoading('reply');
try {
const res = await replyComment({
feed_id: feedId,
xsec_token: xsecToken,
content: replyText,
comment_id: replyCommentId || undefined,
user_id: replyUserId || undefined,
});
addLog('回复', res);
toast('success', '回复已发布');
setReplyText('');
} catch (err) {
const msg = err instanceof Error ? err.message : '操作失败';
addLog('回复', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, replyText, replyCommentId, replyUserId, toast]);
return (
<div className="max-w-3xl space-y-6">
<h1 className="text-2xl font-bold"></h1>
{/* Target */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3"></h2>
<div className="grid grid-cols-2 gap-4">
<Input label="Feed ID" value={feedId} onChange={(e) => setFeedId(e.target.value)} placeholder="Feed ID" />
<Input label="xsec_token" value={xsecToken} onChange={(e) => setXsecToken(e.target.value)} placeholder="xsec_token" />
</div>
</Card>
{/* Like / Favorite */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3"></h2>
<div className="flex flex-wrap gap-3">
<Button onClick={() => void handleLike(false)} loading={loading === 'like'} size="sm">
</Button>
<Button onClick={() => void handleLike(true)} loading={loading === 'unlike'} variant="secondary" size="sm">
</Button>
<Button onClick={() => void handleFavorite(false)} loading={loading === 'favorite'} size="sm">
</Button>
<Button onClick={() => void handleFavorite(true)} loading={loading === 'unfavorite'} variant="secondary" size="sm">
</Button>
</div>
</Card>
{/* Comment */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3"></h2>
<div className="space-y-3">
<Textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder="写评论..." />
<Button onClick={() => void handleComment()} loading={loading === 'comment'} size="sm">
</Button>
</div>
</Card>
{/* Reply */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3"></h2>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<Input label="评论 ID" value={replyCommentId} onChange={(e) => setReplyCommentId(e.target.value)} placeholder="可选" />
<Input label="用户 ID" value={replyUserId} onChange={(e) => setReplyUserId(e.target.value)} placeholder="可选" />
</div>
<Textarea value={replyText} onChange={(e) => setReplyText(e.target.value)} placeholder="写回复..." />
<Button onClick={() => void handleReply()} loading={loading === 'reply'} size="sm">
</Button>
</div>
</Card>
{/* Log */}
{log.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider"></h2>
<Button variant="ghost" size="sm" onClick={() => setLog([])}></Button>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{log.map((entry) => (
<div key={entry.id} className="border-b border-dark-border/50 pb-2 last:border-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-dark-muted">{entry.time}</span>
<span className="text-sm font-medium text-dark-accent">{entry.action}</span>
</div>
<JsonViewer data={entry.result} collapsed maxHeight="120px" />
</div>
))}
</div>
</Card>
)}
</div>
);
}