集成互动功能到帖子详情,删除独立互动页面
- 点赞/收藏改为纯 toggle,移除 unlike/unfavorite 参数 - 帖子详情 API 返回 isLiked/isFavorited 状态(SVG xlink:href 检测) - 前端两个切换按钮替代原四个独立按钮 - 修复 __INITIAL_STATE__ Vue 响应式代理序列化(structuredClone + fallback) - 修复 overlay 场景下点赞按钮误点 feed 列表元素(.last() 定位) - 删除 InteractionsPage 及相关路由/导航
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface FeedDetail {
|
||||
collectCount: number;
|
||||
commentCount: number;
|
||||
shareCount: number;
|
||||
isLiked: boolean;
|
||||
isFavorited: boolean;
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
ipLocation: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface FeedDetail {
|
||||
collectCount: number;
|
||||
commentCount: number;
|
||||
shareCount: number;
|
||||
isLiked: boolean;
|
||||
isFavorited: boolean;
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
ipLocation: string;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user