Files
social-mcp/web/src/pages/LoginPage.tsx
T
kurihada c6a8177718 feat: 添加 Admin Dashboard — React 19 SPA,包含 7 个页面
- 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,零重型依赖
2026-03-01 13:58:55 +08:00

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>
);
}