Merge branch 'feature_bugfix/251217_stock' into feature_2025/251229_stocks

* feature_bugfix/251217_stock:
  style(Auth): 登录弹窗改为黑金主题
  style: 统一个人中心按钮与投资日历按钮样式
This commit is contained in:
zdl
2025-12-30 16:55:26 +08:00
7 changed files with 1068 additions and 941 deletions

View File

@@ -1,34 +1,10 @@
// src/components/Auth/AuthFormContent.js // src/components/Auth/AuthFormContent.js
// 统一的认证表单组件 // 统一的认证表单组件 - Ant Design 版本 - 黑金主题
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { Form, Input, Button, Typography, Space, Row, Col, Modal, message } from 'antd';
Box, import { LockOutlined, WechatOutlined } from '@ant-design/icons';
Button, import { useBreakpointValue } from "@chakra-ui/react";
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 { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { useAuthModal } from "../../hooks/useAuthModal"; import { useAuthModal } from "../../hooks/useAuthModal";
import { useNotification } from "../../contexts/NotificationContext"; import { useNotification } from "../../contexts/NotificationContext";
@@ -36,93 +12,119 @@ import { authService } from "../../services/authService";
import AuthHeader from './AuthHeader'; import AuthHeader from './AuthHeader';
import VerificationCodeInput from './VerificationCodeInput'; import VerificationCodeInput from './VerificationCodeInput';
import WechatRegister from './WechatRegister'; import WechatRegister from './WechatRegister';
import { setCurrentUser } from '../../mocks/data/users';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig'; import { getApiBase } from '../../utils/apiConfig';
import { useAuthEvents } from '../../hooks/useAuthEvents'; 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 = { const AUTH_CONFIG = {
// UI文本
title: "价值前沿", title: "价值前沿",
subtitle: "开启您的投资之旅", subtitle: "开启您的投资之旅",
formTitle: "登陆/注册", formTitle: "登陆/注册",
buttonText: "登录/注册", buttonText: "登录/注册",
loadingText: "验证中...", loadingText: "验证中...",
successTitle: "验证成功",
successDescription: "欢迎!", successDescription: "欢迎!",
errorTitle: "验证失败", errorTitle: "验证失败",
// API配置
api: { api: {
endpoint: '/api/auth/register/phone', purpose: 'login',
purpose: 'login', // ⚡ 统一使用 'login' 模式
}, },
// 功能开关
features: { features: {
successDelay: 1000, // 延迟1秒显示成功提示 successDelay: 1000,
} }
}; };
export default function AuthFormContent() { export default function AuthFormContent() {
const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { checkSession } = useAuth(); const { checkSession } = useAuth();
const { handleLoginSuccess } = useAuthModal(); const { handleLoginSuccess } = useAuthModal();
const { showWelcomeGuide } = useNotification(); const { showWelcomeGuide } = useNotification();
// 使用统一配置
const config = AUTH_CONFIG; const config = AUTH_CONFIG;
// 追踪组件挂载状态,防止内存泄漏
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const cancelRef = useRef(); // AlertDialog 需要的 ref
// 页面状态 // 状态
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({});
// 昵称设置引导对话框
const [showNicknamePrompt, setShowNicknamePrompt] = useState(false); const [showNicknamePrompt, setShowNicknamePrompt] = useState(false);
const [currentPhone, setCurrentPhone] = useState(""); const [currentPhone, setCurrentPhone] = useState("");
const [formData, setFormData] = useState({ phone: "", verificationCode: "" });
// 响应式断点
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 [verificationCodeSent, setVerificationCodeSent] = useState(false);
const [sendingCode, setSendingCode] = useState(false); const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0); const [countdown, setCountdown] = useState(0);
// 输入框变化处理 // 响应式
const isMobile = useBreakpointValue({ base: true, md: false });
// 事件追踪
const authEvents = useAuthEvents({ component: 'AuthFormContent', isMobile });
// 输入变化处理
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData(prev => ({ setFormData(prev => ({ ...prev, [name]: value }));
...prev,
[name]: value
}));
// 追踪用户开始填写手机号 (判断用户选择了手机登录方式)
if (name === 'phone' && value.length === 1 && !formData.phone) { if (name === 'phone' && value.length === 1 && !formData.phone) {
authEvents.trackPhoneLoginInitiated(value); authEvents.trackPhoneLoginInitiated(value);
} }
// 追踪验证码输入变化
if (name === 'verificationCode') { if (name === 'verificationCode') {
authEvents.trackVerificationCodeInputChanged(value.length); authEvents.trackVerificationCodeInputChanged(value.length);
} }
@@ -131,470 +133,314 @@ export default function AuthFormContent() {
// 倒计时逻辑 // 倒计时逻辑
useEffect(() => { useEffect(() => {
let timer; let timer;
let isMounted = true;
if (countdown > 0) { if (countdown > 0) {
timer = setInterval(() => { timer = setInterval(() => setCountdown(prev => prev - 1), 1000);
if (isMounted) { } else if (countdown === 0) {
setCountdown(prev => prev - 1);
}
}, 1000);
} else if (countdown === 0 && isMounted) {
setVerificationCodeSent(false); setVerificationCodeSent(false);
} }
return () => timer && clearInterval(timer);
return () => {
isMounted = false;
if (timer) clearInterval(timer);
};
}, [countdown]); }, [countdown]);
// 发送验证码 // 发送验证码
const sendVerificationCode = async () => { const sendVerificationCode = async () => {
const credential = formData.phone; const credential = formData.phone;
if (!credential) { if (!credential) {
toast({ message.warning("请先输入手机号");
title: "请先输入手机号",
status: "warning",
duration: 3000,
});
return; return;
} }
// 清理手机号格式字符(空格、横线、括号等)
const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, ''); const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, '');
if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) { if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) {
authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format'); authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format');
authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号'); message.warning("请输入有效的手机号");
toast({
title: "请输入有效的手机号",
status: "warning",
duration: 3000,
});
return; return;
} }
// 追踪手机号验证通过
authEvents.trackPhoneNumberValidated(credential, true); authEvents.trackPhoneNumberValidated(credential, true);
try { try {
setSendingCode(true); setSendingCode(true);
const requestData = {
credential: cleanedCredential, // 使用清理后的手机号
type: 'phone',
purpose: config.api.purpose
};
const response = await fetch(`${getApiBase()}/api/auth/send-verification-code`, { const response = await fetch(`${getApiBase()}/api/auth/send-verification-code`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
credentials: 'include', 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(); const data = await response.json();
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
if (!data) {
throw new Error('服务器响应为空');
}
if (response.ok && data.success) { if (response.ok && data.success) {
// 追踪验证码发送成功 (或重发) if (verificationCodeSent) {
const isResend = verificationCodeSent;
if (isResend) {
authEvents.trackVerificationCodeResent(credential, countdown > 0 ? 2 : 1); authEvents.trackVerificationCodeResent(credential, countdown > 0 ? 2 : 1);
} else { } else {
authEvents.trackVerificationCodeSent(credential, config.api.purpose); authEvents.trackVerificationCodeSent(credential, config.api.purpose);
} }
// ✅ 开发环境下在控制台显示验证码
if (data.dev_code) { if (data.dev_code) {
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;'); console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
} }
setVerificationCodeSent(true); setVerificationCodeSent(true);
setCountdown(60); setCountdown(60);
} else { } else {
throw new Error(data.error || '发送验证码失败'); throw new Error(data.error || '发送验证码失败');
} }
} catch (error) { } catch (error) {
// 追踪验证码发送失败
authEvents.trackVerificationCodeSendFailed(credential, error); authEvents.trackVerificationCodeSendFailed(credential, error);
authEvents.trackError('api', error.message || '发送验证码失败', { logger.api.error('POST', '/api/auth/send-verification-code', error);
endpoint: '/api/auth/send-verification-code', message.error(error.message || "发送验证码失败");
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 { } finally {
if (isMountedRef.current) { if (isMountedRef.current) setSendingCode(false);
setSendingCode(false);
}
} }
}; };
// 提交处理(登录或注册) // 提交表单
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e?.preventDefault?.();
setIsLoading(true); setIsLoading(true);
try { try {
const { phone, verificationCode } = formData; const { phone, verificationCode } = formData;
// 表单验证
if (!phone || !verificationCode) { if (!phone || !verificationCode) {
toast({ message.warning("手机号和验证码不能为空");
title: "请填写完整信息",
description: "手机号和验证码不能为空",
status: "warning",
duration: 3000,
});
return; return;
} }
// 清理手机号格式字符(空格、横线、括号等)
const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, ''); const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, '');
if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) { if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) {
toast({ message.warning("请输入有效的手机号");
title: "请输入有效的手机号",
status: "warning",
duration: 3000,
});
return; return;
} }
// 追踪验证码提交
authEvents.trackVerificationCodeSubmitted(phone); 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`, { const response = await fetch(`${getApiBase()}/api/auth/login-with-code`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
credentials: 'include', 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(); const data = await response.json();
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
if (!data) {
throw new Error('服务器响应为空');
}
if (response.ok && data.success) { if (response.ok && data.success) {
// 更新session
await checkSession(); await checkSession();
// ✅ 兼容后端两种命名格式camelCase (isNewUser) 和 snake_case (is_new_user)
const isNewUser = data.isNewUser ?? data.is_new_user ?? false; const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
// 追踪登录成功并识别用户
authEvents.trackLoginSuccess(data.user, 'phone', isNewUser); authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
message.success(isNewUser ? '注册成功' : '登录成功');
// ✅ 保留登录成功 toast关键操作提示
toast({
title: isNewUser ? '注册成功' : '登录成功',
description: config.successDescription,
status: "success",
duration: 2000,
});
// 检查是否为新注册用户
if (isNewUser) { if (isNewUser) {
// 新注册用户,延迟后显示昵称设置引导
setTimeout(() => { setTimeout(() => {
setCurrentPhone(phone); setCurrentPhone(phone);
setShowNicknamePrompt(true); setShowNicknamePrompt(true);
// 追踪昵称设置引导显示
authEvents.trackNicknamePromptShown(phone); authEvents.trackNicknamePromptShown(phone);
}, config.features.successDelay); }, config.features.successDelay);
} else { } else {
// 已有用户,直接登录成功 setTimeout(() => handleLoginSuccess({ phone }), config.features.successDelay);
setTimeout(() => {
handleLoginSuccess({ phone });
}, config.features.successDelay);
} }
// ⚡ 延迟 10 秒显示权限引导(温和、非侵入)
setTimeout(() => { setTimeout(() => {
if (showWelcomeGuide) { if (showWelcomeGuide) showWelcomeGuide();
logger.info('AuthFormContent', '显示欢迎引导');
showWelcomeGuide();
}
}, 10000); }, 10000);
} else { } else {
throw new Error(data.error || `${config.errorTitle}`); throw new Error(data.error || config.errorTitle);
} }
} catch (error) { } catch (error) {
const { phone, verificationCode } = formData; const { phone, verificationCode } = formData;
authEvents.trackLoginFailed('phone', 'api', error.message, {
// 追踪登录失败
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', phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
has_verification_code: !!verificationCode has_verification_code: !!verificationCode
}); });
logger.error('AuthFormContent', 'handleSubmit', error);
logger.error('AuthFormContent', 'handleSubmit', error, { message.error(error.message || "验证失败");
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 { } finally {
if (isMountedRef.current) { if (isMountedRef.current) setIsLoading(false);
setIsLoading(false);
}
} }
}; };
// 微信H5登录处理 // 微信H5登录
const handleWechatH5Login = async () => { const handleWechatH5Login = async () => {
// 追踪用户选择微信登录
authEvents.trackWechatLoginInitiated('icon_button'); authEvents.trackWechatLoginInitiated('icon_button');
try { try {
// 1. 构建回调URL携带当前页面路径以便登录后返回
const currentPath = window.location.pathname + window.location.search; const currentPath = window.location.pathname + window.location.search;
const returnUrl = encodeURIComponent(currentPath); const returnUrl = encodeURIComponent(currentPath);
const redirectUrl = `${window.location.origin}/home/wechat-callback?returnUrl=${returnUrl}`; const redirectUrl = `${window.location.origin}/home/wechat-callback?returnUrl=${returnUrl}`;
// 2. 显示提示 message.info("正在跳转到微信授权页面...");
toast({
title: "即将跳转",
description: "正在跳转到微信授权页面...",
status: "info",
duration: 2000,
isClosable: true,
});
// 3. 获取微信H5授权URL
const response = await authService.getWechatH5AuthUrl(redirectUrl); const response = await authService.getWechatH5AuthUrl(redirectUrl);
if (!response || !response.auth_url) { if (!response?.auth_url) throw new Error('获取授权链接失败');
throw new Error('获取授权链接失败');
}
// 追踪微信H5跳转
authEvents.trackWechatH5Redirect(); authEvents.trackWechatH5Redirect();
setTimeout(() => { window.location.href = response.auth_url; }, 500);
// 4. 延迟跳转,让用户看到提示
setTimeout(() => {
window.location.href = response.auth_url;
}, 500);
} catch (error) { } catch (error) {
// 追踪跳转失败 authEvents.trackError('api', error.message || '获取微信授权链接失败');
authEvents.trackError('api', error.message || '获取微信授权链接失败', {
context: 'wechat_h5_redirect'
});
logger.error('AuthFormContent', 'handleWechatH5Login', error); logger.error('AuthFormContent', 'handleWechatH5Login', error);
toast({ message.error(error.message || "跳转失败");
title: "跳转失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
} }
}; };
// 组件挂载时追踪页面浏览
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
// 追踪登录页面浏览
authEvents.trackLoginPageViewed(); authEvents.trackLoginPageViewed();
return () => { isMountedRef.current = false; };
return () => {
isMountedRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅在挂载时执行一次,避免 countdown 倒计时导致重复触发 }, []);
return ( return (
<> <div className="auth-form-content">
<Box width="100%"> <AuthHeader title={config.title} subtitle={config.subtitle} />
<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>
{/* 验证码输入框 + 移动端微信图标 */} <Row gutter={isMobile ? 0 : 32} align="top">
<Box width="100%" position="relative"> {/* 左侧表单 */}
<VerificationCodeInput value={formData.verificationCode} onChange={handleInputChange} onSendCode={sendVerificationCode} countdown={countdown} isLoading={isLoading} isSending={sendingCode} error={errors.verificationCode} colorScheme="green" /> <Col xs={24} md={14}>
<Title level={5} style={styles.formTitle}>{config.formTitle}</Title>
{/* 移动端:验证码下方的微信登录图标 */} <Form layout="vertical" onFinish={handleSubmit}>
{isMobile && ( <Form.Item>
<HStack spacing={0} mt={2} alignItems="center"> <Input
<Text fontSize="xs" color="gray.500">其他登录方式</Text> name="phone"
<IconButton value={formData.phone}
aria-label="微信登录" onChange={handleInputChange}
icon={<WechatOutlined style={{ fontSize: '16px' }} />} placeholder="请输入11位手机号"
size="sm" size="large"
variant="ghost" style={styles.input}
color="#07C160" />
borderRadius="md" </Form.Item>
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={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}> {isMobile && (
登录即表示您同意价值前沿{" "} <div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<ChakraLink <Text style={styles.otherLoginText}>其他登录方式</Text>
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 <Button
ref={cancelRef} type="text"
onClick={() => { icon={<WechatOutlined />}
authEvents.trackNicknamePromptSkipped(); onClick={handleWechatH5Login}
setShowNicknamePrompt(false); disabled={isLoading}
handleLoginSuccess({ phone: currentPhone }); style={styles.wechatBtn}
}} />
> </div>
稍后再说 )}
</Button>
<Button <Form.Item>
colorScheme="green" <Button
onClick={() => { type="primary"
authEvents.trackNicknamePromptAccepted(); htmlType="submit"
setShowNicknamePrompt(false); size="large"
handleLoginSuccess({ phone: currentPhone }); block
setTimeout(() => { loading={isLoading}
navigate('/home/profile'); icon={<LockOutlined />}
}, 300); style={styles.submitBtn}
}} >
ml={3} {isLoading ? config.loadingText : config.buttonText}
> </Button>
去设置 </Form.Item>
</Button>
</AlertDialogFooter> <div style={styles.privacyText}>
</AlertDialogContent> 登录即表示您同意价值前沿{" "}
</AlertDialogOverlay> <Link
</AlertDialog> 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>
); );
} }

View File

@@ -1,23 +1,46 @@
// src/components/Auth/AuthHeader.js // src/components/Auth/AuthHeader.js
// 认证页面头部组件 - 黑金主题
import React from "react"; 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 }) { export default function AuthHeader({ title, subtitle }) {
return ( return (
<VStack spacing={2} mb={8}> <div style={styles.header}>
<Heading size="xl" color="gray.800" fontWeight="bold"> <Title level={2} style={styles.title}>{title}</Title>
{title} <div style={styles.divider} />
</Heading> <Text style={styles.subtitle}>{subtitle}</Text>
<Text color="gray.600" fontSize="md"> </div>
{subtitle} );
</Text>
</VStack>
);
} }

View 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;
}
}

View File

@@ -1,16 +1,42 @@
// src/components/Auth/AuthModalManager.js // src/components/Auth/AuthModalManager.js
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useMemo } from 'react';
import { Modal } from 'antd'; import { Modal } from 'antd';
import { useBreakpointValue } from '@chakra-ui/react'; import { useBreakpointValue } from '@chakra-ui/react';
import { useAuthModal } from '../../hooks/useAuthModal'; import { useAuthModal } from '../../hooks/useAuthModal';
import AuthFormContent from './AuthFormContent'; import AuthFormContent from './AuthFormContent';
import { trackEventAsync } from '@lib/posthog'; import { trackEventAsync } from '@lib/posthog';
import { ACTIVATION_EVENTS } from '@lib/constants'; 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() { export default function AuthModalManager() {
const { const {
@@ -27,7 +53,7 @@ export default function AuthModalManager() {
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, { trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
modal_type: 'auth_modal', modal_type: 'auth_modal',
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源 trigger_source: 'user_action',
}); });
hasTrackedOpen.current = true; hasTrackedOpen.current = true;
@@ -39,43 +65,64 @@ export default function AuthModalManager() {
} }
}, [isAuthModalOpen]); }, [isAuthModalOpen]);
// 响应式宽度配置Ant Design Modal 使用数字或字符串) // 响应式宽度配置
const modalMaxW = useBreakpointValue( const modalMaxW = useBreakpointValue(
{ {
base: "90%", // 移动端屏幕宽度的90% base: "90%",
sm: "90%", // 小屏90% sm: "90%",
md: "700px", // 中屏固定700px md: "700px",
lg: "700px" // 大屏固定700px lg: "700px"
}, },
{ fallback: "700px", ssr: false } { fallback: "700px", ssr: false }
); );
// ✅ 使用 Ant Design Modal,完全避开 Chakra UI Portal 的 AnimatePresence 问题 // Ant Design 5.x Modal styles 属性 - 直接覆盖 CSS-in-JS 样式
// Ant Design Modal 不使用 Framer Motion不会有 React 18 并发渲染的 insertBefore 错误 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 ( return (
<Modal <>
open={isAuthModalOpen} <GlobalInputStyles />
onCancel={closeModal} <Modal
footer={null} open={isAuthModalOpen}
width={modalMaxW} onCancel={closeModal}
centered footer={null}
destroyOnHidden={true} width={modalMaxW}
maskClosable={false} centered
keyboard={true} destroyOnHidden={true}
zIndex={999} maskClosable={false}
styles={{ keyboard={true}
body: { zIndex={999}
padding: '24px', className="auth-modal"
maxHeight: 'calc(90vh - 120px)', rootClassName="auth-modal-root"
overflowY: 'auto' styles={modalStyles}
}, closeIcon={
mask: { <span style={{ color: THEME.textSecondary, fontSize: '16px' }}></span>
backdropFilter: GLASS_BLUR.sm,
backgroundColor: 'rgba(0, 0, 0, 0.7)'
} }
}} >
> <AuthFormContent />
<AuthFormContent /> </Modal>
</Modal> </>
); );
} }

View File

@@ -1,74 +1,99 @@
import React from "react"; // src/components/Auth/VerificationCodeInput.js
import { FormControl, FormErrorMessage, HStack, Input, Button, Spinner } from "@chakra-ui/react"; // 验证码输入组件 - 黑金主题
import React from 'react';
import { Input, Button, Space } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { logger } from "../../utils/logger"; 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({ export default function VerificationCodeInput({
value, value,
onChange, onChange,
onSendCode, onSendCode,
countdown, countdown = 0,
isLoading, isLoading = false,
isSending, isSending = false,
error,
placeholder = "请输入6位验证码", placeholder = "请输入6位验证码",
buttonText = "获取验证码", buttonText = "获取验证码",
countdownText = (count) => `${count}s`, countdownText = (count) => `${count}s`,
colorScheme = "green",
isRequired = true
}) { }) {
// 包装 onSendCode确保所有错误都被捕获防止被 ErrorBoundary 捕获
const handleSendCode = async () => { const handleSendCode = async () => {
try { try {
if (onSendCode) { if (onSendCode) {
await onSendCode(); await onSendCode();
} }
} catch (error) { } catch (error) {
// 错误已经在父组件处理,这里只需要防止未捕获的 Promise rejection logger.error('VerificationCodeInput', 'handleSendCode', error);
logger.error('VerificationCodeInput', 'handleSendCode', error, {
hasOnSendCode: !!onSendCode,
countdown,
isLoading,
isSending
});
} }
}; };
// 计算按钮显示的文本(避免在 JSX 中使用条件渲染)
const getButtonText = () => { const getButtonText = () => {
if (isSending) { if (isSending) return "发送中...";
return "发送中"; if (countdown > 0) return countdownText(countdown);
}
if (countdown > 0) {
return countdownText(countdown);
}
return buttonText; return buttonText;
}; };
const isDisabled = countdown > 0 || isLoading || isSending;
return ( return (
<FormControl isRequired={isRequired} isInvalid={!!error}> <Space.Compact style={{ width: '100%' }}>
<HStack> <Input
<Input name="verificationCode"
name="verificationCode" value={value}
value={value} onChange={onChange}
onChange={onChange} placeholder={placeholder}
placeholder={placeholder} maxLength={6}
maxLength={6} size="large"
/> style={styles.input}
<Button />
type="button" <Button
colorScheme={colorScheme} onClick={handleSendCode}
onClick={handleSendCode} disabled={isDisabled}
isDisabled={countdown > 0 || isLoading || isSending} size="large"
minW="120px" style={isDisabled ? styles.sendCodeBtnDisabled : styles.sendCodeBtn}
leftIcon={isSending ? <Spinner size="sm" /> : undefined} icon={isSending ? <LoadingOutlined /> : null}
> >
{getButtonText()} {getButtonText()}
</Button> </Button>
</HStack> </Space.Compact>
<FormErrorMessage>{error}</FormErrorMessage>
</FormControl>
); );
} }

View File

@@ -1,208 +1,206 @@
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from "react"; // src/components/Auth/WechatRegister.js
import { // 微信登录组件 - 黑金主题
Box, import React, { useState, useEffect, useRef, useCallback } from "react";
Button, import { Typography, Button, message } from 'antd';
VStack, import { QrcodeOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
HStack,
Center,
Text,
Heading,
Icon,
useToast,
Spinner
} from "@chakra-ui/react";
import { QrCode, AlertCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService"; import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
import { useAuthModal } from "../../hooks/useAuthModal"; import { useAuthModal } from "../../hooks/useAuthModal";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { useAuthEvents } from "../../hooks/useAuthEvents"; import { useAuthEvents } from "../../hooks/useAuthEvents";
import { GLASS_BLUR } from "@/constants/glassConfig";
const { Title, Text } = Typography;
// 配置常量 // 配置常量
const POLL_INTERVAL = 2000; // 轮询间隔2秒 const POLL_INTERVAL = 2000;
const BACKUP_POLL_INTERVAL = 3000; // 备用轮询间隔3秒 const QR_CODE_TIMEOUT = 300000;
const QR_CODE_TIMEOUT = 300000; // 二维码超时5分钟
// 黑金主题样式
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) => { const getStatusColor = (status) => {
switch(status) { switch(status) {
case WECHAT_STATUS.WAITING: return "gray.600"; // ✅ 灰色文字 case WECHAT_STATUS.WAITING: return "rgba(255, 255, 255, 0.6)";
case WECHAT_STATUS.SCANNED: return "green.600"; // ✅ 绿色文字 case WECHAT_STATUS.SCANNED:
case WECHAT_STATUS.AUTHORIZED: return "green.600"; // ✅ 绿色文字 case WECHAT_STATUS.AUTHORIZED:
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字 case WECHAT_STATUS.LOGIN_SUCCESS:
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字 case WECHAT_STATUS.REGISTER_SUCCESS:
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600"; return "#D4AF37";
case WECHAT_STATUS.AUTH_DENIED: return "red.600"; // ✅ 红色文字 case WECHAT_STATUS.EXPIRED: return "#CD853F";
case WECHAT_STATUS.AUTH_FAILED: return "red.600"; // ✅ 红色文字 case WECHAT_STATUS.AUTH_DENIED:
default: return "gray.600"; case WECHAT_STATUS.AUTH_FAILED:
return "#CD5C5C";
default: return "rgba(255, 255, 255, 0.6)";
} }
}; };
/** const getStatusText = (status) => STATUS_MESSAGES[status] || "点击按钮获取二维码";
* 获取状态文字
*/
const getStatusText = (status) => {
return STATUS_MESSAGES[status] || "点击按钮获取二维码";
};
export default function WechatRegister() { export default function WechatRegister() {
// 获取关闭弹窗方法
const { closeModal } = useAuthModal(); const { closeModal } = useAuthModal();
const { refreshSession } = useAuth(); const { refreshSession } = useAuth();
const authEvents = useAuthEvents({ component: 'WechatRegister', isMobile: false });
// 事件追踪
const authEvents = useAuthEvents({
component: 'WechatRegister',
isMobile: false // WechatRegister 只在桌面端显示
});
// 状态管理
const [wechatAuthUrl, setWechatAuthUrl] = useState(""); const [wechatAuthUrl, setWechatAuthUrl] = useState("");
const [wechatSessionId, setWechatSessionId] = useState(""); const [wechatSessionId, setWechatSessionId] = useState("");
const [wechatStatus, setWechatStatus] = useState(WECHAT_STATUS.NONE); const [wechatStatus, setWechatStatus] = useState(WECHAT_STATUS.NONE);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [scale, setScale] = useState(1); // iframe 缩放比例
// 使用 useRef 管理定时器,避免闭包问题和内存泄漏
const pollIntervalRef = useRef(null); const pollIntervalRef = useRef(null);
const backupPollIntervalRef = useRef(null); // 备用轮询定时器
const timeoutRef = useRef(null); const timeoutRef = useRef(null);
const isMountedRef = useRef(true); // 追踪组件挂载状态 const isMountedRef = useRef(true);
const containerRef = useRef(null); // 容器DOM引用 const containerRef = useRef(null);
const sessionIdRef = useRef(null); // 存储最新的 sessionId避免闭包陷阱 const sessionIdRef = useRef(null);
const wechatStatusRef = useRef(WECHAT_STATUS.NONE); // 存储最新的 wechatStatus避免闭包陷阱 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(() => { const clearTimers = useCallback(() => {
if (pollIntervalRef.current) { if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null; pollIntervalRef.current = null;
} }
if (backupPollIntervalRef.current) {
clearInterval(backupPollIntervalRef.current);
backupPollIntervalRef.current = null;
}
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
timeoutRef.current = null; timeoutRef.current = null;
} }
}, []); }, []);
/**
* 处理登录成功
*/
const handleLoginSuccess = useCallback(async (sessionId, status) => { const handleLoginSuccess = useCallback(async (sessionId, status) => {
try { try {
const response = await authService.loginWithWechat(sessionId); const response = await authService.loginWithWechat(sessionId);
if (response?.success) { if (response?.success) {
// 追踪微信登录成功 authEvents.trackLoginSuccess(response.user, 'wechat', response.isNewUser || false);
authEvents.trackLoginSuccess( if (response.token) localStorage.setItem('token', response.token);
response.user, if (response.user) localStorage.setItem('user', JSON.stringify(response.user));
'wechat', message.success(status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!");
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 状态
await refreshSession(); await refreshSession();
// 关闭认证弹窗,留在当前页面
closeModal(); closeModal();
} else { } else {
throw new Error(response?.error || '登录失败'); throw new Error(response?.error || '登录失败');
} }
} catch (error) { } catch (error) {
// 追踪微信登录失败 authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败');
authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败', { logger.error('WechatRegister', 'handleLoginSuccess', error);
session_id: sessionId?.substring(0, 8) + '...', message.error(error.message || "登录失败,请重试");
status: status
});
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
showError("登录失败", error.message || "请重试");
} }
}, [showSuccess, showError, closeModal, refreshSession, authEvents]); }, [closeModal, refreshSession, authEvents]);
/**
* 检查微信扫码状态
* 使用 sessionIdRef.current 避免闭包陷阱
*/
const checkWechatStatus = useCallback(async () => { const checkWechatStatus = useCallback(async () => {
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId if (!isMountedRef.current || !sessionIdRef.current) return;
if (!isMountedRef.current || !sessionIdRef.current) {
return;
}
const currentSessionId = sessionIdRef.current; const currentSessionId = sessionIdRef.current;
try { try {
const response = await authService.checkWechatStatus(currentSessionId); const response = await authService.checkWechatStatus(currentSessionId);
if (!response || typeof response.status === 'undefined') return;
// 安全检查:确保 response 存在且包含 status
if (!response || typeof response.status === 'undefined') {
return;
}
const { status } = response; const { status } = response;
// 组件卸载后不再更新状态
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
// 追踪状态变化(使用 ref 获取最新状态,避免闭包陷阱)
const previousStatus = wechatStatusRef.current; const previousStatus = wechatStatusRef.current;
if (previousStatus !== status) { if (previousStatus !== status) {
authEvents.trackWechatStatusChanged(currentSessionId, previousStatus, status); authEvents.trackWechatStatusChanged(currentSessionId, previousStatus, status);
// 特别追踪扫码事件
if (status === WECHAT_STATUS.SCANNED) { if (status === WECHAT_STATUS.SCANNED) {
authEvents.trackWechatQRScanned(currentSessionId); authEvents.trackWechatQRScanned(currentSessionId);
} }
@@ -210,282 +208,100 @@ export default function WechatRegister() {
setWechatStatus(status); setWechatStatus(status);
// 处理成功状态
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) { if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
clearTimers(); // 停止轮询 clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null;
await handleLoginSuccess(currentSessionId, status); await handleLoginSuccess(currentSessionId, status);
} } else if (status === WECHAT_STATUS.EXPIRED) {
// 处理过期状态
else if (status === WECHAT_STATUS.EXPIRED) {
// 追踪二维码过期
authEvents.trackWechatQRExpired(currentSessionId, QR_CODE_TIMEOUT / 1000); authEvents.trackWechatQRExpired(currentSessionId, QR_CODE_TIMEOUT / 1000);
clearTimers(); clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null;
if (isMountedRef.current) { message.warning("授权已过期,请重新获取");
toast({ } else if (status === WECHAT_STATUS.AUTH_DENIED) {
title: "授权已过期",
description: "请重新获取授权",
status: "warning",
duration: 3000,
isClosable: true,
});
}
}
// 处理用户拒绝授权
else if (status === WECHAT_STATUS.AUTH_DENIED) {
clearTimers(); clearTimers();
if (isMountedRef.current) { message.warning("您已取消微信授权登录");
toast({ } else if (status === WECHAT_STATUS.AUTH_FAILED) {
title: "授权已取消",
description: "您已取消微信授权登录",
status: "warning",
duration: 3000,
isClosable: true,
});
}
}
// 处理授权失败
else if (status === WECHAT_STATUS.AUTH_FAILED) {
clearTimers(); clearTimers();
if (isMountedRef.current) { message.error(response.error || "授权过程出现错误");
const errorMsg = response.error || "授权过程出现错误";
toast({
title: "授权失败",
description: errorMsg,
status: "error",
duration: 5000,
isClosable: true,
});
}
} }
// AUTHORIZED 状态只是中间状态,继续轮询等待 login_ready/register_ready
// 不在此处调用登录接口,避免"会话状态无效"错误
} catch (error) { } catch (error) {
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId }); logger.error('WechatRegister', 'checkWechatStatus', error);
// 轮询过程中的错误不显示给用户,避免频繁提示
// 但如果错误持续发生,停止轮询避免无限重试
if (error.message.includes('网络连接失败')) { if (error.message.includes('网络连接失败')) {
clearTimers(); clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null;
if (isMountedRef.current) { message.error("网络连接失败,请检查网络后重试");
toast({
title: "网络连接失败",
description: "请检查网络后重试",
status: "error",
duration: 3000,
isClosable: true,
});
}
} }
} }
}, [handleLoginSuccess, clearTimers, toast]); }, [handleLoginSuccess, clearTimers, authEvents]);
/**
* 启动轮询
*/
const startPolling = useCallback(() => { const startPolling = useCallback(() => {
// 清理旧的定时器
clearTimers(); clearTimers();
pollIntervalRef.current = setInterval(checkWechatStatus, POLL_INTERVAL);
// 启动轮询
pollIntervalRef.current = setInterval(() => {
checkWechatStatus();
}, POLL_INTERVAL);
// 设置超时
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
clearTimers(); clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null;
setWechatStatus(WECHAT_STATUS.EXPIRED); setWechatStatus(WECHAT_STATUS.EXPIRED);
}, QR_CODE_TIMEOUT); }, QR_CODE_TIMEOUT);
}, [checkWechatStatus, clearTimers]); }, [checkWechatStatus, clearTimers]);
/**
* 获取微信二维码
*/
const getWechatQRCode = useCallback(async () => { const getWechatQRCode = useCallback(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
// 追踪用户选择微信登录(首次或刷新)
const isRefresh = Boolean(wechatSessionId); const isRefresh = Boolean(wechatSessionId);
if (isRefresh) { authEvents.trackWechatLoginInitiated(isRefresh ? 'qr_refresh' : 'qr_area');
const oldSessionId = wechatSessionId;
authEvents.trackWechatLoginInitiated('qr_refresh');
// 稍后会在成功时追踪刷新事件
} else {
authEvents.trackWechatLoginInitiated('qr_area');
}
// 生产环境:调用真实 API
const response = await authService.getWechatQRCode(); const response = await authService.getWechatQRCode();
// 检查组件是否已卸载
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
// 安全检查:确保响应包含必要字段 if (!response || response.code !== 0) {
if (!response) { throw new Error(response?.message || '获取二维码失败');
throw new Error('服务器无响应');
} }
if (response.code !== 0) {
throw new Error(response.message || '获取二维码失败');
}
// 追踪二维码显示 (首次或刷新)
if (isRefresh) { if (isRefresh) {
authEvents.trackWechatQRRefreshed(wechatSessionId, response.data.session_id); authEvents.trackWechatQRRefreshed(wechatSessionId, response.data.session_id);
} else { } else {
authEvents.trackWechatQRDisplayed(response.data.session_id, response.data.auth_url); authEvents.trackWechatQRDisplayed(response.data.session_id, response.data.auth_url);
} }
// 同时更新 ref 和 state确保轮询能立即读取到最新值
sessionIdRef.current = response.data.session_id; sessionIdRef.current = response.data.session_id;
setWechatAuthUrl(response.data.auth_url); setWechatAuthUrl(response.data.auth_url);
setWechatSessionId(response.data.session_id); setWechatSessionId(response.data.session_id);
setWechatStatus(WECHAT_STATUS.WAITING); setWechatStatus(WECHAT_STATUS.WAITING);
// 启动轮询检查扫码状态
startPolling(); startPolling();
} catch (error) { } catch (error) {
// 追踪获取二维码失败 authEvents.trackError('api', error.message || '获取二维码失败');
authEvents.trackError('api', error.message || '获取二维码失败', {
context: 'get_wechat_qrcode'
});
logger.error('WechatRegister', 'getWechatQRCode', error); logger.error('WechatRegister', 'getWechatQRCode', error);
if (isMountedRef.current) { message.error(error.message || "获取微信授权失败,请稍后重试");
showError("获取微信授权失败", error.message || "请稍后重试");
}
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) setIsLoading(false);
setIsLoading(false);
}
} }
}, [startPolling, showError, wechatSessionId, authEvents]); }, [startPolling, wechatSessionId, authEvents]);
/**
* 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
*/
const handleGetQRCodeClick = useCallback(async () => { const handleGetQRCodeClick = useCallback(async () => {
try { try {
await getWechatQRCode(); await getWechatQRCode();
} catch (error) { } catch (error) {
// 错误已经在 getWechatQRCode 中处理,这里只需要防止未捕获的 Promise rejection
logger.error('WechatRegister', 'handleGetQRCodeClick', error); logger.error('WechatRegister', 'handleGetQRCodeClick', error);
} }
}, [getWechatQRCode]); }, [getWechatQRCode]);
/** useEffect(() => { wechatStatusRef.current = wechatStatus; }, [wechatStatus]);
* 同步 wechatStatusRef 与 wechatStatus state
* 确保 checkWechatStatus 回调中能获取到最新状态
*/
useEffect(() => {
wechatStatusRef.current = wechatStatus;
}, [wechatStatus]);
/**
* 组件卸载时清理定时器和标记组件状态
*/
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
clearTimers(); clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null;
}; };
}, [clearTimers]); }, [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 ( return (
<VStack <div style={styles.container}>
spacing={0} // ✅ 手动控制间距 <Title level={5} style={styles.title}>微信登陆</Title>
alignItems="stretch" // ✅ 拉伸对齐
justifyContent="flex-start" // ✅ 顶部对齐(标题对齐关键)
width="auto" // ✅ 自适应宽度
>
{/* ========== 标题区域 ========== */}
<Heading
size="md" // ✅ 16px与左侧"登陆/注册"一致
fontWeight="600"
color="gray.800"
textAlign="center"
mb={3} // 12px底部间距
>
微信登陆
</Heading>
{/* ========== 二维码区域 ========== */} <div ref={containerRef} style={styles.qrcodeContainer}>
<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" // ✅ 添加轻微阴影
>
{wechatStatus === WECHAT_STATUS.WAITING ? ( {wechatStatus === WECHAT_STATUS.WAITING ? (
/* 已获取二维码显示iframe */
<iframe <iframe
src={wechatAuthUrl} src={wechatAuthUrl}
title="微信扫码登录" title="微信扫码登录"
@@ -493,7 +309,6 @@ export default function WechatRegister() {
height="350" height="350"
scrolling="no" scrolling="no"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation"
allow="clipboard-write"
style={{ style={{
border: 'none', border: 'none',
transform: 'scale(0.77) translateY(-35px)', transform: 'scale(0.77) translateY(-35px)',
@@ -502,104 +317,58 @@ export default function WechatRegister() {
pointerEvents: 'auto', pointerEvents: 'auto',
overflow: 'hidden', overflow: 'hidden',
}} }}
onWheel={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
/> />
) : ( ) : (
/* 未获取:显示占位符 */ <div style={styles.placeholder}>
<Center width="100%" height="100%" flexDirection="column"> <QrcodeOutlined style={styles.qrcodeIcon} />
<Icon as={QrCode} w={16} h={16} color="gray.300" mb={4} />
<Button <Button
size="sm" type="primary"
colorScheme="green"
onClick={handleGetQRCodeClick} onClick={handleGetQRCodeClick}
isLoading={isLoading} loading={isLoading}
style={styles.getQrcodeBtn}
> >
{wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"} {wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"}
</Button> </Button>
</Center> </div>
)} )}
{/* ========== 过期蒙层 ========== */}
{wechatStatus === WECHAT_STATUS.EXPIRED && ( {wechatStatus === WECHAT_STATUS.EXPIRED && (
<Box <div style={styles.expiredOverlay}>
position="absolute" <ExclamationCircleOutlined style={styles.expiredIcon} />
top="0" <Text style={styles.expiredText}>二维码已过期</Text>
left="0" <Button size="small" onClick={handleGetQRCodeClick} style={styles.refreshBtn}>
right="0" 点击刷新
bottom="0" </Button>
bg="rgba(0,0,0,0.6)" </div>
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>
)} )}
</Box> </div>
{/* ========== 状态指示器 ========== */}
{wechatStatus !== WECHAT_STATUS.NONE && ( {wechatStatus !== WECHAT_STATUS.NONE && (
<Text <Text style={{ ...styles.statusText, color: getStatusColor(wechatStatus) }}>
mt={3}
fontSize="sm"
fontWeight="500" // ✅ 半粗体
textAlign="center"
color={getStatusColor(wechatStatus)} // ✅ 根据状态显示不同颜色
>
{getStatusText(wechatStatus)} {getStatusText(wechatStatus)}
</Text> </Text>
)} )}
{/* ========== Mock 模式控制按钮(仅开发环境) ========== */}
{process.env.REACT_APP_ENABLE_MOCK === 'true' && wechatStatus === WECHAT_STATUS.WAITING && wechatSessionId && ( {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 <Button
size="xs" size="small"
width="100%" block
colorScheme="purple" style={styles.mockBtn}
variant="outline"
onClick={() => { onClick={() => {
if (window.mockWechatScan) { if (window.mockWechatScan) {
const success = window.mockWechatScan(wechatSessionId); const success = window.mockWechatScan(wechatSessionId);
if (success) { if (success) message.info("正在模拟扫码登录...");
toast({
title: "Mock 模拟触发成功",
description: "正在模拟扫码登录...",
status: "info",
duration: 2000,
isClosable: false,
});
}
} else { } else {
toast({ message.warning("Mock API 未加载,请刷新页面重试");
title: "Mock API 未加载",
description: "请刷新页面重试",
status: "warning",
duration: 2000,
});
} }
}} }}
leftIcon={<Text fontSize="lg">🧪</Text>}
> >
模拟扫码成功测试 🧪 模拟扫码成功测试
</Button> </Button>
<Text fontSize="xs" color="gray.400" textAlign="center" mt={1}> <Text style={styles.mockHint}>开发模式 | 自动登录: 5</Text>
开发模式 | 自动登录: 5 </div>
</Text>
</Box>
)} )}
</VStack> </div>
); );
} }

View File

@@ -12,7 +12,6 @@ import {
Box, Box,
Text, Text,
Badge, Badge,
useColorModeValue,
useDisclosure useDisclosure
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronDown, Home, User, Settings, LogOut, Crown } from 'lucide-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 PersonalCenterMenu = memo(({ user, handleLogout }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const hoverBg = useColorModeValue('gray.100', 'gray.700');
// 🎯 为个人中心菜单创建 useDisclosure Hook // 🎯 为个人中心菜单创建 useDisclosure Hook
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@@ -47,9 +45,10 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
<MenuButton <MenuButton
as={Button} as={Button}
size="sm" size="sm"
variant="ghost" colorScheme="blue"
variant="solid"
borderRadius="full"
rightIcon={<ChevronDown size={16} />} rightIcon={<ChevronDown size={16} />}
_hover={{ bg: hoverBg }}
onMouseEnter={onOpen} onMouseEnter={onOpen}
onMouseLeave={onClose} onMouseLeave={onClose}
> >