Merge branch 'feature_bugfix/251217_stock' into feature_2025/251229_stocks
* feature_bugfix/251217_stock: style(Auth): 登录弹窗改为黑金主题 style: 统一个人中心按钮与投资日历按钮样式
This commit is contained in:
@@ -1,34 +1,10 @@
|
||||
// src/components/Auth/AuthFormContent.js
|
||||
// 统一的认证表单组件
|
||||
// 统一的认证表单组件 - Ant Design 版本 - 黑金主题
|
||||
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 { Lock } from "lucide-react";
|
||||
import { WechatOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Button, Typography, Space, Row, Col, Modal, message } from 'antd';
|
||||
import { LockOutlined, WechatOutlined } from '@ant-design/icons';
|
||||
import { useBreakpointValue } from "@chakra-ui/react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useAuthModal } from "../../hooks/useAuthModal";
|
||||
import { useNotification } from "../../contexts/NotificationContext";
|
||||
@@ -36,93 +12,119 @@ 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 { getApiBase } from '../../utils/apiConfig';
|
||||
import { useAuthEvents } from '../../hooks/useAuthEvents';
|
||||
|
||||
const { Text, Link, Title } = Typography;
|
||||
|
||||
// 黑金主题样式
|
||||
const THEME = {
|
||||
goldPrimary: '#D4AF37',
|
||||
goldLight: '#F4D03F',
|
||||
goldDark: '#B8860B',
|
||||
bgInput: 'rgba(26, 26, 46, 0.8)',
|
||||
bgElevated: '#1A1A2E',
|
||||
lineDefault: 'rgba(212, 175, 55, 0.3)',
|
||||
lineEmphasis: 'rgba(212, 175, 55, 0.5)',
|
||||
textPrimary: 'rgba(255, 255, 255, 0.95)',
|
||||
textMuted: 'rgba(255, 255, 255, 0.4)',
|
||||
wechat: '#07C160',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
formTitle: {
|
||||
color: THEME.goldPrimary,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
input: {
|
||||
background: THEME.bgInput,
|
||||
border: `1px solid ${THEME.lineDefault}`,
|
||||
color: THEME.textPrimary,
|
||||
height: '44px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
submitBtn: {
|
||||
height: '48px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
borderRadius: '8px',
|
||||
background: `linear-gradient(135deg, ${THEME.goldPrimary} 0%, ${THEME.goldDark} 50%, ${THEME.goldPrimary} 100%)`,
|
||||
border: `1px solid ${THEME.lineEmphasis}`,
|
||||
color: THEME.bgElevated,
|
||||
boxShadow: '0 4px 15px rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
privacyText: {
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: THEME.textMuted,
|
||||
marginTop: '12px',
|
||||
},
|
||||
privacyLink: {
|
||||
color: THEME.goldPrimary,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
wechatBtn: {
|
||||
color: THEME.wechat,
|
||||
padding: '4px 8px',
|
||||
},
|
||||
otherLoginText: {
|
||||
color: THEME.textMuted,
|
||||
fontSize: '12px',
|
||||
},
|
||||
};
|
||||
|
||||
// 统一配置对象
|
||||
const AUTH_CONFIG = {
|
||||
// UI文本
|
||||
title: "价值前沿",
|
||||
subtitle: "开启您的投资之旅",
|
||||
formTitle: "登陆/注册",
|
||||
buttonText: "登录/注册",
|
||||
loadingText: "验证中...",
|
||||
successTitle: "验证成功",
|
||||
successDescription: "欢迎!",
|
||||
errorTitle: "验证失败",
|
||||
|
||||
// API配置
|
||||
api: {
|
||||
endpoint: '/api/auth/register/phone',
|
||||
purpose: 'login', // ⚡ 统一使用 'login' 模式
|
||||
purpose: 'login',
|
||||
},
|
||||
|
||||
// 功能开关
|
||||
features: {
|
||||
successDelay: 1000, // 延迟1秒显示成功提示
|
||||
successDelay: 1000,
|
||||
}
|
||||
};
|
||||
|
||||
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 [formData, setFormData] = useState({ phone: "", verificationCode: "" });
|
||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
// 输入框变化处理
|
||||
// 响应式
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({ component: 'AuthFormContent', isMobile });
|
||||
|
||||
// 输入变化处理
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// 追踪用户开始填写手机号 (判断用户选择了手机登录方式)
|
||||
if (name === 'phone' && value.length === 1 && !formData.phone) {
|
||||
authEvents.trackPhoneLoginInitiated(value);
|
||||
}
|
||||
|
||||
// 追踪验证码输入变化
|
||||
if (name === 'verificationCode') {
|
||||
authEvents.trackVerificationCodeInputChanged(value.length);
|
||||
}
|
||||
@@ -131,470 +133,314 @@ export default function AuthFormContent() {
|
||||
// 倒计时逻辑
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
let isMounted = true;
|
||||
|
||||
if (countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
if (isMounted) {
|
||||
setCountdown(prev => prev - 1);
|
||||
}
|
||||
}, 1000);
|
||||
} else if (countdown === 0 && isMounted) {
|
||||
timer = setInterval(() => setCountdown(prev => prev - 1), 1000);
|
||||
} else if (countdown === 0) {
|
||||
setVerificationCodeSent(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
return () => timer && clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = async () => {
|
||||
const credential = formData.phone;
|
||||
|
||||
if (!credential) {
|
||||
toast({
|
||||
title: "请先输入手机号",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
message.warning("请先输入手机号");
|
||||
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,
|
||||
});
|
||||
message.warning("请输入有效的手机号");
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪手机号验证通过
|
||||
authEvents.trackPhoneNumberValidated(credential, true);
|
||||
|
||||
try {
|
||||
setSendingCode(true);
|
||||
|
||||
const requestData = {
|
||||
credential: cleanedCredential, // 使用清理后的手机号
|
||||
type: 'phone',
|
||||
purpose: config.api.purpose
|
||||
};
|
||||
|
||||
const response = await fetch(`${getApiBase()}/api/auth/send-verification-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestData),
|
||||
body: JSON.stringify({
|
||||
credential: cleanedCredential,
|
||||
type: 'phone',
|
||||
purpose: config.api.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) {
|
||||
// 追踪验证码发送成功 (或重发)
|
||||
const isResend = verificationCodeSent;
|
||||
if (isResend) {
|
||||
if (verificationCodeSent) {
|
||||
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,
|
||||
}
|
||||
});
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error);
|
||||
message.error(error.message || "发送验证码失败");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setSendingCode(false);
|
||||
}
|
||||
if (isMountedRef.current) setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交处理(登录或注册)
|
||||
// 提交表单
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
e?.preventDefault?.();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 表单验证
|
||||
if (!phone || !verificationCode) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "手机号和验证码不能为空",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
message.warning("手机号和验证码不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理手机号格式字符(空格、横线、括号等)
|
||||
const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
message.warning("请输入有效的手机号");
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪验证码提交
|
||||
authEvents.trackVerificationCodeSubmitted(phone);
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
credential: cleanedPhone, // 使用清理后的手机号
|
||||
verification_code: verificationCode.trim(), // 添加 trim() 防止空格
|
||||
login_type: 'phone',
|
||||
};
|
||||
|
||||
// 调用API(根据模式选择不同的endpoint
|
||||
const response = await fetch(`${getApiBase()}/api/auth/login-with-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify({
|
||||
credential: cleanedPhone,
|
||||
verification_code: verificationCode.trim(),
|
||||
login_type: 'phone',
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
message.success(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);
|
||||
setTimeout(() => handleLoginSuccess({ phone }), config.features.successDelay);
|
||||
}
|
||||
|
||||
// ⚡ 延迟 10 秒显示权限引导(温和、非侵入)
|
||||
setTimeout(() => {
|
||||
if (showWelcomeGuide) {
|
||||
logger.info('AuthFormContent', '显示欢迎引导');
|
||||
showWelcomeGuide();
|
||||
}
|
||||
if (showWelcomeGuide) showWelcomeGuide();
|
||||
}, 10000);
|
||||
} else {
|
||||
throw new Error(data.error || `${config.errorTitle}`);
|
||||
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, {
|
||||
authEvents.trackLoginFailed('phone', 'api', 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,
|
||||
}
|
||||
});
|
||||
logger.error('AuthFormContent', 'handleSubmit', error);
|
||||
message.error(error.message || "验证失败");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (isMountedRef.current) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 微信H5登录处理
|
||||
// 微信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
|
||||
message.info("正在跳转到微信授权页面...");
|
||||
const response = await authService.getWechatH5AuthUrl(redirectUrl);
|
||||
|
||||
if (!response || !response.auth_url) {
|
||||
throw new Error('获取授权链接失败');
|
||||
}
|
||||
if (!response?.auth_url) throw new Error('获取授权链接失败');
|
||||
|
||||
// 追踪微信H5跳转
|
||||
authEvents.trackWechatH5Redirect();
|
||||
|
||||
// 4. 延迟跳转,让用户看到提示
|
||||
setTimeout(() => {
|
||||
window.location.href = response.auth_url;
|
||||
}, 500);
|
||||
setTimeout(() => { window.location.href = response.auth_url; }, 500);
|
||||
} catch (error) {
|
||||
// 追踪跳转失败
|
||||
authEvents.trackError('api', error.message || '获取微信授权链接失败', {
|
||||
context: 'wechat_h5_redirect'
|
||||
});
|
||||
|
||||
authEvents.trackError('api', error.message || '获取微信授权链接失败');
|
||||
logger.error('AuthFormContent', 'handleWechatH5Login', error);
|
||||
toast({
|
||||
title: "跳转失败",
|
||||
description: error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
message.error(error.message || "跳转失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时追踪页面浏览
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 追踪登录页面浏览
|
||||
authEvents.trackLoginPageViewed();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
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>
|
||||
<div className="auth-form-content">
|
||||
<AuthHeader title={config.title} subtitle={config.subtitle} />
|
||||
|
||||
{/* 验证码输入框 + 移动端微信图标 */}
|
||||
<Box width="100%" position="relative">
|
||||
<VerificationCodeInput value={formData.verificationCode} onChange={handleInputChange} onSendCode={sendVerificationCode} countdown={countdown} isLoading={isLoading} isSending={sendingCode} error={errors.verificationCode} colorScheme="green" />
|
||||
<Row gutter={isMobile ? 0 : 32} align="top">
|
||||
{/* 左侧表单 */}
|
||||
<Col xs={24} md={14}>
|
||||
<Title level={5} style={styles.formTitle}>{config.formTitle}</Title>
|
||||
|
||||
{/* 移动端:验证码下方的微信登录图标 */}
|
||||
{isMobile && (
|
||||
<HStack spacing={0} mt={2} alignItems="center">
|
||||
<Text fontSize="xs" color="gray.500">其他登录方式:</Text>
|
||||
<IconButton
|
||||
aria-label="微信登录"
|
||||
icon={<WechatOutlined style={{ fontSize: '16px' }} />}
|
||||
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>
|
||||
<Form layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入11位手机号"
|
||||
size="large"
|
||||
style={styles.input}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button type="submit" width="100%" size="lg" colorScheme="green" color="white" borderRadius="lg" isLoading={isLoading} loadingText={config.loadingText} fontWeight="bold"><Icon as={Lock} mr={2} />{config.buttonText}</Button>
|
||||
<Form.Item>
|
||||
<VerificationCodeInput
|
||||
value={formData.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
onSendCode={sendVerificationCode}
|
||||
countdown={countdown}
|
||||
isLoading={isLoading}
|
||||
isSending={sendingCode}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 隐私声明 */}
|
||||
<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>
|
||||
{/* 移动端微信登录 */}
|
||||
{isMobile && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<Text style={styles.otherLoginText}>其他登录方式:</Text>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
type="text"
|
||||
icon={<WechatOutlined />}
|
||||
onClick={handleWechatH5Login}
|
||||
disabled={isLoading}
|
||||
style={styles.wechatBtn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={isLoading}
|
||||
icon={<LockOutlined />}
|
||||
style={styles.submitBtn}
|
||||
>
|
||||
{isLoading ? config.loadingText : config.buttonText}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={styles.privacyText}>
|
||||
登录即表示您同意价值前沿{" "}
|
||||
<Link
|
||||
href="/home/user-agreement"
|
||||
target="_blank"
|
||||
onClick={authEvents.trackUserAgreementClicked}
|
||||
style={styles.privacyLink}
|
||||
>
|
||||
《用户协议》
|
||||
</Link>
|
||||
{" "}和{" "}
|
||||
<Link
|
||||
href="/home/privacy-policy"
|
||||
target="_blank"
|
||||
onClick={authEvents.trackPrivacyPolicyClicked}
|
||||
style={styles.privacyLink}
|
||||
>
|
||||
《隐私政策》
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Col>
|
||||
|
||||
{/* 右侧微信二维码 - 仅桌面端 */}
|
||||
{!isMobile && (
|
||||
<Col xs={24} md={10}>
|
||||
<WechatRegister />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* 昵称设置引导 */}
|
||||
<Modal
|
||||
open={showNicknamePrompt}
|
||||
title="完善个人信息"
|
||||
onCancel={() => {
|
||||
authEvents.trackNicknamePromptSkipped();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="skip"
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptSkipped();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>,
|
||||
<Button
|
||||
key="go"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptAccepted();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
setTimeout(() => navigate('/home/profile'), 300);
|
||||
}}
|
||||
style={styles.submitBtn}
|
||||
>
|
||||
去设置
|
||||
</Button>
|
||||
]}
|
||||
styles={{
|
||||
content: {
|
||||
background: THEME.bgElevated,
|
||||
border: `1px solid ${THEME.lineDefault}`,
|
||||
},
|
||||
header: {
|
||||
background: 'transparent',
|
||||
borderBottom: `1px solid ${THEME.lineDefault}`,
|
||||
},
|
||||
body: {
|
||||
color: THEME.textPrimary,
|
||||
},
|
||||
footer: {
|
||||
borderTop: `1px solid ${THEME.lineDefault}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span style={{ color: THEME.textPrimary }}>
|
||||
您已成功注册!是否前往个人资料设置昵称和其他信息?
|
||||
</span>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
// src/components/Auth/AuthHeader.js
|
||||
// 认证页面头部组件 - 黑金主题
|
||||
import React from "react";
|
||||
import { Heading, Text, VStack } from "@chakra-ui/react";
|
||||
import { Typography } from "antd";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 黑金主题样式
|
||||
const styles = {
|
||||
header: {
|
||||
textAlign: 'center',
|
||||
marginBottom: '24px',
|
||||
},
|
||||
title: {
|
||||
marginBottom: '12px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '28px',
|
||||
background: 'linear-gradient(to right, #D4AF37, #F4D03F, #D4AF37)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
divider: {
|
||||
width: '60px',
|
||||
height: '2px',
|
||||
margin: '0 auto 12px',
|
||||
background: 'linear-gradient(to right, transparent, #D4AF37, transparent)',
|
||||
borderRadius: '9999px',
|
||||
},
|
||||
subtitle: {
|
||||
color: 'rgba(212, 175, 55, 0.8)',
|
||||
fontSize: '14px',
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 认证页面通用头部组件
|
||||
* 用于显示页面标题和描述
|
||||
*
|
||||
* @param {string} title - 主标题文字
|
||||
* @param {string} subtitle - 副标题文字
|
||||
*/
|
||||
export default function AuthHeader({ title, subtitle }) {
|
||||
return (
|
||||
<VStack spacing={2} mb={8}>
|
||||
<Heading size="xl" color="gray.800" fontWeight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
<Text color="gray.600" fontSize="md">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
return (
|
||||
<div style={styles.header}>
|
||||
<Title level={2} style={styles.title}>{title}</Title>
|
||||
<div style={styles.divider} />
|
||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
418
src/components/Auth/AuthModal.less
Normal file
418
src/components/Auth/AuthModal.less
Normal file
@@ -0,0 +1,418 @@
|
||||
/* AuthModal.less - 登录弹窗黑金主题样式 */
|
||||
|
||||
// ==================== 变量定义 ====================
|
||||
@color-bg-deep: #0A0A14;
|
||||
@color-bg-primary: #0F0F1A;
|
||||
@color-bg-elevated: #1A1A2E;
|
||||
@color-bg-surface: #16213e;
|
||||
@color-bg-input: rgba(26, 26, 46, 0.8);
|
||||
|
||||
@color-gold-primary: #D4AF37;
|
||||
@color-gold-light: #F4D03F;
|
||||
@color-gold-dark: #B8860B;
|
||||
@color-gold-darker: #8B6914;
|
||||
@color-gold-gradient: linear-gradient(135deg, @color-gold-primary 0%, @color-gold-dark 50%, @color-gold-primary 100%);
|
||||
|
||||
@color-line-subtle: rgba(212, 175, 55, 0.1);
|
||||
@color-line-default: rgba(212, 175, 55, 0.3);
|
||||
@color-line-emphasis: rgba(212, 175, 55, 0.5);
|
||||
|
||||
@color-text-primary: rgba(255, 255, 255, 0.95);
|
||||
@color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||
@color-text-muted: rgba(255, 255, 255, 0.4);
|
||||
|
||||
@color-wechat: #07C160;
|
||||
|
||||
// ==================== Modal 遮罩层 ====================
|
||||
.auth-modal-root {
|
||||
&.ant-modal-root .ant-modal-mask {
|
||||
background: rgba(0, 0, 0, 0.85) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Modal 主体样式 ====================
|
||||
.auth-modal.ant-modal {
|
||||
.ant-modal-content {
|
||||
background: linear-gradient(135deg, @color-bg-elevated 0%, @color-bg-surface 50%, @color-bg-primary 100%) !important;
|
||||
border: 1px solid @color-line-default !important;
|
||||
border-radius: 16px !important;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 60px rgba(212, 175, 55, 0.15),
|
||||
inset 0 1px 0 rgba(212, 175, 55, 0.1) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 32px !important;
|
||||
max-height: calc(90vh - 120px);
|
||||
overflow-y: auto;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: @color-text-secondary !important;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: @color-gold-primary !important;
|
||||
background: rgba(212, 175, 55, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 表单内容区域 ====================
|
||||
.auth-form-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ==================== 头部样式 ====================
|
||||
.auth-modal .auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.auth-title.ant-typography {
|
||||
margin-bottom: 12px !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 28px !important;
|
||||
background: linear-gradient(to right, @color-gold-primary, @color-gold-light, @color-gold-primary) !important;
|
||||
-webkit-background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
background-clip: text !important;
|
||||
letter-spacing: 0.1em;
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
margin: 0 auto 12px;
|
||||
background: linear-gradient(to right, transparent, @color-gold-primary, transparent);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.auth-subtitle.ant-typography {
|
||||
color: rgba(212, 175, 55, 0.8) !important;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 表单标题 ====================
|
||||
.auth-modal .form-section-title.ant-typography {
|
||||
color: @color-gold-primary !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
// ==================== 输入框样式 ====================
|
||||
// 全局 Ant Design Input placeholder 样式(黑金主题)
|
||||
// placeholder 使用更亮的颜色以便在深色背景上清晰可见
|
||||
@color-placeholder: rgba(255, 255, 255, 0.5);
|
||||
|
||||
.auth-modal .ant-input,
|
||||
.auth-modal-root .ant-input {
|
||||
&::placeholder {
|
||||
color: @color-placeholder !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&::-webkit-input-placeholder {
|
||||
color: @color-placeholder !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&::-moz-placeholder {
|
||||
color: @color-placeholder !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:-ms-input-placeholder {
|
||||
color: @color-placeholder !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 验证码按钮 ====================
|
||||
.auth-modal .send-code-btn.ant-btn {
|
||||
background: transparent !important;
|
||||
color: @color-gold-primary !important;
|
||||
border: 1px solid @color-line-emphasis !important;
|
||||
border-left: none !important;
|
||||
min-width: 120px;
|
||||
height: 44px;
|
||||
border-radius: 0 8px 8px 0 !important;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(212, 175, 55, 0.1) !important;
|
||||
border-color: @color-gold-primary !important;
|
||||
color: @color-gold-primary !important;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.ant-btn-disabled {
|
||||
opacity: 0.5;
|
||||
color: @color-text-muted !important;
|
||||
border-color: @color-line-default !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提交按钮 ====================
|
||||
.auth-modal .submit-btn.ant-btn {
|
||||
height: 48px !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: bold !important;
|
||||
border-radius: 8px !important;
|
||||
background: @color-gold-gradient !important;
|
||||
border: 1px solid @color-line-emphasis !important;
|
||||
color: @color-bg-elevated !important;
|
||||
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, @color-gold-light 0%, @color-gold-primary 50%, @color-gold-light 100%) !important;
|
||||
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);
|
||||
transform: translateY(-1px);
|
||||
color: @color-bg-elevated !important;
|
||||
border-color: @color-line-emphasis !important;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: linear-gradient(135deg, @color-gold-dark 0%, @color-gold-darker 50%, @color-gold-dark 100%) !important;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
// Ant Design primary 按钮覆盖
|
||||
&.ant-btn-primary {
|
||||
background: @color-gold-gradient !important;
|
||||
border-color: @color-line-emphasis !important;
|
||||
color: @color-bg-elevated !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 隐私声明 ====================
|
||||
.auth-modal .privacy-text {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: @color-text-muted;
|
||||
margin-top: 12px;
|
||||
|
||||
a,
|
||||
.ant-typography-link {
|
||||
color: @color-gold-primary !important;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: @color-gold-light !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 移动端微信登录 ====================
|
||||
.auth-modal .mobile-wechat-login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.other-login-text.ant-typography {
|
||||
color: @color-text-muted !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wechat-btn.ant-btn {
|
||||
color: @color-wechat !important;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(7, 193, 96, 0.15) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 微信登录区域 ====================
|
||||
.auth-modal .wechat-register {
|
||||
text-align: center;
|
||||
|
||||
.wechat-title.ant-typography {
|
||||
color: @color-gold-primary !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-modal .qrcode-container {
|
||||
position: relative;
|
||||
width: 230px;
|
||||
height: 230px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid @color-line-default;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow:
|
||||
0 4px 20px rgba(0, 0, 0, 0.3),
|
||||
0 0 30px rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.auth-modal .qrcode-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
|
||||
.qrcode-icon {
|
||||
font-size: 64px;
|
||||
color: rgba(212, 175, 55, 0.4);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.get-qrcode-btn.ant-btn {
|
||||
background: @color-gold-gradient !important;
|
||||
border: 1px solid @color-line-emphasis !important;
|
||||
color: @color-bg-elevated !important;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, @color-gold-light 0%, @color-gold-primary 50%, @color-gold-light 100%) !important;
|
||||
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-modal .expired-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(26, 26, 46, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.expired-icon {
|
||||
font-size: 32px;
|
||||
color: @color-gold-primary;
|
||||
}
|
||||
|
||||
.expired-text.ant-typography {
|
||||
color: @color-text-secondary !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.refresh-btn.ant-btn {
|
||||
background: transparent !important;
|
||||
color: @color-gold-primary !important;
|
||||
border: 1px solid @color-line-emphasis !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(212, 175, 55, 0.1) !important;
|
||||
border-color: @color-gold-primary !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-modal .status-text.ant-typography {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// ==================== Mock 模式 ====================
|
||||
.auth-modal .mock-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid @color-line-subtle;
|
||||
|
||||
.mock-btn.ant-btn {
|
||||
background: transparent !important;
|
||||
color: #9370DB !important;
|
||||
border: 1px solid rgba(147, 112, 219, 0.5) !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(147, 112, 219, 0.1) !important;
|
||||
border-color: #9370DB !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mock-hint.ant-typography {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: @color-text-muted !important;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 昵称弹窗 ====================
|
||||
.nickname-modal.ant-modal {
|
||||
.ant-modal-content {
|
||||
background: @color-bg-elevated !important;
|
||||
border: 1px solid @color-line-default !important;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid @color-line-subtle !important;
|
||||
|
||||
.ant-modal-title {
|
||||
color: @color-gold-primary !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
color: @color-text-primary !important;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
border-top: 1px solid @color-line-subtle !important;
|
||||
|
||||
.ant-btn-primary {
|
||||
background: @color-gold-gradient !important;
|
||||
border-color: @color-line-emphasis !important;
|
||||
color: @color-bg-elevated !important;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, @color-gold-light 0%, @color-gold-primary 50%, @color-gold-light 100%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 响应式适配 ====================
|
||||
@media (max-width: 768px) {
|
||||
.auth-modal.ant-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 24px 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-modal .auth-header .auth-title.ant-typography {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.auth-modal .qrcode-container {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,42 @@
|
||||
// src/components/Auth/AuthModalManager.js
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { useBreakpointValue } from '@chakra-ui/react';
|
||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||
import AuthFormContent from './AuthFormContent';
|
||||
import { trackEventAsync } from '@lib/posthog';
|
||||
import { ACTIVATION_EVENTS } from '@lib/constants';
|
||||
import { GLASS_BLUR } from '@/constants/glassConfig';
|
||||
import './AuthModal.less';
|
||||
|
||||
// 黑金主题色彩常量
|
||||
const THEME = {
|
||||
bgElevated: '#1A1A2E',
|
||||
bgSurface: '#16213e',
|
||||
bgPrimary: '#0F0F1A',
|
||||
goldPrimary: '#D4AF37',
|
||||
lineDefault: 'rgba(212, 175, 55, 0.3)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||
};
|
||||
|
||||
// 全局样式注入 - 确保 placeholder 在深色背景上可见
|
||||
const GlobalInputStyles = () => (
|
||||
<style>{`
|
||||
.auth-modal-root .ant-input::placeholder,
|
||||
.auth-modal .ant-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.55) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.auth-modal-root .ant-input::-webkit-input-placeholder,
|
||||
.auth-modal .ant-input::-webkit-input-placeholder {
|
||||
color: rgba(255, 255, 255, 0.55) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
|
||||
/**
|
||||
* 全局认证弹窗管理器
|
||||
* 统一的登录/注册弹窗
|
||||
* 统一的登录/注册弹窗 - 黑金主题
|
||||
*/
|
||||
export default function AuthModalManager() {
|
||||
const {
|
||||
@@ -27,7 +53,7 @@ export default function AuthModalManager() {
|
||||
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
modal_type: 'auth_modal',
|
||||
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源
|
||||
trigger_source: 'user_action',
|
||||
});
|
||||
|
||||
hasTrackedOpen.current = true;
|
||||
@@ -39,43 +65,64 @@ export default function AuthModalManager() {
|
||||
}
|
||||
}, [isAuthModalOpen]);
|
||||
|
||||
// 响应式宽度配置(Ant Design Modal 使用数字或字符串)
|
||||
// 响应式宽度配置
|
||||
const modalMaxW = useBreakpointValue(
|
||||
{
|
||||
base: "90%", // 移动端:屏幕宽度的90%
|
||||
sm: "90%", // 小屏:90%
|
||||
md: "700px", // 中屏:固定700px
|
||||
lg: "700px" // 大屏:固定700px
|
||||
base: "90%",
|
||||
sm: "90%",
|
||||
md: "700px",
|
||||
lg: "700px"
|
||||
},
|
||||
{ fallback: "700px", ssr: false }
|
||||
);
|
||||
|
||||
// ✅ 使用 Ant Design Modal,完全避开 Chakra UI Portal 的 AnimatePresence 问题
|
||||
// Ant Design Modal 不使用 Framer Motion,不会有 React 18 并发渲染的 insertBefore 错误
|
||||
// Ant Design 5.x Modal styles 属性 - 直接覆盖 CSS-in-JS 样式
|
||||
const modalStyles = useMemo(() => ({
|
||||
mask: {
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
content: {
|
||||
background: `linear-gradient(135deg, ${THEME.bgElevated} 0%, ${THEME.bgSurface} 50%, ${THEME.bgPrimary} 100%)`,
|
||||
border: `1px solid ${THEME.lineDefault}`,
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 60px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
},
|
||||
header: {
|
||||
display: 'none',
|
||||
},
|
||||
body: {
|
||||
padding: '32px',
|
||||
maxHeight: 'calc(90vh - 120px)',
|
||||
overflowY: 'auto',
|
||||
background: 'transparent',
|
||||
},
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isAuthModalOpen}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
width={modalMaxW}
|
||||
centered
|
||||
destroyOnHidden={true}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
zIndex={999}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
maxHeight: 'calc(90vh - 120px)',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
mask: {
|
||||
backdropFilter: GLASS_BLUR.sm,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)'
|
||||
<>
|
||||
<GlobalInputStyles />
|
||||
<Modal
|
||||
open={isAuthModalOpen}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
width={modalMaxW}
|
||||
centered
|
||||
destroyOnHidden={true}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
zIndex={999}
|
||||
className="auth-modal"
|
||||
rootClassName="auth-modal-root"
|
||||
styles={modalStyles}
|
||||
closeIcon={
|
||||
<span style={{ color: THEME.textSecondary, fontSize: '16px' }}>✕</span>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AuthFormContent />
|
||||
</Modal>
|
||||
>
|
||||
<AuthFormContent />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,99 @@
|
||||
import React from "react";
|
||||
import { FormControl, FormErrorMessage, HStack, Input, Button, Spinner } from "@chakra-ui/react";
|
||||
// src/components/Auth/VerificationCodeInput.js
|
||||
// 验证码输入组件 - 黑金主题
|
||||
import React from 'react';
|
||||
import { Input, Button, Space } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
/**
|
||||
* 通用验证码输入组件
|
||||
*/
|
||||
// 黑金主题样式
|
||||
const THEME = {
|
||||
goldPrimary: '#D4AF37',
|
||||
bgInput: 'rgba(26, 26, 46, 0.8)',
|
||||
lineDefault: 'rgba(212, 175, 55, 0.3)',
|
||||
lineEmphasis: 'rgba(212, 175, 55, 0.5)',
|
||||
textPrimary: 'rgba(255, 255, 255, 0.95)',
|
||||
textMuted: 'rgba(255, 255, 255, 0.4)',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
input: {
|
||||
background: THEME.bgInput,
|
||||
border: `1px solid ${THEME.lineDefault}`,
|
||||
color: THEME.textPrimary,
|
||||
height: '44px',
|
||||
borderRadius: '8px 0 0 8px',
|
||||
flex: 1,
|
||||
},
|
||||
sendCodeBtn: {
|
||||
background: 'transparent',
|
||||
color: THEME.goldPrimary,
|
||||
border: `1px solid ${THEME.lineEmphasis}`,
|
||||
borderLeft: 'none',
|
||||
minWidth: '120px',
|
||||
height: '44px',
|
||||
borderRadius: '0 8px 8px 0',
|
||||
},
|
||||
sendCodeBtnDisabled: {
|
||||
background: 'transparent',
|
||||
color: THEME.textMuted,
|
||||
border: `1px solid ${THEME.lineDefault}`,
|
||||
borderLeft: 'none',
|
||||
minWidth: '120px',
|
||||
height: '44px',
|
||||
borderRadius: '0 8px 8px 0',
|
||||
opacity: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
export default function VerificationCodeInput({
|
||||
value,
|
||||
onChange,
|
||||
onSendCode,
|
||||
countdown,
|
||||
isLoading,
|
||||
isSending,
|
||||
error,
|
||||
countdown = 0,
|
||||
isLoading = false,
|
||||
isSending = false,
|
||||
placeholder = "请输入6位验证码",
|
||||
buttonText = "获取验证码",
|
||||
countdownText = (count) => `${count}s`,
|
||||
colorScheme = "green",
|
||||
isRequired = true
|
||||
}) {
|
||||
// 包装 onSendCode,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
if (onSendCode) {
|
||||
await onSendCode();
|
||||
}
|
||||
} catch (error) {
|
||||
// 错误已经在父组件处理,这里只需要防止未捕获的 Promise rejection
|
||||
logger.error('VerificationCodeInput', 'handleSendCode', error, {
|
||||
hasOnSendCode: !!onSendCode,
|
||||
countdown,
|
||||
isLoading,
|
||||
isSending
|
||||
});
|
||||
logger.error('VerificationCodeInput', 'handleSendCode', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算按钮显示的文本(避免在 JSX 中使用条件渲染)
|
||||
const getButtonText = () => {
|
||||
if (isSending) {
|
||||
return "发送中";
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return countdownText(countdown);
|
||||
}
|
||||
if (isSending) return "发送中...";
|
||||
if (countdown > 0) return countdownText(countdown);
|
||||
return buttonText;
|
||||
};
|
||||
|
||||
const isDisabled = countdown > 0 || isLoading || isSending;
|
||||
|
||||
return (
|
||||
<FormControl isRequired={isRequired} isInvalid={!!error}>
|
||||
<HStack>
|
||||
<Input
|
||||
name="verificationCode"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
colorScheme={colorScheme}
|
||||
onClick={handleSendCode}
|
||||
isDisabled={countdown > 0 || isLoading || isSending}
|
||||
minW="120px"
|
||||
leftIcon={isSending ? <Spinner size="sm" /> : undefined}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</HStack>
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
name="verificationCode"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
maxLength={6}
|
||||
size="large"
|
||||
style={styles.input}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendCode}
|
||||
disabled={isDisabled}
|
||||
size="large"
|
||||
style={isDisabled ? styles.sendCodeBtnDisabled : styles.sendCodeBtn}
|
||||
icon={isSending ? <LoadingOutlined /> : null}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,208 +1,206 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Center,
|
||||
Text,
|
||||
Heading,
|
||||
Icon,
|
||||
useToast,
|
||||
Spinner
|
||||
} from "@chakra-ui/react";
|
||||
import { QrCode, AlertCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
// src/components/Auth/WechatRegister.js
|
||||
// 微信登录组件 - 黑金主题
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Typography, Button, message } from 'antd';
|
||||
import { QrcodeOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
|
||||
import { useAuthModal } from "../../hooks/useAuthModal";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { useAuthEvents } from "../../hooks/useAuthEvents";
|
||||
import { GLASS_BLUR } from "@/constants/glassConfig";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 配置常量
|
||||
const POLL_INTERVAL = 2000; // 轮询间隔:2秒
|
||||
const BACKUP_POLL_INTERVAL = 3000; // 备用轮询间隔:3秒
|
||||
const QR_CODE_TIMEOUT = 300000; // 二维码超时:5分钟
|
||||
const POLL_INTERVAL = 2000;
|
||||
const QR_CODE_TIMEOUT = 300000;
|
||||
|
||||
// 黑金主题样式
|
||||
const THEME = {
|
||||
goldPrimary: '#D4AF37',
|
||||
goldLight: '#F4D03F',
|
||||
goldDark: '#B8860B',
|
||||
bgInput: 'rgba(26, 26, 46, 0.8)',
|
||||
bgElevated: '#1A1A2E',
|
||||
lineDefault: 'rgba(212, 175, 55, 0.3)',
|
||||
lineEmphasis: 'rgba(212, 175, 55, 0.5)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: THEME.goldPrimary,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
qrcodeContainer: {
|
||||
position: 'relative',
|
||||
width: '230px',
|
||||
height: '230px',
|
||||
margin: '0 auto',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${THEME.lineDefault}`,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3), 0 0 30px rgba(212, 175, 55, 0.1)',
|
||||
},
|
||||
placeholder: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(26, 26, 46, 0.95)',
|
||||
},
|
||||
qrcodeIcon: {
|
||||
fontSize: '64px',
|
||||
color: 'rgba(212, 175, 55, 0.4)',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
getQrcodeBtn: {
|
||||
background: `linear-gradient(135deg, ${THEME.goldPrimary} 0%, ${THEME.goldDark} 50%, ${THEME.goldPrimary} 100%)`,
|
||||
border: `1px solid ${THEME.lineEmphasis}`,
|
||||
color: THEME.bgElevated,
|
||||
fontWeight: 600,
|
||||
},
|
||||
expiredOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(26, 26, 46, 0.9)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
expiredIcon: {
|
||||
fontSize: '32px',
|
||||
color: THEME.goldPrimary,
|
||||
},
|
||||
expiredText: {
|
||||
color: THEME.textSecondary,
|
||||
fontSize: '14px',
|
||||
},
|
||||
refreshBtn: {
|
||||
background: 'transparent',
|
||||
color: THEME.goldPrimary,
|
||||
border: `1px solid ${THEME.lineEmphasis}`,
|
||||
},
|
||||
statusText: {
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
marginTop: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
mockSection: {
|
||||
marginTop: '12px',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid rgba(212, 175, 55, 0.1)',
|
||||
},
|
||||
mockBtn: {
|
||||
background: 'transparent',
|
||||
color: '#9370DB',
|
||||
border: '1px solid rgba(147, 112, 219, 0.5)',
|
||||
},
|
||||
mockHint: {
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态文字颜色
|
||||
*/
|
||||
const getStatusColor = (status) => {
|
||||
switch(status) {
|
||||
case WECHAT_STATUS.WAITING: return "gray.600"; // ✅ 灰色文字
|
||||
case WECHAT_STATUS.SCANNED: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.AUTHORIZED: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字
|
||||
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600";
|
||||
case WECHAT_STATUS.AUTH_DENIED: return "red.600"; // ✅ 红色文字
|
||||
case WECHAT_STATUS.AUTH_FAILED: return "red.600"; // ✅ 红色文字
|
||||
default: return "gray.600";
|
||||
case WECHAT_STATUS.WAITING: return "rgba(255, 255, 255, 0.6)";
|
||||
case WECHAT_STATUS.SCANNED:
|
||||
case WECHAT_STATUS.AUTHORIZED:
|
||||
case WECHAT_STATUS.LOGIN_SUCCESS:
|
||||
case WECHAT_STATUS.REGISTER_SUCCESS:
|
||||
return "#D4AF37";
|
||||
case WECHAT_STATUS.EXPIRED: return "#CD853F";
|
||||
case WECHAT_STATUS.AUTH_DENIED:
|
||||
case WECHAT_STATUS.AUTH_FAILED:
|
||||
return "#CD5C5C";
|
||||
default: return "rgba(255, 255, 255, 0.6)";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态文字
|
||||
*/
|
||||
const getStatusText = (status) => {
|
||||
return STATUS_MESSAGES[status] || "点击按钮获取二维码";
|
||||
};
|
||||
const getStatusText = (status) => STATUS_MESSAGES[status] || "点击按钮获取二维码";
|
||||
|
||||
export default function WechatRegister() {
|
||||
// 获取关闭弹窗方法
|
||||
const { closeModal } = useAuthModal();
|
||||
const { refreshSession } = useAuth();
|
||||
const authEvents = useAuthEvents({ component: 'WechatRegister', isMobile: false });
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'WechatRegister',
|
||||
isMobile: false // WechatRegister 只在桌面端显示
|
||||
});
|
||||
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
const [wechatSessionId, setWechatSessionId] = useState("");
|
||||
const [wechatStatus, setWechatStatus] = useState(WECHAT_STATUS.NONE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [scale, setScale] = useState(1); // iframe 缩放比例
|
||||
|
||||
// 使用 useRef 管理定时器,避免闭包问题和内存泄漏
|
||||
const pollIntervalRef = useRef(null);
|
||||
const backupPollIntervalRef = useRef(null); // 备用轮询定时器
|
||||
const timeoutRef = useRef(null);
|
||||
const isMountedRef = useRef(true); // 追踪组件挂载状态
|
||||
const containerRef = useRef(null); // 容器DOM引用
|
||||
const sessionIdRef = useRef(null); // 存储最新的 sessionId,避免闭包陷阱
|
||||
const wechatStatusRef = useRef(WECHAT_STATUS.NONE); // 存储最新的 wechatStatus,避免闭包陷阱
|
||||
const isMountedRef = useRef(true);
|
||||
const containerRef = useRef(null);
|
||||
const sessionIdRef = useRef(null);
|
||||
const wechatStatusRef = useRef(WECHAT_STATUS.NONE);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
/**
|
||||
* 显示统一的错误提示
|
||||
*/
|
||||
const showError = useCallback((title, description) => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
/**
|
||||
* 显示成功提示
|
||||
*/
|
||||
const showSuccess = useCallback((title, description) => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* 注意:不清理 sessionIdRef,因为 startPolling 时也会调用此函数
|
||||
*/
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
if (backupPollIntervalRef.current) {
|
||||
clearInterval(backupPollIntervalRef.current);
|
||||
backupPollIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理登录成功
|
||||
*/
|
||||
const handleLoginSuccess = useCallback(async (sessionId, status) => {
|
||||
try {
|
||||
const response = await authService.loginWithWechat(sessionId);
|
||||
|
||||
if (response?.success) {
|
||||
// 追踪微信登录成功
|
||||
authEvents.trackLoginSuccess(
|
||||
response.user,
|
||||
'wechat',
|
||||
response.isNewUser || false
|
||||
);
|
||||
|
||||
// Session cookie 会自动管理,不需要手动存储
|
||||
// 如果后端返回了 token,可以选择性存储(兼容旧方式)
|
||||
if (response.token) {
|
||||
localStorage.setItem('token', response.token);
|
||||
}
|
||||
if (response.user) {
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!"
|
||||
);
|
||||
|
||||
// 刷新 AuthContext 状态
|
||||
authEvents.trackLoginSuccess(response.user, 'wechat', response.isNewUser || false);
|
||||
if (response.token) localStorage.setItem('token', response.token);
|
||||
if (response.user) localStorage.setItem('user', JSON.stringify(response.user));
|
||||
message.success(status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!");
|
||||
await refreshSession();
|
||||
|
||||
// 关闭认证弹窗,留在当前页面
|
||||
closeModal();
|
||||
} else {
|
||||
throw new Error(response?.error || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 追踪微信登录失败
|
||||
authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败', {
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
status: status
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
|
||||
showError("登录失败", error.message || "请重试");
|
||||
authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败');
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error);
|
||||
message.error(error.message || "登录失败,请重试");
|
||||
}
|
||||
}, [showSuccess, showError, closeModal, refreshSession, authEvents]);
|
||||
}, [closeModal, refreshSession, authEvents]);
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
* 使用 sessionIdRef.current 避免闭包陷阱
|
||||
*/
|
||||
const checkWechatStatus = useCallback(async () => {
|
||||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMountedRef.current || !sessionIdRef.current) return;
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
|
||||
try {
|
||||
const response = await authService.checkWechatStatus(currentSessionId);
|
||||
|
||||
// 安全检查:确保 response 存在且包含 status
|
||||
if (!response || typeof response.status === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response.status === 'undefined') return;
|
||||
|
||||
const { status } = response;
|
||||
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 追踪状态变化(使用 ref 获取最新状态,避免闭包陷阱)
|
||||
const previousStatus = wechatStatusRef.current;
|
||||
if (previousStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, previousStatus, status);
|
||||
|
||||
// 特别追踪扫码事件
|
||||
if (status === WECHAT_STATUS.SCANNED) {
|
||||
authEvents.trackWechatQRScanned(currentSessionId);
|
||||
}
|
||||
@@ -210,282 +208,100 @@ export default function WechatRegister() {
|
||||
|
||||
setWechatStatus(status);
|
||||
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
clearTimers(); // 停止轮询
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
|
||||
clearTimers();
|
||||
sessionIdRef.current = null;
|
||||
await handleLoginSuccess(currentSessionId, status);
|
||||
}
|
||||
// 处理过期状态
|
||||
else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
// 追踪二维码过期
|
||||
} else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
authEvents.trackWechatQRExpired(currentSessionId, QR_CODE_TIMEOUT / 1000);
|
||||
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已过期",
|
||||
description: "请重新获取授权",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理用户拒绝授权
|
||||
else if (status === WECHAT_STATUS.AUTH_DENIED) {
|
||||
sessionIdRef.current = null;
|
||||
message.warning("授权已过期,请重新获取");
|
||||
} else if (status === WECHAT_STATUS.AUTH_DENIED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已取消",
|
||||
description: "您已取消微信授权登录",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理授权失败
|
||||
else if (status === WECHAT_STATUS.AUTH_FAILED) {
|
||||
message.warning("您已取消微信授权登录");
|
||||
} else if (status === WECHAT_STATUS.AUTH_FAILED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
const errorMsg = response.error || "授权过程出现错误";
|
||||
toast({
|
||||
title: "授权失败",
|
||||
description: errorMsg,
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
message.error(response.error || "授权过程出现错误");
|
||||
}
|
||||
// AUTHORIZED 状态只是中间状态,继续轮询等待 login_ready/register_ready
|
||||
// 不在此处调用登录接口,避免"会话状态无效"错误
|
||||
} catch (error) {
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
// 但如果错误持续发生,停止轮询避免无限重试
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error);
|
||||
if (error.message.includes('网络连接失败')) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "网络连接失败",
|
||||
description: "请检查网络后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
sessionIdRef.current = null;
|
||||
message.error("网络连接失败,请检查网络后重试");
|
||||
}
|
||||
}
|
||||
}, [handleLoginSuccess, clearTimers, toast]);
|
||||
}, [handleLoginSuccess, clearTimers, authEvents]);
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
// 清理旧的定时器
|
||||
clearTimers();
|
||||
|
||||
// 启动轮询
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
checkWechatStatus();
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
// 设置超时
|
||||
pollIntervalRef.current = setInterval(checkWechatStatus, POLL_INTERVAL);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
sessionIdRef.current = null;
|
||||
setWechatStatus(WECHAT_STATUS.EXPIRED);
|
||||
}, QR_CODE_TIMEOUT);
|
||||
}, [checkWechatStatus, clearTimers]);
|
||||
|
||||
/**
|
||||
* 获取微信二维码
|
||||
*/
|
||||
const getWechatQRCode = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 追踪用户选择微信登录(首次或刷新)
|
||||
const isRefresh = Boolean(wechatSessionId);
|
||||
if (isRefresh) {
|
||||
const oldSessionId = wechatSessionId;
|
||||
authEvents.trackWechatLoginInitiated('qr_refresh');
|
||||
// 稍后会在成功时追踪刷新事件
|
||||
} else {
|
||||
authEvents.trackWechatLoginInitiated('qr_area');
|
||||
}
|
||||
authEvents.trackWechatLoginInitiated(isRefresh ? 'qr_refresh' : 'qr_area');
|
||||
|
||||
// 生产环境:调用真实 API
|
||||
const response = await authService.getWechatQRCode();
|
||||
|
||||
// 检查组件是否已卸载
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 安全检查:确保响应包含必要字段
|
||||
if (!response) {
|
||||
throw new Error('服务器无响应');
|
||||
if (!response || response.code !== 0) {
|
||||
throw new Error(response?.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
// 追踪二维码显示 (首次或刷新)
|
||||
if (isRefresh) {
|
||||
authEvents.trackWechatQRRefreshed(wechatSessionId, response.data.session_id);
|
||||
} else {
|
||||
authEvents.trackWechatQRDisplayed(response.data.session_id, response.data.auth_url);
|
||||
}
|
||||
|
||||
// 同时更新 ref 和 state,确保轮询能立即读取到最新值
|
||||
sessionIdRef.current = response.data.session_id;
|
||||
setWechatAuthUrl(response.data.auth_url);
|
||||
setWechatSessionId(response.data.session_id);
|
||||
setWechatStatus(WECHAT_STATUS.WAITING);
|
||||
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
// 追踪获取二维码失败
|
||||
authEvents.trackError('api', error.message || '获取二维码失败', {
|
||||
context: 'get_wechat_qrcode'
|
||||
});
|
||||
|
||||
authEvents.trackError('api', error.message || '获取二维码失败');
|
||||
logger.error('WechatRegister', 'getWechatQRCode', error);
|
||||
if (isMountedRef.current) {
|
||||
showError("获取微信授权失败", error.message || "请稍后重试");
|
||||
}
|
||||
message.error(error.message || "获取微信授权失败,请稍后重试");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (isMountedRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [startPolling, showError, wechatSessionId, authEvents]);
|
||||
}, [startPolling, wechatSessionId, authEvents]);
|
||||
|
||||
/**
|
||||
* 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
|
||||
*/
|
||||
const handleGetQRCodeClick = useCallback(async () => {
|
||||
try {
|
||||
await getWechatQRCode();
|
||||
} catch (error) {
|
||||
// 错误已经在 getWechatQRCode 中处理,这里只需要防止未捕获的 Promise rejection
|
||||
logger.error('WechatRegister', 'handleGetQRCodeClick', error);
|
||||
}
|
||||
}, [getWechatQRCode]);
|
||||
|
||||
/**
|
||||
* 同步 wechatStatusRef 与 wechatStatus state
|
||||
* 确保 checkWechatStatus 回调中能获取到最新状态
|
||||
*/
|
||||
useEffect(() => {
|
||||
wechatStatusRef.current = wechatStatus;
|
||||
}, [wechatStatus]);
|
||||
useEffect(() => { wechatStatusRef.current = wechatStatus; }, [wechatStatus]);
|
||||
|
||||
/**
|
||||
* 组件卸载时清理定时器和标记组件状态
|
||||
*/
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
sessionIdRef.current = null;
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
/**
|
||||
* 测量容器尺寸并计算缩放比例
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
// 微信授权页面的原始尺寸(需要与iframe实际尺寸匹配)
|
||||
const ORIGINAL_WIDTH = 300; // ✅ 修正:与iframe width匹配
|
||||
const ORIGINAL_HEIGHT = 350; // ✅ 修正:与iframe height匹配
|
||||
|
||||
const calculateScale = () => {
|
||||
if (containerRef.current) {
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
|
||||
// 计算宽高比例,取较小值确保完全适配
|
||||
const scaleX = width / ORIGINAL_WIDTH;
|
||||
const scaleY = height / ORIGINAL_HEIGHT;
|
||||
const newScale = Math.min(scaleX, scaleY, 1.0); // 最大不超过1.0
|
||||
|
||||
// 设置最小缩放比例为0.3,避免过小
|
||||
setScale(Math.max(newScale, 0.3));
|
||||
}
|
||||
};
|
||||
|
||||
// 初始计算
|
||||
calculateScale();
|
||||
|
||||
// 使用 ResizeObserver 监听容器尺寸变化
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calculateScale();
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [wechatStatus]); // 当状态变化时重新计算
|
||||
|
||||
// 渲染状态提示文本 - 已注释掉,如需使用可取消注释
|
||||
// const renderStatusText = () => {
|
||||
// if (!wechatAuthUrl || wechatStatus === WECHAT_STATUS.NONE || wechatStatus === WECHAT_STATUS.EXPIRED) {
|
||||
// return null;
|
||||
// }
|
||||
// return (
|
||||
// <Text fontSize="xs" color="gray.500">
|
||||
// {STATUS_MESSAGES[wechatStatus]}
|
||||
// </Text>
|
||||
// );
|
||||
// };
|
||||
|
||||
return (
|
||||
<VStack
|
||||
spacing={0} // ✅ 手动控制间距
|
||||
alignItems="stretch" // ✅ 拉伸对齐
|
||||
justifyContent="flex-start" // ✅ 顶部对齐(标题对齐关键)
|
||||
width="auto" // ✅ 自适应宽度
|
||||
>
|
||||
{/* ========== 标题区域 ========== */}
|
||||
<Heading
|
||||
size="md" // ✅ 16px,与左侧"登陆/注册"一致
|
||||
fontWeight="600"
|
||||
color="gray.800"
|
||||
textAlign="center"
|
||||
mb={3} // 12px底部间距
|
||||
>
|
||||
微信登陆
|
||||
</Heading>
|
||||
<div style={styles.container}>
|
||||
<Title level={5} style={styles.title}>微信登陆</Title>
|
||||
|
||||
{/* ========== 二维码区域 ========== */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="relative"
|
||||
width="230px" // ✅ 升级尺寸
|
||||
height="230px"
|
||||
mx="auto"
|
||||
overflow="hidden"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
<div ref={containerRef} style={styles.qrcodeContainer}>
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
@@ -493,7 +309,6 @@ export default function WechatRegister() {
|
||||
height="350"
|
||||
scrolling="no"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation"
|
||||
allow="clipboard-write"
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-35px)',
|
||||
@@ -502,104 +317,58 @@ export default function WechatRegister() {
|
||||
pointerEvents: 'auto',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
<Center width="100%" height="100%" flexDirection="column">
|
||||
<Icon as={QrCode} w={16} h={16} color="gray.300" mb={4} />
|
||||
<div style={styles.placeholder}>
|
||||
<QrcodeOutlined style={styles.qrcodeIcon} />
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
type="primary"
|
||||
onClick={handleGetQRCodeClick}
|
||||
isLoading={isLoading}
|
||||
loading={isLoading}
|
||||
style={styles.getQrcodeBtn}
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"}
|
||||
</Button>
|
||||
</Center>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 过期蒙层 ========== */}
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="rgba(0,0,0,0.6)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backdropFilter={GLASS_BLUR.xs}
|
||||
>
|
||||
<VStack spacing={2}>
|
||||
<Icon as={AlertCircle} w={8} h={8} color="white" />
|
||||
<Text color="white" fontSize="sm">二维码已过期</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={handleGetQRCodeClick}
|
||||
>
|
||||
点击刷新
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
<div style={styles.expiredOverlay}>
|
||||
<ExclamationCircleOutlined style={styles.expiredIcon} />
|
||||
<Text style={styles.expiredText}>二维码已过期</Text>
|
||||
<Button size="small" onClick={handleGetQRCodeClick} style={styles.refreshBtn}>
|
||||
点击刷新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* ========== 状态指示器 ========== */}
|
||||
{wechatStatus !== WECHAT_STATUS.NONE && (
|
||||
<Text
|
||||
mt={3}
|
||||
fontSize="sm"
|
||||
fontWeight="500" // ✅ 半粗体
|
||||
textAlign="center"
|
||||
color={getStatusColor(wechatStatus)} // ✅ 根据状态显示不同颜色
|
||||
>
|
||||
<Text style={{ ...styles.statusText, color: getStatusColor(wechatStatus) }}>
|
||||
{getStatusText(wechatStatus)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* ========== Mock 模式控制按钮(仅开发环境) ========== */}
|
||||
{process.env.REACT_APP_ENABLE_MOCK === 'true' && wechatStatus === WECHAT_STATUS.WAITING && wechatSessionId && (
|
||||
<Box mt={3} pt={3} borderTop="1px solid" borderColor="gray.200">
|
||||
<div style={styles.mockSection}>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
size="small"
|
||||
block
|
||||
style={styles.mockBtn}
|
||||
onClick={() => {
|
||||
if (window.mockWechatScan) {
|
||||
const success = window.mockWechatScan(wechatSessionId);
|
||||
if (success) {
|
||||
toast({
|
||||
title: "Mock 模拟触发成功",
|
||||
description: "正在模拟扫码登录...",
|
||||
status: "info",
|
||||
duration: 2000,
|
||||
isClosable: false,
|
||||
});
|
||||
}
|
||||
if (success) message.info("正在模拟扫码登录...");
|
||||
} else {
|
||||
toast({
|
||||
title: "Mock API 未加载",
|
||||
description: "请刷新页面重试",
|
||||
status: "warning",
|
||||
duration: 2000,
|
||||
});
|
||||
message.warning("Mock API 未加载,请刷新页面重试");
|
||||
}
|
||||
}}
|
||||
leftIcon={<Text fontSize="lg">🧪</Text>}
|
||||
>
|
||||
模拟扫码成功(测试)
|
||||
🧪 模拟扫码成功(测试)
|
||||
</Button>
|
||||
<Text fontSize="xs" color="gray.400" textAlign="center" mt={1}>
|
||||
开发模式 | 自动登录: 5秒
|
||||
</Text>
|
||||
</Box>
|
||||
<Text style={styles.mockHint}>开发模式 | 自动登录: 5秒</Text>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Box,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDown, Home, User, Settings, LogOut, Crown } from 'lucide-react';
|
||||
@@ -28,7 +27,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
*/
|
||||
const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
const navigate = useNavigate();
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
|
||||
// 🎯 为个人中心菜单创建 useDisclosure Hook
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -47,9 +45,10 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
rightIcon={<ChevronDown size={16} />}
|
||||
_hover={{ bg: hoverBg }}
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={onClose}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user