登录验证

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 });
}
+311 -207
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,20 +210,48 @@ const AIChatPage: React.FC = () => {
// 获取预估积分 - 根据引擎ID、类型和分辨率计算 // 获取预估积分 - 根据引擎ID、类型和分辨率计算
const getEstimatedCredits = (): number => { const getEstimatedCredits = (): number => {
// 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置 // 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置
const config = creditCalculationData.find((item: any) => let config: any = {};
item.modelConfigId === countType && config = creditCalculationData.find((item: any) =>
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;
}
}
} }
console.log(config); console.log(config);
// 根据配置计算积分 // 根据配置计算积分
if (mediaType === 'video') { if (mediaType === 'video') {
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio // 视频:(秒数 × perSecondCredits + baseCredits) × ratio
@@ -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,18 +374,18 @@ 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 || []);
}) })
.catch((error) => { .catch((error) => {
}); });
calculateCredits().then((data: any) => { calculateCredits().then((data: any) => {
console.log('积分计算', data); // console.log('积分计算', data);
// 保存积分计算数据 // 保存积分计算数据
setCreditCalculationData(data); setCreditCalculationData(data);
}) })
getgen_list(Pagebreak).then((data: any) => { getgen_list(Pagebreak).then((data: any) => {
let mess_list = data.items let mess_list = data.items
let total = data.total let total = data.total
@@ -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) => {
@@ -975,218 +1007,289 @@ const AIChatPage: React.FC = () => {
{(() => { {(() => {
const msgApi = message; const msgApi = message;
return gen_list.map((msg) => ( return gen_list.map((msg) => (
<div <div
key={msg.id} key={msg.id}
style={{ style={{
display: 'flex', display: 'flex',
justifyContent: 'flex-start', // 所有消息左对齐 justifyContent: 'flex-start', // 所有消息左对齐
marginBottom: 16, marginBottom: 16,
}} }}
> >
<div style={{ display: 'flex', gap: 10, maxWidth: '70%' }}> <div style={{ display: 'flex', gap: 10, maxWidth: '70%' }}>
{/* 头像 */} {/* 头像 */}
<div
style={{
width: 36,
height: 36,
borderRadius: 50,
background: '#e0e0e0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<RobotOutlined style={{ color: '#666', fontSize: 16 }} />
</div>
{/* 消息内容 */}
<div>
{/* 消息气泡 */}
<div <div
style={{ style={{
background: '#fff', width: 36,
borderRadius: '16px 16px 16px 4px', height: 36,
padding: '12px 16px', borderRadius: 50,
width: '600px', background: '#e0e0e0',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)', display: 'flex',
position: 'relative', alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}} }}
> >
{/* 删除按钮 - 右上角 */} <RobotOutlined style={{ color: '#666', fontSize: 16 }} />
<div style={{ position: 'absolute', top: 8, right: 8 }}> </div>
<Popconfirm
title="确定要删除吗?" {/* 消息内容 */}
onConfirm={async () => { <div>
await deleteHistory(msg.id); {/* 时间戳和参数信息 */}
msgApi.success('删除成功');
// 直接在本地列表中删除对应数据 <div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
setGen_list(prev => prev.filter(item => item.id !== msg.id)); <span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
setTotalnumber(prev => prev - 1); {/* 引擎标签 */}
}} <span >
okText="确定" {/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
cancelText="取消" {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
style={{
background: '#fff',
borderRadius: '16px 16px 16px 4px',
padding: '12px 16px',
width: '600px',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
position: 'relative',
}}
>
{/* 删除按钮 - 右上角 */}
<div style={{ position: 'absolute', top: 8, right: 8 }}>
<Popconfirm
title="确定要删除吗?"
onConfirm={async () => {
try {
await deleteHistory(msg.id);
msgApi.success('删除成功');
// 直接在本地列表中删除对应数据
setGen_list(prev => prev.filter(item => item.id !== msg.id));
setTotalnumber(prev => prev - 1);
} catch (error: any) {
console.log(error);
const errorMsg = error?.response?.data?.message || error?.message || '删除失败';
msgApi.error(errorMsg);
}
}}
okText="确定"
cancelText="取消"
>
<button
onClick={(e) => e.stopPropagation()}
style={{
width: 28,
height: 28,
borderRadius: 8,
border: 'none',
background: 'rgba(0,0,0,0.05)',
color: '#999',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
padding: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#fff2f0';
e.currentTarget.style.color = '#ff4d4f';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(0,0,0,0.05)';
e.currentTarget.style.color = '#999';
}}
>
<DeleteOutlined style={{ fontSize: 12 }} />
</button>
</Popconfirm>
</div>
{/* 文本内容 */}
<Tooltip
title={msg.originalPrompt}
placement="top"
style={{ maxWidth: '400px' }}
> >
<button <p style={{
onClick={(e) => e.stopPropagation()} margin: '8px 0',
style={{ fontSize: 14,
width: 28, color: '#475569',
height: 28, overflow: 'hidden',
borderRadius: 8, textOverflow: 'ellipsis',
border: 'none', whiteSpace: 'nowrap',
background: 'rgba(0,0,0,0.05)', cursor: 'pointer',
color: '#999', padding: '4px 8px',
cursor: 'pointer', borderRadius: 4,
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f1f5f9'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
{msg.originalPrompt}
</p>
</Tooltip>
{/* 根据 status 显示不同内容 */}
{/* 生成中 - 显示加载动画 */}
{msg.status === 'generating' && (
<div style={{ height: 200, marginBottom: 10, marginTop: 12, borderRadius: 8, overflow: 'hidden', position: 'relative', backgroundColor: '#f1f5f9' }}>
<div style={{
position: 'absolute',
inset: 0,
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
transition: 'all 0.2s ease', gap: 16
padding: 0, }}>
}} <div style={{
onMouseEnter={(e) => { width: 48,
e.currentTarget.style.background = '#fff2f0'; height: 48,
e.currentTarget.style.color = '#ff4d4f'; border: '3px solid #e2e8f0',
}} borderTopColor: '#6366f1',
onMouseLeave={(e) => { borderRadius: '50%',
e.currentTarget.style.background = 'rgba(0,0,0,0.05)'; animation: 'spin 1s linear infinite'
e.currentTarget.style.color = '#999'; }} />
}} <span style={{ fontSize: 14, color: '#64748b' }}>...</span>
> </div>
<DeleteOutlined style={{ fontSize: 12 }} /> <div style={{
</button> position: 'absolute',
</Popconfirm> inset: 0,
</div> background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent)',
animation: 'shimmer 2s infinite'
{/* 文本内容 */} }} />
<p style={{ margin: 0, fontSize: 14, color: '#333' }}> </div>
{msg.originalPrompt} )}
{/* 生成失败 - 显示失败提示 */}
</p> {msg.status === 'failed' && (
<div style={{
height: 200,
{/* 根据 status 显示不同内容 */} marginBottom: 10,
{/* 生成中 - 显示加载动画 */} marginTop: 12,
{msg.status === 'generating' && ( borderRadius: 8,
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 16, marginTop: 12 }}> overflow: 'hidden',
<div style={{ background: '#fff', borderRadius: '16px 16px 16px 4px', padding: '12px 16px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}> position: 'relative',
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> backgroundColor: '#fafafa',
<span style={{ color: '#6366f1', fontSize: 14, fontWeight: 500 }}></span> border: '1px dashed #e2e8f0',
<div style={{ display: 'flex', gap: 4 }}> }}>
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite' }} /> <div style={{
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite 0.2s' }} /> position: 'absolute',
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite 0.4s' }} /> 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> </div>
<span style={{ fontSize: 14, color: '#94a3b8' }}></span>
{/* <span style={{ fontSize: 12, color: '#cbd5e1' }}>请重试</span> */}
</div> </div>
</div> </div>
</div> )}
)} {/* 已完成 - 显示媒体内容 */}
{/* 生成失败 - 显示失败提示 */} {msg.status === 'completed' && (
{msg.status === 'failed' && ( <div style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10, marginTop: 12, width: '100%', }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, marginTop: 12, padding: '12px', background: '#fff2f0', borderRadius: 8 }}> <div
<WarningOutlined style={{ color: '#ff4d4f', fontSize: 16 }} /> onClick={() => {
<span style={{ fontSize: 14, color: '#ff4d4f' }}></span> setPreviewUrl(msg.genType === 'image' ? msg.imageUrl : msg.videoUrl);
</div> setPreviewType(msg.genType === 'image' ? 'image' : 'video');
)} setPreviewVisible(true);
{/* 已完成 - 显示媒体内容 */} }}
{msg.status === 'completed' && ( style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative', height: 200 }}
<div style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10, marginTop: 12, width: '100%', }}> >
<div {msg.genType === 'image' ? (
onClick={() => {
setPreviewUrl(msg.genType === 'image' ? msg.imageUrl : msg.videoUrl);
setPreviewType(msg.genType === 'image' ? 'image' : 'video');
setPreviewVisible(true);
}}
style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative', height: 200 }}
>
{msg.genType === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.imageUrl}&w=300&p=50`}
alt={msg.name}
style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'contain', backgroundColor: '#f1f5f9', transition: 'transform 0.2s' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
/>
) : (
<>
<img <img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.videoCoverUrl}&w=300&p=50`} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.imageUrl}&w=300&p=50`}
alt={msg.name} alt={msg.name}
style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'contain', backgroundColor: '#f1f5f9', transition: 'transform 0.2s' }} style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'contain', backgroundColor: '#f1f5f9', transition: 'transform 0.2s' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
/> />
{/* 视频播放按钮 */} ) : (
<div style={{ <>
position: 'absolute', <img
top: '50%', src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.videoCoverUrl}&w=300&p=50`}
left: '50%', alt={msg.name}
transform: 'translate(-50%, -50%)', style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'contain', backgroundColor: '#f1f5f9', transition: 'transform 0.2s' }}
width: 56, onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
height: 56, onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
background: 'rgba(0,0,0,0.6)', />
borderRadius: '50%', {/* 视频播放按钮 */}
display: 'flex', <div style={{
alignItems: 'center', position: 'absolute',
justifyContent: 'center', top: '50%',
pointerEvents: 'none', left: '50%',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)', transform: 'translate(-50%, -50%)',
}}> width: 56,
<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff"> height: 56,
<path d="M8 5v14l11-7z" /> background: 'rgba(0,0,0,0.6)',
</svg> borderRadius: '50%',
</div> display: 'flex',
</> alignItems: 'center',
)} justifyContent: 'center',
pointerEvents: 'none',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</>
)}
</div>
</div> </div>
</div> )}
)}
</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> ))
))})()} })()}
{/* 消息列表底部标记 - 用于自动滚动 */} {/* 消息列表底部标记 - 用于自动滚动 */}
@@ -1464,10 +1567,10 @@ const AIChatPage: React.FC = () => {
<Select <Select
value={mediaType} value={mediaType}
onChange={(val) => setMediaType(val)} onChange={(val) => setMediaType(val)}
style={{ style={{
width: 100, width: 100,
outline: 'none', outline: 'none',
background: '#f1f5f9', background: '#f1f5f9',
border: 'none', border: 'none',
borderRadius: 8, borderRadius: 8,
height: 34, height: 34,
@@ -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,40 +90,116 @@ 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 {
message.error('发送失败'); message.error('发送失败');
} }
}; };
// 滑动验证成功后的回调
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 }}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
/>
</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={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 }}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
/>
</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={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;