diff --git a/src/platforms/xiaohongshu/feed-detail.ts b/src/platforms/xiaohongshu/feed-detail.ts index f87cd15..b8e4724 100644 --- a/src/platforms/xiaohongshu/feed-detail.ts +++ b/src/platforms/xiaohongshu/feed-detail.ts @@ -217,8 +217,28 @@ export async function getFeedDetail( } } + // ----------------------------------------------------------------------- + // Detect current user's like / favorite state from the overlay DOM. + // .interact-container is unique to the overlay (feed list cards don't have it). + // XHS loads user state asynchronously, so wait up to 3s for the buttons. + // ----------------------------------------------------------------------- + await page.waitForSelector('.interact-container .like-wrapper', { timeout: 3_000 }).catch(() => {}); + await page.waitForTimeout(1_000); // extra time for async state update + + // XHS uses SVG xlink:href to indicate state: #like vs #liked, #collect vs #collected + const interactionState = await page.evaluate(() => { + const likeIcon = document.querySelector('.interact-container .like-wrapper use'); + const favIcon = document.querySelector('.interact-container .collect-wrapper use'); + return { + isLiked: likeIcon?.getAttribute('xlink:href') === '#liked', + isFavorited: favIcon?.getAttribute('xlink:href') === '#collected', + }; + }); + detail.isLiked = interactionState.isLiked ?? false; + detail.isFavorited = interactionState.isFavorited ?? false; + log.info( - { feedId, commentCount: detail.comments.length, imageCount: detail.images.length }, + { feedId, commentCount: detail.comments.length, imageCount: detail.images.length, isLiked: detail.isLiked, isFavorited: detail.isFavorited }, 'Feed detail extraction complete', ); @@ -358,6 +378,8 @@ function parseDetailFromState( collectCount, commentCount, shareCount, + isLiked: false, + isFavorited: false, createTime, lastUpdateTime, ipLocation, @@ -513,6 +535,8 @@ async function scrapeDetailFromDom( collectCount, commentCount, shareCount, + isLiked: false, + isFavorited: false, createTime, lastUpdateTime: '', ipLocation, diff --git a/src/platforms/xiaohongshu/feeds.ts b/src/platforms/xiaohongshu/feeds.ts index f854e2b..343c5c2 100644 --- a/src/platforms/xiaohongshu/feeds.ts +++ b/src/platforms/xiaohongshu/feeds.ts @@ -155,8 +155,42 @@ export async function listFeeds(page: Page): Promise { */ async function extractInitialState(page: Page): Promise { try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const state: unknown = await page.evaluate('window.__INITIAL_STATE__'); + // Only extract keys we care about — the full __INITIAL_STATE__ can contain + // circular references or be too large, causing serialization errors. + // Return a JSON string from the browser to avoid Playwright's own + // serialization hitting Vue reactive proxy circular references. + // We use structuredClone to break Vue proxy wrappers, then stringify. + const json: string | null = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (window as any).__INITIAL_STATE__; + if (!s || typeof s !== 'object') return null; + try { + // structuredClone strips Vue proxies and produces a plain object. + const plain = structuredClone({ noteData: s.noteData, note: s.note, feed: s.feed, feeds: s.feeds, user: s.user }); + return JSON.stringify(plain); + } catch { + // structuredClone may fail on some Vue internals — fall back to + // stringify with a depth counter to avoid stack overflow. + let depth = 0; + const MAX_DEPTH = 20; + const seen = new Set(); + const result = JSON.stringify( + { noteData: s.noteData, note: s.note, feed: s.feed, feeds: s.feeds, user: s.user }, + function (_key, value) { + if (typeof value === 'function') return undefined; + if (typeof value === 'object' && value !== null) { + if (seen.has(value) || depth > MAX_DEPTH) return undefined; + seen.add(value); + depth++; + } + return value; + }, + ); + return result; + } + }); + + const state: unknown = json ? JSON.parse(json) : null; if (state && typeof state === 'object') { return state as InitialState; diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index d83d80c..a0330f3 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -497,7 +497,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { server.tool( 'xhs_like', - 'Like or unlike a Xiaohongshu note', + 'Toggle like on a Xiaohongshu note', LikeSchema, async (args) => { return withErrorHandling('xhs_like', async () => { @@ -509,7 +509,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { const result = await browser.withPage( PLATFORM, async (page) => - toggleLike(page, args.feed_id, args.xsec_token, args.unlike), + toggleLike(page, args.feed_id, args.xsec_token), timeoutMs, ); @@ -531,7 +531,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { server.tool( 'xhs_favorite', - 'Favorite or unfavorite a Xiaohongshu note', + 'Toggle favorite on a Xiaohongshu note', FavoriteSchema, async (args) => { return withErrorHandling('xhs_favorite', async () => { @@ -543,12 +543,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { const result = await browser.withPage( PLATFORM, async (page) => - toggleFavorite( - page, - args.feed_id, - args.xsec_token, - args.unfavorite, - ), + toggleFavorite(page, args.feed_id, args.xsec_token), timeoutMs, ); diff --git a/src/platforms/xiaohongshu/interaction.ts b/src/platforms/xiaohongshu/interaction.ts index f680376..e046d79 100644 --- a/src/platforms/xiaohongshu/interaction.ts +++ b/src/platforms/xiaohongshu/interaction.ts @@ -12,203 +12,92 @@ const FEED_DETAIL_URL = 'https://www.xiaohongshu.com/explore'; /** Wait after clicking like/favorite to let the state update. */ const TOGGLE_SETTLE_MS = 1_000; -const selInteraction = XHS_SELECTORS.interaction; const selDetail = XHS_SELECTORS.feedDetail; const log = logger.child({ module: 'xhs-interaction' }); -// --------------------------------------------------------------------------- -// toggleLike -// --------------------------------------------------------------------------- - -/** - * Like or unlike a Xiaohongshu note. - * - * @param page - Playwright Page managed by BrowserManager. - * @param feedId - The note / feed ID. - * @param xsecToken - Security token for accessing the feed page. - * @param unlike - If true, unlike the note (toggle off). Default: false. - * @returns Object with success status and the resulting liked state. - */ -export async function toggleLike( - page: Page, - feedId: string, - xsecToken: string, - unlike?: boolean, -): Promise<{ success: boolean; liked: boolean }> { - log.info({ feedId, unlike: unlike ?? false }, 'Toggling like on note'); - - // ------------------------------------------------------------------------- - // 1. Navigate to the feed detail page - // ------------------------------------------------------------------------- - - const feedUrl = buildFeedUrl(feedId, xsecToken); - await page.goto(feedUrl, { waitUntil: 'domcontentloaded' }); - - // Wait for the note container and interaction bar to be visible. - await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 }); - await page.waitForTimeout(1_000); - - // ------------------------------------------------------------------------- - // 2. Check the current like state - // ------------------------------------------------------------------------- - - const isCurrentlyLiked = await isElementActive( - page, - selInteraction.likeButtonActive, - ); - - log.debug({ isCurrentlyLiked, desiredUnlike: unlike ?? false }, 'Current like state'); - - // Determine whether we need to toggle. - // - unlike=true means we want the note to NOT be liked → toggle only if currently liked. - // - unlike=false means we want the note to BE liked → toggle only if currently not liked. - const shouldToggle = unlike ? isCurrentlyLiked : !isCurrentlyLiked; - - if (!shouldToggle) { - // Already in the desired state — no action needed. - const liked = !unlike; - log.info({ feedId, liked, alreadyInState: true }, 'Like already in desired state'); - return { success: true, liked }; - } - - // ------------------------------------------------------------------------- - // 3. Click the like button - // ------------------------------------------------------------------------- - - const likeBtn = await page.$(selInteraction.likeButton); - - if (!likeBtn) { - log.warn('Like button not found on feed detail page'); - return { success: false, liked: isCurrentlyLiked }; - } - - await likeBtn.click(); - await page.waitForTimeout(TOGGLE_SETTLE_MS); - - // ------------------------------------------------------------------------- - // 4. Verify the new state - // ------------------------------------------------------------------------- - - const isNowLiked = await isElementActive( - page, - selInteraction.likeButtonActive, - ); - - const expectedLiked = !unlike; - const success = isNowLiked === expectedLiked; - - log.info({ feedId, liked: isNowLiked, success }, 'Like toggle complete'); - - return { success, liked: isNowLiked }; -} - -// --------------------------------------------------------------------------- -// toggleFavorite -// --------------------------------------------------------------------------- - -/** - * Favorite or unfavorite a Xiaohongshu note. - * - * @param page - Playwright Page managed by BrowserManager. - * @param feedId - The note / feed ID. - * @param xsecToken - Security token for accessing the feed page. - * @param unfavorite - If true, unfavorite the note (toggle off). Default: false. - * @returns Object with success status and the resulting favorited state. - */ -export async function toggleFavorite( - page: Page, - feedId: string, - xsecToken: string, - unfavorite?: boolean, -): Promise<{ success: boolean; favorited: boolean }> { - log.info({ feedId, unfavorite: unfavorite ?? false }, 'Toggling favorite on note'); - - // ------------------------------------------------------------------------- - // 1. Navigate to the feed detail page - // ------------------------------------------------------------------------- - - const feedUrl = buildFeedUrl(feedId, xsecToken); - await page.goto(feedUrl, { waitUntil: 'domcontentloaded' }); - - await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 }); - await page.waitForTimeout(1_000); - - // ------------------------------------------------------------------------- - // 2. Check the current favorite state - // ------------------------------------------------------------------------- - - const isCurrentlyFavorited = await isElementActive( - page, - selInteraction.favoriteButtonActive, - ); - - log.debug( - { isCurrentlyFavorited, desiredUnfavorite: unfavorite ?? false }, - 'Current favorite state', - ); - - const shouldToggle = unfavorite ? isCurrentlyFavorited : !isCurrentlyFavorited; - - if (!shouldToggle) { - const favorited = !unfavorite; - log.info( - { feedId, favorited, alreadyInState: true }, - 'Favorite already in desired state', - ); - return { success: true, favorited }; - } - - // ------------------------------------------------------------------------- - // 3. Click the favorite button - // ------------------------------------------------------------------------- - - const favBtn = await page.$(selInteraction.favoriteButton); - - if (!favBtn) { - log.warn('Favorite button not found on feed detail page'); - return { success: false, favorited: isCurrentlyFavorited }; - } - - await favBtn.click(); - await page.waitForTimeout(TOGGLE_SETTLE_MS); - - // ------------------------------------------------------------------------- - // 4. Verify the new state - // ------------------------------------------------------------------------- - - const isNowFavorited = await isElementActive( - page, - selInteraction.favoriteButtonActive, - ); - - const expectedFavorited = !unfavorite; - const success = isNowFavorited === expectedFavorited; - - log.info({ feedId, favorited: isNowFavorited, success }, 'Favorite toggle complete'); - - return { success, favorited: isNowFavorited }; -} - // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- -/** - * Build the URL for a feed detail page. - */ function buildFeedUrl(feedId: string, xsecToken: string): string { return `${FEED_DETAIL_URL}/${feedId}?xsec_token=${encodeURIComponent(xsecToken)}&xsec_source=pc_search`; } /** - * Check whether an element matching the given selector exists on the page. - * Used to determine the active/inactive state of like/favorite buttons. - * - * The selector for the "active" state uses CSS classes that are only present - * when the button is in its toggled-on state (e.g. `.like-wrapper.active`). + * Click the LAST element matching the selector (the overlay's button). + * XHS opens note detail as an overlay on /explore — the overlay is rendered + * LAST in DOM, so .last() targets the correct button. */ -async function isElementActive(page: Page, selector: string): Promise { - const el = await page.$(selector); - return el !== null; +async function clickLastMatch(page: Page, selector: string): Promise { + try { + await page.locator(selector).last().click({ timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +/** + * Read post-click state by checking the SVG icon href inside the button. + * XHS uses xlink:href #like vs #liked, #collect vs #collected. + */ +async function readState(page: Page, btnSelector: string, activeHref: string): Promise { + return page.locator(btnSelector).last() + .evaluate((el, href) => el.querySelector('use')?.getAttribute('xlink:href') === href, activeHref) + .catch(() => false); +} + +// --------------------------------------------------------------------------- +// toggleLike — pure toggle, clicks the like button once +// --------------------------------------------------------------------------- + +export async function toggleLike( + page: Page, + feedId: string, + xsecToken: string, +): Promise<{ success: boolean; liked: boolean }> { + log.info({ feedId }, 'Toggling like on note'); + + await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' }); + await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 }); + + const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper'); + if (!clicked) { + log.warn('Like button not found in note detail overlay'); + return { success: false, liked: false }; + } + + await page.waitForTimeout(TOGGLE_SETTLE_MS); + + const liked = await readState(page, '.engage-bar-style .like-wrapper', '#liked'); + log.info({ feedId, liked }, 'Like toggle complete'); + return { success: true, liked }; +} + +// --------------------------------------------------------------------------- +// toggleFavorite — pure toggle, clicks the favorite button once +// --------------------------------------------------------------------------- + +export async function toggleFavorite( + page: Page, + feedId: string, + xsecToken: string, +): Promise<{ success: boolean; favorited: boolean }> { + log.info({ feedId }, 'Toggling favorite on note'); + + await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' }); + await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 }); + + const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper'); + if (!clicked) { + log.warn('Favorite button not found in note detail overlay'); + return { success: false, favorited: false }; + } + + await page.waitForTimeout(TOGGLE_SETTLE_MS); + + const favorited = await readState(page, '.engage-bar-style .collect-wrapper', '#collected'); + log.info({ feedId, favorited }, 'Favorite toggle complete'); + return { success: true, favorited }; } diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index e00ad1f..9bdf611 100644 --- a/src/platforms/xiaohongshu/routes.ts +++ b/src/platforms/xiaohongshu/routes.ts @@ -107,13 +107,11 @@ const ReplyCommentBodySchema = z.object({ const LikeBodySchema = z.object({ feed_id: LikeSchema.feed_id, xsec_token: LikeSchema.xsec_token, - unlike: LikeSchema.unlike, }); const FavoriteBodySchema = z.object({ feed_id: FavoriteSchema.feed_id, xsec_token: FavoriteSchema.xsec_token, - unfavorite: FavoriteSchema.unfavorite, }); // --------------------------------------------------------------------------- @@ -551,7 +549,7 @@ export function createXhsRoutes(browser: BrowserManager): Router { const result = await browser.withPage( PLATFORM, async (page) => - toggleLike(page, body.feed_id, body.xsec_token, body.unlike), + toggleLike(page, body.feed_id, body.xsec_token), timeoutMs, ); @@ -578,12 +576,7 @@ export function createXhsRoutes(browser: BrowserManager): Router { const result = await browser.withPage( PLATFORM, async (page) => - toggleFavorite( - page, - body.feed_id, - body.xsec_token, - body.unfavorite, - ), + toggleFavorite(page, body.feed_id, body.xsec_token), timeoutMs, ); diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index cc0c4fd..869ca92 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -128,13 +128,8 @@ export const ReplyCommentSchema = { /** xhs_like */ export const LikeSchema = { - feed_id: z.string().describe('Feed ID to like'), + feed_id: z.string().describe('Feed ID to toggle like'), xsec_token: z.string().describe('Security token for the feed'), - unlike: z - .boolean() - .optional() - .default(false) - .describe('Set to true to unlike'), }; /** xhs_list_my_notes — no parameters. */ @@ -142,11 +137,6 @@ export const ListMyNotesSchema = {}; /** xhs_favorite */ export const FavoriteSchema = { - feed_id: z.string().describe('Feed ID to favorite'), + feed_id: z.string().describe('Feed ID to toggle favorite'), xsec_token: z.string().describe('Security token for the feed'), - unfavorite: z - .boolean() - .optional() - .default(false) - .describe('Set to true to unfavorite'), }; diff --git a/src/platforms/xiaohongshu/selectors.ts b/src/platforms/xiaohongshu/selectors.ts index f653596..a9f0257 100644 --- a/src/platforms/xiaohongshu/selectors.ts +++ b/src/platforms/xiaohongshu/selectors.ts @@ -190,15 +190,15 @@ export const XHS_SELECTORS = { interaction: { /** Like button on the feed detail page. */ - likeButton: '.engage-bar .like-wrapper, span.like-wrapper', + likeButton: '.engage-bar-style .like-wrapper', /** Like button in active/liked state. */ - likeButtonActive: '.engage-bar .like-wrapper.active, span.like-wrapper.active', + likeButtonActive: '.engage-bar-style .like-wrapper.like-active', /** Like count element next to the like button. */ likeCount: '.engage-bar .like-wrapper .count', /** Favorite / collect button on the feed detail page. */ - favoriteButton: '.engage-bar .collect-wrapper, span.collect-wrapper', + favoriteButton: '.engage-bar-style .collect-wrapper', /** Favorite button in active/favorited state. */ - favoriteButtonActive: '.engage-bar .collect-wrapper.active, span.collect-wrapper.active', + favoriteButtonActive: '.engage-bar-style .collect-wrapper.collect-active', /** Favorite count element next to the favorite button. */ favoriteCount: '.engage-bar .collect-wrapper .count', /** Container for the interaction bar at the bottom of a feed detail. */ diff --git a/src/platforms/xiaohongshu/types.ts b/src/platforms/xiaohongshu/types.ts index d77bc6c..672e769 100644 --- a/src/platforms/xiaohongshu/types.ts +++ b/src/platforms/xiaohongshu/types.ts @@ -54,6 +54,8 @@ export interface FeedDetail { collectCount: number; commentCount: number; shareCount: number; + isLiked: boolean; + isFavorited: boolean; createTime: string; lastUpdateTime: string; ipLocation: string; diff --git a/web/src/App.tsx b/web/src/App.tsx index 4e3da63..7e83659 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { }> } /> } /> - } /> } /> } /> diff --git a/web/src/api/endpoints.ts b/web/src/api/endpoints.ts index 74e70a1..d3523fa 100644 --- a/web/src/api/endpoints.ts +++ b/web/src/api/endpoints.ts @@ -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>('/api/xhs/like', { +export const toggleLike = (feedId: string, xsecToken: string) => + apiFetch>('/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>('/api/xhs/favorite', { +export const toggleFavorite = (feedId: string, xsecToken: string) => + apiFetch>('/api/xhs/favorite', { method: 'POST', - body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, unfavorite }), + body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }), }); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 61a0931..54ecb12 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -43,6 +43,8 @@ export interface FeedDetail { collectCount: number; commentCount: number; shareCount: number; + isLiked: boolean; + isFavorited: boolean; createTime: string; lastUpdateTime: string; ipLocation: string; diff --git a/web/src/components/feed/CommentTree.tsx b/web/src/components/feed/CommentTree.tsx index 84c6dca..f1f6458 100644 --- a/web/src/components/feed/CommentTree.tsx +++ b/web/src/components/feed/CommentTree.tsx @@ -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 (
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 && ( {comment.ipLocation} )} + {onReply && ( + + )}

{comment.content}

{comment.likeCount > 0 && ( @@ -30,7 +39,7 @@ export function CommentTree({ comments, depth = 0 }: Props) { {comment.subComments.length > 0 && ( - + )} ))} diff --git a/web/src/components/feed/FeedDetail.tsx b/web/src/components/feed/FeedDetail.tsx index 7de3523..3d71dfb 100644 --- a/web/src/components/feed/FeedDetail.tsx +++ b/web/src/components/feed/FeedDetail.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [currentImage, setCurrentImage] = useState(0); + const [liked, setLiked] = useState(false); + const [favorited, setFavorited] = useState(false); + const [actionLoading, setActionLoading] = useState(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 (
@@ -120,6 +203,26 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) { ))}
+ {/* Interaction buttons */} +
+ + +
+ {/* Author */}
+ {/* Comment input */} +
+ {replyTarget && ( +
+ 回复 @{replyTarget.nickname} + +
+ )} +