Files
vf_react/src/components/Auth/AuthFormContent.js
2025-12-12 12:38:43 +08:00

599 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 currentPath = window.location.pathname + window.location.search;
const returnUrl = encodeURIComponent(currentPath);
const redirectUrl = `${window.location.origin}/home/wechat-callback?returnUrl=${returnUrl}`;
// 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和pWechatRegister自带白色背景和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>
)}
</>
);
}