整合小红书页面:内联扫码登录、用户主页、移除独立登录和内容浏览页
- 新增 XiaohongshuPage:顶部搜索栏、登录用户头像/ID、退出按钮 - 未登录时内联显示二维码,无需跳转独立登录页 - 点击笔记作者可打开用户主页 slide-over(复用 UserCard) - 修复用户主页关注/粉丝/获赞数为零:选择器从 .data-area .data-item 改为 .user-interactions > div - 修复用户头像选择器:img.user-image(img 本身带该 class) - backend LoginStatus 新增 avatar/userId 字段,登录状态接口返回头像和用户 ID - 删除 LoginPage、BrowserPage,侧边栏精简为小红书单入口
This commit is contained in:
@@ -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<string, unknown>).__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); });
|
||||
|
||||
@@ -49,9 +49,23 @@ export async function checkLoginStatus(page: Page): Promise<LoginStatus> {
|
||||
// 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
export interface LoginStatus {
|
||||
loggedIn: boolean;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface QRCodeResult {
|
||||
|
||||
+2
-4
@@ -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() {
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="browser" element={<BrowserPage />} />
|
||||
<Route path="xhs" element={<XiaohongshuPage />} />
|
||||
<Route path="publish" element={<PublishPage />} />
|
||||
<Route path="interactions" element={<InteractionsPage />} />
|
||||
<Route path="api-tester" element={<ApiTesterPage />} />
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export interface LoginStatus {
|
||||
loggedIn: boolean;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface QRCodeResult {
|
||||
|
||||
@@ -29,7 +29,12 @@ export const SettingsIcon = () => (
|
||||
<svg {...s}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
|
||||
);
|
||||
|
||||
export const XhsIcon = () => (
|
||||
<svg {...s}><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 4c.55 0 1 .45 1 1v4.5l3.2 1.85a1 1 0 0 1-1 1.73L12 13.27l-3.2 1.81a1 1 0 0 1-1-1.73L11 11.5V7c0-.55.45-1 1-1z" fill="currentColor" stroke="none" /></svg>
|
||||
);
|
||||
|
||||
export const iconMap: Record<string, React.FC> = {
|
||||
xhs: XhsIcon,
|
||||
dashboard: DashboardIcon,
|
||||
login: LoginIcon,
|
||||
browser: BrowserIcon,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<TabKey>('explore');
|
||||
|
||||
// Explore state
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
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<Feed[]>([]);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">内容浏览</h1>
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'explore', label: '探索' },
|
||||
{ key: 'search', label: '搜索' },
|
||||
{ key: 'user', label: '用户主页' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={(k) => setTab(k as TabKey)}
|
||||
/>
|
||||
|
||||
{/* Explore Tab */}
|
||||
{tab === 'explore' && (
|
||||
<div className="space-y-4">
|
||||
<Button onClick={() => void handleExplore()} loading={feedsLoading}>
|
||||
加载推荐
|
||||
</Button>
|
||||
<FeedGrid
|
||||
feeds={feeds}
|
||||
loading={feedsLoading}
|
||||
onSelect={handleFeedSelect}
|
||||
emptyText="点击「加载推荐」获取推荐内容"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Tab */}
|
||||
{tab === 'search' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
label="关键词"
|
||||
placeholder="搜索小红书..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="排序"
|
||||
options={[
|
||||
{ value: 'general', label: '默认' },
|
||||
{ value: 'time_descending', label: '最新' },
|
||||
{ value: 'popularity_descending', label: '热门' },
|
||||
]}
|
||||
value={sortFilter}
|
||||
onChange={(e) => setSortFilter(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="类型"
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'note', label: '图文' },
|
||||
{ value: 'video', label: '视频' },
|
||||
]}
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="时间"
|
||||
options={[
|
||||
{ value: 'all', label: '不限' },
|
||||
{ value: 'day', label: '一天内' },
|
||||
{ value: 'week', label: '一周内' },
|
||||
{ value: 'half_year', label: '半年内' },
|
||||
]}
|
||||
value={timeFilter}
|
||||
onChange={(e) => setTimeFilter(e.target.value)}
|
||||
/>
|
||||
<Button onClick={() => void handleSearch()} loading={searchLoading}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<FeedGrid
|
||||
feeds={searchResults}
|
||||
loading={searchLoading}
|
||||
onSelect={handleFeedSelect}
|
||||
emptyText="输入关键词进行搜索"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Tab */}
|
||||
{tab === 'user' && (
|
||||
<div className="space-y-4">
|
||||
{!userView && (
|
||||
<div className="text-center py-12 text-dark-muted">
|
||||
<p>在笔记详情中点击用户查看主页</p>
|
||||
<p className="text-xs mt-2">或手动输入用户信息:</p>
|
||||
<div className="flex gap-3 items-end justify-center mt-4">
|
||||
<Input
|
||||
placeholder="用户 ID"
|
||||
value={manualUserId}
|
||||
onChange={(e) => setManualUserId(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="xsec_token"
|
||||
value={manualUserToken}
|
||||
onChange={(e) => setManualUserToken(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (manualUserId && manualUserToken) {
|
||||
setUserView({ userId: manualUserId, xsecToken: manualUserToken });
|
||||
}
|
||||
}}
|
||||
>
|
||||
加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{userView && userView.userId && userView.xsecToken && (
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setUserView(null)} className="mb-4">
|
||||
← 返回
|
||||
</Button>
|
||||
<UserCard
|
||||
userId={userView.userId}
|
||||
xsecToken={userView.xsecToken}
|
||||
onFeedSelect={handleFeedSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed Detail Slide-over */}
|
||||
{selectedFeed && (
|
||||
<FeedDetail
|
||||
feedId={selectedFeed.id}
|
||||
xsecToken={selectedFeed.xsecToken}
|
||||
onClose={() => setSelectedFeed(null)}
|
||||
onUserClick={handleUserClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [logoutLoading, setLogoutLoading] = useState(false);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | 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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<h1 className="text-2xl font-bold">小红书登录</h1>
|
||||
|
||||
{!token && (
|
||||
<Card className="border-dark-warning/30 bg-dark-warning/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-dark-warning">
|
||||
Bearer Token 未配置,API 请求将返回 401。请先在设置中配置 Token。
|
||||
</p>
|
||||
<Button size="sm" variant="secondary" onClick={() => navigate('/settings')}>
|
||||
前往设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current Status */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-2">
|
||||
当前状态
|
||||
</h2>
|
||||
{statusLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : status ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={status.loggedIn ? 'success' : 'warning'}>
|
||||
{status.loggedIn ? '已登录' : '未登录'}
|
||||
</Badge>
|
||||
{status.username && <span className="text-sm">{status.username}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-dark-muted">点击刷新检查登录状态</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => void refreshStatus()}>
|
||||
刷新
|
||||
</Button>
|
||||
{status?.loggedIn && (
|
||||
<Button variant="danger" size="sm" onClick={() => void handleLogout()} loading={logoutLoading}>
|
||||
登出
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* QR Code Login — only shown after check completes and user is not logged in */}
|
||||
{token && initialCheckDone && !status?.loggedIn && (
|
||||
<Card>
|
||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-4">
|
||||
二维码登录
|
||||
</h2>
|
||||
|
||||
{!qrData && !qrLoading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-dark-muted mb-4">点击按钮生成登录二维码</p>
|
||||
<Button onClick={() => void handleGetQR()}>
|
||||
获取二维码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrLoading && (
|
||||
<div className="flex flex-col items-center py-8 gap-3">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-dark-muted">生成二维码中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrData && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="bg-white rounded-xl p-4">
|
||||
<img src={qrData} alt="登录二维码" className="w-64 h-64" />
|
||||
</div>
|
||||
<p className="text-sm text-dark-muted">使用小红书 App 扫码登录</p>
|
||||
{polling && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-dark-accent">
|
||||
等待扫码... {formatCountdown(countdown)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleGetQR()}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={stopPolling}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | 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<Feed[]>([]);
|
||||
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 (
|
||||
<div className="flex flex-col -m-6 h-[calc(100vh-3.5rem)]">
|
||||
|
||||
{/* ── Top bar ── */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-dark-border bg-dark-card shrink-0">
|
||||
<span className="text-base font-bold text-[#ff2442] shrink-0">小红书</span>
|
||||
|
||||
{/* Search — only when logged in */}
|
||||
{status?.loggedIn && (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="搜索笔记..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSearch()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'general', label: '默认' },
|
||||
{ value: 'time_descending', label: '最新' },
|
||||
{ value: 'popularity_descending', label: '热门' },
|
||||
]}
|
||||
value={sortFilter}
|
||||
onChange={(e) => setSortFilter(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'note', label: '图文' },
|
||||
{ value: 'video', label: '视频' },
|
||||
]}
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" onClick={() => void handleSearch()} loading={searchLoading}>
|
||||
搜索
|
||||
</Button>
|
||||
{isSearchMode && (
|
||||
<Button size="sm" variant="ghost" onClick={() => void loadFeed()}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User area */}
|
||||
<div className="shrink-0 flex items-center gap-2 ml-auto">
|
||||
{!token ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => navigate('/settings')}>
|
||||
配置 Token
|
||||
</Button>
|
||||
) : !initialCheckDone || statusLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : status?.loggedIn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{status.avatar ? (
|
||||
<img
|
||||
src={status.avatar}
|
||||
alt="avatar"
|
||||
className="w-8 h-8 rounded-full object-cover ring-1 ring-dark-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-[#ff2442]/20 flex items-center justify-center text-sm font-bold text-[#ff2442]">
|
||||
{status.username?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-sm font-medium text-dark-text">{status.username}</span>
|
||||
{status.userId && (
|
||||
<span className="text-xs text-dark-muted">{status.userId.slice(0, 12)}…</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="danger" onClick={() => void handleLogout()} loading={logoutLoading}>
|
||||
退出
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{/* Checking login state */}
|
||||
{!initialCheckDone && (
|
||||
<div className="flex justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not logged in → inline QR login */}
|
||||
{initialCheckDone && !status?.loggedIn && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-6">
|
||||
<p className="text-xl font-semibold text-dark-text">扫码登录小红书</p>
|
||||
|
||||
{/* QR idle */}
|
||||
{!qrData && !qrLoading && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-dark-muted">使用小红书 App 扫描二维码登录</p>
|
||||
<Button onClick={() => void handleGetQR()}>获取二维码</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR loading */}
|
||||
{qrLoading && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-dark-muted">生成二维码中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR ready */}
|
||||
{qrData && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-lg">
|
||||
<img src={qrData} alt="登录二维码" className="w-52 h-52" />
|
||||
</div>
|
||||
<p className="text-sm text-dark-muted">使用小红书 App 扫码登录</p>
|
||||
{polling && (
|
||||
<div className="flex items-center gap-2 text-sm text-dark-accent">
|
||||
<Spinner size="sm" />
|
||||
<span>等待扫码… {formatCountdown(countdown)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => void handleGetQR()}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={stopPolling}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logged in → feed */}
|
||||
{initialCheckDone && status?.loggedIn && (
|
||||
<>
|
||||
{isSearchMode && !isLoading && (
|
||||
<p className="text-xs text-dark-muted mb-4">
|
||||
搜索「{keyword}」共 {feeds.length} 条结果
|
||||
</p>
|
||||
)}
|
||||
<FeedGrid
|
||||
feeds={feeds}
|
||||
loading={isLoading}
|
||||
onSelect={(feed) => setSelectedFeed({ id: feed.id, xsecToken: feed.xsecToken })}
|
||||
emptyText={isSearchMode ? '未找到相关笔记' : '暂无推荐内容'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Feed detail slide-over ── */}
|
||||
{selectedFeed && (
|
||||
<FeedDetail
|
||||
feedId={selectedFeed.id}
|
||||
xsecToken={selectedFeed.xsecToken}
|
||||
onClose={() => setSelectedFeed(null)}
|
||||
onUserClick={(userId, xsecToken) => {
|
||||
setSelectedFeed(null);
|
||||
setUserView({ userId, xsecToken });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── User profile slide-over ── */}
|
||||
{userView && (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setUserView(null)} />
|
||||
<div className="relative ml-auto w-full max-w-2xl bg-dark-card border-l border-dark-border overflow-y-auto">
|
||||
<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>
|
||||
<button onClick={() => setUserView(null)} className="text-dark-muted hover:text-dark-text text-xl">×</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<UserCard
|
||||
userId={userView.userId}
|
||||
xsecToken={userView.xsecToken}
|
||||
onFeedSelect={(feed) => {
|
||||
setUserView(null);
|
||||
setSelectedFeed({ id: feed.id, xsecToken: feed.xsecToken });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user