新增评论通知功能: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:
@@ -11,6 +11,7 @@ import type {
|
||||
ApiResponse,
|
||||
PublishResult,
|
||||
CommentResult,
|
||||
CommentNotification,
|
||||
} from './types';
|
||||
|
||||
// Health (no auth required)
|
||||
@@ -112,6 +113,20 @@ export const replyComment = (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) =>
|
||||
apiFetch<ApiResponse<{ success: boolean; liked: boolean }>>('/api/xhs/like', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -77,6 +77,18 @@ export interface UserProfile {
|
||||
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 {
|
||||
sort?: 'general' | 'time_descending' | 'popularity_descending';
|
||||
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: '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: '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;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FeedGrid } from '@/components/feed/FeedGrid';
|
||||
import { FeedDetail } from '@/components/feed/FeedDetail';
|
||||
import { UserCard } from '@/components/feed/UserCard';
|
||||
import { PublishModal } from '@/components/feed/PublishModal';
|
||||
import { NotificationPanel } from '@/components/feed/NotificationPanel';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useToast } from '@/context/ToastContext';
|
||||
import { useLoginStatus } from '@/hooks/useLoginStatus';
|
||||
@@ -136,6 +137,7 @@ export function XiaohongshuPage() {
|
||||
const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null);
|
||||
const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null);
|
||||
const [publishOpen, setPublishOpen] = useState(false);
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
|
||||
const loadFeed = useCallback(async () => {
|
||||
setFeedsLoading(true);
|
||||
@@ -231,6 +233,9 @@ export function XiaohongshuPage() {
|
||||
<Button size="sm" variant="secondary" onClick={() => setPublishOpen(true)}>
|
||||
发布
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setNotificationOpen(true)}>
|
||||
通知
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -364,6 +369,9 @@ export function XiaohongshuPage() {
|
||||
{/* ── Publish modal ── */}
|
||||
{publishOpen && <PublishModal onClose={() => setPublishOpen(false)} />}
|
||||
|
||||
{/* ── Notification panel ── */}
|
||||
{notificationOpen && <NotificationPanel onClose={() => setNotificationOpen(false)} />}
|
||||
|
||||
{/* ── User profile slide-over ── */}
|
||||
{userView && (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
|
||||
Reference in New Issue
Block a user