c6a8177718
- Dashboard: 健康状态轮询、状态卡片、内存统计、快捷操作 - Login: 二维码展示 + 3 秒自动轮询 + 倒计时 + 登出 - Browser: 探索/搜索/用户三标签页,Feed 网格、详情面板、评论树 - Publish: 图文/视频发布表单,支持标签、可见性、定时发布 - Interactions: 点赞/取消点赞、收藏、评论、回复 + 操作日志 - API Tester: 端点选择器、请求体编辑器、cURL 生成、响应查看、历史记录 - Settings: Token 配置、服务器 URL 设置 后端改动: - app.ts: 生产环境提供 dist/web/ 静态文件服务 + SPA fallback - Dockerfile: 添加 web 构建阶段 - package.json: 添加 build:web、build:all、dev:web 脚本 技术栈: React 19 + TypeScript + Vite 6 + Tailwind CSS(暗色主题) 产物: 85.5 KB gzip JS + 4 KB gzip CSS,零重型依赖
193 lines
6.5 KiB
TypeScript
193 lines
6.5 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
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 { useToast } from '@/context/ToastContext';
|
|
import { getLoginQRCode, deleteCookies, getLoginStatus } from '@/api/endpoints';
|
|
|
|
export function LoginPage() {
|
|
const { status, loading: statusLoading, refresh: refreshStatus } = useLoginStatus();
|
|
const { toast } = useToast();
|
|
|
|
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', 'Already logged in!');
|
|
void refreshStatus();
|
|
return;
|
|
}
|
|
setQrData(res.data.qrcodeData);
|
|
// Start polling
|
|
setPolling(true);
|
|
setCountdown(240); // 4 min
|
|
pollRef.current = setInterval(async () => {
|
|
try {
|
|
const statusRes = await getLoginStatus();
|
|
if (statusRes.success && statusRes.data?.loggedIn) {
|
|
stopPolling();
|
|
setQrData(null);
|
|
toast('success', `Logged in as ${statusRes.data.username || 'user'}`);
|
|
void refreshStatus();
|
|
}
|
|
} catch {
|
|
// ignore poll errors
|
|
}
|
|
}, 3000);
|
|
countdownRef.current = setInterval(() => {
|
|
setCountdown((prev) => {
|
|
if (prev <= 1) {
|
|
stopPolling();
|
|
setQrData(null);
|
|
toast('warning', 'QR code expired');
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
} else {
|
|
toast('error', res.error?.message || 'Failed to get QR code');
|
|
}
|
|
} catch (err) {
|
|
toast('error', err instanceof Error ? err.message : 'Failed to get QR code');
|
|
} finally {
|
|
setQrLoading(false);
|
|
}
|
|
}, [stopPolling, toast, refreshStatus]);
|
|
|
|
const handleLogout = useCallback(async () => {
|
|
setLogoutLoading(true);
|
|
try {
|
|
const res = await deleteCookies();
|
|
if (res.success) {
|
|
toast('success', 'Logged out successfully');
|
|
void refreshStatus();
|
|
} else {
|
|
toast('error', res.error?.message || 'Failed to logout');
|
|
}
|
|
} catch (err) {
|
|
toast('error', err instanceof Error ? err.message : 'Failed to logout');
|
|
} 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">Xiaohongshu Login</h1>
|
|
|
|
{/* 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">
|
|
Current Status
|
|
</h2>
|
|
{statusLoading ? (
|
|
<Spinner size="sm" />
|
|
) : status ? (
|
|
<div className="flex items-center gap-3">
|
|
<Badge variant={status.loggedIn ? 'success' : 'warning'}>
|
|
{status.loggedIn ? 'Logged In' : 'Not Logged In'}
|
|
</Badge>
|
|
{status.username && <span className="text-sm">{status.username}</span>}
|
|
</div>
|
|
) : (
|
|
<Badge variant="danger">Unable to check</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => void refreshStatus()}>
|
|
Refresh
|
|
</Button>
|
|
{status?.loggedIn && (
|
|
<Button variant="danger" size="sm" onClick={() => void handleLogout()} loading={logoutLoading}>
|
|
Logout
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* QR Code Login */}
|
|
<Card>
|
|
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-4">
|
|
QR Code Login
|
|
</h2>
|
|
|
|
{!qrData && !qrLoading && (
|
|
<div className="text-center py-8">
|
|
<p className="text-dark-muted mb-4">Click the button to generate a QR code for login</p>
|
|
<Button onClick={() => void handleGetQR()} disabled={status?.loggedIn}>
|
|
Get QR Code
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{qrLoading && (
|
|
<div className="flex flex-col items-center py-8 gap-3">
|
|
<Spinner size="lg" />
|
|
<p className="text-sm text-dark-muted">Generating QR code...</p>
|
|
</div>
|
|
)}
|
|
|
|
{qrData && (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="bg-white rounded-xl p-4">
|
|
<img src={qrData} alt="Login QR Code" className="w-64 h-64" />
|
|
</div>
|
|
<p className="text-sm text-dark-muted">Scan with Xiaohongshu app to login</p>
|
|
{polling && (
|
|
<div className="flex items-center gap-3">
|
|
<Spinner size="sm" />
|
|
<span className="text-sm text-dark-accent">
|
|
Waiting for scan... {formatCountdown(countdown)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => void handleGetQR()}>
|
|
Refresh QR
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={stopPolling}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|