登录验证

This commit is contained in:
孙佳艺
2026-06-08 09:13:29 +08:00
parent b1c49cd2d9
commit 41b597a785
3 changed files with 640 additions and 230 deletions
+15 -2
View File
@@ -20,6 +20,12 @@ export async function login(username: string, password: string, captchaToken?: s
setToken(res.accessToken); setToken(res.accessToken);
return res.user; return res.user;
} }
export async function phonelogin(phone: string, code: string): Promise<User> {
// if (USE_MOCK) return mock.mockLogin({ username, password });
const res = await api.post<{ accessToken: string; user: User }>('/auth/sms-login', { phone, code }, false);
setToken(res.accessToken);
return res.user;
}
export async function register(phone: string, code: string, password: string): Promise<User> { export async function register(phone: string, code: string, password: string): Promise<User> {
const res = await api.post<{ accessToken: string; user: User }>('/auth/register', { phone, code, password }, false); const res = await api.post<{ accessToken: string; user: User }>('/auth/register', { phone, code, password }, false);
@@ -211,9 +217,9 @@ export async function getParameters(): Promise<any[]> {
// ── SMS ─────────────────────────────────────────────────── // ── SMS ───────────────────────────────────────────────────
export async function sendSms(phone: string, captchaToken?: string): Promise<void> { export async function sendSms(phone: string, scene: string): Promise<void> {
if (USE_MOCK) return; if (USE_MOCK) return;
await api.post('/sms/send', { phone, captcha_token: captchaToken }, false); await api.post('/sms/send', { phone, scene: scene }, false);
} }
export async function verifySms(phone: string, code: string): Promise<{ token: string }> { export async function verifySms(phone: string, code: string): Promise<{ token: string }> {
@@ -351,3 +357,10 @@ export async function deleteHistory(id: string): Promise<void> {
export async function calculateCredits(): Promise<any[]> { export async function calculateCredits(): Promise<any[]> {
return api.get('/credits/credit-ratios'); return api.get('/credits/credit-ratios');
} }
// 获取验证码
export async function getSendcode(phone: string): Promise<any> {
return api.post('/sms/send', { phone });
}
+164 -60
View File
@@ -17,7 +17,7 @@ import {
import { import {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage, getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
uploadVideo, getCreditRatios, deleteHistory,calculateCredits uploadVideo, getCreditRatios, deleteHistory, calculateCredits
} from '../api'; } from '../api';
import { import {
@@ -119,6 +119,7 @@ const AIChatPage: React.FC = () => {
const [previewVisible, setPreviewVisible] = useState<boolean>(false); const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>(''); const [previewUrl, setPreviewUrl] = useState<string>('');
const [previewType, setPreviewType] = useState<'image' | 'video'>('image'); const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = useRef<HTMLVideoElement>(null);
// 从URL中提取exp时间戳(支持相对路径和完整URL) // 从URL中提取exp时间戳(支持相对路径和完整URL)
const extractExpTimestamp = (url: string): number | null => { const extractExpTimestamp = (url: string): number | null => {
@@ -209,14 +210,42 @@ const AIChatPage: React.FC = () => {
// 获取预估积分 - 根据引擎ID、类型和分辨率计算 // 获取预估积分 - 根据引擎ID、类型和分辨率计算
const getEstimatedCredits = (): number => { const getEstimatedCredits = (): number => {
// 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置 // 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置
const config = creditCalculationData.find((item: any) => let config: any = {};
config = creditCalculationData.find((item: any) =>
item.modelConfigId === countType && item.modelConfigId === countType &&
item.genType === mediaType && item.genType === mediaType &&
item.resolution === (mediaType === 'video' ? videoResolution : selectedResolution) item.resolution === (mediaType === 'video' ? videoResolution : selectedResolution)
); );
if (!config) { if (!config) {
return 0; if (mediaType === 'video') {
// 如果没有找到对应的配置,则使用默认配置
config = {
perSecondCredits: 2,
baseCredits: 60,
ratio: 1,
};
if (videoResolution === '1080p') {
config.ratio = 2;
} else if (videoResolution === '720p') {
config.ratio = 1.5;
} else if (videoResolution === '480p') {
config.ratio = 1;
}
}
else {
// 如果没有找到对应的配置,则使用默认配置
config = {
perSecondCredits: 0.1,
baseCredits: 4,
ratio: 1,
};
if (selectedResolution === '2K') {
config.ratio = 1;
} else if (selectedResolution === '4K') {
config.ratio = 2;
}
}
} }
@@ -315,7 +344,7 @@ const AIChatPage: React.FC = () => {
useEffect(() => { useEffect(() => {
getEngine() getEngine()
.then((data: any) => { .then((data: any) => {
console.log('引擎', data); // console.log('引擎', data);
setEnginesele(data.engine); setEnginesele(data.engine);
@@ -345,7 +374,7 @@ const AIChatPage: React.FC = () => {
}); });
getCreditRatios() getCreditRatios()
.then((data: any) => { .then((data: any) => {
console.log('积分', data); // console.log('积分', data);
setCreditRatios(data.video || []); setCreditRatios(data.video || []);
setCimage(data.image || []); setCimage(data.image || []);
@@ -353,7 +382,7 @@ const AIChatPage: React.FC = () => {
.catch((error) => { .catch((error) => {
}); });
calculateCredits().then((data: any) => { calculateCredits().then((data: any) => {
console.log('积分计算', data); // console.log('积分计算', data);
// 保存积分计算数据 // 保存积分计算数据
setCreditCalculationData(data); setCreditCalculationData(data);
}) })
@@ -775,6 +804,9 @@ const AIChatPage: React.FC = () => {
const handleClosePreview = () => { const handleClosePreview = () => {
setPreviewVisible(false); setPreviewVisible(false);
setPreviewUrl(''); setPreviewUrl('');
if (videoRef.current) {
videoRef.current.pause();
}
}; };
const handleDownload = (e: React.MouseEvent) => { const handleDownload = (e: React.MouseEvent) => {
@@ -1002,7 +1034,44 @@ const AIChatPage: React.FC = () => {
{/* 消息内容 */} {/* 消息内容 */}
<div> <div>
{/* 时间戳和参数信息 */}
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
{/* 引擎标签 */}
<span >
{/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
{msg.engineSnapshot.name}
</span>
{/* 参数标签 */}
<span >
{/* <LayoutGridOutlined style={{ fontSize: 12 }} /> */}
{msg.genType === 'image'
? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}`
: `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`
}
</span>
<span style={{ marginLeft: 8 }}>{msg.creditsCost}</span>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span
style={{ marginLeft: 20, color: '#6366f1', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setAttachmentPopupPosition({
x: rect.left,
y: rect.top - 10
});
setAttachmentPopupMessageId(msg.id);
setAttachmentPopupVisible(true);
}}
>
</span>
)}
</div>
{/* 消息气泡 */} {/* 消息气泡 */}
<div <div
style={{ style={{
@@ -1019,11 +1088,18 @@ const AIChatPage: React.FC = () => {
<Popconfirm <Popconfirm
title="确定要删除吗?" title="确定要删除吗?"
onConfirm={async () => { onConfirm={async () => {
try {
await deleteHistory(msg.id); await deleteHistory(msg.id);
msgApi.success('删除成功'); msgApi.success('删除成功');
// 直接在本地列表中删除对应数据 // 直接在本地列表中删除对应数据
setGen_list(prev => prev.filter(item => item.id !== msg.id)); setGen_list(prev => prev.filter(item => item.id !== msg.id));
setTotalnumber(prev => prev - 1); setTotalnumber(prev => prev - 1);
} catch (error: any) {
console.log(error);
const errorMsg = error?.response?.data?.message || error?.message || '删除失败';
msgApi.error(errorMsg);
}
}} }}
okText="确定" okText="确定"
cancelText="取消" cancelText="取消"
@@ -1059,33 +1135,96 @@ const AIChatPage: React.FC = () => {
</div> </div>
{/* 文本内容 */} {/* 文本内容 */}
<p style={{ margin: 0, fontSize: 14, color: '#333' }}> <Tooltip
title={msg.originalPrompt}
placement="top"
style={{ maxWidth: '400px' }}
>
<p style={{
margin: '8px 0',
fontSize: 14,
color: '#475569',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 4,
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f1f5f9'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
{msg.originalPrompt} {msg.originalPrompt}
</p> </p>
</Tooltip>
{/* 根据 status 显示不同内容 */} {/* 根据 status 显示不同内容 */}
{/* 生成中 - 显示加载动画 */} {/* 生成中 - 显示加载动画 */}
{msg.status === 'generating' && ( {msg.status === 'generating' && (
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 16, marginTop: 12 }}> <div style={{ height: 200, marginBottom: 10, marginTop: 12, borderRadius: 8, overflow: 'hidden', position: 'relative', backgroundColor: '#f1f5f9' }}>
<div style={{ background: '#fff', borderRadius: '16px 16px 16px 4px', padding: '12px 16px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}> <div style={{
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> position: 'absolute',
<span style={{ color: '#6366f1', fontSize: 14, fontWeight: 500 }}></span> inset: 0,
<div style={{ display: 'flex', gap: 4 }}> display: 'flex',
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite' }} /> flexDirection: 'column',
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite 0.2s' }} /> alignItems: 'center',
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite 0.4s' }} /> justifyContent: 'center',
</div> gap: 16
</div> }}>
<div style={{
width: 48,
height: 48,
border: '3px solid #e2e8f0',
borderTopColor: '#6366f1',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<span style={{ fontSize: 14, color: '#64748b' }}>...</span>
</div> </div>
<div style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent)',
animation: 'shimmer 2s infinite'
}} />
</div> </div>
)} )}
{/* 生成失败 - 显示失败提示 */} {/* 生成失败 - 显示失败提示 */}
{msg.status === 'failed' && ( {msg.status === 'failed' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, marginTop: 12, padding: '12px', background: '#fff2f0', borderRadius: 8 }}> <div style={{
<WarningOutlined style={{ color: '#ff4d4f', fontSize: 16 }} /> height: 200,
<span style={{ fontSize: 14, color: '#ff4d4f' }}></span> marginBottom: 10,
marginTop: 12,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
backgroundColor: '#fafafa',
border: '1px dashed #e2e8f0',
}}>
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12
}}>
<div style={{
width: 48,
height: 48,
borderRadius: '50%',
background: '#fff2f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<WarningOutlined style={{ color: '#ff4d4f', fontSize: 22 }} />
</div>
<span style={{ fontSize: 14, color: '#94a3b8' }}></span>
{/* <span style={{ fontSize: 12, color: '#cbd5e1' }}>请重试</span> */}
</div>
</div> </div>
)} )}
{/* 已完成 - 显示媒体内容 */} {/* 已完成 - 显示媒体内容 */}
@@ -1143,50 +1282,14 @@ const AIChatPage: React.FC = () => {
)} )}
</div> </div>
{/* 时间戳和参数信息 */}
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
{/* 引擎标签 */}
<span >
{/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
{msg.engineSnapshot.name}
</span>
{/* 参数标签 */}
<span >
{/* <LayoutGridOutlined style={{ fontSize: 12 }} /> */}
{msg.genType === 'image'
? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}`
: `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`
}
</span>
<span style={{ marginLeft: 8 }}>{msg.creditsCost}</span>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span
style={{ marginLeft: 20, color: '#6366f1', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setAttachmentPopupPosition({
x: rect.left,
y: rect.top - 10
});
setAttachmentPopupMessageId(msg.id);
setAttachmentPopupVisible(true);
}}
>
</span>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
))})()} ))
})()}
{/* 消息列表底部标记 - 用于自动滚动 */} {/* 消息列表底部标记 - 用于自动滚动 */}
@@ -2222,6 +2325,7 @@ const AIChatPage: React.FC = () => {
/> />
) : ( ) : (
<video <video
ref={videoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
controls controls
style={{ maxWidth: '100%', maxHeight: '400px' }} style={{ maxWidth: '100%', maxHeight: '400px' }}
+314 -21
View File
@@ -7,17 +7,21 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore'; import { useAuthStore } from '../store/useAuthStore';
import { sendSms, getSiteInfo, register as registerApi } from '../api'; import { sendSms,phonelogin, getSiteInfo, register } from '../api';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { checkAuth } = useAuthStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password'); const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password');
const [tab, setTab] = useState<'password' | 'phone'>('password'); const [tab, setTab] = useState<'password' | 'phone'>('password');
const [countdown, setCountdown] = useState(0); const [countdown, setCountdown] = useState(0);
const [regCountdown, setRegCountdown] = useState(0); const [regCountdown, setRegCountdown] = useState(0);
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [showSliderVerify, setShowSliderVerify] = useState(false);
const [sliderVerified, setSliderVerified] = useState(false);
const [loginSliderVerified, setLoginSliderVerified] = useState(false);
const [siteName, setSiteName] = useState('VideoGen.AI'); const [siteName, setSiteName] = useState('VideoGen.AI');
const [siteLogo, setSiteLogo] = useState(''); const [siteLogo, setSiteLogo] = useState('');
const [agreementUrl, setAgreementUrl] = useState(''); const [agreementUrl, setAgreementUrl] = useState('');
@@ -52,6 +56,7 @@ const LoginPage: React.FC = () => {
setLoading(true); setLoading(true);
await login(values.phone, values.password, undefined, values.rememberMe); await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来'); message.success('登录成功,欢迎回来');
await checkAuth();
navigate('/projects'); navigate('/projects');
} catch { } catch {
message.error('登录失败'); message.error('登录失败');
@@ -69,8 +74,9 @@ const LoginPage: React.FC = () => {
return; return;
} }
setLoading(true); setLoading(true);
await login(values.phone, values.code); await phonelogin(values.phone, values.code);
message.success('登录成功,欢迎回来'); message.success('登录成功,欢迎回来');
await checkAuth();
navigate('/projects'); navigate('/projects');
} catch { } catch {
message.error('登录失败'); message.error('登录失败');
@@ -84,30 +90,68 @@ const LoginPage: React.FC = () => {
try { try {
const values = await regForm.validateFields(); const values = await regForm.validateFields();
setLoading(true); setLoading(true);
await registerApi(values.phone, values.regCode, values.password); const user = await register(values.phone, values.regCode, values.password);
message.success('注册成功'); message.success('注册成功');
await checkAuth();
navigate('/projects'); navigate('/projects');
} catch { } catch (error: any) {
message.error('注册失败'); const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败';
message.error(errorMsg);
} finally { } finally {
setLoading(false); // setLoading(false);
} }
}; };
const startCountdown = (setter: React.Dispatch<React.SetStateAction<number>>) => { const startCountdown = (setter: React.Dispatch<React.SetStateAction<number>>) => {
setter(60); setter(60);
const timer = setInterval(() => { const timer = setInterval(() => {
setter((c) => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }); setter((c) => {
if (c <= 1) {
clearInterval(timer);
setShowSliderVerify(false);
return 0;
}
return c - 1;
});
}, 1000); }, 1000);
}; };
// 登录模式倒计时结束后重置验证状态
useEffect(() => {
if (countdown === 0 && mode === 'phone') {
setLoginSliderVerified(false);
}
}, [countdown, mode]);
const handleSendCode = async (phone: string, isReg?: boolean) => { const handleSendCode = async (phone: string, isReg?: boolean) => {
try { try {
if (!phone || !/^1\d{10}$/.test(phone)) { if (!phone || !/^1\d{10}$/.test(phone)) {
message.error('请输入正确的手机号'); message.error('请输入正确的手机号');
return; return;
} }
await sendSms(phone);
// 注册时需要滑动验证
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); startCountdown(isReg ? setRegCountdown : setCountdown);
message.success('验证码已发送'); message.success('验证码已发送');
} catch { } catch {
@@ -115,9 +159,47 @@ const LoginPage: React.FC = () => {
} }
}; };
// 滑动验证成功后的回调
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);
} else {
setLoginSliderVerified(true);
startCountdown(setCountdown);
}
message.success('验证码已发送');
} catch {
message.error('发送失败');
// 发送失败,重置滑动验证组件
if (mode === 'register') {
setSliderVerified(false);
} else {
setLoginSliderVerified(false);
}
}
};
const switchTab = (t: 'password' | 'phone') => { const switchTab = (t: 'password' | 'phone') => {
setTab(t); setTab(t);
setMode(t); setMode(t);
setCountdown(0);
setLoginSliderVerified(false);
setShowSliderVerify(false);
}; };
const features = [ const features = [
@@ -256,7 +338,7 @@ const LoginPage: React.FC = () => {
{/* Password Login */} {/* Password Login */}
{mode === 'password' && ( {mode === 'password' && (
<Form form={pwdForm} onFinish={handlePasswordLogin} size="large" layout="vertical"> <Form form={pwdForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}> <Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} /> <Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} />
</Form.Item> </Form.Item>
@@ -267,7 +349,7 @@ const LoginPage: React.FC = () => {
<Checkbox></Checkbox> <Checkbox></Checkbox>
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 12 }}> <Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ <Button type="primary" onClick={handlePasswordLogin} loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600, height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)', border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
@@ -278,7 +360,7 @@ const LoginPage: React.FC = () => {
{/* Phone Login */} {/* Phone Login */}
{mode === 'phone' && ( {mode === 'phone' && (
<Form form={phoneForm} onFinish={handlePhoneLogin} size="large" layout="vertical"> <Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}> <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} /> <Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item> </Form.Item>
@@ -287,20 +369,36 @@ const LoginPage: React.FC = () => {
<Input prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入验证码" maxLength={6} <Input prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入验证码" maxLength={6}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} /> style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0} <Button disabled={countdown > 0}
onClick={() => handleSendCode(phoneForm.getFieldValue('phone'))} onClick={() => {
if (loginSliderVerified) {
handleSliderSuccess(true);
} else {
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
style={{ style={{
height: 48, borderRadius: '0 10px 10px 0', height: 48, borderRadius: '0 10px 10px 0',
background: countdown > 0 ? '#f1f5f9' : 'rgba(99,102,241,0.1)', background: countdown > 0 || loginSliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)',
border: '1.5px solid #e2e8f0', borderLeft: 'none', border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: countdown > 0 ? '#94a3b8' : '#6366f1', color: countdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100, fontWeight: 600, minWidth: 100,
}}> }}>
{countdown > 0 ? `${countdown}s` : '获取验证码'} {countdown > 0 ? `${countdown}s` : (loginSliderVerified ? '重新发送' : '获取验证码')}
</Button> </Button>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
{/* 滑动验证 - 获取验证码后显示 */}
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }}> <Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ <SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600, height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)', border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
@@ -311,7 +409,7 @@ const LoginPage: React.FC = () => {
{/* Register */} {/* Register */}
{mode === 'register' && ( {mode === 'register' && (
<Form form={regForm} onFinish={handleRegister} size="large" layout="vertical"> <Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}> <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} /> <Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item> </Form.Item>
@@ -320,24 +418,40 @@ const LoginPage: React.FC = () => {
<Input prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入验证码" maxLength={6} <Input prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入验证码" maxLength={6}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} /> style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0} <Button disabled={regCountdown > 0}
onClick={() => handleSendCode(regForm.getFieldValue('phone'), true)} onClick={() => {
if (sliderVerified) {
handleSliderSuccess(true);
} else {
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
style={{ style={{
height: 48, borderRadius: '0 10px 10px 0', height: 48, borderRadius: '0 10px 10px 0',
background: regCountdown > 0 ? '#f1f5f9' : 'rgba(99,102,241,0.1)', background: regCountdown > 0 || sliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)',
border: '1.5px solid #e2e8f0', borderLeft: 'none', border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: regCountdown > 0 ? '#94a3b8' : '#6366f1', color: regCountdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100, fontWeight: 600, minWidth: 100,
}}> }}>
{regCountdown > 0 ? `${regCountdown}s` : '获取验证码'} {regCountdown > 0 ? `${regCountdown}s` : (sliderVerified ? '重新发送' : '获取验证码')}
</Button> </Button>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请设置密码' }, { min: 6, message: '密码至少6位' }]}> <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} /> <Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item> </Form.Item>
{/* 滑动验证 - 获取验证码后显示 */}
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }}> <Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ <SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handleRegister} loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600, height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)', border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
@@ -388,4 +502,183 @@ const LoginPage: React.FC = () => {
); );
}; };
// 滑动验证组件
const SliderVerify: React.FC<{
onSuccess: () => void;
isVerified: boolean;
}> = ({ onSuccess, isVerified }) => {
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 = 360; // 容器宽度
const sliderWidth = 60; // 滑块宽度
const maxPosition = containerWidth - sliderWidth;
const updatePosition = (x: number) => {
// 如果已经成功,不再处理
if (successRef.current || isVerified) return;
const newPosition = Math.max(0, Math.min(x, maxPosition));
positionRef.current = newPosition;
// 直接操作DOM,避免React状态更新的开销
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);
};
return (
<div
ref={containerRef}
onMouseDown={handleMouseDown}
style={{
width: containerWidth,
height: 44,
background: '#f8fafc',
borderRadius: 8,
position: 'relative',
overflow: 'hidden',
border: '1px solid #e2e8f0',
cursor: isVerified ? 'default' : 'pointer',
userSelect: 'none',
touchAction: 'none',
}}
>
{/* 已滑动部分背景 */}
<div
ref={trackRef}
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: isVerified ? containerWidth : sliderWidth,
background: isVerified ? '#dcfce7' : '#e0e7ff',
}}
/>
{/* 文字提示 */}
<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',
}}
>
{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" 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: 4,
width: sliderWidth - 8,
height: 36,
background: isVerified ? '#22c55e' : '#fff',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
cursor: isVerified ? 'default' : 'grab',
userSelect: 'none',
zIndex: 2,
}}
>
{isVerified ? (
<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>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8L22 12L18 16"></path>
<path d="M2 12h20"></path>
</svg>
)}
</div>
</div>
);
};
export default LoginPage; export default LoginPage;