624 lines
24 KiB
JavaScript
624 lines
24 KiB
JavaScript
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 { FaQrcode } from "react-icons/fa";
|
||
import { FiAlertCircle } from "react-icons/fi";
|
||
import { useNavigate } from "react-router-dom";
|
||
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";
|
||
|
||
// 配置常量
|
||
const POLL_INTERVAL = 2000; // 轮询间隔:2秒
|
||
const BACKUP_POLL_INTERVAL = 3000; // 备用轮询间隔:3秒
|
||
const QR_CODE_TIMEOUT = 300000; // 二维码超时:5分钟
|
||
|
||
/**
|
||
* 获取状态文字颜色
|
||
*/
|
||
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";
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取状态文字
|
||
*/
|
||
const getStatusText = (status) => {
|
||
return STATUS_MESSAGES[status] || "点击按钮获取二维码";
|
||
};
|
||
|
||
export default function WechatRegister() {
|
||
// 获取关闭弹窗方法
|
||
const { closeModal } = useAuthModal();
|
||
const { refreshSession } = useAuth();
|
||
|
||
// 事件追踪
|
||
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 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 {
|
||
logger.info('WechatRegister', '开始调用登录接口', { sessionId: sessionId.substring(0, 8) + '...', status });
|
||
|
||
const response = await authService.loginWithWechat(sessionId);
|
||
|
||
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
|
||
|
||
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 状态
|
||
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 || "请重试");
|
||
}
|
||
}, [showSuccess, showError, closeModal, refreshSession, authEvents]);
|
||
|
||
/**
|
||
* 检查微信扫码状态
|
||
* 使用 sessionIdRef.current 避免闭包陷阱
|
||
*/
|
||
const checkWechatStatus = useCallback(async () => {
|
||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
|
||
isMounted: isMountedRef.current,
|
||
hasSessionId: !!sessionIdRef.current
|
||
});
|
||
return;
|
||
}
|
||
|
||
const currentSessionId = sessionIdRef.current;
|
||
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
|
||
|
||
try {
|
||
const response = await authService.checkWechatStatus(currentSessionId);
|
||
|
||
// 安全检查:确保 response 存在且包含 status
|
||
if (!response || typeof response.status === 'undefined') {
|
||
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
|
||
return;
|
||
}
|
||
|
||
const { status } = response;
|
||
logger.debug('WechatRegister', '微信状态', { status });
|
||
|
||
logger.debug('WechatRegister', '检测到微信状态', {
|
||
sessionId: wechatSessionId.substring(0, 8) + '...',
|
||
status,
|
||
userInfo: response.user_info
|
||
});
|
||
|
||
// 组件卸载后不再更新状态
|
||
if (!isMountedRef.current) return;
|
||
|
||
// 追踪状态变化
|
||
if (wechatStatus !== status) {
|
||
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status);
|
||
|
||
// 特别追踪扫码事件
|
||
if (status === WECHAT_STATUS.SCANNED) {
|
||
authEvents.trackWechatQRScanned(currentSessionId);
|
||
}
|
||
}
|
||
|
||
setWechatStatus(status);
|
||
|
||
// 处理成功状态
|
||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
|
||
clearTimers(); // 停止轮询
|
||
sessionIdRef.current = null; // 清理 sessionId
|
||
|
||
await handleLoginSuccess(currentSessionId, status);
|
||
}
|
||
// 处理过期状态
|
||
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) {
|
||
clearTimers();
|
||
if (isMountedRef.current) {
|
||
toast({
|
||
title: "授权已取消",
|
||
description: "您已取消微信授权登录",
|
||
status: "warning",
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
}
|
||
// 处理授权失败
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||
// 但如果错误持续发生,停止轮询避免无限重试
|
||
if (error.message.includes('网络连接失败')) {
|
||
clearTimers();
|
||
sessionIdRef.current = null; // 清理 sessionId
|
||
if (isMountedRef.current) {
|
||
toast({
|
||
title: "网络连接失败",
|
||
description: "请检查网络后重试",
|
||
status: "error",
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}, [handleLoginSuccess, clearTimers, toast]);
|
||
|
||
/**
|
||
* 启动轮询
|
||
*/
|
||
const startPolling = useCallback(() => {
|
||
logger.debug('WechatRegister', '启动轮询', {
|
||
sessionId: sessionIdRef.current,
|
||
interval: POLL_INTERVAL
|
||
});
|
||
|
||
// 清理旧的定时器
|
||
clearTimers();
|
||
|
||
// 启动轮询
|
||
pollIntervalRef.current = setInterval(() => {
|
||
checkWechatStatus();
|
||
}, POLL_INTERVAL);
|
||
|
||
// 设置超时
|
||
timeoutRef.current = setTimeout(() => {
|
||
logger.debug('WechatRegister', '二维码超时');
|
||
clearTimers();
|
||
sessionIdRef.current = null; // 清理 sessionId
|
||
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');
|
||
}
|
||
|
||
// 生产环境:调用真实 API
|
||
const response = await authService.getWechatQRCode();
|
||
|
||
// 检查组件是否已卸载
|
||
if (!isMountedRef.current) return;
|
||
|
||
// 安全检查:确保响应包含必要字段
|
||
if (!response) {
|
||
throw new Error('服务器无响应');
|
||
}
|
||
|
||
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);
|
||
|
||
logger.debug('WechatRegister', '获取二维码成功', {
|
||
sessionId: response.data.session_id,
|
||
authUrl: response.data.auth_url
|
||
});
|
||
|
||
// 启动轮询检查扫码状态
|
||
startPolling();
|
||
} catch (error) {
|
||
// 追踪获取二维码失败
|
||
authEvents.trackError('api', error.message || '获取二维码失败', {
|
||
context: 'get_wechat_qrcode'
|
||
});
|
||
|
||
logger.error('WechatRegister', 'getWechatQRCode', error);
|
||
if (isMountedRef.current) {
|
||
showError("获取微信授权失败", error.message || "请稍后重试");
|
||
}
|
||
} finally {
|
||
if (isMountedRef.current) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
}, [startPolling, showError, wechatSessionId, authEvents]);
|
||
|
||
/**
|
||
* 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
|
||
*/
|
||
const handleGetQRCodeClick = useCallback(async () => {
|
||
try {
|
||
await getWechatQRCode();
|
||
} catch (error) {
|
||
// 错误已经在 getWechatQRCode 中处理,这里只需要防止未捕获的 Promise rejection
|
||
logger.error('WechatRegister', 'handleGetQRCodeClick', error);
|
||
}
|
||
}, [getWechatQRCode]);
|
||
|
||
/**
|
||
* 组件卸载时清理定时器和标记组件状态
|
||
*/
|
||
useEffect(() => {
|
||
isMountedRef.current = true;
|
||
|
||
return () => {
|
||
isMountedRef.current = false;
|
||
clearTimers();
|
||
sessionIdRef.current = null; // 清理 sessionId
|
||
};
|
||
}, [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>
|
||
|
||
{/* ========== 二维码区域 ========== */}
|
||
<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.NONE ? (
|
||
/* 已获取二维码:显示iframe */
|
||
<iframe
|
||
src={wechatAuthUrl}
|
||
title="微信扫码登录"
|
||
width="300"
|
||
height="350"
|
||
scrolling="no" // ✅ 新增:禁止滚动
|
||
sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
|
||
style={{
|
||
border: 'none',
|
||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||
transformOrigin: 'top left',
|
||
marginLeft: '-5px',
|
||
pointerEvents: 'auto', // 允许点击 │ │
|
||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||
}}
|
||
// 使用 onWheel 事件阻止滚动 │ │
|
||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||
|
||
/>
|
||
) : (
|
||
/* 未获取:显示占位符 */
|
||
<Center width="100%" height="100%" flexDirection="column">
|
||
<Icon as={FaQrcode} w={16} h={16} color="gray.300" mb={4} />
|
||
<Button
|
||
size="sm"
|
||
colorScheme="green"
|
||
onClick={handleGetQRCodeClick}
|
||
isLoading={isLoading}
|
||
>
|
||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"}
|
||
</Button>
|
||
</Center>
|
||
)}
|
||
|
||
{/* ========== 过期蒙层 ========== */}
|
||
{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="blur(4px)"
|
||
>
|
||
<VStack spacing={2}>
|
||
<Icon as={FiAlertCircle} w={8} h={8} color="white" />
|
||
<Text color="white" fontSize="sm">二维码已过期</Text>
|
||
<Button
|
||
size="xs"
|
||
colorScheme="whiteAlpha"
|
||
onClick={handleGetQRCodeClick}
|
||
>
|
||
点击刷新
|
||
</Button>
|
||
</VStack>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{/* ========== 状态指示器 ========== */}
|
||
{wechatStatus !== WECHAT_STATUS.NONE && (
|
||
<Text
|
||
mt={3}
|
||
fontSize="sm"
|
||
fontWeight="500" // ✅ 半粗体
|
||
textAlign="center"
|
||
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">
|
||
<Button
|
||
size="xs"
|
||
width="100%"
|
||
colorScheme="purple"
|
||
variant="outline"
|
||
onClick={() => {
|
||
if (window.mockWechatScan) {
|
||
const success = window.mockWechatScan(wechatSessionId);
|
||
if (success) {
|
||
toast({
|
||
title: "Mock 模拟触发成功",
|
||
description: "正在模拟扫码登录...",
|
||
status: "info",
|
||
duration: 2000,
|
||
isClosable: false,
|
||
});
|
||
}
|
||
} else {
|
||
toast({
|
||
title: "Mock API 未加载",
|
||
description: "请刷新页面重试",
|
||
status: "warning",
|
||
duration: 2000,
|
||
});
|
||
}
|
||
}}
|
||
leftIcon={<Text fontSize="lg">🧪</Text>}
|
||
>
|
||
模拟扫码成功(测试)
|
||
</Button>
|
||
<Text fontSize="xs" color="gray.400" textAlign="center" mt={1}>
|
||
开发模式 | 自动登录: 5秒
|
||
</Text>
|
||
</Box>
|
||
)}
|
||
</VStack>
|
||
);
|
||
}
|