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

- 点赞/收藏改为纯 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
+25 -1
View File
@@ -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,
+36 -2
View File
@@ -155,8 +155,42 @@ export async function listFeeds(page: Page): Promise<Feed[]> {
*/
async function extractInitialState(page: Page): Promise<InitialState | null> {
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<unknown>();
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;
+4 -9
View File
@@ -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,
);
+74 -185
View File
@@ -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<boolean> {
const el = await page.$(selector);
return el !== null;
async function clickLastMatch(page: Page, selector: string): Promise<boolean> {
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<boolean> {
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 };
}
+2 -9
View File
@@ -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,
);
+2 -12
View File
@@ -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'),
};
+4 -4
View File
@@ -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. */
+2
View File
@@ -54,6 +54,8 @@ export interface FeedDetail {
collectCount: number;
commentCount: number;
shareCount: number;
isLiked: boolean;
isFavorited: boolean;
createTime: string;
lastUpdateTime: string;
ipLocation: string;
-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>
);
}