整合小红书页面:内联扫码登录、用户主页、移除独立登录和内容浏览页
- 新增 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,12 +27,36 @@ async function main() {
|
|||||||
const nickname = await page.$eval('.user-info .user-name', el => el.textContent?.trim() ?? '').catch(() => 'NOT FOUND');
|
const nickname = await page.$eval('.user-info .user-name', el => el.textContent?.trim() ?? '').catch(() => 'NOT FOUND');
|
||||||
console.log('nickname:', nickname);
|
console.log('nickname:', nickname);
|
||||||
|
|
||||||
const feeds = await page.$$('.feeds-container .note-item');
|
// Check __INITIAL_STATE__
|
||||||
console.log('note items:', feeds.length);
|
const initialState = await page.evaluate(() => {
|
||||||
if (feeds.length > 0) {
|
const s = (window as unknown as Record<string, unknown>).__INITIAL_STATE__;
|
||||||
const href = await feeds[0]!.$eval('a.cover', el => el.getAttribute('href') ?? '').catch(() => '');
|
return s ? JSON.stringify(s) : null;
|
||||||
console.log('first note href:', href);
|
}).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();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,23 @@ export async function checkLoginStatus(page: Page): Promise<LoginStatus> {
|
|||||||
// Attempt to extract a username from the indicator area.
|
// Attempt to extract a username from the indicator area.
|
||||||
const username = await indicator.textContent().catch(() => null);
|
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 {
|
return {
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
...(username ? { username: username.trim() } : {}),
|
...(username ? { username: username.trim() } : {}),
|
||||||
|
...(avatar ? { avatar } : {}),
|
||||||
|
...(userId ? { userId } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const XHS_SELECTORS = {
|
|||||||
loggedInIndicator: '.user .link-wrapper .channel',
|
loggedInIndicator: '.user .link-wrapper .channel',
|
||||||
/** The "login" button that opens the QR code modal (if not already shown). */
|
/** The "login" button that opens the QR code modal (if not already shown). */
|
||||||
loginButton: '.login-btn',
|
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: {
|
feed: {
|
||||||
@@ -108,8 +112,8 @@ export const XHS_SELECTORS = {
|
|||||||
headerContainer: '.user-info',
|
headerContainer: '.user-info',
|
||||||
/** User nickname. */
|
/** User nickname. */
|
||||||
nickname: '.user-info .user-name',
|
nickname: '.user-info .user-name',
|
||||||
/** User avatar image. */
|
/** User avatar image (the img itself carries class user-image). */
|
||||||
avatar: '.user-info .user-image img',
|
avatar: '.user-info img.user-image',
|
||||||
/** User bio / description text. */
|
/** User bio / description text. */
|
||||||
description: '.user-info .user-desc',
|
description: '.user-info .user-desc',
|
||||||
/** User gender icon or text. */
|
/** User gender icon or text. */
|
||||||
@@ -117,7 +121,7 @@ export const XHS_SELECTORS = {
|
|||||||
/** IP location. */
|
/** IP location. */
|
||||||
ipLocation: '.user-info .user-ip',
|
ipLocation: '.user-info .user-ip',
|
||||||
/** Follower / following / interaction count elements. */
|
/** 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). */
|
/** Note count (displayed somewhere on the profile page). */
|
||||||
noteCountTab: '.reds-tab-item',
|
noteCountTab: '.reds-tab-item',
|
||||||
/** Individual feed items on the user profile. */
|
/** Individual feed items on the user profile. */
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
export interface LoginStatus {
|
export interface LoginStatus {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QRCodeResult {
|
export interface QRCodeResult {
|
||||||
|
|||||||
+2
-4
@@ -3,8 +3,7 @@ import { AuthProvider } from '@/context/AuthContext';
|
|||||||
import { ToastProvider } from '@/context/ToastContext';
|
import { ToastProvider } from '@/context/ToastContext';
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from '@/components/layout/Layout';
|
||||||
import { DashboardPage } from '@/pages/DashboardPage';
|
import { DashboardPage } from '@/pages/DashboardPage';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { XiaohongshuPage } from '@/pages/XiaohongshuPage';
|
||||||
import { BrowserPage } from '@/pages/BrowserPage';
|
|
||||||
import { PublishPage } from '@/pages/PublishPage';
|
import { PublishPage } from '@/pages/PublishPage';
|
||||||
import { InteractionsPage } from '@/pages/InteractionsPage';
|
import { InteractionsPage } from '@/pages/InteractionsPage';
|
||||||
import { ApiTesterPage } from '@/pages/ApiTesterPage';
|
import { ApiTesterPage } from '@/pages/ApiTesterPage';
|
||||||
@@ -18,8 +17,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="login" element={<LoginPage />} />
|
<Route path="xhs" element={<XiaohongshuPage />} />
|
||||||
<Route path="browser" element={<BrowserPage />} />
|
|
||||||
<Route path="publish" element={<PublishPage />} />
|
<Route path="publish" element={<PublishPage />} />
|
||||||
<Route path="interactions" element={<InteractionsPage />} />
|
<Route path="interactions" element={<InteractionsPage />} />
|
||||||
<Route path="api-tester" element={<ApiTesterPage />} />
|
<Route path="api-tester" element={<ApiTesterPage />} />
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
export interface LoginStatus {
|
export interface LoginStatus {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QRCodeResult {
|
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>
|
<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> = {
|
export const iconMap: Record<string, React.FC> = {
|
||||||
|
xhs: XhsIcon,
|
||||||
dashboard: DashboardIcon,
|
dashboard: DashboardIcon,
|
||||||
login: LoginIcon,
|
login: LoginIcon,
|
||||||
browser: BrowserIcon,
|
browser: BrowserIcon,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export const NAV_ITEMS = [
|
export const NAV_ITEMS = [
|
||||||
{ path: '/', label: '仪表盘', icon: 'dashboard' },
|
{ path: '/', label: '仪表盘', icon: 'dashboard' },
|
||||||
{ path: '/login', label: '登录', icon: 'login' },
|
{ path: '/xhs', label: '小红书', icon: 'xhs' },
|
||||||
{ path: '/browser', label: '内容浏览', icon: 'browser' },
|
|
||||||
{ path: '/publish', label: '发布', icon: 'publish' },
|
{ path: '/publish', label: '发布', icon: 'publish' },
|
||||||
{ path: '/interactions', label: '互动', icon: 'interactions' },
|
{ path: '/interactions', label: '互动', icon: 'interactions' },
|
||||||
{ path: '/api-tester', label: 'API 测试', icon: 'api' },
|
{ 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