Compare commits

...

2 Commits

Author SHA1 Message Date
xiaoxia a83ed58864 feat(#1718): 微信登录/绑定改为弹窗内嵌二维码,不再整页跳转 (#1726)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 12m1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 00:31:48 +08:00
xiaoxia cdcb032e45 fix(#1718/#1714): 微信回调state误杀修复+错误透传防连点、上传失败完整可观测、哈希阈值降至64MB、昵称不预填 (#1723)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m28s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m15s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Style (push) Successful in 2m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m58s
CI/CD Pipeline / Unit Tests (push) Successful in 8m4s
CI/CD Pipeline / Validate - Security (push) Successful in 16m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-05 22:52:49 +08:00
22 changed files with 1400 additions and 88 deletions
+9 -1
View File
@@ -126,7 +126,15 @@ export const prepareDirectUploadHandle = async (data: {
/** 本次逻辑上传的幂等 tokenprepare/complete 一致、重试复用 */
clientUploadId?: string
}): Promise<DirectUploadHandle> => {
const project = await getOrCreateDefaultProject()
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
// 不与 prepare 的签名接口错误混在一起
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
try {
project = await getOrCreateDefaultProject()
} catch (err) {
const reason = err instanceof Error ? err.message : "网络异常"
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
}
const prepared = await prepareDirectUpload({
project_id: project.id,
+7 -7
View File
@@ -13,10 +13,10 @@
* 重试复用同一 ID,重新入队才生成新 ID)
*/
/** 大文件抽样阈值:超过此大小只哈希头尾片段,避免上传前长时间卡 UI */
export const HASH_FULL_READ_LIMIT = 256 * 1024 * 1024 // 256MB
/** 抽样读取的头尾片段大小(各 8MB */
export const HASH_SAMPLE_CHUNK = 8 * 1024 * 1024
/** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */
export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB
/** 抽样读取的头尾片段大小(各 16MB */
export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024
/** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */
export type DedupExcludeStatus = "error" | "done"
@@ -95,10 +95,10 @@ function toHex(buffer: ArrayBuffer): string {
/**
* 计算文件内容 SHA-256hex64 字符,与后端 file_hash 字段长度一致)。
* - ≤256MB:全量哈希,内容一致必然一致
* - >256MB:哈希「头部 8MB + 尾部 8MB + 文件大小」,视频素材体积大、
* - ≤64MB:全量哈希,内容一致必然一致
* - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、
* 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略,
* 且避免上传前对 2GB 文件全量读取造成长时间卡顿
* 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃
*
* 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串,
* 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。
+112
View File
@@ -0,0 +1,112 @@
/**
* 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析
*
* 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
* 后挂载全局 window.WxLoginnew 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
}
}
+132
View File
@@ -0,0 +1,132 @@
/**
* 统一错误信息提取
* 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、
* 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。
*
* 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast,
* 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。
*/
import type { AxiosError } from "axios"
/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg */
interface ErrorBody {
detail?: unknown
message?: unknown
msg?: unknown
}
/** FastAPI 422 校验错误单项 */
interface ValidationItem {
loc?: (string | number)[]
msg?: string
}
/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */
function extractBodyMessage(data: unknown): string {
if (!data || typeof data !== "object") return ""
const body = data as ErrorBody
const walk = (val: unknown): string => {
if (typeof val === "string") return val
if (Array.isArray(val)) {
// FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接
const parts = val
.map((item) => {
if (typeof item === "string") return item
if (item && typeof item === "object") {
const v = item as ValidationItem
if (typeof v.msg === "string") {
const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : ""
return field ? `${field}: ${v.msg}` : v.msg
}
return walk(item)
}
return ""
})
.filter(Boolean)
return parts.join("")
}
if (val && typeof val === "object") {
const obj = val as Record<string, unknown>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (obj.message && typeof obj.message === "object") return walk(obj.message)
if (obj.msg && typeof obj.msg === "object") return walk(obj.msg)
try {
return JSON.stringify(val)
} catch {
return ""
}
}
return ""
}
return walk(body.detail) || walk(body.message) || walk(body.msg)
}
/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */
function statusFallback(status: number): string {
switch (status) {
case 400:
return "请求参数有误(HTTP 400"
case 401:
return "登录状态已失效,请重新登录(HTTP 401)"
case 403:
return "没有权限执行该操作(HTTP 403"
case 404:
return "请求的资源不存在(HTTP 404"
case 409:
return "操作冲突,资源状态已变化(HTTP 409)"
case 413:
return "文件过大,请缩小后重试(HTTP 413)"
case 415:
return "不支持的文件格式(HTTP 415"
case 429:
return "操作过于频繁,请稍后再试(HTTP 429)"
case 503:
return "服务暂不可用,请稍后再试(HTTP 503)"
default:
if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status}`
return `请求失败(HTTP ${status}`
}
}
/**
* 从任意抛出值提取可展示的错误信息。
* @param fallback 全部提取失败时的兜底文案
*/
export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string {
if (!err) return fallback
// axios 错误(后端 JSON 响应 / HTTP 错误状态)
const ax = err as AxiosError<ErrorBody>
if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) {
// 超时
if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) {
return "请求超时,请检查网络后重试"
}
const resp = ax.response
if (resp) {
const bodyMsg = extractBodyMessage(resp.data)
if (bodyMsg) return bodyMsg
return statusFallback(resp.status)
}
// 请求已发出但无响应(断网/CORS/DNS)
if (ax.request) return "网络连接异常,请检查网络设置"
return ax.message || fallback
}
if (err instanceof Error) {
// XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message
if (err.message) return err.message
}
if (typeof err === "string") return err
return fallback
}
/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */
export function isErrorMsgShown(err: unknown): boolean {
return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown)
}
@@ -0,0 +1,74 @@
.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;
}
@@ -0,0 +1,265 @@
/**
* 微信扫码二维码弹窗(登录 / 绑定复用)
*
* 微信官方嵌入式二维码方案:弹窗内用 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
@@ -0,0 +1,71 @@
/**
* 微信扫码弹窗与 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)
}
+14
View File
@@ -831,6 +831,20 @@
color: #ef4444;
}
.xx-upload-queue-error-detail {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: #ef4444;
word-break: break-word;
white-space: normal;
}
.xx-upload-queue-error-hint {
margin-top: 2px;
color: #b45309;
}
.xx-upload-queue-actions {
display: flex;
gap: 6px;
@@ -12,7 +12,8 @@ import {
ReloadOutlined,
CloseOutlined,
} from "@ant-design/icons"
import type { UploadItem } from "../hooks/useAssetUpload"
import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload"
import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload"
export interface UploadQueuePanelProps {
items: UploadItem[]
@@ -29,6 +30,13 @@ const STATUS_TEXT: Record<UploadItem["status"], string> = {
error: "上传失败",
}
/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */
const FAIL_STAGE_TEXT: Record<UploadFailStage, string> = {
prepare: "准备上传阶段",
transfer: "文件传输阶段",
complete: "确认入库阶段",
}
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
items,
onRetry,
@@ -82,8 +90,23 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
{it.status === "preparing" && it.hint ? `${it.hint}` : ""}
{it.status === "uploading" ? ` ${it.progress}%` : ""}
{it.status === "error" && it.error ? `${it.error}` : ""}
{it.status === "error" && it.failedStage
? `${FAIL_STAGE_TEXT[it.failedStage]}`
: ""}
</div>
{it.status === "error" && it.error ? (
<div className="xx-upload-queue-error-detail" title={it.error}>
{it.error.split("\n").map((line, idx) =>
line === COMPLETE_RETRY_HINT ? (
<div key={idx} className="xx-upload-queue-error-hint">
{line}
</div>
) : (
<div key={idx}>{line}</div>
),
)}
</div>
) : null}
</div>
<span className="xx-upload-queue-actions">
{it.status === "error" && (
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
import { MAX_FILE_SIZE } from "../constants"
import {
computeFileHash,
@@ -44,9 +45,15 @@ export interface UploadItem {
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
const MAX_CONCURRENT = 3
/** complete 阶段失败后的错误提示:素材可能已在服务器处理中,重试不会重新上传 */
const COMPLETE_ERROR_HINT =
"确认请求失败,素材可能已在服务器处理中;点重试将安全确认,不会重新上传文件"
/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */
export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件"
/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */
const STAGE_LABEL: Record<UploadFailStage, string> = {
prepare: "准备上传",
transfer: "文件传输",
complete: "确认入库",
}
/**
* 素材批量上传 Hook
@@ -165,20 +172,25 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "上传失败"
// 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail
// 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细)
const detail = getErrorMessage(err, "未知错误")
console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err)
if (stage === "complete") {
// complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试;
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作
// 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传)
refreshList()
updateItem(item.tempId, {
status: "error",
failedStage: "complete",
error: COMPLETE_ERROR_HINT,
error: `${detail}\n${COMPLETE_RETRY_HINT}`,
hint: undefined,
})
message.error(`"${item.fileName}" ${COMPLETE_ERROR_HINT}`)
if (!isErrorMsgShown(err)) {
message.error(`"${item.fileName}" 确认入库失败:${detail}`)
}
} else {
// prepare / transfer 失败:后端尚无素材记录,可安全全量重跑
handlesRef.current.delete(item.tempId)
@@ -188,7 +200,11 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
error: detail,
hint: undefined,
})
message.error(`"${item.fileName}" 上传失败:${detail}`)
// 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹;
// OSS XHR 直传错误不走 axios,必须在这里弹
if (!isErrorMsgShown(err)) {
message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`)
}
}
}
},
+32 -24
View File
@@ -5,8 +5,8 @@ import React, { 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 Button from "@/components/ui/Button"
import WechatQrModal from "@/components/auth/WechatQrModal"
import "./Login.css"
interface LoginFormValues {
@@ -19,7 +19,7 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const loginMutation = useLogin()
const [form] = Form.useForm()
const [wechatLoading, setWechatLoading] = useState(false)
const [wechatQrOpen, setWechatQrOpen] = useState(false)
const onFinish = async (values: LoginFormValues) => {
try {
@@ -35,27 +35,28 @@ const Login: React.FC = () => {
}
}
const handleWechatLogin = async () => {
try {
setWechatLoading(true)
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) {
if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("微信登录暂不可用,请稍后重试")
} finally {
setWechatLoading(false)
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")
}
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 })
}
return (
@@ -126,10 +127,10 @@ const Login: React.FC = () => {
type="button"
className="xx-btn-wechat"
onClick={handleWechatLogin}
disabled={wechatLoading}
disabled={wechatQrOpen}
>
<span className="xx-wechat-icon">💬</span>
{wechatLoading ? "加载中..." : "微信登录"}
</button>
</div>
@@ -137,6 +138,13 @@ const Login: React.FC = () => {
<Link to="/register"></Link>
</div>
</div>
<WechatQrModal
open={wechatQrOpen}
scene="login"
onClose={() => setWechatQrOpen(false)}
onLoginSuccess={handleWechatQrSuccess}
/>
</div>
)
}
+31 -10
View File
@@ -1,49 +1,70 @@
/**
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
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) {
setError("无效的回调参数")
fail("无效的回调参数,请回到设置页重新扫码绑定")
return
}
const handleBind = async () => {
// state 校验:绑定场景由设置页生成并落库,前缀 bind:
const savedState = localStorage.getItem("wechat_bind_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新绑定")
return
}
// state 校验由后端 state store 一次性消费兜底(前端不再比对 localStorage
// 微信内打开/跨浏览器场景本地无 state 会误杀);清理绑定前写入的 state
localStorage.removeItem("wechat_bind_state")
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 {
navigate("/app/profile?wechat_bind=failed", { replace: true })
} catch (err) {
// 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走
fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
}
}
handleBind()
}, [searchParams, navigate, setUser])
}, [searchParams, navigate, setUser, inIframe])
if (error) {
return (
+42 -13
View File
@@ -2,13 +2,20 @@
* 微信登录回调页
* 扫码授权后由微信重定向回来:用 code 换登录态,
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin } from "antd"
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
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()
@@ -16,26 +23,41 @@ 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")
if (!code || !state) {
setError("无效的回调参数")
const fail = (message: string) => {
if (inIframe) {
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可
postWechatQrResult("login", false, { detail: message })
return
}
setError(message)
setLoading(false)
}
// 微信重定向出错时(如用户拒绝授权 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}`)
return
}
if (!code || !state) {
fail("无效的回调参数,请重新扫码登录")
return
}
const handleCallback = async () => {
try {
// 校验 state,防止 CSRF
const savedState = localStorage.getItem("wechat_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新登录")
setLoading(false)
return
}
// state CSRF 校验由后端 state store 一次性消费兜底(前端不再比对
// localStorage——微信内打开、跨浏览器等场景本地没有 state,会误杀正常回调);
// 清理登录前写入的 state,避免残留
localStorage.removeItem("wechat_state")
const result = await wechatCallback(code, state)
@@ -54,6 +76,13 @@ 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
@@ -63,14 +92,14 @@ const WechatCallback: React.FC = () => {
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch {
setError("微信登录失败,请重试")
setLoading(false)
} catch (err) {
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
}
}
handleCallback()
}, [searchParams, navigate, setAuth])
}, [searchParams, navigate, setAuth, inIframe])
if (loading) {
return (
+2 -1
View File
@@ -67,7 +67,8 @@ const WechatOnboarding: React.FC = () => {
onFinish={onFinish}
autoComplete="off"
layout="vertical"
initialValues={{ display_name: user?.display_name || "" }}
// 不预填:新微信用户必须自己输入昵称(user.display_name 可能是微信昵称/系统占位)
initialValues={{ display_name: "" }}
>
<Form.Item
name="display_name"
+16 -10
View File
@@ -8,9 +8,10 @@ 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, getWechatBindUrl, unbindWechat } from "@/api/auth"
import { getCurrentUser, updateProfile, 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 = () => {
@@ -19,6 +20,7 @@ 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)
// 拉取最新用户信息(微信绑定状态以后端为准)
@@ -62,14 +64,11 @@ const Settings: React.FC = () => {
},
})
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 handleBindSuccess = () => {
setWechatBindOpen(false)
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
message.success("微信绑定成功")
}
const unbindMutation = useMutation({
@@ -177,13 +176,20 @@ const Settings: React.FC = () => {
</Button>
) : (
<Button buttonType="primary" buttonSize="md" onClick={handleBindWechat}>
<Button buttonType="primary" buttonSize="md" onClick={() => setWechatBindOpen(true)}>
</Button>
)}
</div>
</div>
</div>
<WechatQrModal
open={wechatBindOpen}
scene="bind"
onClose={() => setWechatBindOpen(false)}
onBindSuccess={handleBindSuccess}
/>
</div>
)
}
+31 -2
View File
@@ -1,10 +1,12 @@
/**
* 上传去重/幂等工具单测(Issue #1714
*/
import { describe, it, expect } from "vitest"
import { describe, it, expect, vi } from "vitest"
import {
computeFileHash,
findDuplicateInQueue,
HASH_FULL_READ_LIMIT,
HASH_SAMPLE_CHUNK,
makeClientUploadId,
makeFileFingerprint,
} from "@/api/assets/uploadDedup"
@@ -83,7 +85,7 @@ describe("computeFileHash", () => {
})
})
describe("computeFileHash 大文件抽样(>256MB", () => {
describe("computeFileHash 大文件抽样(>64MB", () => {
it("抽样路径正常返回 64 位 hex,且大小不同则 hash 不同", async () => {
// mock 一个「声称」300MB 的 Fileslice 返回小 buffer 即可,不真分配 300MB
const makeBig = (declaredSize: number, head: number) => {
@@ -98,4 +100,31 @@ describe("computeFileHash 大文件抽样(>256MB", () => {
// 声明大小不同 → 写入的 64 位 size 字段不同 → hash 必须不同(锁定 setBigUint64 路径)
expect(h1).not.toBe(h2)
})
it("≤64MB 走全量读取(slice 一次覆盖整个文件)", async () => {
const f = new File([new Uint8Array(1024).fill(9)], "full.mp4", { type: "video/mp4" })
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT, configurable: true })
const sliceSpy = vi.spyOn(f, "slice")
await computeFileHash(f)
// 全量路径:唯一一次 slice 为 (0, size)
expect(sliceSpy).toHaveBeenCalledTimes(1)
expect(sliceSpy).toHaveBeenCalledWith(0, HASH_FULL_READ_LIMIT)
sliceSpy.mockRestore()
})
it(">64MB 只读取头尾各 16MB 抽样,绝不整文件读入内存", async () => {
const f = new File([new Uint8Array(1024).fill(9)], "big.mp4", { type: "video/mp4" })
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT + 1, configurable: true })
const sliceSpy = vi.spyOn(f, "slice")
await computeFileHash(f)
// 抽样路径:两次 slice —— 头部 (0, 16MB) 与尾部 (size-16MB, size)
expect(sliceSpy).toHaveBeenCalledTimes(2)
expect(sliceSpy).toHaveBeenNthCalledWith(1, 0, HASH_SAMPLE_CHUNK)
expect(sliceSpy).toHaveBeenNthCalledWith(
2,
HASH_FULL_READ_LIMIT + 1 - HASH_SAMPLE_CHUNK,
HASH_FULL_READ_LIMIT + 1,
)
sliceSpy.mockRestore()
})
})
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
describe("wxLogin 工具", () => {
describe("parseWxAuthUrl", () => {
it("从微信授权链接解析出 appid/redirect_uri/stateredirect_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(/加载失败/)
})
})
})
@@ -0,0 +1,165 @@
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()
})
})
@@ -224,6 +224,11 @@ describe("useAssetUpload", () => {
})
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error"))
// 失败卡片记录失败阶段与完整错误原因(不再只显示"上传失败")
const failed = result.current.uploadItems[0]
expect(failed.failedStage).toBe("transfer")
expect(failed.error).toContain("OSS boom")
// 重试:重新 preparehandles[1] 成功)
const tempId = result.current.uploadItems[0].tempId
await act(async () => {
@@ -334,6 +339,9 @@ describe("useAssetUpload", () => {
const it = result.current.uploadItems.find((x) => x.tempId === tempId)
expect(it?.status).toBe("error")
expect(it?.failedStage).toBe("complete")
// 卡片同时展示真实失败原因与"重试不会重新上传"提示
expect(it?.error).toContain("complete timeout")
expect(it?.error).toContain("不会重新上传文件")
})
// 点重试:pump 复用 handle,只再调一次 completetransfer/prepare 不重复)
@@ -352,4 +360,24 @@ describe("useAssetUpload", () => {
expect(result.current.uploadItems.find((x) => x.tempId === tempId)?.status).toBe("done")
})
})
it("prepare 阶段失败:标记 prepare 阶段并保留后端错误明细", async () => {
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { detail: "签名服务内部错误" } },
message: "Request failed with status code 500",
})
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads([mp4("prep-fail.mp4")])
})
await waitFor(() => expect(result.current.uploadItems[0]?.status).toBe("error"))
const it = result.current.uploadItems[0]
expect(it.failedStage).toBe("prepare")
expect(it.error).toContain("签名服务内部错误")
})
})
@@ -0,0 +1,144 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, screen, waitFor, cleanup } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import WechatBindCallback from "@/pages/auth/WechatBindCallback"
const mockNavigate = vi.fn()
const mockSetUser = vi.fn()
const mockParams = new URLSearchParams({ code: "bind_code", state: "bind_state" })
const mockSearchParams = [mockParams] as const
const localStorageStore: Record<string, string> = {}
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => localStorageStore[key] || null)
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
localStorageStore[key] = val
})
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
delete localStorageStore[key]
})
let bindError: unknown = null
const mockBindResult = { user: { id: "u1", wechat_bound: true } }
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => mockNavigate,
useSearchParams: () => mockSearchParams,
}
})
vi.mock("@/api/auth", () => ({
bindWechat: vi.fn(async () => {
if (bindError) throw bindError
return mockBindResult
}),
normalizeUser: (u: unknown) => u,
}))
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>
<WechatBindCallback />
</MemoryRouter>,
)
describe("WechatBindCallback Page", () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
vi.clearAllMocks()
mockIsInIframe.mockReturnValue(false)
bindError = null
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
mockParams.set("code", "bind_code")
mockParams.set("state", "bind_state")
localStorageStore.wechat_bind_state = "bind_state"
})
it("绑定成功跳转设置页并携带 success 标记", async () => {
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
replace: true,
})
})
expect(mockSetUser).toHaveBeenCalled()
})
it("本地无 wechat_bind_state(微信内/跨浏览器)不再误杀,绑定正常完成", async () => {
delete localStorageStore.wechat_bind_state
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
replace: true,
})
})
})
it("后端报错(微信已被其他账号绑定)时页面透传真实原因,不静默跳走", async () => {
bindError = {
isAxiosError: true,
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
message: "Request failed with status code 409",
}
renderPage()
await waitFor(() => {
expect(screen.getByText(/该微信已绑定其他账号/)).toBeTruthy()
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("缺少 code/state 时提示无效回调", async () => {
mockParams.delete("code")
renderPage()
await waitFor(() => {
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()
})
})
})
@@ -5,7 +5,11 @@ import WechatCallback from "@/pages/auth/WechatCallback"
const mockNavigate = vi.fn()
const mockSetAuth = vi.fn()
const mockSearchParams = [new URLSearchParams({ code: "test_code", state: "test_state" })] as const
// useSearchParams 返回模块级稳定引用(数组元素同一 URLSearchParams 实例),
// 避免每次 render 返回新数组/新实例导致 useEffect 依赖变化重跑
const mockParams = new URLSearchParams({ code: "test_code", state: "test_state" })
const mockSearchParams = [mockParams] as const
const mockAuthState = { setAuth: mockSetAuth }
// 文件级 localStorage mock(避免每个用例重复 spy 导致链式污染)
@@ -20,7 +24,7 @@ vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
let mockCallbackResult: Record<string, unknown> = {}
let mockCurrentUser: Record<string, unknown> = {}
let callbackShouldFail = false
let callbackError: unknown = null
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
@@ -33,7 +37,7 @@ vi.mock("react-router-dom", async () => {
vi.mock("@/api/auth", () => ({
wechatCallback: vi.fn(async () => {
if (callbackShouldFail) throw new Error("fail")
if (callbackError) throw callbackError
return mockCallbackResult
}),
getCurrentUser: vi.fn(async () => mockCurrentUser),
@@ -49,6 +53,16 @@ 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>
@@ -63,7 +77,12 @@ describe("WechatCallback Page", () => {
beforeEach(() => {
vi.clearAllMocks()
callbackShouldFail = false
mockIsInIframe.mockReturnValue(false)
callbackError = null
// 默认正常回调参数;用例可改写 mockParams 模拟 error 重定向
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
mockParams.set("code", "test_code")
mockParams.set("state", "test_state")
localStorageStore.wechat_state = "test_state"
mockCallbackResult = {
access_token: "at",
@@ -103,20 +122,47 @@ describe("WechatCallback Page", () => {
})
})
it("state 不匹配显示安全错误", async () => {
localStorageStore.wechat_state = "other_state"
it("本地无 wechat_state(微信内打开/跨浏览器场景)不再误杀,正常完成登录", async () => {
delete localStorageStore.wechat_state
renderPage()
await waitFor(() => {
expect(screen.getByText("安全校验失败,请重新登录")).toBeTruthy()
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
})
// state 已被清理
expect(localStorageStore.wechat_state).toBeUndefined()
})
it("后端返回 detail 错误时,页面透传真实原因(不再吞成通用提示)", async () => {
callbackError = {
isAxiosError: true,
response: { status: 400, data: { detail: "微信授权码已过期,请重新扫码" } },
message: "Request failed with status code 400",
}
renderPage()
await waitFor(() => {
expect(screen.getByText(/微信授权码已过期,请重新扫码/)).toBeTruthy()
})
expect(screen.queryByText(/^微信登录失败,请重试$/)).toBeNull()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("微信重定向带 error(用户拒绝授权)时展示授权失败原因", async () => {
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
mockParams.set("error", "access_denied")
mockParams.set("error_description", "The+user+denied+the+request")
renderPage()
await waitFor(() => {
expect(screen.getByText(/微信授权失败/)).toBeTruthy()
expect(screen.getByText(/access_denied/)).toBeTruthy()
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("接口失败显示错误提示", async () => {
callbackShouldFail = true
it("缺少 code/state 参数时提示无效回调", async () => {
mockParams.delete("code")
renderPage()
await waitFor(() => {
expect(screen.getByText("微信登录失败,请重试")).toBeTruthy()
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
})
})
@@ -124,4 +170,54 @@ 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"),
})
})
})
})
})
@@ -83,6 +83,12 @@ describe("WechatOnboarding 昵称引导页", () => {
expect(screen.queryByText("进入小虾智剪")).toBeNull()
})
it("昵称输入框不预填,必须用户自己输入", () => {
renderPage()
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
expect((screen.getByPlaceholderText("请输入您的昵称") as HTMLInputElement).value).toBe("")
})
it("新用户可见昵称表单并能提交", async () => {
renderPage()
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()