diff --git a/scripts/debug-profile.ts b/scripts/debug-profile.ts index 95863ac..ab6686f 100644 --- a/scripts/debug-profile.ts +++ b/scripts/debug-profile.ts @@ -27,13 +27,37 @@ async function main() { const nickname = await page.$eval('.user-info .user-name', el => el.textContent?.trim() ?? '').catch(() => 'NOT FOUND'); console.log('nickname:', nickname); - const feeds = await page.$$('.feeds-container .note-item'); - console.log('note items:', feeds.length); - if (feeds.length > 0) { - const href = await feeds[0]!.$eval('a.cover', el => el.getAttribute('href') ?? '').catch(() => ''); - console.log('first note href:', href); + // Check __INITIAL_STATE__ + const initialState = await page.evaluate(() => { + const s = (window as unknown as Record).__INITIAL_STATE__; + return s ? JSON.stringify(s) : null; + }).catch(() => null); + + if (!initialState) { + console.log('__INITIAL_STATE__: NOT FOUND'); + } else { + const state = JSON.parse(initialState); + const keys = Object.keys(state); + console.log('\n__INITIAL_STATE__ top-level keys:', keys); + + // Dump user / userProfile subtrees + if (state.user) console.log('\nstate.user keys:', Object.keys(state.user)); + if (state.user?.userPageData) { + const upd = state.user.userPageData; + console.log('\nstate.user.userPageData keys:', Object.keys(upd)); + console.log(' basicInfo:', JSON.stringify(upd.basicInfo)?.slice(0, 300)); + console.log(' interactions:', JSON.stringify(upd.interactions)); + console.log(' noteCount:', upd.noteCount, '/ note_count:', upd.note_count); + } + if (state.userProfile) { + console.log('\nstate.userProfile keys:', Object.keys(state.userProfile)); + console.log(' userInfo:', JSON.stringify(state.userProfile.userInfo)?.slice(0, 300)); + } } + const feeds = await page.$$('.feeds-container .note-item'); + console.log('\nnote items:', feeds.length); + await browser.close(); } main().catch(e => { console.error(e); process.exit(1); }); diff --git a/src/platforms/xiaohongshu/login.ts b/src/platforms/xiaohongshu/login.ts index f90b89a..ea952a4 100644 --- a/src/platforms/xiaohongshu/login.ts +++ b/src/platforms/xiaohongshu/login.ts @@ -49,9 +49,23 @@ export async function checkLoginStatus(page: Page): Promise { // Attempt to extract a username from the indicator area. const username = await indicator.textContent().catch(() => null); + // Attempt to extract the logged-in user's avatar URL. + const avatar = await page + .$eval(XHS_SELECTORS.login.userAvatar, (el) => el.getAttribute('src') ?? '') + .catch(() => ''); + + // Attempt to extract the userId from the profile link href. + const userLinkHref = await page + .$eval(XHS_SELECTORS.login.userLink, (el) => el.getAttribute('href') ?? '') + .catch(() => ''); + const userIdMatch = userLinkHref.match(/\/user\/profile\/([a-f0-9]+)/); + const userId = userIdMatch?.[1] ?? ''; + return { loggedIn: true, ...(username ? { username: username.trim() } : {}), + ...(avatar ? { avatar } : {}), + ...(userId ? { userId } : {}), }; } diff --git a/src/platforms/xiaohongshu/selectors.ts b/src/platforms/xiaohongshu/selectors.ts index ddb0805..9d49c61 100644 --- a/src/platforms/xiaohongshu/selectors.ts +++ b/src/platforms/xiaohongshu/selectors.ts @@ -10,6 +10,10 @@ export const XHS_SELECTORS = { loggedInIndicator: '.user .link-wrapper .channel', /** The "login" button that opens the QR code modal (if not already shown). */ loginButton: '.login-btn', + /** Logged-in user's avatar image in the sidebar. */ + userAvatar: '.user .avatar img', + /** Logged-in user's profile link in the sidebar (href contains userId). */ + userLink: '.user .link-wrapper a', }, feed: { @@ -108,8 +112,8 @@ export const XHS_SELECTORS = { headerContainer: '.user-info', /** User nickname. */ nickname: '.user-info .user-name', - /** User avatar image. */ - avatar: '.user-info .user-image img', + /** User avatar image (the img itself carries class user-image). */ + avatar: '.user-info img.user-image', /** User bio / description text. */ description: '.user-info .user-desc', /** User gender icon or text. */ @@ -117,7 +121,7 @@ export const XHS_SELECTORS = { /** IP location. */ ipLocation: '.user-info .user-ip', /** Follower / following / interaction count elements. */ - followCount: '.user-info .data-area .data-item', + followCount: '.user-info .user-interactions > div', /** Note count (displayed somewhere on the profile page). */ noteCountTab: '.reds-tab-item', /** Individual feed items on the user profile. */ diff --git a/src/platforms/xiaohongshu/types.ts b/src/platforms/xiaohongshu/types.ts index d7b4a55..d77bc6c 100644 --- a/src/platforms/xiaohongshu/types.ts +++ b/src/platforms/xiaohongshu/types.ts @@ -7,6 +7,8 @@ export interface LoginStatus { loggedIn: boolean; username?: string; + avatar?: string; + userId?: string; } export interface QRCodeResult { diff --git a/web/src/App.tsx b/web/src/App.tsx index a5edaa7..fa6fd52 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,8 +3,7 @@ import { AuthProvider } from '@/context/AuthContext'; import { ToastProvider } from '@/context/ToastContext'; import { Layout } from '@/components/layout/Layout'; import { DashboardPage } from '@/pages/DashboardPage'; -import { LoginPage } from '@/pages/LoginPage'; -import { BrowserPage } from '@/pages/BrowserPage'; +import { XiaohongshuPage } from '@/pages/XiaohongshuPage'; import { PublishPage } from '@/pages/PublishPage'; import { InteractionsPage } from '@/pages/InteractionsPage'; import { ApiTesterPage } from '@/pages/ApiTesterPage'; @@ -18,8 +17,7 @@ export default function App() { }> } /> - } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6f9f8ad..61a0931 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3,6 +3,8 @@ export interface LoginStatus { loggedIn: boolean; username?: string; + avatar?: string; + userId?: string; } export interface QRCodeResult { diff --git a/web/src/components/layout/Icons.tsx b/web/src/components/layout/Icons.tsx index e056d0d..89feebf 100644 --- a/web/src/components/layout/Icons.tsx +++ b/web/src/components/layout/Icons.tsx @@ -29,7 +29,12 @@ export const SettingsIcon = () => ( ); +export const XhsIcon = () => ( + +); + export const iconMap: Record = { + xhs: XhsIcon, dashboard: DashboardIcon, login: LoginIcon, browser: BrowserIcon, diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 34a7028..f2ec42e 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -1,7 +1,6 @@ export const NAV_ITEMS = [ { path: '/', label: '仪表盘', icon: 'dashboard' }, - { path: '/login', label: '登录', icon: 'login' }, - { path: '/browser', label: '内容浏览', icon: 'browser' }, + { path: '/xhs', label: '小红书', icon: 'xhs' }, { path: '/publish', label: '发布', icon: 'publish' }, { path: '/interactions', label: '互动', icon: 'interactions' }, { path: '/api-tester', label: 'API 测试', icon: 'api' }, diff --git a/web/src/pages/BrowserPage.tsx b/web/src/pages/BrowserPage.tsx deleted file mode 100644 index c496612..0000000 --- a/web/src/pages/BrowserPage.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { useState, useCallback } from 'react'; -import { Tabs } from '@/components/ui/Tabs'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { Select } from '@/components/ui/Select'; -import { FeedGrid } from '@/components/feed/FeedGrid'; -import { FeedDetail } from '@/components/feed/FeedDetail'; -import { UserCard } from '@/components/feed/UserCard'; -import { useToast } from '@/context/ToastContext'; -import { listFeeds, searchFeeds } from '@/api/endpoints'; -import type { Feed, SearchFilters } from '@/api/types'; - -type TabKey = 'explore' | 'search' | 'user'; - -export function BrowserPage() { - const { toast } = useToast(); - const [tab, setTab] = useState('explore'); - - // Explore state - const [feeds, setFeeds] = useState([]); - const [feedsLoading, setFeedsLoading] = useState(false); - - // Search state - const [keyword, setKeyword] = useState(''); - const [sortFilter, setSortFilter] = useState('general'); - const [typeFilter, setTypeFilter] = useState('all'); - const [timeFilter, setTimeFilter] = useState('all'); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - - // Detail panel - const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null); - - // User panel - const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null); - const [manualUserId, setManualUserId] = useState(''); - const [manualUserToken, setManualUserToken] = useState(''); - - const handleExplore = useCallback(async () => { - setFeedsLoading(true); - try { - const res = await listFeeds(); - if (res.success && res.data) { - setFeeds(res.data); - } else { - toast('error', res.error?.message || '加载推荐失败'); - } - } catch (err) { - toast('error', err instanceof Error ? err.message : '加载推荐失败'); - } finally { - setFeedsLoading(false); - } - }, [toast]); - - const handleSearch = useCallback(async () => { - if (!keyword.trim()) { - toast('warning', '请输入关键词'); - return; - } - setSearchLoading(true); - try { - const filters: SearchFilters = {}; - if (sortFilter !== 'general') filters.sort = sortFilter as SearchFilters['sort']; - if (typeFilter !== 'all') filters.type = typeFilter as SearchFilters['type']; - if (timeFilter !== 'all') filters.time = timeFilter as SearchFilters['time']; - - const res = await searchFeeds(keyword, Object.keys(filters).length > 0 ? filters : undefined); - if (res.success && res.data) { - setSearchResults(res.data); - } else { - toast('error', res.error?.message || '搜索失败'); - } - } catch (err) { - toast('error', err instanceof Error ? err.message : '搜索失败'); - } finally { - setSearchLoading(false); - } - }, [keyword, sortFilter, typeFilter, timeFilter, toast]); - - const handleFeedSelect = useCallback((feed: Feed) => { - setSelectedFeed({ id: feed.id, xsecToken: feed.xsecToken }); - }, []); - - const handleUserClick = useCallback((userId: string, xsecToken: string) => { - setUserView({ userId, xsecToken }); - setTab('user'); - setSelectedFeed(null); - }, []); - - return ( -
-

内容浏览

- - setTab(k as TabKey)} - /> - - {/* Explore Tab */} - {tab === 'explore' && ( -
- - -
- )} - - {/* Search Tab */} - {tab === 'search' && ( -
-
-
- setKeyword(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && void handleSearch()} - /> -
- setTypeFilter(e.target.value)} - /> - setManualUserId(e.target.value)} - /> - setManualUserToken(e.target.value)} - /> - -
-
- )} - {userView && userView.userId && userView.xsecToken && ( -
- - -
- )} -
- )} - - {/* Feed Detail Slide-over */} - {selectedFeed && ( - setSelectedFeed(null)} - onUserClick={handleUserClick} - /> - )} - - ); -} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx deleted file mode 100644 index 8bab840..0000000 --- a/web/src/pages/LoginPage.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Card } from '@/components/ui/Card'; -import { Button } from '@/components/ui/Button'; -import { Badge } from '@/components/ui/Badge'; -import { Spinner } from '@/components/ui/Spinner'; -import { useLoginStatus } from '@/hooks/useLoginStatus'; -import { useAuth } from '@/context/AuthContext'; -import { useToast } from '@/context/ToastContext'; -import { getLoginQRCode, deleteCookies, checkLoginCookie } from '@/api/endpoints'; - -export function LoginPage() { - const { status, loading: statusLoading, refresh: refreshStatus, reset: resetStatus } = useLoginStatus(); - const { token } = useAuth(); - const { toast } = useToast(); - - // Whether the initial login check has completed (used to decide when to show QR section) - const [initialCheckDone, setInitialCheckDone] = useState(false); - - // Auto-check cookie on mount (lightweight, no browser opened) - useEffect(() => { - if (!token) return; - void checkLoginCookie().then((res) => { - if (res.success && res.data?.hasCookies) { - // Cookies exist — open browser to verify actual login state - void refreshStatus().finally(() => setInitialCheckDone(true)); - } else { - // No cookies — definitely not logged in, no need to open a browser - setInitialCheckDone(true); - } - }).catch(() => { - setInitialCheckDone(true); - }); - }, [token, refreshStatus]); - const navigate = useNavigate(); - - const [qrData, setQrData] = useState(null); - const [qrLoading, setQrLoading] = useState(false); - const [polling, setPolling] = useState(false); - const [countdown, setCountdown] = useState(0); - const [logoutLoading, setLogoutLoading] = useState(false); - - const pollRef = useRef | undefined>(undefined); - const countdownRef = useRef | undefined>(undefined); - - const stopPolling = useCallback(() => { - setPolling(false); - if (pollRef.current) clearInterval(pollRef.current); - if (countdownRef.current) clearInterval(countdownRef.current); - }, []); - - const handleGetQR = useCallback(async () => { - stopPolling(); - setQrLoading(true); - setQrData(null); - try { - const res = await getLoginQRCode(); - if (res.success && res.data) { - if (res.data.alreadyLoggedIn) { - toast('info', '已经登录!'); - void refreshStatus(); - return; - } - setQrData(res.data.qrcodeData); - // Start polling - setPolling(true); - setCountdown(240); // 4 min - pollRef.current = setInterval(async () => { - try { - const cookieRes = await checkLoginCookie(); - if (cookieRes.success && cookieRes.data?.hasCookies) { - stopPolling(); - setQrData(null); - toast('success', '登录成功!'); - void refreshStatus(); - } - } catch { - // ignore poll errors - } - }, 3000); - countdownRef.current = setInterval(() => { - setCountdown((prev) => { - if (prev <= 1) { - stopPolling(); - setQrData(null); - toast('warning', '二维码已过期'); - return 0; - } - return prev - 1; - }); - }, 1000); - } else { - toast('error', res.error?.message || '获取二维码失败'); - } - } catch (err) { - toast('error', err instanceof Error ? err.message : '获取二维码失败'); - } finally { - setQrLoading(false); - } - }, [stopPolling, toast, refreshStatus]); - - const handleLogout = useCallback(async () => { - setLogoutLoading(true); - try { - const res = await deleteCookies(); - if (res.success) { - toast('success', '已成功登出'); - resetStatus(); - } else { - toast('error', res.error?.message || '登出失败'); - } - } catch (err) { - toast('error', err instanceof Error ? err.message : '登出失败'); - } finally { - setLogoutLoading(false); - } - }, [toast, refreshStatus]); - - useEffect(() => { - return () => stopPolling(); - }, [stopPolling]); - - const formatCountdown = (secs: number) => { - const m = Math.floor(secs / 60); - const s = secs % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - }; - - return ( -
-

小红书登录

- - {!token && ( - -
-

- Bearer Token 未配置,API 请求将返回 401。请先在设置中配置 Token。 -

- -
-
- )} - - {/* Current Status */} - -
-
-

- 当前状态 -

- {statusLoading ? ( - - ) : status ? ( -
- - {status.loggedIn ? '已登录' : '未登录'} - - {status.username && {status.username}} -
- ) : ( - 点击刷新检查登录状态 - )} -
-
- - {status?.loggedIn && ( - - )} -
-
-
- - {/* QR Code Login — only shown after check completes and user is not logged in */} - {token && initialCheckDone && !status?.loggedIn && ( - -

- 二维码登录 -

- - {!qrData && !qrLoading && ( -
-

点击按钮生成登录二维码

- -
- )} - - {qrLoading && ( -
- -

生成二维码中...

-
- )} - - {qrData && ( -
-
- 登录二维码 -
-

使用小红书 App 扫码登录

- {polling && ( -
- - - 等待扫码... {formatCountdown(countdown)} - -
- )} -
- - -
-
- )} -
- )} -
- ); -} diff --git a/web/src/pages/XiaohongshuPage.tsx b/web/src/pages/XiaohongshuPage.tsx new file mode 100644 index 0000000..ce10a88 --- /dev/null +++ b/web/src/pages/XiaohongshuPage.tsx @@ -0,0 +1,378 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { Select } from '@/components/ui/Select'; +import { Spinner } from '@/components/ui/Spinner'; +import { FeedGrid } from '@/components/feed/FeedGrid'; +import { FeedDetail } from '@/components/feed/FeedDetail'; +import { UserCard } from '@/components/feed/UserCard'; +import { useAuth } from '@/context/AuthContext'; +import { useToast } from '@/context/ToastContext'; +import { useLoginStatus } from '@/hooks/useLoginStatus'; +import { + checkLoginCookie, + getLoginQRCode, + deleteCookies, + listFeeds, + searchFeeds, +} from '@/api/endpoints'; +import type { Feed, SearchFilters } from '@/api/types'; + +export function XiaohongshuPage() { + const { token } = useAuth(); + const { toast } = useToast(); + const navigate = useNavigate(); + const { status, loading: statusLoading, refresh: refreshStatus, reset: resetStatus } = useLoginStatus(); + + // ── Login check ────────────────────────────────────────────────────────── + const [initialCheckDone, setInitialCheckDone] = useState(false); + + useEffect(() => { + if (!token) { setInitialCheckDone(true); return; } + void checkLoginCookie() + .then((res) => { + if (res.success && res.data?.hasCookies) { + void refreshStatus().finally(() => setInitialCheckDone(true)); + } else { + setInitialCheckDone(true); + } + }) + .catch(() => setInitialCheckDone(true)); + }, [token, refreshStatus]); + + // ── QR login ───────────────────────────────────────────────────────────── + const [qrData, setQrData] = useState(null); + const [qrLoading, setQrLoading] = useState(false); + const [polling, setPolling] = useState(false); + const [countdown, setCountdown] = useState(0); + const pollRef = useRef | undefined>(undefined); + const countdownRef = useRef | undefined>(undefined); + + const stopPolling = useCallback(() => { + setPolling(false); + clearInterval(pollRef.current); + clearInterval(countdownRef.current); + }, []); + + useEffect(() => () => stopPolling(), [stopPolling]); + + const handleGetQR = useCallback(async () => { + stopPolling(); + setQrLoading(true); + setQrData(null); + try { + const res = await getLoginQRCode(); + if (!res.success || !res.data) { + toast('error', res.error?.message || '获取二维码失败'); + return; + } + if (res.data.alreadyLoggedIn) { + void refreshStatus().finally(() => setInitialCheckDone(true)); + return; + } + setQrData(res.data.qrcodeData); + setPolling(true); + setCountdown(240); + pollRef.current = setInterval(async () => { + try { + const r = await checkLoginCookie(); + if (r.success && r.data?.hasCookies) { + stopPolling(); + setQrData(null); + toast('success', '登录成功!'); + void refreshStatus().finally(() => setInitialCheckDone(true)); + } + } catch { /* ignore */ } + }, 3000); + countdownRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + stopPolling(); + setQrData(null); + toast('warning', '二维码已过期'); + return 0; + } + return prev - 1; + }); + }, 1000); + } catch (err) { + toast('error', err instanceof Error ? err.message : '获取二维码失败'); + } finally { + setQrLoading(false); + } + }, [stopPolling, toast, refreshStatus]); + + const formatCountdown = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + + // ── Logout ─────────────────────────────────────────────────────────────── + const [logoutLoading, setLogoutLoading] = useState(false); + + const handleLogout = useCallback(async () => { + setLogoutLoading(true); + try { + await deleteCookies(); + resetStatus(); + setFeeds([]); + setQrData(null); + stopPolling(); + toast('success', '已退出登录'); + } catch (err) { + toast('error', err instanceof Error ? err.message : '退出失败'); + } finally { + setLogoutLoading(false); + } + }, [toast, resetStatus, stopPolling]); + + // ── Feed / Search ───────────────────────────────────────────────────────── + const [feeds, setFeeds] = useState([]); + const [feedsLoading, setFeedsLoading] = useState(false); + const [keyword, setKeyword] = useState(''); + const [isSearchMode, setIsSearchMode] = useState(false); + const [sortFilter, setSortFilter] = useState('general'); + const [typeFilter, setTypeFilter] = useState('all'); + const [searchLoading, setSearchLoading] = useState(false); + const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null); + const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null); + + const loadFeed = useCallback(async () => { + setFeedsLoading(true); + setIsSearchMode(false); + setKeyword(''); + try { + const res = await listFeeds(); + if (res.success && res.data) { + setFeeds(res.data); + } else { + toast('error', res.error?.message || '加载推荐失败'); + } + } catch (err) { + toast('error', err instanceof Error ? err.message : '加载推荐失败'); + } finally { + setFeedsLoading(false); + } + }, [toast]); + + useEffect(() => { + if (initialCheckDone && status?.loggedIn && feeds.length === 0 && !isSearchMode) { + void loadFeed(); + } + }, [initialCheckDone, status?.loggedIn]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleSearch = useCallback(async () => { + if (!keyword.trim()) { void loadFeed(); return; } + setSearchLoading(true); + setIsSearchMode(true); + try { + const filters: SearchFilters = {}; + if (sortFilter !== 'general') filters.sort = sortFilter as SearchFilters['sort']; + if (typeFilter !== 'all') filters.type = typeFilter as SearchFilters['type']; + const res = await searchFeeds(keyword, Object.keys(filters).length > 0 ? filters : undefined); + if (res.success && res.data) { + setFeeds(res.data); + } else { + toast('error', res.error?.message || '搜索失败'); + } + } catch (err) { + toast('error', err instanceof Error ? err.message : '搜索失败'); + } finally { + setSearchLoading(false); + } + }, [keyword, sortFilter, typeFilter, toast, loadFeed]); + + const isLoading = feedsLoading || searchLoading; + + // ── Render ──────────────────────────────────────────────────────────────── + return ( +
+ + {/* ── Top bar ── */} +
+ 小红书 + + {/* Search — only when logged in */} + {status?.loggedIn && ( +
+ setKeyword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void handleSearch()} + className="flex-1" + /> + setTypeFilter(e.target.value)} + /> + + {isSearchMode && ( + + )} +
+ )} + + {/* User area */} +
+ {!token ? ( + + ) : !initialCheckDone || statusLoading ? ( + + ) : status?.loggedIn ? ( +
+ {status.avatar ? ( + avatar + ) : ( +
+ {status.username?.[0]?.toUpperCase() ?? '?'} +
+ )} +
+ {status.username} + {status.userId && ( + {status.userId.slice(0, 12)}… + )} +
+ +
+ ) : null} +
+
+ + {/* ── Content ── */} +
+ {/* Checking login state */} + {!initialCheckDone && ( +
+ +
+ )} + + {/* Not logged in → inline QR login */} + {initialCheckDone && !status?.loggedIn && ( +
+

扫码登录小红书

+ + {/* QR idle */} + {!qrData && !qrLoading && ( +
+

使用小红书 App 扫描二维码登录

+ +
+ )} + + {/* QR loading */} + {qrLoading && ( +
+ +

生成二维码中...

+
+ )} + + {/* QR ready */} + {qrData && ( +
+
+ 登录二维码 +
+

使用小红书 App 扫码登录

+ {polling && ( +
+ + 等待扫码… {formatCountdown(countdown)} +
+ )} +
+ + +
+
+ )} +
+ )} + + {/* Logged in → feed */} + {initialCheckDone && status?.loggedIn && ( + <> + {isSearchMode && !isLoading && ( +

+ 搜索「{keyword}」共 {feeds.length} 条结果 +

+ )} + setSelectedFeed({ id: feed.id, xsecToken: feed.xsecToken })} + emptyText={isSearchMode ? '未找到相关笔记' : '暂无推荐内容'} + /> + + )} +
+ + {/* ── Feed detail slide-over ── */} + {selectedFeed && ( + setSelectedFeed(null)} + onUserClick={(userId, xsecToken) => { + setSelectedFeed(null); + setUserView({ userId, xsecToken }); + }} + /> + )} + + {/* ── User profile slide-over ── */} + {userView && ( +
+
setUserView(null)} /> +
+
+

用户主页

+ +
+
+ { + setUserView(null); + setSelectedFeed({ id: feed.id, xsecToken: feed.xsecToken }); + }} + /> +
+
+
+ )} +
+ ); +}