Files
video-gen/video-gen-app/src/pages/LoginPage.tsx
T
2026-07-16 09:33:13 +08:00

758 lines
27 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd';
import {
LockOutlined, ThunderboltOutlined,
MobileOutlined, SafetyOutlined,
} from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
import './LoginPage.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
const LoginPage: React.FC = () => {
const { checkAuth } = useAuthStore();
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password');
const [tab, setTab] = useState<'password' | 'phone'>('password');
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
const tabParam = searchParams.get('tab');
const [countdown, setCountdown] = useState(0);
const [regCountdown, setRegCountdown] = useState(0);
const [agreed, setAgreed] = useState(false);
const [showSliderVerify, setShowSliderVerify] = useState(false);
const [sliderVerified, setSliderVerified] = useState(false);
const [loginSliderVerified, setLoginSliderVerified] = useState(false);
const [sliderKey, setSliderKey] = useState(0);
const [sliderShake, setSliderShake] = useState(false);
const [showResend, setShowResend] = useState(false);
const [loginShowResend, setLoginShowResend] = useState(false);
const getInitialSiteInfo = () => {
try {
const cached = localStorage.getItem('siteInfo');
if (cached) {
const info = JSON.parse(cached);
return {
siteName: info.siteName || 'VideoGen.AI',
siteLogo: info.siteLogo || '',
};
}
} catch { }
return { siteName: 'VideoGen.AI', siteLogo: '' };
};
const initialInfo = getInitialSiteInfo();
const [siteName, setSiteName] = useState(initialInfo.siteName);
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
const [loginBgVideo, setLoginBgVideo] = useState('');
const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
const [siteCopyright, setSiteCopyright] = useState('');
const navigate = useNavigate();
const { login } = useAuthStore();
const [pwdForm] = Form.useForm();
const [phoneForm] = Form.useForm();
const [regForm] = Form.useForm();
useEffect(() => {
getSiteInfo().then(info => {
setSiteName(info.siteName);
setSiteLogo(info.siteLogo);
setLoginBgVideo(info.loginBgVideo || '');
setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
setSiteCopyright(info.siteCopyright);
}).catch(() => {});
}, []);
useEffect(() => {
if (tabParam === 'register') {
setMode('register');
}
}, [tabParam]);
const goToRedirect = () => {
if (redirect) {
try {
const decoded = decodeURIComponent(redirect);
navigate(decoded);
return;
} catch {}
}
navigate('/home');
};
const checkAgreed = (): boolean => {
if (!agreed) {
message.warning('请先阅读并同意用户协议及隐私政策');
return false;
}
return true;
};
const handlePasswordLogin = async () => {
if (!checkAgreed()) return;
try {
const values = await pwdForm.validateFields();
await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来');
await checkAuth();
goToRedirect();
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
} finally {
}
};
const handlePhoneLogin = async () => {
if (!checkAgreed()) return;
try {
const values = phoneForm.getFieldsValue();
if (!values.phone || !values.code) {
message.error('请填写手机号和验证码');
return;
}
setLoading(true);
await phonelogin(values.phone, values.code);
message.success('登录成功,欢迎回来');
await checkAuth();
goToRedirect();
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
} finally {
setLoading(false);
}
};
const handleRegister = async () => {
if (!checkAgreed()) return;
try {
const values = await regForm.validateFields();
setLoading(true);
const user = await register(values.phone, values.regCode, values.password);
message.success('注册成功');
await checkAuth();
goToRedirect();
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败';
message.error(errorMsg);
} finally {
setLoading(false);
}
};
const startCountdown = (setter: React.Dispatch<React.SetStateAction<number>>, isReg?: boolean) => {
setter(60);
const timer = setInterval(() => {
setter((c) => {
if (c <= 1) {
clearInterval(timer);
setShowSliderVerify(false);
if (isReg) {
setShowResend(true);
} else {
setLoginShowResend(true);
}
return 0;
}
return c - 1;
});
}, 1000);
};
useEffect(() => {
if (countdown === 0 && mode === 'phone') {
setLoginSliderVerified(false);
}
}, [countdown, mode]);
const handleSendCode = async (phone: string, isReg?: boolean) => {
try {
if (!phone || !/^1\d{10}$/.test(phone)) {
message.error('请输入正确的手机号');
return;
}
if (isReg) {
setShowSliderVerify(true);
setSliderVerified(false);
return;
}
if (!loginSliderVerified) {
setShowSliderVerify(true);
setLoginSliderVerified(false);
return;
}
let mode2 = '';
if (mode === 'register') {
mode2 = 'register';
} else {
mode2 = 'login';
}
await sendSms(phone,mode2);
startCountdown(isReg ? setRegCountdown : setCountdown);
message.success('验证码已发送');
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
if (mode === 'register') {
setSliderVerified(false);
setRegCountdown(0);
} else {
setLoginSliderVerified(false);
setCountdown(0);
}
setSliderKey(prev => prev + 1);
}
};
const handleSliderSuccess = async (isResend = false) => {
const phone = mode === 'register' ? regForm.getFieldValue('phone') : phoneForm.getFieldValue('phone');
try {
let mode2 = '';
if (mode === 'register') {
mode2 = 'register';
} else {
mode2 = 'login';
}
await sendSms(phone,mode2);
if (mode === 'register') {
setSliderVerified(true);
startCountdown(setRegCountdown, true);
} else {
setLoginSliderVerified(true);
startCountdown(setCountdown, false);
}
message.success('验证码已发送');
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
if (mode === 'register') {
setSliderVerified(false);
setRegCountdown(0);
setShowResend(true);
} else {
setLoginSliderVerified(false);
setCountdown(0);
setLoginShowResend(true);
}
setSliderKey(prev => prev + 1);
}
};
const switchTab = (t: 'password' | 'phone') => {
setTab(t);
setMode(t);
setCountdown(0);
setRegCountdown(0);
setLoginSliderVerified(false);
setSliderVerified(false);
setShowSliderVerify(false);
setShowResend(false);
setLoginShowResend(false);
setSliderKey(prev => prev + 1);
if (t === 'password') {
pwdForm.resetFields();
} else {
phoneForm.resetFields();
}
};
const inputStyle: React.CSSProperties = {
background: '#fff',
border: '1.5px solid #e2e8f0',
color: '#1e293b',
borderRadius: 10,
fontSize: 14,
};
const openPdf = (url: string) => {
if (!url) {
message.warning('暂未上传协议文件');
return;
}
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
} else {
window.open(`${API_BASE.replace(/\/api$/, '')}${url}`, '_blank');
}
};
return (
<div className="login-page">
{/* 背景视频/动图全屏铺满 */}
{loginBgVideo && (loginBgVideo.toLowerCase().endsWith('.gif') || loginBgVideo.toLowerCase().endsWith('.webp')) ? (
<img className="login-bg-video" src={loginBgVideo} alt="" />
) : loginBgVideo ? (
<video className="login-bg-video" autoPlay loop muted playsInline preload="auto">
<source src={loginBgVideo} type={loginBgVideo.endsWith('.webm') ? 'video/webm' : loginBgVideo.endsWith('.mov') ? 'video/quicktime' : 'video/mp4'} />
</video>
) : null}
<div className="login-bg-overlay" />
{/* 左上角 slogan */}
<div className="login-slogan">
<span className="login-slogan-text">AI赋能创意,素材触手可及</span>
</div>
{/* 居中登录卡片 */}
<div className="login-center">
<Card className="login-card" styles={{ body: { padding: '32px 32px' } }}>
<div className="login-card-header">
<div className="login-logo-row">
{siteLogo ? (
<img src={siteLogo} alt="logo" className="login-logo-img" />
) : (
<div className="login-logo-placeholder">
<ThunderboltOutlined style={{ fontSize: 20, color: '#fff' }} />
</div>
)}
<span className="login-site-name">{siteName}</span>
</div>
</div>
<Typography.Text className="login-card-subtitle">
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作'}
</Typography.Text>
{mode !== 'register' && (
<div className="login-tabs">
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入密码" style={inputStyle} />
</Form.Item>
<Form.Item name="rememberMe" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox>记住我的登录状态</Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'phone' && (
<Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!loginSliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0}
onClick={() => {
if (loginShowResend) {
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else if (showSliderVerify && !loginSliderVerified) {
setSliderShake(true);
setTimeout(() => setSliderShake(false), 500);
} else {
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
className="login-code-btn">
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
shake={sliderShake}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'register' && (
<Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="regCode" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!sliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0}
onClick={() => {
if (showResend) {
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else if (showSliderVerify && !sliderVerified) {
setSliderShake(true);
setTimeout(() => setSliderShake(false), 500);
} else {
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
className="login-code-btn">
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
shake={sliderShake}
/>
</Form.Item>
)}
<Form.Item name="password" rules={[{ required: true, message: '请设置密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
<div className="login-agreement">
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span className="login-agreement-text">
我已阅读并同意
<span
onClick={e => { e.stopPropagation(); openPdf(agreementPrivacyUrl); }}
className="login-link"
>《用户协议及隐私政策》</span>
</span>
</Checkbox>
</div>
<div className="login-footer">
{mode === 'register' ? (
<Typography.Text
className="login-switch-btn"
onClick={() => {
setMode('password');
setTab('password');
setShowResend(false);
setLoginShowResend(false);
setSliderVerified(false);
setShowSliderVerify(false);
setSliderKey(prev => prev + 1);
regForm.resetFields();
}}
>
已有账号?去登录
</Typography.Text>
) : (
<Typography.Text
className="login-switch-btn"
onClick={() => {
setMode('register');
setShowResend(false);
setLoginShowResend(false);
setSliderVerified(false);
setShowSliderVerify(false);
setSliderKey(prev => prev + 1);
}}
>
没有账号?立即注册
</Typography.Text>
)}
</div>
</Card>
</div>
{siteCopyright && (
<div className="login-copyright-wrapper">
<div className="login-copyright">
{siteCopyright}
</div>
</div>
)}
</div>
);
};
const SliderVerify: React.FC<{
onSuccess: () => void;
isVerified: boolean;
shake?: boolean;
}> = ({ onSuccess, isVerified, shake }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const sliderRef = React.useRef<HTMLDivElement>(null);
const trackRef = React.useRef<HTMLDivElement>(null);
const positionRef = React.useRef(0);
const successRef = React.useRef(false);
const [containerWidth, setContainerWidth] = React.useState(360);
const sliderWidth = 50;
const maxPosition = containerWidth - sliderWidth;
React.useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
if (width > 0) {
setContainerWidth(width);
}
}
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
window.addEventListener('resize', updateWidth);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updateWidth);
};
}, []);
const updatePosition = (x: number) => {
if (successRef.current || isVerified) return;
const newPosition = Math.max(0, Math.min(x, maxPosition));
positionRef.current = newPosition;
if (sliderRef.current) {
sliderRef.current.style.left = `${newPosition}px`;
}
if (trackRef.current) {
trackRef.current.style.width = `${newPosition + sliderWidth}px`;
}
if (newPosition >= maxPosition - 5) {
successRef.current = true;
if (sliderRef.current) {
sliderRef.current.style.background = '#22c55e';
sliderRef.current.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
}
if (trackRef.current) {
trackRef.current.style.width = `${containerWidth}px`;
trackRef.current.style.background = '#dcfce7';
}
onSuccess();
}
};
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
if (successRef.current || isVerified) return;
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const handleMouseMove = (moveEvent: MouseEvent) => {
const x = moveEvent.clientX - rect.left - sliderWidth / 2;
updatePosition(x);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (!successRef.current && positionRef.current < maxPosition - 5) {
positionRef.current = 0;
if (sliderRef.current) {
sliderRef.current.style.left = '0px';
}
if (trackRef.current) {
trackRef.current.style.width = `${sliderWidth}px`;
}
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleTouchStart = (e: React.TouchEvent) => {
if (successRef.current || isVerified) return;
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const touch = e.touches[0];
const startX = touch.clientX - rect.left - sliderWidth / 2;
updatePosition(startX);
const handleTouchMove = (moveEvent: TouchEvent) => {
const moveTouch = moveEvent.touches[0];
const x = moveTouch.clientX - rect.left - sliderWidth / 2;
updatePosition(x);
};
const handleTouchEnd = () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
if (!successRef.current && positionRef.current < maxPosition - 5) {
positionRef.current = 0;
if (sliderRef.current) {
sliderRef.current.style.left = '0px';
}
if (trackRef.current) {
trackRef.current.style.width = `${sliderWidth}px`;
}
}
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
};
return (
<div
ref={containerRef}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
style={{
width: '100%',
height: 50,
background: '#ffffff',
borderRadius: 25,
position: 'relative',
overflow: 'hidden',
border: shake ? '2px solid #f59e0b' : '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: shake
? '0 0 15px rgba(245, 158, 11, 0.4), inset 0 2px 6px rgba(148, 163, 184, 0.15)'
: 'inset 0 2px 6px rgba(148, 163, 184, 0.15), 0 4px 12px rgba(0, 0, 0, 0.04)',
cursor: isVerified ? 'default' : 'pointer',
userSelect: 'none',
touchAction: 'none',
animation: shake ? 'sliderShake 0.5s ease-in-out' : 'none',
}}
>
<div
ref={trackRef}
style={{
position: 'absolute',
left: -2,
top: 0,
bottom: 0,
width: isVerified ? containerWidth + 4 : sliderWidth,
background: isVerified
? 'linear-gradient(90deg, #dcfce7 0%, #bbf7d0 100%)'
: 'linear-gradient(90deg, rgba(224, 231, 255, 0.6) 0%, rgba(199, 210, 254, 0.8) 100%)',
borderRadius: isVerified ? 0 : '0 25px 25px 0',
boxShadow: isVerified
? 'none'
: '4px 0 12px rgba(99, 102, 241, 0.2), 2px 0 4px rgba(99, 102, 241, 0.1)',
transition: successRef.current ? 'all 0.3s ease' : 'none',
}}
/>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
fontWeight: 500,
color: isVerified ? '#16a34a' : '#64748b',
zIndex: 1,
pointerEvents: 'none',
letterSpacing: 0.5,
}}
>
{isVerified ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
验证成功
</span>
) : (
'滑动发送'
)}
</div>
<div
ref={sliderRef}
style={{
position: 'absolute',
left: isVerified ? maxPosition : 0,
top: 5,
width: sliderWidth - 10,
height: 40,
background: isVerified
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: isVerified
? '0 4px 12px rgba(34, 197, 94, 0.4), 0 0 0 4px rgba(34, 197, 94, 0.1)'
: '0 4px 12px rgba(99, 102, 241, 0.35), 0 0 0 4px rgba(255, 255, 255, 0.8)',
cursor: isVerified ? 'default' : 'grab',
transition: successRef.current ? 'all 0.3s ease' : 'none',
userSelect: 'none',
zIndex: 2,
}}
>
{isVerified ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
)}
</div>
</div>
);
};
export default LoginPage;