新增评论通知功能:MCP工具 + REST端点 + 前端通知面板
- 新增 xhs_get_comment_notifications / xhs_reply_notification MCP工具 - 通知获取前先读取首页未读小红点数字,无未读则直接返回空,避免重复处理 - 新增 REST 端点 GET /notifications/comments 和 POST /notifications/reply - 前端小红书页面新增「通知」按钮和 NotificationPanel slide-over 组件 - 通知面板支持查看评论通知列表和行内回复
This commit is contained in:
@@ -15,6 +15,7 @@ import { publishVideoNote } from './publish-video.js';
|
|||||||
import { listMyNotes } from './my-notes.js';
|
import { listMyNotes } from './my-notes.js';
|
||||||
import { postComment, replyComment } from './comment.js';
|
import { postComment, replyComment } from './comment.js';
|
||||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
import { toggleLike, toggleFavorite } from './interaction.js';
|
||||||
|
import { getCommentNotifications, replyNotification } from './notification.js';
|
||||||
import { createXhsRoutes } from './routes.js';
|
import { createXhsRoutes } from './routes.js';
|
||||||
import {
|
import {
|
||||||
CheckLoginSchema,
|
CheckLoginSchema,
|
||||||
@@ -32,6 +33,8 @@ import {
|
|||||||
ReplyCommentSchema,
|
ReplyCommentSchema,
|
||||||
LikeSchema,
|
LikeSchema,
|
||||||
FavoriteSchema,
|
FavoriteSchema,
|
||||||
|
GetCommentNotificationsSchema,
|
||||||
|
ReplyNotificationSchema,
|
||||||
} from './schemas.js';
|
} from './schemas.js';
|
||||||
import type { SearchFilters } from './types.js';
|
import type { SearchFilters } from './types.js';
|
||||||
import type { PlatformPlugin } from '../../server/app.js';
|
import type { PlatformPlugin } from '../../server/app.js';
|
||||||
@@ -588,5 +591,67 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Notifications (2 tools)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// xhs_get_comment_notifications
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'xhs_get_comment_notifications',
|
||||||
|
'Get comment and @ notifications from Xiaohongshu notification page',
|
||||||
|
GetCommentNotificationsSchema,
|
||||||
|
async (args) => {
|
||||||
|
return withErrorHandling('xhs_get_comment_notifications', async () => {
|
||||||
|
const timeoutMs =
|
||||||
|
config.operationTimeouts['feed_detail'] ??
|
||||||
|
config.operationTimeouts['default'] ??
|
||||||
|
60_000;
|
||||||
|
|
||||||
|
const notifications = await browser.withPage(
|
||||||
|
PLATFORM,
|
||||||
|
async (page) =>
|
||||||
|
getCommentNotifications(page, args.max_count),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(notifications) }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// xhs_reply_notification
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'xhs_reply_notification',
|
||||||
|
'Reply to a comment notification inline on the Xiaohongshu notification page',
|
||||||
|
ReplyNotificationSchema,
|
||||||
|
async (args) => {
|
||||||
|
return withErrorHandling('xhs_reply_notification', async () => {
|
||||||
|
const timeoutMs =
|
||||||
|
config.operationTimeouts['reply'] ??
|
||||||
|
config.operationTimeouts['default'] ??
|
||||||
|
20_000;
|
||||||
|
|
||||||
|
const result = await browser.withPage(
|
||||||
|
PLATFORM,
|
||||||
|
async (page) =>
|
||||||
|
replyNotification(page, args.user_id, args.comment_content, args.reply_content),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import type { Page } from 'rebrowser-playwright';
|
||||||
|
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { XHS_SELECTORS } from './selectors.js';
|
||||||
|
import type { CommentNotification } from './types.js';
|
||||||
|
|
||||||
|
const log = logger.child({ module: 'xhs-notification' });
|
||||||
|
const sel = XHS_SELECTORS.notification;
|
||||||
|
|
||||||
|
const EXPLORE_URL = 'https://www.xiaohongshu.com/explore';
|
||||||
|
const NOTIFICATION_URL = 'https://www.xiaohongshu.com/notification';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: extract userId from avatar href like /user/profile/xxx?xsecToken=yyy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseUserHref(href: string): { userId: string; xsecToken: string } {
|
||||||
|
const url = new URL(href, 'https://www.xiaohongshu.com');
|
||||||
|
const parts = url.pathname.split('/');
|
||||||
|
const userId = parts[parts.length - 1] || '';
|
||||||
|
const xsecToken = url.searchParams.get('xsecToken') || '';
|
||||||
|
return { userId, xsecToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeedHref(href: string): { feedId: string; xsecToken: string } {
|
||||||
|
const url = new URL(href, 'https://www.xiaohongshu.com');
|
||||||
|
const parts = url.pathname.split('/');
|
||||||
|
const feedId = parts[parts.length - 1] || '';
|
||||||
|
const xsecToken = url.searchParams.get('xsecToken') || '';
|
||||||
|
return { feedId, xsecToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getUnreadCount — read the badge number from the explore page bottom menu
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function getUnreadCount(page: Page): Promise<number> {
|
||||||
|
const currentUrl = page.url();
|
||||||
|
// Navigate to explore page if not already there
|
||||||
|
if (!currentUrl.includes('/explore')) {
|
||||||
|
await page.goto(EXPLORE_URL, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = await page.$(sel.unreadBadge);
|
||||||
|
if (!badge) {
|
||||||
|
log.info('No unread badge found');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (await badge.textContent())?.trim() || '';
|
||||||
|
const count = Number.parseInt(text, 10);
|
||||||
|
if (Number.isNaN(count) || count <= 0) {
|
||||||
|
log.info({ badgeText: text }, 'Unread badge text is not a positive number');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ unreadCount: count }, 'Unread notification count');
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getCommentNotifications
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function getCommentNotifications(
|
||||||
|
page: Page,
|
||||||
|
maxCount = 20,
|
||||||
|
): Promise<CommentNotification[]> {
|
||||||
|
// 1. Check unread count on explore page first
|
||||||
|
const unreadCount = await getUnreadCount(page);
|
||||||
|
if (unreadCount === 0) {
|
||||||
|
log.info('No unread notifications, returning empty');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the smaller of unreadCount and maxCount
|
||||||
|
const limit = Math.min(unreadCount, maxCount);
|
||||||
|
log.info({ unreadCount, maxCount, limit }, 'Fetching comment notifications');
|
||||||
|
|
||||||
|
// 2. Navigate to notification page
|
||||||
|
await page.goto(NOTIFICATION_URL, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector(sel.container, { timeout: 15_000 });
|
||||||
|
// Small delay for DOM to settle
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const containers = await page.$$(sel.container);
|
||||||
|
log.info({ count: containers.length }, 'Found notification containers');
|
||||||
|
|
||||||
|
const results: CommentNotification[] = [];
|
||||||
|
|
||||||
|
for (const container of containers) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
|
||||||
|
// Check interaction hint — only include comment/@ notifications
|
||||||
|
const hintEl = await container.$(sel.interactionHint);
|
||||||
|
const hintText = hintEl ? (await hintEl.textContent())?.trim() || '' : '';
|
||||||
|
if (!hintText.includes('评论') && !hintText.includes('@')) continue;
|
||||||
|
|
||||||
|
// Extract user info from avatar link
|
||||||
|
const avatarLink = await container.$(sel.userAvatar);
|
||||||
|
const avatarHref = avatarLink ? await avatarLink.getAttribute('href') : '';
|
||||||
|
const avatarImg = avatarLink ? await avatarLink.$('img') : null;
|
||||||
|
const avatarSrc = avatarImg ? (await avatarImg.getAttribute('src')) || '' : '';
|
||||||
|
const { userId } = avatarHref ? parseUserHref(avatarHref) : { userId: '' };
|
||||||
|
|
||||||
|
// Nickname
|
||||||
|
const nameEl = await container.$(sel.userName);
|
||||||
|
const nickname = nameEl ? (await nameEl.textContent())?.trim() || '' : '';
|
||||||
|
|
||||||
|
// Comment content
|
||||||
|
const contentEl = await container.$(sel.interactionContent);
|
||||||
|
const content = contentEl ? (await contentEl.textContent())?.trim() || '' : '';
|
||||||
|
|
||||||
|
// Time
|
||||||
|
const timeEl = await container.$(sel.interactionTime);
|
||||||
|
const time = timeEl ? (await timeEl.textContent())?.trim() || '' : '';
|
||||||
|
|
||||||
|
// Feed info from thumbnail link
|
||||||
|
const extraImg = await container.$(sel.extraImage);
|
||||||
|
const noteImage = extraImg ? (await extraImg.getAttribute('src')) || '' : '';
|
||||||
|
const extraHref = extraImg
|
||||||
|
? await extraImg.evaluate((el) => {
|
||||||
|
const anchor = el.closest('a');
|
||||||
|
return anchor ? anchor.href : '';
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
const { feedId, xsecToken } = extraHref
|
||||||
|
? parseFeedHref(extraHref)
|
||||||
|
: { feedId: '', xsecToken: '' };
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
userId,
|
||||||
|
nickname,
|
||||||
|
avatar: avatarSrc,
|
||||||
|
content,
|
||||||
|
type: hintText,
|
||||||
|
time,
|
||||||
|
feedId,
|
||||||
|
xsecToken,
|
||||||
|
noteImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ resultCount: results.length }, 'Comment notifications extracted');
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// replyNotification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function replyNotification(
|
||||||
|
page: Page,
|
||||||
|
userId: string,
|
||||||
|
commentContent: string,
|
||||||
|
replyContent: string,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
log.info({ userId, commentContent: commentContent.slice(0, 30) }, 'Replying to notification');
|
||||||
|
|
||||||
|
await page.goto(NOTIFICATION_URL, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector(sel.container, { timeout: 15_000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const containers = await page.$$(sel.container);
|
||||||
|
|
||||||
|
let targetContainer: Awaited<ReturnType<Page['$']>> = null;
|
||||||
|
|
||||||
|
for (const container of containers) {
|
||||||
|
// Match by userId
|
||||||
|
const avatarLink = await container.$(sel.userAvatar);
|
||||||
|
const avatarHref = avatarLink ? await avatarLink.getAttribute('href') : '';
|
||||||
|
const { userId: uid } = avatarHref ? parseUserHref(avatarHref) : { userId: '' };
|
||||||
|
if (uid !== userId) continue;
|
||||||
|
|
||||||
|
// Match by comment content
|
||||||
|
const contentEl = await container.$(sel.interactionContent);
|
||||||
|
const content = contentEl ? (await contentEl.textContent())?.trim() || '' : '';
|
||||||
|
if (content !== commentContent) continue;
|
||||||
|
|
||||||
|
targetContainer = container;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetContainer) {
|
||||||
|
log.warn({ userId, commentContent }, 'Target notification not found');
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click the reply button
|
||||||
|
const replyBtn = await targetContainer.$(sel.replyButton);
|
||||||
|
if (!replyBtn) {
|
||||||
|
log.warn('Reply button not found on target notification');
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
await replyBtn.click();
|
||||||
|
|
||||||
|
// Wait for the reply textarea to appear
|
||||||
|
const textarea = await targetContainer.waitForSelector(sel.replyInput, { timeout: 5_000 });
|
||||||
|
if (!textarea) {
|
||||||
|
log.warn('Reply textarea did not appear');
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type the reply
|
||||||
|
await textarea.fill(replyContent);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Click submit
|
||||||
|
const submitBtn = await targetContainer.$(sel.replySubmit);
|
||||||
|
if (!submitBtn) {
|
||||||
|
log.warn('Reply submit button not found');
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
await submitBtn.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
log.info('Reply submitted successfully');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { publishVideoNote } from './publish-video.js';
|
|||||||
import { listMyNotes } from './my-notes.js';
|
import { listMyNotes } from './my-notes.js';
|
||||||
import { postComment, replyComment } from './comment.js';
|
import { postComment, replyComment } from './comment.js';
|
||||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
import { toggleLike, toggleFavorite } from './interaction.js';
|
||||||
|
import { getCommentNotifications, replyNotification } from './notification.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SearchSchema,
|
SearchSchema,
|
||||||
@@ -31,6 +32,8 @@ import {
|
|||||||
ReplyCommentSchema,
|
ReplyCommentSchema,
|
||||||
LikeSchema,
|
LikeSchema,
|
||||||
FavoriteSchema,
|
FavoriteSchema,
|
||||||
|
GetCommentNotificationsSchema,
|
||||||
|
ReplyNotificationSchema,
|
||||||
} from './schemas.js';
|
} from './schemas.js';
|
||||||
|
|
||||||
import type { SearchFilters } from './types.js';
|
import type { SearchFilters } from './types.js';
|
||||||
@@ -111,6 +114,16 @@ const ReplyCommentBodySchema = z.object({
|
|||||||
user_id: ReplyCommentSchema.user_id,
|
user_id: ReplyCommentSchema.user_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const GetCommentNotificationsQuerySchema = z.object({
|
||||||
|
max_count: GetCommentNotificationsSchema.max_count,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReplyNotificationBodySchema = z.object({
|
||||||
|
user_id: ReplyNotificationSchema.user_id,
|
||||||
|
comment_content: ReplyNotificationSchema.comment_content,
|
||||||
|
reply_content: ReplyNotificationSchema.reply_content,
|
||||||
|
});
|
||||||
|
|
||||||
const LikeBodySchema = z.object({
|
const LikeBodySchema = z.object({
|
||||||
feed_id: LikeSchema.feed_id,
|
feed_id: LikeSchema.feed_id,
|
||||||
xsec_token: LikeSchema.xsec_token,
|
xsec_token: LikeSchema.xsec_token,
|
||||||
@@ -616,6 +629,65 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Notifications
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /notifications/comments
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/notifications/comments', readRateLimiter, (req, res) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const query = GetCommentNotificationsQuerySchema.parse({
|
||||||
|
max_count: req.query.max_count ? Number(req.query.max_count) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutMs =
|
||||||
|
config.operationTimeouts['feed_detail'] ??
|
||||||
|
config.operationTimeouts['default'] ??
|
||||||
|
60_000;
|
||||||
|
|
||||||
|
const notifications = await browser.withPage(
|
||||||
|
PLATFORM,
|
||||||
|
async (page) => getCommentNotifications(page, query.max_count),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(successResponse(notifications) as ApiResponse<typeof notifications>);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /notifications/reply
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.post('/notifications/reply', writeRateLimiter, (req, res) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const body = ReplyNotificationBodySchema.parse(req.body);
|
||||||
|
|
||||||
|
const timeoutMs =
|
||||||
|
config.operationTimeouts['reply'] ??
|
||||||
|
config.operationTimeouts['default'] ??
|
||||||
|
20_000;
|
||||||
|
|
||||||
|
const result = await browser.withPage(
|
||||||
|
PLATFORM,
|
||||||
|
async (page) =>
|
||||||
|
replyNotification(page, body.user_id, body.comment_content, body.reply_content),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(successResponse(result) as ApiResponse<typeof result>);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,27 @@ export const LikeSchema = {
|
|||||||
/** xhs_list_my_notes — no parameters. */
|
/** xhs_list_my_notes — no parameters. */
|
||||||
export const ListMyNotesSchema = {};
|
export const ListMyNotesSchema = {};
|
||||||
|
|
||||||
|
// -- Phase 5: Notifications (2 tools) --------------------------------------
|
||||||
|
|
||||||
|
/** xhs_get_comment_notifications */
|
||||||
|
export const GetCommentNotificationsSchema = {
|
||||||
|
max_count: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.optional()
|
||||||
|
.default(20)
|
||||||
|
.describe('Maximum number of notifications to return (1–50, default 20)'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_reply_notification */
|
||||||
|
export const ReplyNotificationSchema = {
|
||||||
|
user_id: z.string().describe('User ID of the comment author (from notification)'),
|
||||||
|
comment_content: z.string().describe('Original comment content to match the notification'),
|
||||||
|
reply_content: z.string().min(1).describe('Reply text to send'),
|
||||||
|
};
|
||||||
|
|
||||||
/** xhs_favorite */
|
/** xhs_favorite */
|
||||||
export const FavoriteSchema = {
|
export const FavoriteSchema = {
|
||||||
feed_id: z.string().describe('Feed ID to toggle favorite'),
|
feed_id: z.string().describe('Feed ID to toggle favorite'),
|
||||||
|
|||||||
@@ -187,6 +187,33 @@ export const XHS_SELECTORS = {
|
|||||||
|
|
||||||
// -- Phase 4: Interaction (Like / Favorite) --------------------------------
|
// -- Phase 4: Interaction (Like / Favorite) --------------------------------
|
||||||
|
|
||||||
|
// -- Phase 5: Notification ------------------------------------------------
|
||||||
|
|
||||||
|
notification: {
|
||||||
|
/** Each notification item container. */
|
||||||
|
container: '.container',
|
||||||
|
/** User avatar link (href contains userId + xsecToken). */
|
||||||
|
userAvatar: 'a.user-avatar',
|
||||||
|
/** User name link. */
|
||||||
|
userName: '.user-info a',
|
||||||
|
/** Interaction type hint (e.g. "评论了你的笔记"). */
|
||||||
|
interactionHint: '.interaction-hint span:first-child',
|
||||||
|
/** Notification time. */
|
||||||
|
interactionTime: '.interaction-time',
|
||||||
|
/** Comment content text. */
|
||||||
|
interactionContent: '.interaction-content',
|
||||||
|
/** Note thumbnail image (parent link href contains feedId + xsecToken). */
|
||||||
|
extraImage: '.extra img',
|
||||||
|
/** Reply button to expand inline reply. */
|
||||||
|
replyButton: '.action-reply',
|
||||||
|
/** Reply textarea that appears after clicking reply. */
|
||||||
|
replyInput: 'textarea.comment-input',
|
||||||
|
/** Reply submit button. */
|
||||||
|
replySubmit: 'button.submit',
|
||||||
|
/** Unread badge on the explore page bottom menu. */
|
||||||
|
unreadBadge: '#global > div.main-container > div.bottom-menu > div > li.link-wrapper.bottom-channel > a > div > div',
|
||||||
|
},
|
||||||
|
|
||||||
interaction: {
|
interaction: {
|
||||||
/** Like button on the feed detail page. */
|
/** Like button on the feed detail page. */
|
||||||
likeButton: '.engage-bar-style .like-wrapper',
|
likeButton: '.engage-bar-style .like-wrapper',
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ export interface UserProfile {
|
|||||||
feeds: Feed[];
|
feeds: Feed[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Comment Notification -------------------------------------------------
|
||||||
|
|
||||||
|
export interface CommentNotification {
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
content: string;
|
||||||
|
type: string;
|
||||||
|
time: string;
|
||||||
|
feedId: string;
|
||||||
|
xsecToken: string;
|
||||||
|
noteImage: string;
|
||||||
|
}
|
||||||
|
|
||||||
// -- Search Filters -------------------------------------------------------
|
// -- Search Filters -------------------------------------------------------
|
||||||
|
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
PublishResult,
|
PublishResult,
|
||||||
CommentResult,
|
CommentResult,
|
||||||
|
CommentNotification,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Health (no auth required)
|
// Health (no auth required)
|
||||||
@@ -112,6 +113,20 @@ export const replyComment = (data: {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
export const getCommentNotifications = (maxCount = 20) =>
|
||||||
|
apiFetch<ApiResponse<CommentNotification[]>>(`/api/xhs/notifications/comments?max_count=${maxCount}`);
|
||||||
|
|
||||||
|
export const replyNotification = (data: {
|
||||||
|
user_id: string;
|
||||||
|
comment_content: string;
|
||||||
|
reply_content: string;
|
||||||
|
}) =>
|
||||||
|
apiFetch<ApiResponse<CommentResult>>('/api/xhs/notifications/reply', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
export const toggleLike = (feedId: string, xsecToken: string) =>
|
export const toggleLike = (feedId: string, xsecToken: string) =>
|
||||||
apiFetch<ApiResponse<{ success: boolean; liked: boolean }>>('/api/xhs/like', {
|
apiFetch<ApiResponse<{ success: boolean; liked: boolean }>>('/api/xhs/like', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ export interface UserProfile {
|
|||||||
feeds: Feed[];
|
feeds: Feed[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommentNotification {
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
content: string;
|
||||||
|
type: string;
|
||||||
|
time: string;
|
||||||
|
feedId: string;
|
||||||
|
xsecToken: string;
|
||||||
|
noteImage: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
sort?: 'general' | 'time_descending' | 'popularity_descending';
|
sort?: 'general' | 'time_descending' | 'popularity_descending';
|
||||||
type?: 'all' | 'note' | 'video';
|
type?: 'all' | 'note' | 'video';
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getCommentNotifications, replyNotification } from '@/api/endpoints';
|
||||||
|
import type { CommentNotification } from '@/api/types';
|
||||||
|
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 { useToast } from '@/context/ToastContext';
|
||||||
|
import { formatTime } from '@/lib/formatters';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationPanel({ onClose }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [notifications, setNotifications] = useState<CommentNotification[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [replyingId, setReplyingId] = useState<string | null>(null);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [sendingReply, setSendingReply] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await getCommentNotifications(20);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setNotifications(res.data);
|
||||||
|
} else {
|
||||||
|
setError(res.error?.message || '加载通知失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载通知失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleReply = async (n: CommentNotification) => {
|
||||||
|
if (!replyText.trim()) {
|
||||||
|
toast('warning', '回复内容不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSendingReply(true);
|
||||||
|
try {
|
||||||
|
const res = await replyNotification({
|
||||||
|
user_id: n.userId,
|
||||||
|
comment_content: n.content,
|
||||||
|
reply_content: replyText,
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
toast('success', '回复成功');
|
||||||
|
setReplyingId(null);
|
||||||
|
setReplyText('');
|
||||||
|
} else {
|
||||||
|
toast('error', res.error?.message || '回复失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast('error', err instanceof Error ? err.message : '回复失败');
|
||||||
|
} finally {
|
||||||
|
setSendingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueKey = (n: CommentNotification, i: number) =>
|
||||||
|
`${n.userId}-${n.feedId}-${i}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
|
<div className="relative ml-auto w-full max-w-2xl bg-dark-card border-l border-dark-border overflow-y-auto">
|
||||||
|
{/* Sticky header */}
|
||||||
|
<div className="sticky top-0 bg-dark-card border-b border-dark-border px-5 py-3 flex items-center justify-between z-10">
|
||||||
|
<h3 className="font-semibold">评论通知</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void load()} disabled={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<button onClick={onClose} className="text-dark-muted hover:text-dark-text text-xl">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-20"><Spinner size="lg" /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-5 text-dark-danger">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty */}
|
||||||
|
{!loading && !error && notifications.length === 0 && (
|
||||||
|
<div className="p-5 text-center text-dark-muted">暂无评论通知</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notification list */}
|
||||||
|
{!loading && notifications.length > 0 && (
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{notifications.map((n, i) => {
|
||||||
|
const key = uniqueKey(n, i);
|
||||||
|
const isReplying = replyingId === key;
|
||||||
|
return (
|
||||||
|
<div key={key} className="bg-dark-bg rounded-lg p-4 space-y-3">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{n.avatar ? (
|
||||||
|
<img src={n.avatar} alt="" className="w-10 h-10 rounded-full object-cover shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-dark-accent/20 flex items-center justify-center text-sm font-bold text-dark-accent shrink-0">
|
||||||
|
{n.nickname?.[0]?.toUpperCase() ?? '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-dark-text">{n.nickname}</span>
|
||||||
|
<Badge variant="info">{n.type}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-dark-text/80 mt-1 break-words">{n.content}</p>
|
||||||
|
<p className="text-xs text-dark-muted mt-1">{formatTime(n.time)}</p>
|
||||||
|
</div>
|
||||||
|
{/* Note thumbnail */}
|
||||||
|
{n.noteImage && (
|
||||||
|
<img
|
||||||
|
src={n.noteImage}
|
||||||
|
alt=""
|
||||||
|
className="w-14 h-14 rounded-lg object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply toggle */}
|
||||||
|
{!isReplying ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setReplyingId(key); setReplyText(''); }}
|
||||||
|
>
|
||||||
|
回复
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder={`回复 @${n.nickname}...`}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleReply(n)}
|
||||||
|
loading={sendingReply}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setReplyingId(null); setReplyText(''); }}
|
||||||
|
disabled={sendingReply}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,4 +19,6 @@ export const API_ENDPOINTS = [
|
|||||||
{ key: 'comment_reply', method: 'POST', path: '/api/xhs/comment/reply', label: '回复评论', category: '互动', body: { feed_id: '', xsec_token: '', content: '', comment_id: '', user_id: '' } },
|
{ 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: '' } },
|
{ 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: '' } },
|
{ key: 'favorite', method: 'POST', path: '/api/xhs/favorite', label: '收藏/取消', category: '互动', body: { feed_id: '', xsec_token: '' } },
|
||||||
|
{ key: 'notifications_comments', method: 'GET', path: '/api/xhs/notifications/comments?max_count=20', label: '获取评论通知', category: '通知' },
|
||||||
|
{ key: 'notifications_reply', method: 'POST', path: '/api/xhs/notifications/reply', label: '回复评论通知', category: '通知', body: { user_id: '', comment_content: '', reply_content: '' } },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FeedGrid } from '@/components/feed/FeedGrid';
|
|||||||
import { FeedDetail } from '@/components/feed/FeedDetail';
|
import { FeedDetail } from '@/components/feed/FeedDetail';
|
||||||
import { UserCard } from '@/components/feed/UserCard';
|
import { UserCard } from '@/components/feed/UserCard';
|
||||||
import { PublishModal } from '@/components/feed/PublishModal';
|
import { PublishModal } from '@/components/feed/PublishModal';
|
||||||
|
import { NotificationPanel } from '@/components/feed/NotificationPanel';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useToast } from '@/context/ToastContext';
|
import { useToast } from '@/context/ToastContext';
|
||||||
import { useLoginStatus } from '@/hooks/useLoginStatus';
|
import { useLoginStatus } from '@/hooks/useLoginStatus';
|
||||||
@@ -136,6 +137,7 @@ export function XiaohongshuPage() {
|
|||||||
const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null);
|
const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null);
|
||||||
const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null);
|
const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null);
|
||||||
const [publishOpen, setPublishOpen] = useState(false);
|
const [publishOpen, setPublishOpen] = useState(false);
|
||||||
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||||
|
|
||||||
const loadFeed = useCallback(async () => {
|
const loadFeed = useCallback(async () => {
|
||||||
setFeedsLoading(true);
|
setFeedsLoading(true);
|
||||||
@@ -231,6 +233,9 @@ export function XiaohongshuPage() {
|
|||||||
<Button size="sm" variant="secondary" onClick={() => setPublishOpen(true)}>
|
<Button size="sm" variant="secondary" onClick={() => setPublishOpen(true)}>
|
||||||
发布
|
发布
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setNotificationOpen(true)}>
|
||||||
|
通知
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -364,6 +369,9 @@ export function XiaohongshuPage() {
|
|||||||
{/* ── Publish modal ── */}
|
{/* ── Publish modal ── */}
|
||||||
{publishOpen && <PublishModal onClose={() => setPublishOpen(false)} />}
|
{publishOpen && <PublishModal onClose={() => setPublishOpen(false)} />}
|
||||||
|
|
||||||
|
{/* ── Notification panel ── */}
|
||||||
|
{notificationOpen && <NotificationPanel onClose={() => setNotificationOpen(false)} />}
|
||||||
|
|
||||||
{/* ── User profile slide-over ── */}
|
{/* ── User profile slide-over ── */}
|
||||||
{userView && (
|
{userView && (
|
||||||
<div className="fixed inset-0 z-50 flex">
|
<div className="fixed inset-0 z-50 flex">
|
||||||
|
|||||||
Reference in New Issue
Block a user