整合小红书页面:内联扫码登录、用户主页、移除独立登录和内容浏览页

- 新增 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:
2026-03-02 00:12:24 +08:00
parent ee154f990d
commit 9d0a9c93f4
11 changed files with 440 additions and 477 deletions
+2 -4
View File
@@ -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 />} />
+2
View File
@@ -3,6 +3,8 @@
export interface LoginStatus {
loggedIn: boolean;
username?: string;
avatar?: string;
userId?: string;
}
export interface QRCodeResult {
+5
View File
@@ -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 -2
View File
@@ -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' },
-233
View File
@@ -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">
&larr;
</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>
);
}
-230
View File
@@ -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>
);
}
+378
View File
@@ -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">&times;</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>
);
}