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
// 统一的认证表单组件
// 统一的认证表单组件 - 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和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>
{/* 移动端微信登录 */}
{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>
);
}

View File

@@ -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>
);
}

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
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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
>