feat: 登陆注册UI调整,用户协议和隐私政策跳转调整

This commit is contained in:
zdl
2025-10-15 11:03:00 +08:00
parent 29816de72b
commit 4e9acd12c2
18 changed files with 3068 additions and 49 deletions

View File

@@ -5,12 +5,17 @@ import { Link } from "react-router-dom";
/**
* 认证页面底部组件
* 包含页面切换链接和登录方式切换链接
*
* 支持两种模式:
* 1. 页面模式:使用 linkTo 进行路由跳转
* 2. 弹窗模式:使用 onClick 进行弹窗切换
*/
export default function AuthFooter({
// 左侧链接配置
linkText, // 提示文本,如 "还没有账号," 或 "已有账号?"
linkLabel, // 链接文本,如 "去注册" 或 "去登录"
linkTo, // 链接路径,如 "/auth/sign-up" 或 "/auth/sign-in"
linkTo, // 链接路径,如 "/auth/sign-up" 或 "/auth/sign-in"(页面模式)
onClick, // 点击回调函数(弹窗模式,优先级高于 linkTo
// 右侧切换配置
useVerificationCode, // 当前是否使用验证码登录
@@ -19,24 +24,35 @@ export default function AuthFooter({
return (
<HStack justify="space-between" width="100%">
{/* 左侧:页面切换链接(去注册/去登录) */}
<HStack spacing={1} as={Link} to={linkTo}>
<Text fontSize="sm" color="gray.600">{linkText}</Text>
<Text fontSize="sm" color="blue.500" fontWeight="bold">{linkLabel}</Text>
</HStack>
{onClick ? (
// 弹窗模式:使用 onClick
<HStack spacing={1} cursor="pointer" onClick={onClick}>
<Text fontSize="sm" color="gray.600">{linkText}</Text>
<Text fontSize="sm" color="blue.500" fontWeight="bold">{linkLabel}</Text>
</HStack>
) : (
// 页面模式:使用 Link 组件跳转
<HStack spacing={1} as={Link} to={linkTo}>
<Text fontSize="sm" color="gray.600">{linkText}</Text>
<Text fontSize="sm" color="blue.500" fontWeight="bold">{linkLabel}</Text>
</HStack>
)}
{/* 右侧:登录方式切换链接 */}
<ChakraLink
href="#"
fontSize="sm"
color="blue.500"
fontWeight="bold"
onClick={(e) => {
e.preventDefault();
onSwitchMethod();
}}
>
{useVerificationCode ? '密码登陆' : '验证码登陆'}
</ChakraLink>
{/* 右侧:登录方式切换链接(仅在提供了切换方法时显示) */}
{onSwitchMethod && (
<ChakraLink
href="#"
fontSize="sm"
color="blue.500"
fontWeight="bold"
onClick={(e) => {
e.preventDefault();
onSwitchMethod();
}}
>
{useVerificationCode ? '密码登陆' : '验证码登陆'}
</ChakraLink>
)}
</HStack>
);
}

View File

@@ -0,0 +1,379 @@
// 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,
useToast,
Icon,
FormErrorMessage,
Center,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
Text,
Link as ChakraLink,
} from "@chakra-ui/react";
import { FaLock } from "react-icons/fa";
import { useAuth } from "../../contexts/AuthContext";
import { useAuthModal } from "../../contexts/AuthModalContext";
import AuthHeader from './AuthHeader';
import VerificationCodeInput from './VerificationCodeInput';
import WechatRegister from './WechatRegister';
// API配置
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : "http://49.232.185.254:5000";
// 统一配置对象
const AUTH_CONFIG = {
// UI文本
title: "欢迎使用价值前沿",
subtitle: "开启您的投资之旅",
formTitle: "手机号验证",
buttonText: "登录/注册",
loadingText: "验证中...",
successTitle: "验证成功",
successDescription: "欢迎!",
errorTitle: "验证失败",
// API配置
api: {
endpoint: '/api/auth/register-with-code',
purpose: 'register',
},
// 功能开关
features: {
successDelay: 1000, // 延迟1秒显示成功提示
}
};
export default function AuthFormContent() {
const toast = useToast();
const navigate = useNavigate();
const { checkSession } = useAuth();
const { handleLoginSuccess } = useAuthModal();
// 使用统一配置
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 [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
}));
};
// 倒计时逻辑
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;
}
if (!/^1[3-9]\d{9}$/.test(credential)) {
toast({
title: "请输入有效的手机号",
status: "warning",
duration: 3000,
});
return;
}
try {
setSendingCode(true);
const response = await fetch(`${API_BASE_URL}/api/auth/send-verification-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential,
type: 'phone',
purpose: config.api.purpose // 根据模式使用不同的purpose
}),
});
if (!response) {
throw new Error('网络请求失败,请检查网络连接');
}
const data = await response.json();
if (!isMountedRef.current) return;
if (!data) {
throw new Error('服务器响应为空');
}
if (response.ok && data.success) {
toast({
title: "验证码已发送",
description: "验证码已发送到您的手机号",
status: "success",
duration: 3000,
});
setVerificationCodeSent(true);
setCountdown(60);
} else {
throw new Error(data.error || '发送验证码失败');
}
} catch (error) {
if (isMountedRef.current) {
toast({
title: "发送验证码失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setSendingCode(false);
}
}
};
// 提交处理(登录或注册)
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
const { phone, verificationCode, nickname } = formData;
// 表单验证
if (!phone || !verificationCode) {
toast({
title: "请填写完整信息",
description: "手机号和验证码不能为空",
status: "warning",
duration: 3000,
});
return;
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
toast({
title: "请输入有效的手机号",
status: "warning",
duration: 3000,
});
return;
}
// 构建请求体
const requestBody = {
credential: phone,
verification_code: verificationCode,
register_type: 'phone',
};
// 调用API根据模式选择不同的endpoint
const response = await fetch(`${API_BASE_URL}${config.api.endpoint}`, {
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();
toast({
title: config.successTitle,
description: config.successDescription,
status: "success",
duration: 2000,
});
// 检查是否为新注册用户
if (data.isNewUser) {
// 新注册用户,延迟后显示昵称设置引导
setTimeout(() => {
setCurrentPhone(phone);
setShowNicknamePrompt(true);
}, config.features.successDelay);
} else {
// 已有用户,直接登录成功
setTimeout(() => {
handleLoginSuccess({ phone });
}, config.features.successDelay);
}
} else {
throw new Error(data.error || `${config.errorTitle}`);
}
} catch (error) {
console.error('Auth error:', error);
if (isMountedRef.current) {
toast({
title: config.errorTitle,
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
// 组件卸载时清理
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return (
<>
<Box width="100%">
<AuthHeader title={config.title} subtitle={config.subtitle} />
<HStack spacing={8} align="stretch">
<Box flex="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>
<VerificationCodeInput value={formData.verificationCode} onChange={handleInputChange} onSendCode={sendVerificationCode} countdown={countdown} isLoading={isLoading} isSending={sendingCode} error={errors.verificationCode} colorScheme="green" />
<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" }}
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
as="a"
href="/home/privacy-policy"
target="_blank"
rel="noopener noreferrer"
color="blue.500"
textDecoration="underline"
_hover={{ color: "blue.600" }}
>
隐私政策
</ChakraLink>
</Text>
</VStack>
</form>
</Box>
<Box flex="1">
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}><WechatRegister /></Center>
</Box>
</HStack>
</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={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说</Button>
<Button colorScheme="green" onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/admin/profile'); }, 300); }} ml={3}>去设置</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
)}
</>
);
}

View File

@@ -0,0 +1,84 @@
// src/components/Auth/AuthModalManager.js
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
useBreakpointValue
} from '@chakra-ui/react';
import { useAuthModal } from '../../contexts/AuthModalContext';
import AuthFormContent from './AuthFormContent';
/**
* 全局认证弹窗管理器
* 统一的登录/注册弹窗
*/
export default function AuthModalManager() {
const {
isAuthModalOpen,
closeModal
} = useAuthModal();
// 响应式尺寸配置
const modalSize = useBreakpointValue({
base: "full", // 移动端:全屏
sm: "xl", // 小屏xl
md: "2xl", // 中屏2xl
lg: "4xl" // 大屏4xl
});
// 条件渲染:只在打开时才渲染 Modal避免创建不必要的 Portal
if (!isAuthModalOpen) {
return null;
}
return (
<Modal
isOpen={isAuthModalOpen}
onClose={closeModal}
size={modalSize}
isCentered
closeOnOverlayClick={false} // 防止误点击背景关闭
closeOnEsc={true} // 允许ESC键关闭
scrollBehavior="inside" // 内容滚动
zIndex={999} // 低于导航栏(1000),不覆盖导航
>
{/* 半透明背景 + 模糊效果 */}
<ModalOverlay
bg="blackAlpha.700"
backdropFilter="blur(10px)"
/>
{/* 弹窗内容容器 */}
<ModalContent
bg="white"
boxShadow="2xl"
borderRadius="2xl"
maxW={modalSize === "full" ? "100%" : "900px"}
my={modalSize === "full" ? 0 : 8}
position="relative"
>
{/* 关闭按钮 */}
<ModalCloseButton
position="absolute"
right={4}
top={4}
zIndex={9999}
color="gray.500"
bg="transparent"
_hover={{ bg: "gray.100" }}
borderRadius="full"
size="lg"
onClick={closeModal}
/>
{/* 弹窗主体内容 */}
<ModalBody p={8}>
<AuthFormContent />
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@@ -36,6 +36,7 @@ import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/ic
import { FiStar, FiCalendar } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useAuthModal } from '../../contexts/AuthModalContext';
/** 桌面端导航 - 完全按照原网站
* @TODO 添加逻辑 不展示导航case
@@ -200,6 +201,7 @@ export default function HomeNavbar() {
const navigate = useNavigate();
const isMobile = useBreakpointValue({ base: true, md: false });
const { user, isAuthenticated, logout, isLoading } = useAuth();
const { openAuthModal } = useAuthModal();
const { colorMode, toggleColorMode } = useColorMode();
const navbarBg = useColorModeValue('white', 'gray.800');
const navbarBorder = useColorModeValue('gray.200', 'gray.700');
@@ -231,10 +233,6 @@ export default function HomeNavbar() {
}
};
// 处理登录按钮点击
const handleLoginClick = () => {
navigate('/auth/signin');
};
// 检查是否为禁用的链接没有NEW标签的链接
// const isDisabledLink = true;
@@ -733,13 +731,13 @@ export default function HomeNavbar() {
</Menu>
</HStack>
) : (
// 未登录状态
// 未登录状态 - 单一按钮
<Button
colorScheme="blue"
variant="solid"
size="sm"
borderRadius="full"
onClick={handleLoginClick}
onClick={() => openAuthModal()}
_hover={{
transform: "translateY(-1px)",
boxShadow: "md"
@@ -960,7 +958,7 @@ export default function HomeNavbar() {
colorScheme="blue"
size="sm"
onClick={() => {
handleLoginClick();
openAuthModal();
onClose();
}}
>

View File

@@ -18,6 +18,11 @@ const PrivacyPolicyModal = ({ isOpen, onClose }) => {
const headingColor = useColorModeValue("gray.800", "white");
const textColor = useColorModeValue("gray.600", "gray.300");
// Conditional rendering: only render Modal when open
if (!isOpen) {
return null;
}
return (
<Modal
isOpen={isOpen}

View File

@@ -1,11 +1,22 @@
// src/components/ProtectedRoute.js - Session版本
import React from 'react';
import { Navigate } from 'react-router-dom';
// src/components/ProtectedRoute.js - 弹窗拦截版本
import React, { useEffect } from 'react';
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import { useAuthModal } from '../contexts/AuthModalContext';
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, isLoading, user } = useAuth();
const { openAuthModal, isAuthModalOpen } = useAuthModal();
// 记录当前路径,登录成功后可以跳转回来
const currentPath = window.location.pathname + window.location.search;
// 未登录时自动弹出认证窗口
useEffect(() => {
if (!isLoading && !isAuthenticated && !user && !isAuthModalOpen) {
openAuthModal(currentPath);
}
}, [isAuthenticated, user, isLoading, isAuthModalOpen, currentPath, openAuthModal]);
// 显示加载状态
if (isLoading) {
@@ -25,26 +36,26 @@ const ProtectedRoute = ({ children }) => {
);
}
// 记录当前路径,登录后可以回到这里
let currentPath = window.location.pathname + window.location.search;
let redirectUrl = `/auth/signin?redirect=${encodeURIComponent(currentPath)}`;
// 检查是否已登录
// 未登录时显示占位符(弹窗会自动打开)
if (!isAuthenticated || !user) {
return <Navigate to={redirectUrl} replace />;
return (
<Box
height="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text fontSize="lg" color="gray.600">请先登录...</Text>
</VStack>
</Box>
);
}
// 已登录,渲染子组件
// return children;
// 更新逻辑 如果 currentPath 是首页 登陆成功后跳转到个人中心
if (currentPath === '/' || currentPath === '/home') {
currentPath = '/profile';
redirectUrl = `/auth/signin?redirect=${encodeURIComponent(currentPath)}`;
return <Navigate to={redirectUrl} replace />;
} else { // 否则正常渲染
return children;
}
return children;
};
export default ProtectedRoute;
export default ProtectedRoute;

View File

@@ -18,6 +18,11 @@ const UserAgreementModal = ({ isOpen, onClose }) => {
const headingColor = useColorModeValue("gray.800", "white");
const textColor = useColorModeValue("gray.600", "gray.300");
// Conditional rendering: only render Modal when open
if (!isOpen) {
return null;
}
return (
<Modal
isOpen={isOpen}