392 lines
17 KiB
TypeScript
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;
|