a83ed58864
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
AI Code Review / AI Code Review (pull_request) Failing after 2m5s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m47s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Validate - Security (push) Successful in 5m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m7s
CI/CD Pipeline / Unit Tests (push) Successful in 8m26s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 12m1s
CI/CD Pipeline / Canary Release to Production (push) Failing after 254h44m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 254h44m4s
CI/CD Pipeline / Build Production Web Image (push) Failing after 254h44m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 254h48m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 254h50m6s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 254h50m8s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 254h51m11s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 254h51m20s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 254h51m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 254h51m27s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 254h51m32s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 254h51m34s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 254h51m49s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 254h52m26s
CI/CD Pipeline / Deploy Production (push) Failing after 254h44m2s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 254h52m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 254h44m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 254h52m26s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 254h52m27s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 254h52m28s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 254h52m28s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 254h52m29s
CI/CD Pipeline / PR Build Web Image (push) Failing after 254h52m30s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 254h51m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 254h52m31s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 254h51m52s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 254h52m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 254h52m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 254h52m28s
CI/CD Pipeline / CI Gate (push) Failing after 255h18m38s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 255h24m41s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 255h25m52s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 255h25m58s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 255h26m6s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 255h26m59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 255h27m0s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 255h27m1s
CI/CD Pipeline / PR Build API Image (push) Failing after 255h27m5s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
266 lines
8.7 KiB
TypeScript
266 lines
8.7 KiB
TypeScript
/**
|
|
* 微信扫码二维码弹窗(登录 / 绑定复用)
|
|
*
|
|
* 微信官方嵌入式二维码方案:弹窗内用 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
|