597 lines
23 KiB
JavaScript
597 lines
23 KiB
JavaScript
// src/components/Auth/AuthFormContent.js
|
||
// 统一的认证表单组件
|
||
import React, { useState, useEffect, useRef } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import {
|
||
Box,
|
||
Button,
|
||
FormControl,
|
||
Input,
|
||
Heading,
|
||
VStack,
|
||
HStack,
|
||
Stack,
|
||
useToast,
|
||
Icon,
|
||
FormErrorMessage,
|
||
Center,
|
||
AlertDialog,
|
||
AlertDialogBody,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogContent,
|
||
AlertDialogOverlay,
|
||
Text,
|
||
Link as ChakraLink,
|
||
useBreakpointValue,
|
||
Divider,
|
||
IconButton,
|
||
} from "@chakra-ui/react";
|
||
import { FaLock, FaWeixin } from "react-icons/fa";
|
||
import { useAuth } from "../../contexts/AuthContext";
|
||
import { useAuthModal } from "../../hooks/useAuthModal";
|
||
import { useNotification } from "../../contexts/NotificationContext";
|
||
import { authService } from "../../services/authService";
|
||
import AuthHeader from './AuthHeader';
|
||
import VerificationCodeInput from './VerificationCodeInput';
|
||
import WechatRegister from './WechatRegister';
|
||
import { setCurrentUser } from '../../mocks/data/users';
|
||
import { logger } from '../../utils/logger';
|
||
import { useAuthEvents } from '../../hooks/useAuthEvents';
|
||
|
||
// 统一配置对象
|
||
const AUTH_CONFIG = {
|
||
// UI文本
|
||
title: "价值前沿",
|
||
subtitle: "开启您的投资之旅",
|
||
formTitle: "登陆/注册",
|
||
buttonText: "登录/注册",
|
||
loadingText: "验证中...",
|
||
successTitle: "验证成功",
|
||
successDescription: "欢迎!",
|
||
errorTitle: "验证失败",
|
||
|
||
// API配置
|
||
api: {
|
||
endpoint: '/api/auth/register/phone',
|
||
purpose: 'login', // ⚡ 统一使用 'login' 模式
|
||
},
|
||
|
||
// 功能开关
|
||
features: {
|
||
successDelay: 1000, // 延迟1秒显示成功提示
|
||
}
|
||
};
|
||
|
||
export default function AuthFormContent() {
|
||
const toast = useToast();
|
||
const navigate = useNavigate();
|
||
const { checkSession } = useAuth();
|
||
const { handleLoginSuccess } = useAuthModal();
|
||
const { showWelcomeGuide } = useNotification();
|
||
|
||
// 使用统一配置
|
||
const config = AUTH_CONFIG;
|
||
|
||
// 追踪组件挂载状态,防止内存泄漏
|
||
const isMountedRef = useRef(true);
|
||
const cancelRef = useRef(); // AlertDialog 需要的 ref
|
||
|
||
// 页面状态
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [errors, setErrors] = useState({});
|
||
|
||
// 昵称设置引导对话框
|
||
const [showNicknamePrompt, setShowNicknamePrompt] = useState(false);
|
||
const [currentPhone, setCurrentPhone] = useState("");
|
||
|
||
// 响应式断点
|
||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||
|
||
// 事件追踪
|
||
const authEvents = useAuthEvents({
|
||
component: 'AuthFormContent',
|
||
isMobile,
|
||
});
|
||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||
|
||
// 表单数据
|
||
const [formData, setFormData] = useState({
|
||
phone: "",
|
||
verificationCode: "",
|
||
});
|
||
|
||
// 验证码状态
|
||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||
const [sendingCode, setSendingCode] = useState(false);
|
||
const [countdown, setCountdown] = useState(0);
|
||
|
||
// 输入框变化处理
|
||
const handleInputChange = (e) => {
|
||
const { name, value } = e.target;
|
||
setFormData(prev => ({
|
||
...prev,
|
||
[name]: value
|
||
}));
|
||
|
||
// 追踪用户开始填写手机号 (判断用户选择了手机登录方式)
|
||
if (name === 'phone' && value.length === 1 && !formData.phone) {
|
||
authEvents.trackPhoneLoginInitiated(value);
|
||
}
|
||
|
||
// 追踪验证码输入变化
|
||
if (name === 'verificationCode') {
|
||
authEvents.trackVerificationCodeInputChanged(value.length);
|
||
}
|
||
};
|
||
|
||
// 倒计时逻辑
|
||
useEffect(() => {
|
||
let timer;
|
||
let isMounted = true;
|
||
|
||
if (countdown > 0) {
|
||
timer = setInterval(() => {
|
||
if (isMounted) {
|
||
setCountdown(prev => prev - 1);
|
||
}
|
||
}, 1000);
|
||
} else if (countdown === 0 && isMounted) {
|
||
setVerificationCodeSent(false);
|
||
}
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
if (timer) clearInterval(timer);
|
||
};
|
||
}, [countdown]);
|
||
|
||
// 发送验证码
|
||
const sendVerificationCode = async () => {
|
||
const credential = formData.phone;
|
||
|
||
if (!credential) {
|
||
toast({
|
||
title: "请先输入手机号",
|
||
status: "warning",
|
||
duration: 3000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 清理手机号格式字符(空格、横线、括号等)
|
||
const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, '');
|
||
|
||
if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) {
|
||
authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format');
|
||
authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号');
|
||
toast({
|
||
title: "请输入有效的手机号",
|
||
status: "warning",
|
||
duration: 3000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 追踪手机号验证通过
|
||
authEvents.trackPhoneNumberValidated(credential, true);
|
||
|
||
try {
|
||
setSendingCode(true);
|
||
|
||
const requestData = {
|
||
credential: cleanedCredential, // 使用清理后的手机号
|
||
type: 'phone',
|
||
purpose: config.api.purpose
|
||
};
|
||
|
||
const response = await fetch('/api/auth/send-verification-code', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify(requestData),
|
||
});
|
||
|
||
if (!response) {
|
||
throw new Error('网络请求失败,请检查网络连接');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (!isMountedRef.current) return;
|
||
|
||
if (!data) {
|
||
throw new Error('服务器响应为空');
|
||
}
|
||
|
||
if (response.ok && data.success) {
|
||
// 追踪验证码发送成功 (或重发)
|
||
const isResend = verificationCodeSent;
|
||
if (isResend) {
|
||
authEvents.trackVerificationCodeResent(credential, countdown > 0 ? 2 : 1);
|
||
} else {
|
||
authEvents.trackVerificationCodeSent(credential, config.api.purpose);
|
||
}
|
||
|
||
// ✅ 开发环境下在控制台显示验证码
|
||
if (data.dev_code) {
|
||
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||
}
|
||
|
||
setVerificationCodeSent(true);
|
||
setCountdown(60);
|
||
} else {
|
||
throw new Error(data.error || '发送验证码失败');
|
||
}
|
||
} catch (error) {
|
||
// 追踪验证码发送失败
|
||
authEvents.trackVerificationCodeSendFailed(credential, error);
|
||
authEvents.trackError('api', error.message || '发送验证码失败', {
|
||
endpoint: '/api/auth/send-verification-code',
|
||
phone_masked: credential.substring(0, 3) + '****' + credential.substring(7)
|
||
});
|
||
|
||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7)
|
||
});
|
||
|
||
// ✅ 显示错误提示给用户
|
||
toast({
|
||
id: 'send-code-error',
|
||
title: "发送验证码失败",
|
||
description: error.message || "请稍后重试",
|
||
status: "error",
|
||
duration: 3000,
|
||
isClosable: true,
|
||
position: 'top',
|
||
containerStyle: {
|
||
zIndex: 10000,
|
||
}
|
||
});
|
||
} finally {
|
||
if (isMountedRef.current) {
|
||
setSendingCode(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 提交处理(登录或注册)
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
const { phone, verificationCode } = formData;
|
||
|
||
// 表单验证
|
||
if (!phone || !verificationCode) {
|
||
toast({
|
||
title: "请填写完整信息",
|
||
description: "手机号和验证码不能为空",
|
||
status: "warning",
|
||
duration: 3000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 清理手机号格式字符(空格、横线、括号等)
|
||
const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, '');
|
||
|
||
if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) {
|
||
toast({
|
||
title: "请输入有效的手机号",
|
||
status: "warning",
|
||
duration: 3000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 追踪验证码提交
|
||
authEvents.trackVerificationCodeSubmitted(phone);
|
||
|
||
// 构建请求体
|
||
const requestBody = {
|
||
credential: cleanedPhone, // 使用清理后的手机号
|
||
verification_code: verificationCode.trim(), // 添加 trim() 防止空格
|
||
login_type: 'phone',
|
||
};
|
||
|
||
// 调用API(根据模式选择不同的endpoint
|
||
const response = await fetch('/api/auth/login-with-code', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify(requestBody),
|
||
});
|
||
|
||
if (!response) {
|
||
throw new Error('网络请求失败,请检查网络连接');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (!isMountedRef.current) return;
|
||
|
||
if (!data) {
|
||
throw new Error('服务器响应为空');
|
||
}
|
||
|
||
if (response.ok && data.success) {
|
||
// 更新session
|
||
await checkSession();
|
||
|
||
// ✅ 兼容后端两种命名格式:camelCase (isNewUser) 和 snake_case (is_new_user)
|
||
const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
|
||
|
||
// 追踪登录成功并识别用户
|
||
authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
|
||
|
||
// ✅ 保留登录成功 toast(关键操作提示)
|
||
toast({
|
||
title: isNewUser ? '注册成功' : '登录成功',
|
||
description: config.successDescription,
|
||
status: "success",
|
||
duration: 2000,
|
||
});
|
||
|
||
// 检查是否为新注册用户
|
||
if (isNewUser) {
|
||
// 新注册用户,延迟后显示昵称设置引导
|
||
setTimeout(() => {
|
||
setCurrentPhone(phone);
|
||
setShowNicknamePrompt(true);
|
||
// 追踪昵称设置引导显示
|
||
authEvents.trackNicknamePromptShown(phone);
|
||
}, config.features.successDelay);
|
||
} else {
|
||
// 已有用户,直接登录成功
|
||
setTimeout(() => {
|
||
handleLoginSuccess({ phone });
|
||
}, config.features.successDelay);
|
||
}
|
||
|
||
// ⚡ 延迟 10 秒显示权限引导(温和、非侵入)
|
||
setTimeout(() => {
|
||
if (showWelcomeGuide) {
|
||
logger.info('AuthFormContent', '显示欢迎引导');
|
||
showWelcomeGuide();
|
||
}
|
||
}, 10000);
|
||
} else {
|
||
throw new Error(data.error || `${config.errorTitle}`);
|
||
}
|
||
} catch (error) {
|
||
const { phone, verificationCode } = formData;
|
||
|
||
// 追踪登录失败
|
||
const errorType = error.message.includes('网络') ? 'network' :
|
||
error.message.includes('服务器') ? 'api' : 'validation';
|
||
authEvents.trackLoginFailed('phone', errorType, error.message, {
|
||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||
has_verification_code: !!verificationCode
|
||
});
|
||
|
||
logger.error('AuthFormContent', 'handleSubmit', error, {
|
||
phone: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||
hasVerificationCode: !!verificationCode
|
||
});
|
||
|
||
// ✅ 显示错误提示给用户
|
||
toast({
|
||
id: 'auth-verification-error',
|
||
title: config.errorTitle,
|
||
description: error.message || "请检查验证码是否正确",
|
||
status: "error",
|
||
duration: 3000,
|
||
isClosable: true,
|
||
position: 'top',
|
||
containerStyle: {
|
||
zIndex: 10000,
|
||
}
|
||
});
|
||
} finally {
|
||
if (isMountedRef.current) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 微信H5登录处理
|
||
const handleWechatH5Login = async () => {
|
||
// 追踪用户选择微信登录
|
||
authEvents.trackWechatLoginInitiated('icon_button');
|
||
|
||
try {
|
||
// 1. 构建回调URL
|
||
const redirectUrl = `${window.location.origin}/home/wechat-callback`;
|
||
|
||
// 2. 显示提示
|
||
toast({
|
||
title: "即将跳转",
|
||
description: "正在跳转到微信授权页面...",
|
||
status: "info",
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
|
||
// 3. 获取微信H5授权URL
|
||
const response = await authService.getWechatH5AuthUrl(redirectUrl);
|
||
|
||
if (!response || !response.auth_url) {
|
||
throw new Error('获取授权链接失败');
|
||
}
|
||
|
||
// 追踪微信H5跳转
|
||
authEvents.trackWechatH5Redirect();
|
||
|
||
// 4. 延迟跳转,让用户看到提示
|
||
setTimeout(() => {
|
||
window.location.href = response.auth_url;
|
||
}, 500);
|
||
} catch (error) {
|
||
// 追踪跳转失败
|
||
authEvents.trackError('api', error.message || '获取微信授权链接失败', {
|
||
context: 'wechat_h5_redirect'
|
||
});
|
||
|
||
logger.error('AuthFormContent', 'handleWechatH5Login', error);
|
||
toast({
|
||
title: "跳转失败",
|
||
description: error.message || "请稍后重试",
|
||
status: "error",
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 组件挂载时追踪页面浏览
|
||
useEffect(() => {
|
||
isMountedRef.current = true;
|
||
|
||
// 追踪登录页面浏览
|
||
authEvents.trackLoginPageViewed();
|
||
|
||
return () => {
|
||
isMountedRef.current = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []); // 仅在挂载时执行一次,避免 countdown 倒计时导致重复触发
|
||
|
||
return (
|
||
<>
|
||
<Box width="100%">
|
||
<AuthHeader title={config.title} subtitle={config.subtitle} />
|
||
<Stack direction={stackDirection} spacing={stackSpacing} align="stretch">
|
||
<Box flex={{ base: "1", md: "4" }}>
|
||
<form onSubmit={handleSubmit}>
|
||
<VStack spacing={4}>
|
||
<Heading size="md" color="gray.700" alignSelf="flex-start">{config.formTitle}</Heading>
|
||
<FormControl isRequired isInvalid={!!errors.phone}>
|
||
<Input name="phone" value={formData.phone} onChange={handleInputChange} placeholder="请输入11位手机号" />
|
||
<FormErrorMessage>{errors.phone}</FormErrorMessage>
|
||
</FormControl>
|
||
|
||
{/* 验证码输入框 + 移动端微信图标 */}
|
||
<Box width="100%" position="relative">
|
||
<VerificationCodeInput value={formData.verificationCode} onChange={handleInputChange} onSendCode={sendVerificationCode} countdown={countdown} isLoading={isLoading} isSending={sendingCode} error={errors.verificationCode} colorScheme="green" />
|
||
|
||
{/* 移动端:验证码下方的微信登录图标 */}
|
||
{isMobile && (
|
||
<HStack spacing={0} mt={2} alignItems="center">
|
||
<Text fontSize="xs" color="gray.500">其他登录方式:</Text>
|
||
<IconButton
|
||
aria-label="微信登录"
|
||
icon={<Icon as={FaWeixin} w={4} h={4} />}
|
||
size="sm"
|
||
variant="ghost"
|
||
color="#07C160"
|
||
borderRadius="md"
|
||
minW="24px"
|
||
minH="24px"
|
||
_hover={{
|
||
bg: "green.50",
|
||
color: "#06AD56"
|
||
}}
|
||
_active={{
|
||
bg: "green.100"
|
||
}}
|
||
onClick={handleWechatH5Login}
|
||
isDisabled={isLoading}
|
||
/>
|
||
</HStack>
|
||
)}
|
||
</Box>
|
||
|
||
<Button type="submit" width="100%" size="lg" colorScheme="green" color="white" borderRadius="lg" isLoading={isLoading} loadingText={config.loadingText} fontWeight="bold"><Icon as={FaLock} mr={2} />{config.buttonText}</Button>
|
||
|
||
{/* 隐私声明 */}
|
||
<Text fontSize="xs" color="gray.500" textAlign="center" mt={2}>
|
||
登录即表示您同意价值前沿{" "}
|
||
<ChakraLink
|
||
as="a"
|
||
href="/home/user-agreement"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
color="blue.500"
|
||
textDecoration="underline"
|
||
_hover={{ color: "blue.600" }}
|
||
onClick={authEvents.trackUserAgreementClicked}
|
||
>
|
||
《用户协议》
|
||
</ChakraLink>
|
||
{" "}和{" "}
|
||
<ChakraLink
|
||
as="a"
|
||
href="/home/privacy-policy"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
color="blue.500"
|
||
textDecoration="underline"
|
||
_hover={{ color: "blue.600" }}
|
||
onClick={authEvents.trackPrivacyPolicyClicked}
|
||
>
|
||
《隐私政策》
|
||
</ChakraLink>
|
||
</Text>
|
||
</VStack>
|
||
</form>
|
||
</Box>
|
||
|
||
{/* 桌面端:右侧二维码扫描 */}
|
||
{!isMobile && (
|
||
<Box flex={{ base: "1", md: "0 0 auto" }}> {/* ✅ 桌面端让右侧自适应宽度 */}
|
||
<Center width="100%"> {/* ✅ 移除bg和p,WechatRegister自带白色背景和padding */}
|
||
<WechatRegister />
|
||
</Center>
|
||
</Box>
|
||
)}
|
||
</Stack>
|
||
</Box>
|
||
|
||
{/* 只在需要时才渲染 AlertDialog,避免创建不必要的 Portal */}
|
||
{showNicknamePrompt && (
|
||
<AlertDialog isOpen={showNicknamePrompt} leastDestructiveRef={cancelRef} onClose={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }} isCentered closeOnEsc={true} closeOnOverlayClick={false}>
|
||
<AlertDialogOverlay>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
||
<AlertDialogBody>您已成功注册!是否前往个人资料设置昵称和其他信息?</AlertDialogBody>
|
||
<AlertDialogFooter>
|
||
<Button
|
||
ref={cancelRef}
|
||
onClick={() => {
|
||
authEvents.trackNicknamePromptSkipped();
|
||
setShowNicknamePrompt(false);
|
||
handleLoginSuccess({ phone: currentPhone });
|
||
}}
|
||
>
|
||
稍后再说
|
||
</Button>
|
||
<Button
|
||
colorScheme="green"
|
||
onClick={() => {
|
||
authEvents.trackNicknamePromptAccepted();
|
||
setShowNicknamePrompt(false);
|
||
handleLoginSuccess({ phone: currentPhone });
|
||
setTimeout(() => {
|
||
navigate('/home/profile');
|
||
}, 300);
|
||
}}
|
||
ml={3}
|
||
>
|
||
去设置
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialogOverlay>
|
||
</AlertDialog>
|
||
)}
|
||
</>
|
||
);
|
||
}
|