803 lines
28 KiB
TypeScript
803 lines
28 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 || '智创',
|
|
siteLogo: info.siteLogo || '',
|
|
};
|
|
}
|
|
} catch { }
|
|
return { siteName: '智创', siteLogo: '' };
|
|
};
|
|
|
|
const initialInfo = getInitialSiteInfo();
|
|
const [siteName, setSiteName] = useState(initialInfo.siteName);
|
|
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
|
|
const [loginBgVideo, setLoginBgVideo] = useState('');
|
|
const [mediaReady, setMediaReady] = useState(false);
|
|
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">
|
|
{/* 背景视频/动图全屏铺满 — 预加载完成后再显示页面 */}
|
|
<BackgroundVideo src={loginBgVideo} onReady={() => setMediaReady(true)} />
|
|
<div className="login-bg-overlay" style={{ opacity: mediaReady ? 1 : 0 }} />
|
|
|
|
{/* 内容区 — 媒体加载完成后淡入 */}
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', opacity: mediaReady ? 1 : 0, transition: 'opacity 0.5s ease', pointerEvents: mediaReady ? 'auto' : 'none' }}>
|
|
|
|
{/* 左上角 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}
|
|
style={{fontSize: 14,fontWeight: 400}}
|
|
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}
|
|
style={{fontSize: 14,fontWeight: 400}}
|
|
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>{/* end media-ready wrapper */}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const BackgroundVideo: React.FC<{ src: string; onReady: () => void }> = ({ src, onReady }) => {
|
|
const [ready, setReady] = useState(false);
|
|
const vidRef = React.useRef<HTMLVideoElement>(null);
|
|
|
|
const handleReady = () => {
|
|
if (ready) return;
|
|
setReady(true);
|
|
vidRef.current?.play().catch(() => {});
|
|
onReady();
|
|
};
|
|
|
|
if (!src) { onReady(); return null; }
|
|
|
|
const isGif = src.toLowerCase().endsWith('.gif');
|
|
const isWebp = src.toLowerCase().endsWith('.webp');
|
|
|
|
if (isGif || isWebp) {
|
|
return <img className="login-bg-video" src={src} alt="" onLoad={handleReady} onError={handleReady} />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* 视频加载前显示占位色,不显示默认背景图 */}
|
|
{!ready && <div className="login-bg-video login-bg-placeholder" />}
|
|
<video
|
|
ref={vidRef}
|
|
className="login-bg-video"
|
|
style={{ opacity: ready ? 1 : 0 }}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
preload="auto"
|
|
onCanPlayThrough={handleReady}
|
|
onCanPlay={handleReady}
|
|
onError={handleReady}
|
|
>
|
|
<source src={src} type={src.endsWith('.webm') ? 'video/webm' : src.endsWith('.mov') ? 'video/quicktime' : 'video/mp4'} />
|
|
</video>
|
|
</>
|
|
);
|
|
};
|
|
|
|
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;
|