Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbfb1e2bcc | |||
| e061685982 | |||
| 9a0b9c3234 | |||
| 8156bb1e13 | |||
| 6f4499afff |
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析
|
||||
*
|
||||
* 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
|
||||
* 后挂载全局 window.WxLogin,new WxLogin({...}) 会在指定容器内渲染二维码 iframe。
|
||||
* 本模块负责:动态加载该脚本(带超时/失败检测)、从后端返回的 auth_url 中解析
|
||||
* WxLogin 所需的 appid / redirect_uri / state。
|
||||
*/
|
||||
|
||||
const WX_LOGIN_SRC = "https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"
|
||||
/** 脚本加载超时(毫秒):超时视为加载失败,调用方回退整页跳转 */
|
||||
const WX_LOGIN_LOAD_TIMEOUT = 8000
|
||||
|
||||
/** WxLogin 构造参数(微信官方字段,保持原名) */
|
||||
export interface WxLoginOptions {
|
||||
/** 是否内嵌二维码(回调在 iframe 内完成) */
|
||||
self_redirect: boolean
|
||||
/** 二维码容器元素 id */
|
||||
id: string
|
||||
/** 微信开放平台 AppID */
|
||||
appid: string
|
||||
/** 应用授权作用域,网站应用固定 snsapi_login */
|
||||
scope: "snsapi_login"
|
||||
/** 回调地址(需与微信开放平台配置一致,WxLogin 内部会 encodeURIComponent) */
|
||||
redirect_uri: string
|
||||
/** 防 CSRF 随机串,由后端 state store 生成并在回调时一次性消费 */
|
||||
state: string
|
||||
/** 二维码样式:black / white */
|
||||
style?: "black" | "white"
|
||||
/** 自定义样式链接(可选) */
|
||||
href?: string
|
||||
}
|
||||
|
||||
/** 微信脚本挂载到 window 上的全局构造函数类型 */
|
||||
export interface WxLoginConstructor {
|
||||
new (options: WxLoginOptions): unknown
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WxLogin?: WxLoginConstructor
|
||||
}
|
||||
}
|
||||
|
||||
let loadPromise: Promise<WxLoginConstructor> | null = null
|
||||
|
||||
/**
|
||||
* 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。
|
||||
* 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。
|
||||
*/
|
||||
export function loadWxLoginScript(): Promise<WxLoginConstructor> {
|
||||
if (window.WxLogin) return Promise.resolve(window.WxLogin)
|
||||
if (loadPromise) return loadPromise
|
||||
|
||||
loadPromise = new Promise<WxLoginConstructor>((resolve, reject) => {
|
||||
const script = document.createElement("script")
|
||||
script.src = WX_LOGIN_SRC
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if (window.WxLogin) {
|
||||
resolve(window.WxLogin)
|
||||
} else {
|
||||
loadPromise = null
|
||||
reject(new Error("微信登录脚本加载完成但 WxLogin 未挂载"))
|
||||
}
|
||||
}
|
||||
script.onerror = () => {
|
||||
loadPromise = null
|
||||
script.remove()
|
||||
reject(new Error("微信登录脚本加载失败"))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
|
||||
// 超时兜底:部分网络环境下脚本既不 onload 也不 onerror
|
||||
window.setTimeout(() => {
|
||||
if (window.WxLogin) {
|
||||
resolve(window.WxLogin)
|
||||
return
|
||||
}
|
||||
loadPromise = null
|
||||
script.remove()
|
||||
reject(new Error("微信登录脚本加载超时"))
|
||||
}, WX_LOGIN_LOAD_TIMEOUT)
|
||||
})
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
/** 从微信授权链接 query 中解析出的 WxLogin 所需参数 */
|
||||
export interface ParsedWxAuthParams {
|
||||
appid: string
|
||||
/** 已 URL 解码的回调地址(传给 WxLogin 时由其内部再次编码) */
|
||||
redirect_uri: string
|
||||
state: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端返回的微信授权链接(https://open.weixin.qq.com/connect/qrconnect?appid=...&redirect_uri=...&state=...)
|
||||
* 中解析 appid / redirect_uri / state。解析失败时返回 null,由调用方回退整页跳转。
|
||||
*/
|
||||
export function parseWxAuthUrl(authUrl: string, stateFallback?: string): ParsedWxAuthParams | null {
|
||||
try {
|
||||
const url = new URL(authUrl)
|
||||
const appid = url.searchParams.get("appid")
|
||||
const redirectUri = url.searchParams.get("redirect_uri")
|
||||
const state = url.searchParams.get("state") || stateFallback || ""
|
||||
if (!appid || !redirectUri || !state) return null
|
||||
return { appid, redirect_uri: redirectUri, state }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
.xx-wechat-qr-modal {
|
||||
position: relative;
|
||||
padding: 8px 0 4px;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 常驻二维码容器(WxLogin 渲染目标) */
|
||||
.xx-wechat-qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
/* loading / error 遮罩层,覆盖在二维码容器之上 */
|
||||
.xx-wechat-qr-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-overlay p {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-container iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-tip {
|
||||
margin: 12px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error-msg {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-fallback {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 微信扫码二维码弹窗(登录 / 绑定复用)
|
||||
*
|
||||
* 微信官方嵌入式二维码方案:弹窗内用 new WxLogin({ self_redirect: true }) 渲染二维码,
|
||||
* 扫码后微信重定向到本站回调页(在二维码 iframe 内加载),回调页通过 postMessage
|
||||
* 把成功/失败结果通知本弹窗(消息协议见 ./messages)。
|
||||
*
|
||||
* 兜底:获取授权链接成功但 WxLogin JS 加载失败/超时时,自动回退整页跳转授权
|
||||
* (与旧流程一致);获取授权链接本身失败时在弹窗内展示错误并提供重试。
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { Spin } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import {
|
||||
getWechatAuthUrl,
|
||||
getWechatBindUrl,
|
||||
getCurrentUser,
|
||||
normalizeUser,
|
||||
type User,
|
||||
} from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { loadWxLoginScript, parseWxAuthUrl } from "@/api/auth/wxLogin"
|
||||
import { isWechatQrMessage, type WechatQrScene } from "./messages"
|
||||
import "./WechatQrModal.css"
|
||||
|
||||
export interface WechatQrModalProps {
|
||||
open: boolean
|
||||
scene: WechatQrScene
|
||||
onClose: () => void
|
||||
/** 登录场景成功回调(needOnboarding=true 时调用方应跳昵称引导页) */
|
||||
onLoginSuccess?: (needOnboarding: boolean) => void
|
||||
/** 绑定场景成功回调(调用方刷新用户信息/提示) */
|
||||
onBindSuccess?: () => void
|
||||
}
|
||||
|
||||
type QrStatus = "loading" | "qrcode" | "error"
|
||||
|
||||
const CONTAINER_ID: Record<WechatQrScene, string> = {
|
||||
login: "wechat-qr-login-container",
|
||||
bind: "wechat-qr-bind-container",
|
||||
}
|
||||
|
||||
const STATE_STORAGE_KEY: Record<WechatQrScene, string> = {
|
||||
login: "wechat_state",
|
||||
bind: "wechat_bind_state",
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画,
|
||||
* 父组件 effect 首次执行时容器可能尚未出现在 document 中。
|
||||
*/
|
||||
function waitForContainer(id: string, timeoutMs = 3000): Promise<HTMLElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
const check = () => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
resolve(el)
|
||||
return
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
setTimeout(check, 50)
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
const WechatQrModal: React.FC<WechatQrModalProps> = ({
|
||||
open,
|
||||
scene,
|
||||
onClose,
|
||||
onLoginSuccess,
|
||||
onBindSuccess,
|
||||
}) => {
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [status, setStatus] = useState<QrStatus>("loading")
|
||||
const [errorMsg, setErrorMsg] = useState("")
|
||||
/** 刷新二维码计数:变化时重新请求授权链接并重渲染 */
|
||||
const [renderSeq, setRenderSeq] = useState(0)
|
||||
/** 最新授权链接,用于"整页打开"兜底 */
|
||||
const authUrlRef = useRef<string | null>(null)
|
||||
|
||||
const isLogin = scene === "login"
|
||||
|
||||
// 初始化:获取授权链接 → 加载 WxLogin JS → 内嵌渲染二维码
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
authUrlRef.current = null
|
||||
setStatus("loading")
|
||||
setErrorMsg("")
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const fetchUrl = isLogin ? getWechatAuthUrl : getWechatBindUrl
|
||||
const result = await fetchUrl()
|
||||
if (cancelled) return
|
||||
// 写 state(整页跳转兜底路径的回调页也会清理它)
|
||||
localStorage.setItem(STATE_STORAGE_KEY[scene], result.state)
|
||||
authUrlRef.current = result.auth_url
|
||||
|
||||
const params = parseWxAuthUrl(result.auth_url, result.state)
|
||||
if (!params) {
|
||||
// 授权链接格式异常:直接整页跳转,由微信侧/回调页兜底
|
||||
window.location.href = result.auth_url
|
||||
return
|
||||
}
|
||||
|
||||
const WxLogin = await loadWxLoginScript()
|
||||
if (cancelled) return
|
||||
// 等 Modal portal 中的容器挂载完成
|
||||
const container = await waitForContainer(CONTAINER_ID[scene])
|
||||
if (cancelled) return
|
||||
if (!container) {
|
||||
window.location.href = result.auth_url
|
||||
return
|
||||
}
|
||||
container.innerHTML = ""
|
||||
new WxLogin({
|
||||
self_redirect: true,
|
||||
id: CONTAINER_ID[scene],
|
||||
appid: params.appid,
|
||||
scope: "snsapi_login",
|
||||
redirect_uri: params.redirect_uri,
|
||||
state: params.state,
|
||||
style: "black",
|
||||
})
|
||||
if (!cancelled) setStatus("qrcode")
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
if (authUrlRef.current) {
|
||||
// 授权链接已拿到但二维码脚本加载失败/超时:回退整页跳转
|
||||
window.location.href = authUrlRef.current
|
||||
return
|
||||
}
|
||||
// 授权链接接口本身失败:弹窗内展示真实原因,允许重试
|
||||
setErrorMsg(getErrorMessage(err, "微信服务暂不可用,请稍后重试"))
|
||||
setStatus("error")
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, scene, isLogin, renderSeq])
|
||||
|
||||
// 监听 iframe 内回调页 postMessage 回来的扫码结果
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
// 只接受同源消息
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (!isWechatQrMessage(event.data, scene)) return
|
||||
const msg = event.data
|
||||
|
||||
if (msg.success) {
|
||||
if (isLogin) {
|
||||
// iframe 内回调页已把 token 写入 localStorage(同源共享),
|
||||
// 父窗口同步内存登录态后交给调用方跳转
|
||||
try {
|
||||
const userData = await getCurrentUser()
|
||||
const user = normalizeUser(userData) as User
|
||||
setAuth(
|
||||
user,
|
||||
localStorage.getItem("access_token") || "",
|
||||
localStorage.getItem("refresh_token"),
|
||||
)
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// token 已持久化,即使这里失败路由守卫/刷新也能恢复登录态
|
||||
}
|
||||
onLoginSuccess?.(msg.payload?.needOnboarding ?? false)
|
||||
} else {
|
||||
try {
|
||||
const userData = await getCurrentUser()
|
||||
setUser(normalizeUser(userData) as User)
|
||||
} catch {
|
||||
// 绑定结果以后端为准,调用方 invalidateQueries 会兜底刷新
|
||||
}
|
||||
onBindSuccess?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 失败:弹窗内展示回调页透传的真实原因,提供刷新/整页跳转
|
||||
setErrorMsg(msg.detail || "微信授权失败,请重试")
|
||||
setStatus("error")
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [open, scene, isLogin, onLoginSuccess, onBindSuccess, setAuth, setUser])
|
||||
|
||||
const handleRefresh = () => setRenderSeq((seq) => seq + 1)
|
||||
|
||||
const handleFullPageRedirect = () => {
|
||||
if (authUrlRef.current) {
|
||||
window.location.href = authUrlRef.current
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isLogin ? "微信扫码登录" : "绑定微信"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={380}
|
||||
maskClosable={false}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="xx-wechat-qr-modal">
|
||||
{/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来,
|
||||
不能按 status 条件渲染,否则 effect 里永远找不到容器 */}
|
||||
<div
|
||||
id={CONTAINER_ID[scene]}
|
||||
className="xx-wechat-qr-container"
|
||||
style={{ visibility: status === "qrcode" ? "visible" : "hidden" }}
|
||||
/>
|
||||
|
||||
{status === "loading" && (
|
||||
<div className="xx-wechat-qr-overlay">
|
||||
<Spin size="large" />
|
||||
<p>正在生成微信二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "qrcode" && (
|
||||
<p className="xx-wechat-qr-tip">请使用微信扫描二维码{isLogin ? "登录" : "绑定账号"}</p>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="xx-wechat-qr-overlay xx-wechat-qr-error">
|
||||
<p className="xx-wechat-qr-error-msg">{errorMsg}</p>
|
||||
<div className="xx-wechat-qr-error-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleRefresh}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
{authUrlRef.current && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-wechat-qr-fallback"
|
||||
onClick={handleFullPageRedirect}
|
||||
>
|
||||
使用整页方式打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatQrModal
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 微信扫码弹窗与 iframe 内回调页之间的 postMessage 消息协议
|
||||
*
|
||||
* 流程:弹窗内 WxLogin(self_redirect:true) 渲染的二维码 iframe 扫码后,
|
||||
* 微信重定向到本站回调页(同源,在 iframe 内加载);回调页完成换 token/绑定后,
|
||||
* 通过 window.parent.postMessage 把结果通知弹窗,弹窗负责关闭/展示错误/同步登录态。
|
||||
*/
|
||||
|
||||
/** 扫码场景:登录 / 绑定 */
|
||||
export type WechatQrScene = "login" | "bind"
|
||||
|
||||
export interface WechatQrSuccessPayload {
|
||||
/** 登录场景:是否需要昵称引导(新用户或资料未完善) */
|
||||
needOnboarding?: boolean
|
||||
}
|
||||
|
||||
export interface WechatQrMessageData {
|
||||
/** 固定协议标识,父窗口只认该 source */
|
||||
source: "xiaoxia-wechat-qr"
|
||||
/** 场景,需与弹窗发起时一致(login/bind),父窗口据此过滤 */
|
||||
scene: WechatQrScene
|
||||
/** 成功 / 失败 */
|
||||
success: boolean
|
||||
/** 失败时的真实原因(已在回调页拼好,含后端 detail) */
|
||||
detail?: string
|
||||
payload?: WechatQrSuccessPayload
|
||||
}
|
||||
|
||||
export const WECHAT_QR_MESSAGE_SOURCE = "xiaoxia-wechat-qr"
|
||||
|
||||
/** 判断收到的 message 是否为本协议消息(且场景匹配) */
|
||||
export function isWechatQrMessage(
|
||||
data: unknown,
|
||||
scene: WechatQrScene,
|
||||
): data is WechatQrMessageData {
|
||||
if (!data || typeof data !== "object") return false
|
||||
const msg = data as Partial<WechatQrMessageData>
|
||||
return msg.source === WECHAT_QR_MESSAGE_SOURCE && msg.scene === scene
|
||||
}
|
||||
|
||||
/** 当前页面是否运行在 iframe(弹窗内嵌二维码)中 */
|
||||
export function isInIframe(): boolean {
|
||||
try {
|
||||
return window.parent !== window
|
||||
} catch {
|
||||
// 跨域访问 window.parent 可能抛异常,按非 iframe 处理
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe 内回调页向父窗口上报扫码结果。同源回调页加载,targetOrigin 限定本站 origin。
|
||||
*/
|
||||
export function postWechatQrResult(
|
||||
scene: WechatQrScene,
|
||||
success: boolean,
|
||||
options?: { detail?: string; needOnboarding?: boolean },
|
||||
): void {
|
||||
if (!isInIframe()) return
|
||||
const data: WechatQrMessageData = {
|
||||
source: WECHAT_QR_MESSAGE_SOURCE,
|
||||
scene,
|
||||
success,
|
||||
detail: options?.detail,
|
||||
payload:
|
||||
success && options?.needOnboarding !== undefined
|
||||
? { needOnboarding: options.needOnboarding }
|
||||
: undefined,
|
||||
}
|
||||
window.parent.postMessage(data, window.location.origin)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* 登录页面 - V21 完全对标
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React, { useRef, useState } from "react"
|
||||
import { Form, Input, Checkbox, message } from "antd"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { useLogin } from "@/hooks/useAuth"
|
||||
import { getWechatAuthUrl } from "@/api/auth"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import Button from "@/components/ui/Button"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
import "./Login.css"
|
||||
|
||||
interface LoginFormValues {
|
||||
@@ -19,7 +20,10 @@ const Login: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const loginMutation = useLogin()
|
||||
const [form] = Form.useForm()
|
||||
const [wechatQrOpen, setWechatQrOpen] = useState(false)
|
||||
const [wechatLoading, setWechatLoading] = useState(false)
|
||||
// 同步防连点守卫:state 更新有渲染间隙,连点两次会各自请求授权 URL,
|
||||
// 后一次的 state 覆盖前一次写入 localStorage 的 state,导致回调校验失败
|
||||
const wechatStartingRef = useRef(false)
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
@@ -35,28 +39,33 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleWechatLogin = () => {
|
||||
// 记录登录前的来源页,登录成功后(弹窗回调)跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
const handleWechatLogin = async () => {
|
||||
if (wechatStartingRef.current) return
|
||||
wechatStartingRef.current = true
|
||||
setWechatLoading(true)
|
||||
try {
|
||||
const result = await getWechatAuthUrl()
|
||||
// 保存 state 到 localStorage 用于回调时验证
|
||||
localStorage.setItem("wechat_state", result.state)
|
||||
// 记录登录前的来源页,登录成功后跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
// 跳转到微信授权页
|
||||
window.location.href = result.auth_url
|
||||
} catch (error) {
|
||||
// 跳走前才可能回到这里;拦截器已弹过后端 detail 时不重复弹,
|
||||
// 否则透传真实原因(如微信服务未配置、网络异常)
|
||||
if (!isErrorMsgShown(error)) {
|
||||
message.error(`微信登录启动失败:${getErrorMessage(error, "请稍后重试")}`)
|
||||
}
|
||||
wechatStartingRef.current = false
|
||||
setWechatLoading(false)
|
||||
}
|
||||
setWechatQrOpen(true)
|
||||
// 弹窗打开期间按钮 disabled;WxLogin 脚本加载失败/超时时弹窗内会自动回退整页跳转
|
||||
}
|
||||
|
||||
// 弹窗扫码登录成功:登录态已由弹窗同步,按用户类型跳转
|
||||
const handleWechatQrSuccess = (needOnboarding: boolean) => {
|
||||
setWechatQrOpen(false)
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
}
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
// 成功时 window.location 跳走,不复位 loading(页面即将卸载)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -127,10 +136,10 @@ const Login: React.FC = () => {
|
||||
type="button"
|
||||
className="xx-btn-wechat"
|
||||
onClick={handleWechatLogin}
|
||||
disabled={wechatQrOpen}
|
||||
disabled={wechatLoading}
|
||||
>
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
微信登录
|
||||
{wechatLoading ? "加载中..." : "微信登录"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -138,13 +147,6 @@ const Login: React.FC = () => {
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatQrModal
|
||||
open={wechatQrOpen}
|
||||
scene="login"
|
||||
onClose={() => setWechatQrOpen(false)}
|
||||
onLoginSuccess={handleWechatQrSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
|
||||
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
@@ -13,30 +8,19 @@ import { Spin } from "antd"
|
||||
import { bindWechat, normalizeUser } from "@/api/auth"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
|
||||
const WechatBindCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示
|
||||
postWechatQrResult("bind", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
fail("无效的回调参数,请回到设置页重新扫码绑定")
|
||||
setError("无效的回调参数,请回到设置页重新扫码绑定")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -48,23 +32,16 @@ const WechatBindCallback: React.FC = () => {
|
||||
try {
|
||||
const result = await bindWechat(code, state)
|
||||
setUser(normalizeUser(result.user))
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:通知父窗口关闭弹窗并刷新绑定状态
|
||||
postWechatQrResult("bind", true)
|
||||
return
|
||||
}
|
||||
|
||||
// 用 replace 回设置页,query 携带成功标记由设置页提示
|
||||
navigate("/app/profile?wechat_bind=success", { replace: true })
|
||||
} catch (err) {
|
||||
// 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走
|
||||
fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
|
||||
// 绑定失败直接在本页展示真实原因(如微信已被其他账号绑定),不静默跳走
|
||||
setError(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
|
||||
}
|
||||
}
|
||||
|
||||
handleBind()
|
||||
}, [searchParams, navigate, setUser, inIframe])
|
||||
}, [searchParams, navigate, setUser])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
* 微信登录回调页
|
||||
* 扫码授权后由微信重定向回来:用 code 换登录态,
|
||||
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
@@ -15,7 +10,6 @@ import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
|
||||
const WechatCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -23,33 +17,24 @@ const WechatCallback: React.FC = () => {
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可
|
||||
postWechatQrResult("login", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示/上报原因
|
||||
// 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示原因
|
||||
const wxErrorCode = searchParams.get("error")
|
||||
const wxErrDesc = searchParams.get("error_description")
|
||||
if (wxErrorCode || wxErrDesc) {
|
||||
const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join(":")
|
||||
fail(`微信授权失败:${reason}`)
|
||||
setError(`微信授权失败:${reason}`)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
fail("无效的回调参数,请重新扫码登录")
|
||||
setError("无效的回调参数,请重新扫码登录")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,13 +61,6 @@ const WechatCallback: React.FC = () => {
|
||||
|
||||
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
|
||||
const needOnboarding = result.is_new_user || user.profile_completed === false
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转
|
||||
postWechatQrResult("login", true, { needOnboarding })
|
||||
return
|
||||
}
|
||||
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
@@ -94,12 +72,13 @@ const WechatCallback: React.FC = () => {
|
||||
navigate(redirect, { replace: true })
|
||||
} catch (err) {
|
||||
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
|
||||
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
|
||||
setError(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [searchParams, navigate, setAuth, inIframe])
|
||||
}, [searchParams, navigate, setAuth])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -8,10 +8,9 @@ import { useSearchParams } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { Button, Input, Modal } from "@/components/ui"
|
||||
import { getCurrentUser, updateProfile, unbindWechat } from "@/api/auth"
|
||||
import { getCurrentUser, updateProfile, getWechatBindUrl, unbindWechat } from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
import "./ProfileSettings.css"
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
@@ -20,7 +19,6 @@ const Settings: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [displayName, setDisplayName] = useState(user?.display_name || "")
|
||||
const [wechatBindOpen, setWechatBindOpen] = useState(false)
|
||||
const bindTipShownRef = useRef(false)
|
||||
|
||||
// 拉取最新用户信息(微信绑定状态以后端为准)
|
||||
@@ -64,11 +62,14 @@ const Settings: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
// 弹窗扫码绑定成功:关闭弹窗,刷新用户信息并提示
|
||||
const handleBindSuccess = () => {
|
||||
setWechatBindOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
message.success("微信绑定成功")
|
||||
const handleBindWechat = async () => {
|
||||
try {
|
||||
const result = await getWechatBindUrl()
|
||||
localStorage.setItem("wechat_bind_state", result.state)
|
||||
window.location.href = result.auth_url
|
||||
} catch {
|
||||
message.error("微信绑定暂不可用,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
const unbindMutation = useMutation({
|
||||
@@ -176,20 +177,13 @@ const Settings: React.FC = () => {
|
||||
解绑
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setWechatBindOpen(true)}>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleBindWechat}>
|
||||
绑定微信
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatQrModal
|
||||
open={wechatBindOpen}
|
||||
scene="bind"
|
||||
onClose={() => setWechatBindOpen(false)}
|
||||
onBindSuccess={handleBindSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
|
||||
describe("wxLogin 工具", () => {
|
||||
describe("parseWxAuthUrl", () => {
|
||||
it("从微信授权链接解析出 appid/redirect_uri/state(redirect_uri 解码)", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
const authUrl =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
|
||||
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback" +
|
||||
"&response_type=code&scope=snsapi_login&state=abc123#wechat_redirect"
|
||||
const params = parseWxAuthUrl(authUrl)
|
||||
expect(params).not.toBeNull()
|
||||
expect(params?.appid).toBe("wxb7ae80b48e53980d")
|
||||
expect(params?.redirect_uri).toBe("https://staging.xiaoxiajianji.com/auth/wechat/callback")
|
||||
expect(params?.state).toBe("abc123")
|
||||
})
|
||||
|
||||
it("链接里缺 state 时回退使用 stateFallback", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
const authUrl =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wx123" +
|
||||
"&redirect_uri=https%3A%2F%2Fexample.com%2Fcb"
|
||||
const params = parseWxAuthUrl(authUrl, "fallback-state")
|
||||
expect(params?.state).toBe("fallback-state")
|
||||
})
|
||||
|
||||
it("缺 appid 或 redirect_uri 时返回 null(调用方应回退整页跳转)", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
expect(parseWxAuthUrl("https://open.weixin.qq.com/connect/qrconnect?appid=wx123")).toBeNull()
|
||||
expect(parseWxAuthUrl("not a url")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadWxLoginScript", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
document.head.querySelectorAll("script[src*='wxLogin']").forEach((el) => el.remove())
|
||||
delete (window as unknown as { WxLogin?: unknown }).WxLogin
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("window.WxLogin 已存在时直接复用,不重复插入 script", async () => {
|
||||
const fakeCtor = vi.fn()
|
||||
;(window as unknown as { WxLogin: unknown }).WxLogin = fakeCtor
|
||||
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
|
||||
const ctor = await loadWxLoginScript()
|
||||
expect(ctor).toBe(fakeCtor)
|
||||
expect(document.head.querySelector("script[src*='wxLogin']")).toBeNull()
|
||||
})
|
||||
|
||||
it("脚本 onerror 时 reject(调用方据此回退整页跳转)", async () => {
|
||||
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
|
||||
const promise = loadWxLoginScript()
|
||||
const script = document.head.querySelector(
|
||||
"script[src*='wxLogin']",
|
||||
) as HTMLScriptElement | null
|
||||
expect(script).not.toBeNull()
|
||||
script?.dispatchEvent(new Event("error"))
|
||||
await expect(promise).rejects.toThrow(/加载失败/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
|
||||
const { mockWxLoginCtor, mockGetAuthUrl, mockGetBindUrl, mockGetCurrentUser } = vi.hoisted(() => ({
|
||||
mockWxLoginCtor: vi.fn(),
|
||||
mockGetAuthUrl: vi.fn(),
|
||||
mockGetBindUrl: vi.fn(),
|
||||
mockGetCurrentUser: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
getWechatAuthUrl: (...args: unknown[]) => mockGetAuthUrl(...args),
|
||||
getWechatBindUrl: (...args: unknown[]) => mockGetBindUrl(...args),
|
||||
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/wxLogin", () => ({
|
||||
loadWxLoginScript: vi.fn(async () => mockWxLoginCtor),
|
||||
parseWxAuthUrl: vi.fn(() => ({
|
||||
appid: "wxb7ae80b48e53980d",
|
||||
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
|
||||
state: "state-from-url",
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/tokenRefresh", () => ({
|
||||
scheduleProactiveRefresh: vi.fn(),
|
||||
cancelProactiveRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
const { mockSetAuth, mockSetUser } = vi.hoisted(() => ({
|
||||
mockSetAuth: vi.fn(),
|
||||
mockSetUser: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (s: unknown) => unknown) =>
|
||||
selector({ setAuth: mockSetAuth, setUser: mockSetUser }),
|
||||
}))
|
||||
|
||||
const AUTH_URL =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
|
||||
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback&state=st123"
|
||||
|
||||
const postMessage = (data: Record<string, unknown>) =>
|
||||
window.dispatchEvent(new MessageEvent("message", { data, origin: window.location.origin }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAuthUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
|
||||
mockGetBindUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
|
||||
mockGetCurrentUser.mockResolvedValue({ id: 1, display_name: "测试用户" })
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe("WechatQrModal", () => {
|
||||
it("open=false 时不渲染弹窗内容", () => {
|
||||
render(<WechatQrModal open={false} scene="login" onClose={vi.fn()} />)
|
||||
expect(screen.queryByText("微信扫码登录")).toBeNull()
|
||||
})
|
||||
|
||||
it("登录场景:open 后请求授权链接、写入 state、用 WxLogin 渲染二维码", async () => {
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(1))
|
||||
expect(localStorage.getItem("wechat_state")).toBe("st123")
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
expect(mockWxLoginCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
self_redirect: true,
|
||||
appid: "wxb7ae80b48e53980d",
|
||||
scope: "snsapi_login",
|
||||
state: "state-from-url",
|
||||
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
|
||||
}),
|
||||
)
|
||||
expect(screen.getByText(/请使用微信扫描二维码登录/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("绑定场景:请求 bind/url 且写入 wechat_bind_state", async () => {
|
||||
render(<WechatQrModal open scene="bind" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockGetBindUrl).toHaveBeenCalledTimes(1))
|
||||
expect(mockGetAuthUrl).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem("wechat_bind_state")).toBe("st123")
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it("获取授权链接失败时弹窗内展示错误并提供刷新", async () => {
|
||||
mockGetAuthUrl.mockRejectedValueOnce({
|
||||
response: { status: 500, data: { detail: "微信服务内部错误" } },
|
||||
})
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
expect(await screen.findByText(/微信服务内部错误/)).toBeTruthy()
|
||||
expect(screen.getByText("刷新二维码")).toBeTruthy()
|
||||
// 点刷新后重新请求
|
||||
fireEvent.click(screen.getByText("刷新二维码"))
|
||||
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it("登录成功消息:同步登录态并回调 onLoginSuccess(needOnboarding)", async () => {
|
||||
const onSuccess = vi.fn()
|
||||
localStorage.setItem("access_token", "tok-123")
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({
|
||||
source: "xiaoxia-wechat-qr",
|
||||
scene: "login",
|
||||
success: true,
|
||||
payload: { needOnboarding: true },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(true))
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled()
|
||||
expect(mockSetAuth).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), "tok-123", null)
|
||||
})
|
||||
|
||||
it("登录失败消息:弹窗内展示回调页透传的真实原因", async () => {
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({
|
||||
source: "xiaoxia-wechat-qr",
|
||||
scene: "login",
|
||||
success: false,
|
||||
detail: "微信登录失败:state 已过期或已被使用",
|
||||
})
|
||||
|
||||
expect(await screen.findByText(/state 已过期或已被使用/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("绑定成功消息:刷新用户并回调 onBindSuccess", async () => {
|
||||
const onBindSuccess = vi.fn()
|
||||
render(<WechatQrModal open scene="bind" onClose={vi.fn()} onBindSuccess={onBindSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
|
||||
|
||||
await waitFor(() => expect(onBindSuccess).toHaveBeenCalledTimes(1))
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("忽略跨源消息和其他场景的消息", async () => {
|
||||
const onSuccess = vi.fn()
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
// 跨源
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "xiaoxia-wechat-qr", scene: "login", success: true },
|
||||
origin: "https://evil.example.com",
|
||||
}),
|
||||
)
|
||||
// 场景不符(bind 消息发给 login 弹窗)
|
||||
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
|
||||
// 无协议标识
|
||||
postMessage({ foo: "bar" })
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -41,16 +41,6 @@ vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setUser: mockSetUser }),
|
||||
}))
|
||||
|
||||
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
|
||||
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
|
||||
mockIsInIframe: vi.fn(() => false),
|
||||
mockPostResult: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
|
||||
isInIframe: () => mockIsInIframe(),
|
||||
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
|
||||
}))
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -65,7 +55,6 @@ describe("WechatBindCallback Page", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsInIframe.mockReturnValue(false)
|
||||
bindError = null
|
||||
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
|
||||
mockParams.set("code", "bind_code")
|
||||
@@ -113,32 +102,4 @@ describe("WechatBindCallback Page", () => {
|
||||
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("iframe(弹窗内嵌二维码)场景", () => {
|
||||
it("绑定成功时 postMessage 通知父窗口,不做 navigate", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("bind", true)
|
||||
})
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("绑定失败时把真实原因 postMessage 给父窗口", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
bindError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("bind", false, {
|
||||
detail: expect.stringContaining("该微信已绑定其他账号"),
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText(/返回设置/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,16 +53,6 @@ vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }),
|
||||
}))
|
||||
|
||||
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
|
||||
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
|
||||
mockIsInIframe: vi.fn(() => false),
|
||||
mockPostResult: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
|
||||
isInIframe: () => mockIsInIframe(),
|
||||
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
|
||||
}))
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -77,7 +67,6 @@ describe("WechatCallback Page", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsInIframe.mockReturnValue(false)
|
||||
callbackError = null
|
||||
// 默认正常回调参数;用例可改写 mockParams 模拟 error 重定向
|
||||
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
|
||||
@@ -170,54 +159,4 @@ describe("WechatCallback Page", () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("微信登录中...")).toBeTruthy()
|
||||
})
|
||||
|
||||
describe("iframe(弹窗内嵌二维码)场景", () => {
|
||||
it("登录成功时 postMessage 通知父窗口(needOnboarding=false),不做 navigate", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: false })
|
||||
})
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("新用户成功时上报 needOnboarding=true", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: true })
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("后端报错时把真实原因 postMessage 给父窗口,页面不渲染错误/按钮", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
callbackError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 400, data: { detail: "state 已过期或已被使用" } },
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
|
||||
detail: expect.stringContaining("state 已过期或已被使用"),
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText(/返回登录/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("微信重定向 error(拒绝授权)在 iframe 内也上报父窗口", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
|
||||
mockParams.set("error", "access_denied")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
|
||||
detail: expect.stringContaining("access_denied"),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user