444 lines
17 KiB
JavaScript
Executable File
444 lines
17 KiB
JavaScript
Executable File
// src\views\Authentication\SignUp/SignUpIllustration.js
|
||
import React, { useState, useEffect, useRef } from "react";
|
||
import {
|
||
Box,
|
||
Button,
|
||
Flex,
|
||
FormControl,
|
||
Input,
|
||
Text,
|
||
Heading,
|
||
VStack,
|
||
HStack,
|
||
useToast,
|
||
InputGroup,
|
||
InputRightElement,
|
||
IconButton,
|
||
Center,
|
||
FormErrorMessage,
|
||
Link as ChakraLink,
|
||
useDisclosure
|
||
} from "@chakra-ui/react";
|
||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||
import { useNavigate } from "react-router-dom";
|
||
import axios from "axios";
|
||
import AuthBackground from '../../../components/Auth/AuthBackground';
|
||
import AuthHeader from '../../../components/Auth/AuthHeader';
|
||
import AuthFooter from '../../../components/Auth/AuthFooter';
|
||
import VerificationCodeInput from '../../../components/Auth/VerificationCodeInput';
|
||
import WechatRegister from '../../../components/Auth/WechatRegister';
|
||
import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
|
||
import UserAgreementModal from '../../../components/UserAgreementModal';
|
||
|
||
const isProduction = process.env.NODE_ENV === 'production';
|
||
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||
|
||
export default function SignUpPage() {
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [countdown, setCountdown] = useState(0);
|
||
const [errors, setErrors] = useState({});
|
||
|
||
const [formData, setFormData] = useState({
|
||
username: "",
|
||
email: "",
|
||
phone: "",
|
||
password: "",
|
||
confirmPassword: "",
|
||
verificationCode: ""
|
||
});
|
||
|
||
const navigate = useNavigate();
|
||
const toast = useToast();
|
||
|
||
// 追踪组件挂载状态,防止内存泄漏
|
||
const isMountedRef = useRef(true);
|
||
|
||
// 隐私政策弹窗状态
|
||
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
|
||
|
||
// 用户协议弹窗状态
|
||
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
|
||
|
||
// 验证码登录状态 是否开启验证码
|
||
const [useVerificationCode, setUseVerificationCode] = useState(false);
|
||
|
||
// 切换注册方式
|
||
const handleChangeMethod = () => {
|
||
setUseVerificationCode(!useVerificationCode);
|
||
// 切换到密码模式时清空验证码
|
||
if (useVerificationCode) {
|
||
setFormData(prev => ({ ...prev, verificationCode: "" }));
|
||
}
|
||
};
|
||
|
||
// 发送验证码
|
||
const sendVerificationCode = async () => {
|
||
const contact = formData.phone;
|
||
const endpoint = "send-sms-code";
|
||
const fieldName = "phone";
|
||
|
||
if (!contact) {
|
||
toast({
|
||
title: "请输入手机号",
|
||
status: "warning",
|
||
duration: 2000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!/^1[3-9]\d{9}$/.test(contact)) {
|
||
toast({
|
||
title: "请输入正确的手机号",
|
||
status: "warning",
|
||
duration: 2000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsLoading(true);
|
||
const response = await axios.post(`${API_BASE_URL}/api/auth/${endpoint}`, {
|
||
[fieldName]: contact
|
||
}, {
|
||
timeout: 10000 // 添加10秒超时
|
||
});
|
||
|
||
// 组件卸载后不再执行后续操作
|
||
if (!isMountedRef.current) return;
|
||
|
||
// ✅ 安全检查:验证 response 和 data 存在
|
||
if (!response || !response.data) {
|
||
throw new Error('服务器响应为空');
|
||
}
|
||
|
||
toast({
|
||
title: "验证码已发送",
|
||
description: "请查收短信",
|
||
status: "success",
|
||
duration: 3000,
|
||
});
|
||
|
||
setCountdown(60);
|
||
} catch (error) {
|
||
if (isMountedRef.current) {
|
||
toast({
|
||
title: "发送失败",
|
||
description: error.response?.data?.error || error.message || "请稍后重试",
|
||
status: "error",
|
||
duration: 3000,
|
||
});
|
||
}
|
||
} finally {
|
||
if (isMountedRef.current) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 倒计时效果
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
|
||
if (countdown > 0) {
|
||
const timer = setTimeout(() => {
|
||
if (isMounted) {
|
||
setCountdown(countdown - 1);
|
||
}
|
||
}, 1000);
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
clearTimeout(timer);
|
||
};
|
||
}
|
||
}, [countdown]);
|
||
|
||
// 表单验证
|
||
const validateForm = () => {
|
||
const newErrors = {};
|
||
|
||
// 手机号验证(两种方式都需要)
|
||
if (!formData.phone) {
|
||
newErrors.phone = "请输入手机号";
|
||
} else if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
|
||
newErrors.phone = "请输入正确的手机号";
|
||
}
|
||
|
||
if (useVerificationCode) {
|
||
// 验证码注册方式:只验证手机号和验证码
|
||
if (!formData.verificationCode) {
|
||
newErrors.verificationCode = "请输入验证码";
|
||
}
|
||
} else {
|
||
// 密码注册方式:验证用户名、密码和确认密码
|
||
if (!formData.password || formData.password.length < 6) {
|
||
newErrors.password = "密码至少6个字符";
|
||
}
|
||
|
||
if (formData.password !== formData.confirmPassword) {
|
||
newErrors.confirmPassword = "两次密码不一致";
|
||
}
|
||
}
|
||
|
||
setErrors(newErrors);
|
||
return Object.keys(newErrors).length === 0;
|
||
};
|
||
|
||
// 处理注册提交
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
|
||
if (!validateForm()) {
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
let endpoint, data;
|
||
|
||
if (useVerificationCode) {
|
||
// 验证码注册:只发送手机号和验证码
|
||
endpoint = "/api/auth/register/phone-code";
|
||
data = {
|
||
phone: formData.phone,
|
||
code: formData.verificationCode
|
||
};
|
||
} else {
|
||
// 密码注册:发送手机号、用户名和密码
|
||
endpoint = "/api/auth/register/phone";
|
||
data = {
|
||
phone: formData.phone,
|
||
username: formData.username,
|
||
password: formData.password
|
||
};
|
||
}
|
||
|
||
const response = await axios.post(`${API_BASE_URL}${endpoint}`, data, {
|
||
timeout: 10000 // 添加10秒超时
|
||
});
|
||
|
||
// 组件卸载后不再执行后续操作
|
||
if (!isMountedRef.current) return;
|
||
|
||
// ✅ 安全检查:验证 response 和 data 存在
|
||
if (!response || !response.data) {
|
||
throw new Error('注册请求失败:服务器响应为空');
|
||
}
|
||
|
||
toast({
|
||
title: "注册成功",
|
||
description: "即将跳转到登录页面",
|
||
status: "success",
|
||
duration: 2000,
|
||
});
|
||
|
||
setTimeout(() => {
|
||
if (isMountedRef.current) {
|
||
navigate("/auth/sign-in");
|
||
}
|
||
}, 2000);
|
||
} catch (error) {
|
||
if (isMountedRef.current) {
|
||
toast({
|
||
title: "注册失败",
|
||
description: error.response?.data?.error || error.message || "请稍后重试",
|
||
status: "error",
|
||
duration: 3000,
|
||
});
|
||
}
|
||
} finally {
|
||
if (isMountedRef.current) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleInputChange = (e) => {
|
||
const { name, value } = e.target;
|
||
setFormData(prev => ({ ...prev, [name]: value }));
|
||
if (errors[name]) {
|
||
setErrors(prev => ({ ...prev, [name]: "" }));
|
||
}
|
||
};
|
||
|
||
// 组件卸载时清理
|
||
useEffect(() => {
|
||
isMountedRef.current = true;
|
||
|
||
return () => {
|
||
isMountedRef.current = false;
|
||
};
|
||
}, []);
|
||
|
||
// 公用的用户名和密码输入框组件
|
||
const commonAuthFields = (
|
||
<VStack spacing={4} width="100%">
|
||
<FormControl isRequired isInvalid={!!errors.password}>
|
||
<InputGroup>
|
||
<Input
|
||
name="password"
|
||
type={showPassword ? "text" : "password"}
|
||
value={formData.password}
|
||
onChange={handleInputChange}
|
||
placeholder="设置密码(至少6个字符)"
|
||
pr="3rem"
|
||
/>
|
||
<InputRightElement width="3rem">
|
||
<IconButton
|
||
size="sm"
|
||
variant="ghost"
|
||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||
onClick={() => setShowPassword(!showPassword)}
|
||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||
/>
|
||
</InputRightElement>
|
||
</InputGroup>
|
||
<FormErrorMessage>{errors.password}</FormErrorMessage>
|
||
</FormControl>
|
||
|
||
<FormControl isRequired isInvalid={!!errors.confirmPassword}>
|
||
<Input
|
||
name="confirmPassword"
|
||
type="password"
|
||
value={formData.confirmPassword}
|
||
onChange={handleInputChange}
|
||
placeholder="再次输入密码"
|
||
/>
|
||
<FormErrorMessage>{errors.confirmPassword}</FormErrorMessage>
|
||
</FormControl>
|
||
</VStack>
|
||
);
|
||
|
||
return (
|
||
<Flex minH="100vh" position="relative" overflow="hidden">
|
||
{/* 背景 */}
|
||
<AuthBackground />
|
||
|
||
{/* 主要内容 */}
|
||
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
|
||
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
|
||
{/* 头部区域 */}
|
||
<AuthHeader title="创建账户" subtitle="加入价值前沿,开启投资新征程" />
|
||
{/* 左右布局 */}
|
||
<HStack spacing={8} align="stretch">
|
||
{/* 左侧:手机号注册 - 80% 宽度 */}
|
||
<Box flex="4">
|
||
<form onSubmit={handleSubmit}>
|
||
<VStack spacing={4}>
|
||
<Heading size="md" color="gray.700" alignSelf="flex-start">
|
||
注册
|
||
</Heading>
|
||
<FormControl isRequired isInvalid={!!errors.phone}>
|
||
<Input
|
||
name="phone"
|
||
value={formData.phone}
|
||
onChange={handleInputChange}
|
||
placeholder="请输入11位手机号"
|
||
pr="2.5rem"
|
||
/>
|
||
<FormErrorMessage>{errors.phone}</FormErrorMessage>
|
||
</FormControl>
|
||
|
||
{/* 表单字段区域 */}
|
||
<Box width="100%">
|
||
{
|
||
useVerificationCode ? (
|
||
<VStack spacing={4} width="100%">
|
||
<VerificationCodeInput
|
||
value={formData.verificationCode}
|
||
onChange={handleInputChange}
|
||
onSendCode={sendVerificationCode}
|
||
countdown={countdown}
|
||
isLoading={isLoading}
|
||
isSending={isLoading && countdown === 0}
|
||
error={errors.verificationCode}
|
||
colorScheme="green"
|
||
/>
|
||
{/* 隐藏的占位元素,保持与密码模式相同的高度 */}
|
||
<Box height="40px" width="100%" visibility="hidden" />
|
||
</VStack>
|
||
) : (
|
||
<>
|
||
{commonAuthFields}
|
||
</>
|
||
)
|
||
}
|
||
</Box>
|
||
|
||
<AuthFooter
|
||
linkText="已有账号?"
|
||
linkLabel="去登录"
|
||
linkTo="/auth/sign-in"
|
||
useVerificationCode={useVerificationCode}
|
||
onSwitchMethod={handleChangeMethod}
|
||
/>
|
||
|
||
<Button
|
||
type="submit"
|
||
width="100%"
|
||
size="lg"
|
||
colorScheme="green"
|
||
color="white"
|
||
borderRadius="lg"
|
||
_hover={{
|
||
transform: "translateY(-2px)",
|
||
boxShadow: "lg"
|
||
}}
|
||
_active={{ transform: "translateY(0)" }}
|
||
isLoading={isLoading}
|
||
loadingText="注册中..."
|
||
fontWeight="bold"
|
||
cursor="pointer"
|
||
>
|
||
注册
|
||
</Button>
|
||
|
||
{/* 协议同意文本 */}
|
||
<Text fontSize="sm" color="gray.600" textAlign="center" width="100%">
|
||
注册登录即表示阅读并同意{" "}
|
||
<ChakraLink
|
||
color="blue.500"
|
||
fontSize="sm"
|
||
onClick={onUserAgreementModalOpen}
|
||
textDecoration="underline"
|
||
_hover={{ color: "blue.600" }}
|
||
cursor="pointer"
|
||
>
|
||
《用户协议》
|
||
</ChakraLink>
|
||
{" "}和{" "}
|
||
<ChakraLink
|
||
color="blue.500"
|
||
fontSize="sm"
|
||
onClick={onPrivacyModalOpen}
|
||
textDecoration="underline"
|
||
_hover={{ color: "blue.600" }}
|
||
cursor="pointer"
|
||
>
|
||
《隐私政策》
|
||
</ChakraLink>
|
||
</Text>
|
||
</VStack>
|
||
</form>
|
||
</Box>
|
||
|
||
{/* 右侧:微信注册 - 20% 宽度 */}
|
||
<Box flex="1">
|
||
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
|
||
<WechatRegister />
|
||
</Center>
|
||
</Box>
|
||
</HStack>
|
||
</Box>
|
||
</Flex>
|
||
|
||
{/* 隐私政策弹窗 */}
|
||
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
|
||
|
||
{/* 用户协议弹窗 */}
|
||
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
|
||
</Flex>
|
||
);
|
||
} |