Files
video-gen/video-gen-app/src/pages/LoginPage.tsx
T

392 lines
17 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd';
import {
LockOutlined, ThunderboltOutlined,
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
MobileOutlined, SafetyOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { sendSms, getSiteInfo, register as registerApi } from '../api';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password');
const [tab, setTab] = useState<'password' | 'phone'>('password');
const [countdown, setCountdown] = useState(0);
const [regCountdown, setRegCountdown] = useState(0);
const [agreed, setAgreed] = useState(false);
const [siteName, setSiteName] = useState('VideoGen.AI');
const [siteLogo, setSiteLogo] = useState('');
const [agreementUrl, setAgreementUrl] = useState('');
const [policyUrl, setPolicyUrl] = 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);
setAgreementUrl(info.userAgreementUrl);
setPolicyUrl(info.privacyPolicyUrl);
}).catch(() => {});
}, []);
const checkAgreed = (): boolean => {
if (!agreed) {
message.warning('请先阅读并同意用户协议和隐私政策');
return false;
}
return true;
};
const handlePasswordLogin = async () => {
if (!checkAgreed()) return;
try {
const values = await pwdForm.validateFields();
setLoading(true);
await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来');
navigate('/projects');
} catch {
message.error('登录失败');
} finally {
setLoading(false);
}
};
const handlePhoneLogin = async () => {
if (!checkAgreed()) return;
try {
const values = phoneForm.getFieldsValue();
if (!values.phone || !values.code) {
message.error('请填写手机号和验证码');
return;
}
setLoading(true);
await login(values.phone, values.code);
message.success('登录成功,欢迎回来');
navigate('/projects');
} catch {
message.error('登录失败');
} finally {
setLoading(false);
}
};
const handleRegister = async () => {
if (!checkAgreed()) return;
try {
const values = await regForm.validateFields();
setLoading(true);
await registerApi(values.phone, values.regCode, values.password);
message.success('注册成功');
navigate('/projects');
} catch {
message.error('注册失败');
} finally {
setLoading(false);
}
};
const startCountdown = (setter: React.Dispatch<React.SetStateAction<number>>) => {
setter(60);
const timer = setInterval(() => {
setter((c) => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; });
}, 1000);
};
const handleSendCode = async (phone: string, isReg?: boolean) => {
try {
if (!phone || !/^1\d{10}$/.test(phone)) {
message.error('请输入正确的手机号');
return;
}
await sendSms(phone);
startCountdown(isReg ? setRegCountdown : setCountdown);
message.success('验证码已发送');
} catch {
message.error('发送失败');
}
};
const switchTab = (t: 'password' | 'phone') => {
setTab(t);
setMode(t);
};
const features = [
{ icon: <BulbOutlined />, title: 'AI 智能优化', desc: '输入原始提示词,AI 自动为您生成专业级视频描述' },
{ icon: <PlayCircleOutlined />, title: '一键生成视频', desc: '支持多种画质与时长选择,最快 30 秒出片' },
{ icon: <HistoryOutlined />, title: '项目维度管理', desc: '按项目组织视频,支持多种行业模板' },
];
const inputStyle: React.CSSProperties = {
background: '#fff',
border: '1.5px solid #e2e8f0',
color: '#1e293b',
height: 48,
borderRadius: 10,
fontSize: 14,
};
const openPdf = (url: string) => {
if (url) window.open(`${API_BASE.replace(/\/api$/, '')}${url}`, '_blank');
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
background: 'linear-gradient(135deg, #f0f4ff 0%, #e8ecf8 40%, #f0f0ff 70%, #f8f9ff 100%)',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
position: 'absolute',
width: 500, height: 500, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)',
top: -150, right: -100, filter: 'blur(40px)',
}} />
<div style={{
position: 'absolute',
width: 400, height: 400, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%)',
bottom: -100, left: -80, filter: 'blur(50px)',
}} />
{/* Left side - features */}
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '0 80px', zIndex: 1,
}}>
<Space direction="vertical" size={36}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 52, height: 52, borderRadius: 14, objectFit: 'contain' }} />
) : (
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}>
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
</div>
)}
<span style={{ color: '#1e293b', fontSize: 28, fontWeight: 800, letterSpacing: -0.5 }}>
{siteName}
</span>
</div>
<Typography.Paragraph style={{ color: '#64748b', fontSize: 17, maxWidth: 480, lineHeight: 1.8 }}>
专业的 AI 视频生成平台,通过智能提示词优化,<br />让您的创意快速转化为精美视频
</Typography.Paragraph>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{features.map((f, i) => (
<div key={i} style={{
display: 'flex', gap: 16, alignItems: 'flex-start',
padding: '18px 22px', borderRadius: 14,
background: 'rgba(255,255,255,0.7)',
border: '1px solid rgba(99,102,241,0.1)',
backdropFilter: 'blur(10px)',
}}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#6366f1', fontSize: 20,
}}>{f.icon}</div>
<div>
<Typography.Text style={{ color: '#1e293b', fontSize: 15, fontWeight: 600, display: 'block', marginBottom: 4 }}>{f.title}</Typography.Text>
<Typography.Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.6 }}>{f.desc}</Typography.Text>
</div>
</div>
))}
</div>
</Space>
</div>
{/* Right side - login/register form */}
<div style={{
flex: 1, display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1, padding: '40px 24px',
}}>
<Card style={{
width: 440, maxWidth: '100%', borderRadius: 20,
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
border: '1px solid #e2e8f0', background: '#fff',
}} styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 6, color: '#1e293b', fontWeight: 700 }}>
{mode === 'register' ? '创建账号' : '欢迎回来'}
</Typography.Title>
<Typography.Text style={{ display: 'block', textAlign: 'center', marginBottom: 28, color: '#94a3b8', fontSize: 14 }}>
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{/* Tab switcher - only for login modes */}
{mode !== 'register' && (
<div style={{
display: 'flex', gap: 0, marginBottom: 24,
background: '#f1f5f9', borderRadius: 10, padding: 4,
border: '1px solid #e2e8f0',
}}>
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)} style={{
flex: 1, textAlign: 'center', padding: '10px 0',
borderRadius: 8, cursor: 'pointer', fontSize: 14,
fontWeight: tab === t ? 600 : 400,
color: tab === t ? '#6366f1' : '#64748b',
background: tab === t ? '#fff' : 'transparent',
border: tab === t ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
boxShadow: tab === t ? '0 2px 8px rgba(99,102,241,0.1)' : 'none',
transition: 'all 0.3s ease',
}}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{/* Password Login */}
{mode === 'password' && (
<Form form={pwdForm} onFinish={handlePasswordLogin} 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="请输入手机号" 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 style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
</Form.Item>
</Form>
)}
{/* Phone Login */}
{mode === 'phone' && (
<Form form={phoneForm} onFinish={handlePhoneLogin} 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}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0}
onClick={() => handleSendCode(phoneForm.getFieldValue('phone'))}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: countdown > 0 ? '#f1f5f9' : 'rgba(99,102,241,0.1)',
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: countdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
</Form.Item>
</Form>
)}
{/* Register */}
{mode === 'register' && (
<Form form={regForm} onFinish={handleRegister} 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}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0}
onClick={() => handleSendCode(regForm.getFieldValue('phone'), true)}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: regCountdown > 0 ? '#f1f5f9' : 'rgba(99,102,241,0.1)',
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: regCountdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
{regCountdown > 0 ? `${regCountdown}s` : '获取验证码'}
</Button>
</Space.Compact>
</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" htmlType="submit" loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
</Form.Item>
</Form>
)}
{/* Agreement checkbox */}
<div style={{ marginBottom: 16 }}>
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span style={{ fontSize: 13, color: '#64748b' }}>
我已阅读并同意
<span
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
>《用户协议》</span>
<span
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
>《隐私政策》</span>
</span>
</Checkbox>
</div>
{/* Bottom left: switch between login and register */}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{mode === 'register' ? (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
onClick={() => { setMode('password'); setTab('password'); }}
>
已有账号?去登录
</Typography.Text>
) : (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
onClick={() => { setMode('register'); }}
>
没有账号?立即注册
</Typography.Text>
)}
</div>
</Card>
</div>
</div>
);
};
export default LoginPage;