feat(#1718): 微信扫码登录前端——昵称引导页 + 设置页微信绑定/解绑 #1720

Merged
auto-approve-bot merged 1 commits from feat/1718-wechat-login-frontend into develop 2026-09-05 17:30:43 +08:00
15 changed files with 908 additions and 181 deletions
+14 -3
View File
@@ -12,13 +12,18 @@ export type {
UserResponse,
WechatAuthUrlResponse,
WechatCallbackResponse,
WechatBindUrlResponse,
WechatBindCompleteResponse,
WechatUnbindResponse,
UpdateProfileRequest,
UpdateProfileResponse,
SendVerificationCodeRequest,
BindContactRequest,
BindContactResponse,
} from "./types"
// 用户工具函数
export { normalizeUser } from "./user"
export { normalizeUser, updateProfile } from "./user"
// 登录/注册/登出/刷新
export { login, refreshAccessToken, register, logout } from "./login"
@@ -32,8 +37,14 @@ export { requestPasswordReset, resetPassword } from "./password"
// 邮箱验证
export { verifyEmail } from "./email"
// 微信登录
export { getWechatAuthUrl, wechatCallback } from "./wechat"
// 微信登录 / 绑定
export {
getWechatAuthUrl,
wechatCallback,
getWechatBindUrl,
bindWechat,
unbindWechat,
} from "./wechat"
// 联系方式
export { sendVerificationCode, bindContact } from "./contact"
+44
View File
@@ -34,6 +34,17 @@ export interface User {
is_email_verified: boolean
email_verified: boolean
created_at?: string
/** 微信是否已绑定 */
wechat_bound?: boolean
/** 微信昵称(绑定后展示) */
wechat_nickname?: string
/** 头像 URL(微信头像等) */
avatar_url?: string
/** 手机号 */
phone?: string
phone_verified?: boolean
/** 资料是否完善(微信新用户首次登录为 false,需填昵称引导) */
profile_completed?: boolean
}
export interface UserResponse {
@@ -45,6 +56,12 @@ export interface UserResponse {
is_email_verified?: boolean
email_verified?: boolean
created_at?: string
wechat_bound?: boolean
wechat_nickname?: string
avatar_url?: string
phone?: string
phone_verified?: boolean
profile_completed?: boolean
}
export interface WechatAuthUrlResponse {
@@ -80,3 +97,30 @@ export interface BindContactResponse {
success: boolean
user: User
}
/** 更新个人资料请求 */
export interface UpdateProfileRequest {
display_name?: string
}
/** 更新个人资料响应(返回最新用户信息) */
export interface UpdateProfileResponse {
user: UserResponse
}
/** 微信绑定授权链接响应 */
export interface WechatBindUrlResponse {
auth_url: string
state: string
}
/** 微信绑定完成响应 */
export interface WechatBindCompleteResponse {
success: boolean
user: UserResponse
}
/** 微信解绑响应 */
export interface WechatUnbindResponse {
success: boolean
}
+16 -1
View File
@@ -1,4 +1,5 @@
import type { User, UserResponse } from "./types"
import apiClient from "../client"
import type { User, UserResponse, UpdateProfileRequest, UpdateProfileResponse } from "./types"
/**
* 规范化用户数据,兼容不同后端返回格式
@@ -16,5 +17,19 @@ export const normalizeUser = (data: UserResponse): User => {
is_email_verified: emailVerified,
email_verified: emailVerified,
created_at: data.created_at,
wechat_bound: data.wechat_bound,
wechat_nickname: data.wechat_nickname,
avatar_url: data.avatar_url,
phone: data.phone,
phone_verified: data.phone_verified,
profile_completed: data.profile_completed,
}
}
/**
* 更新个人资料(昵称等)
*/
export const updateProfile = async (data: UpdateProfileRequest): Promise<User> => {
const response = await apiClient.patch<UpdateProfileResponse>("/auth/me", data)
return normalizeUser(response.data.user)
}
+35 -2
View File
@@ -1,8 +1,14 @@
import apiClient from "../client"
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
import type {
WechatAuthUrlResponse,
WechatCallbackResponse,
WechatBindUrlResponse,
WechatBindCompleteResponse,
WechatUnbindResponse,
} from "./types"
/**
* 获取微信授权链接
* 获取微信授权链接(登录场景)
*/
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
@@ -19,3 +25,30 @@ export const wechatCallback = async (
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
/**
* 获取微信绑定授权链接(已登录用户绑定场景)
*/
export const getWechatBindUrl = async (): Promise<WechatBindUrlResponse> => {
const response = await apiClient.get("/auth/wechat/bind/url")
return response.data
}
/**
* 微信绑定完成(扫码回调后用 code 绑定到当前登录账号)
*/
export const bindWechat = async (
code: string,
state: string,
): Promise<WechatBindCompleteResponse> => {
const response = await apiClient.post("/auth/wechat/bind", { code, state })
return response.data
}
/**
* 解绑微信
*/
export const unbindWechat = async (): Promise<WechatUnbindResponse> => {
const response = await apiClient.delete("/auth/wechat/bind")
return response.data
}
@@ -0,0 +1,97 @@
/**
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin } from "antd"
import { bindWechat, normalizeUser } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
const WechatBindCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setUser = useAuthStore((state) => state.setUser)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
if (!code || !state) {
setError("无效的回调参数")
return
}
const handleBind = async () => {
// state 校验:绑定场景由设置页生成并落库,前缀 bind:
const savedState = localStorage.getItem("wechat_bind_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新绑定")
return
}
localStorage.removeItem("wechat_bind_state")
try {
const result = await bindWechat(code, state)
setUser(normalizeUser(result.user))
// 用 replace 回设置页,query 携带成功标记由设置页提示
navigate("/app/profile?wechat_bind=success", { replace: true })
} catch {
navigate("/app/profile?wechat_bind=failed", { replace: true })
}
}
handleBind()
}, [searchParams, navigate, setUser])
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/app/profile")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
export default WechatBindCallback
+44 -67
View File
@@ -1,19 +1,20 @@
/**
* 微信登录回调页
* 扫码授权后由微信重定向回来:用 code 换登录态,
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin, message } from "antd"
import { Spin } from "antd"
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import BindContactModal from "@/components/auth/BindContactModal"
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
const WechatCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const [loading, setLoading] = useState(true)
const [showBindModal, setShowBindModal] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
@@ -49,20 +50,21 @@ const WechatCallback: React.FC = () => {
const userData = await getCurrentUser()
const user: User = normalizeUser(userData)
setAuth(user, result.access_token, result.refresh_token)
scheduleProactiveRefresh()
if (result.binding_complete) {
// 已绑定,跳转到登录前页面或首页
message.success("登录成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} else {
// 未绑定,显示绑定弹窗
setLoading(false)
setShowBindModal(true)
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
const needOnboarding = result.is_new_user || user.profile_completed === false
if (needOnboarding) {
navigate("/welcome/wechat", { replace: true })
return
}
} catch (err) {
setError("登录失败,请重试")
// 老用户:回登录前页面或首页
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch {
setError("微信登录失败,请重试")
setLoading(false)
}
}
@@ -70,21 +72,6 @@ const WechatCallback: React.FC = () => {
handleCallback()
}, [searchParams, navigate, setAuth])
const handleBindSuccess = (user: User) => {
const setUser = useAuthStore.getState().setUser
setUser(user)
setShowBindModal(false)
message.success("绑定成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
}
const handleBindCancel = () => {
setShowBindModal(false)
navigate("/login")
}
if (loading) {
return (
<div
@@ -98,49 +85,39 @@ const WechatCallback: React.FC = () => {
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
return (
<BindContactModal
open={showBindModal}
onSuccess={handleBindSuccess}
onCancel={handleBindCancel}
/>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
@@ -0,0 +1,102 @@
/**
* 微信新用户昵称引导页
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
*/
import React from "react"
import { Form, Input, message } from "antd"
import { Navigate, useNavigate } from "react-router-dom"
import { useMutation } from "@tanstack/react-query"
import { updateProfile } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import Button from "@/components/ui/Button"
import "./Login.css"
interface OnboardingFormValues {
display_name: string
}
const WechatOnboarding: React.FC = () => {
const navigate = useNavigate()
const setUser = useAuthStore((state) => state.setUser)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const user = useAuthStore((state) => state.user)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
const [form] = Form.useForm<OnboardingFormValues>()
const saveMutation = useMutation({
mutationFn: (displayName: string) => updateProfile({ display_name: displayName }),
})
// 已登录且资料已完善的用户不该停留在引导页
if (isAuthenticated && hasAccessToken && user?.profile_completed === true) {
return <Navigate to="/app/dashboard" replace />
}
// 未登录(如手动输入 URL)回登录页
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
const onFinish = async (values: OnboardingFormValues) => {
try {
const updated = await saveMutation.mutateAsync(values.display_name.trim())
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
setUser({ ...updated, profile_completed: true })
message.success("欢迎加入小虾智剪!")
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch {
message.error("保存失败,请重试")
}
}
return (
<div className="xx-auth-page">
<div className="xx-auth-card">
<div className="xx-auth-header">
<div className="xx-auth-brand">
<span className="xx-auth-logo">🦐</span>
<span className="xx-auth-brand-name"></span>
</div>
<p>使</p>
</div>
<Form
form={form}
name="wechat-onboarding"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
initialValues={{ display_name: user?.display_name || "" }}
>
<Form.Item
name="display_name"
label="昵称"
rules={[
{ required: true, message: "请输入昵称" },
{ whitespace: true, message: "昵称不能为空白" },
{ min: 1, max: 20, message: "昵称长度需在 1-20 个字符之间" },
]}
extra="昵称将展示在您的作品和账户中,之后可在个人设置中修改"
>
<Input placeholder="请输入您的昵称" size="large" maxLength={20} showCount />
</Form.Item>
<Form.Item>
<Button
buttonType="primary"
buttonSize="lg"
htmlType="submit"
loading={saveMutation.isPending}
style={{ width: "100%" }}
>
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
</Button>
</Form.Item>
</Form>
</div>
</div>
)
}
export default WechatOnboarding
@@ -178,3 +178,35 @@
border-color: var(--border-color);
margin: var(--space-lg) 0;
}
/* 微信账号绑定卡片 */
.xx-settings-wechat {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
}
.xx-settings-wechat-info {
display: flex;
align-items: center;
gap: var(--space-md);
}
.xx-settings-wechat-info .xx-wechat-icon {
font-size: 28px;
line-height: 1;
}
.xx-settings-wechat-info strong {
display: block;
color: var(--text-primary);
font-size: var(--font-size-md);
}
.xx-settings-wechat-info p {
margin: 2px 0 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
+148 -25
View File
@@ -1,39 +1,111 @@
/**
* 个人设置页面
* P1-2: 添加 PageHead
* P1-3: antd Form/Input/Button/Alert → 自定义 UI 组件
* - 个人资料(昵称)保存
* - 微信账号绑定状态 / 绑定 / 解绑
*/
import React, { useState } from "react"
import React, { useEffect, useRef, useState } from "react"
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 { useAuthStore } from "@/store/authStore"
import PageHead from "@/components/layout/PageHead"
import "./ProfileSettings.css"
const Settings: React.FC = () => {
const user = useAuthStore((state) => state.user)
const setUser = useAuthStore((state) => state.setUser)
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [displayName, setDisplayName] = useState(user?.display_name || "")
const bindTipShownRef = useRef(false)
const handleSave = () => {
Modal.info({
title: "提示",
content: "个人资料修改接口暂未开放,保存功能即将上线。",
// 拉取最新用户信息(微信绑定状态以后端为准)
const { data: freshUser } = useQuery({
queryKey: ["currentUser"],
queryFn: getCurrentUser,
})
useEffect(() => {
if (freshUser) {
setUser(freshUser)
setDisplayName((prev) => prev || freshUser.display_name || "")
}
}, [freshUser, setUser])
// 绑定回调结果提示(?wechat_bind=success|failed
useEffect(() => {
if (bindTipShownRef.current) return
const result = searchParams.get("wechat_bind")
if (!result) return
bindTipShownRef.current = true
if (result === "success") {
message.success("微信绑定成功")
} else if (result === "failed") {
message.error("微信绑定失败,请重试")
}
searchParams.delete("wechat_bind")
setSearchParams(searchParams, { replace: true })
}, [searchParams, setSearchParams])
const wechatBound = user?.wechat_bound === true
const saveProfileMutation = useMutation({
mutationFn: () => updateProfile({ display_name: displayName.trim() }),
onSuccess: (updated) => {
setUser(updated)
message.success("资料已保存")
},
onError: () => {
message.error("保存失败,请重试")
},
})
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({
mutationFn: unbindWechat,
onSuccess: () => {
message.success("已解绑微信")
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
// 本地立即更新,避免等待刷新
if (user) {
setUser({ ...user, wechat_bound: false, wechat_nickname: "" })
}
},
onError: () => {
message.error("解绑失败,请重试")
},
})
const handleUnbind = () => {
Modal.confirm({
title: "解绑微信",
content: "解绑后将无法使用微信登录该账号,确定要解绑吗?",
okText: "确定解绑",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => unbindMutation.mutateAsync(),
})
}
const displayNameDirty = displayName.trim() !== (user?.display_name || "")
return (
<div className="xx-settings-page">
<PageHead title="个人设置" description="管理您的账户信息" />
<div className="xx-settings-card">
<h3></h3>
<div className="xx-settings-notice">
<span className="xx-settings-notice-icon"></span>
<div>
<strong></strong>
<p></p>
</div>
</div>
<div className="xx-settings-form">
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
@@ -42,25 +114,76 @@ const Settings: React.FC = () => {
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input value={user?.email || ""} disabled placeholder="邮箱" />
</div>
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="请输入显示名称"
value={user?.email && !user.email.endsWith("@wechat.local") ? user.email : ""}
disabled
placeholder={user?.email?.endsWith("@wechat.local") ? "微信账号暂未绑定邮箱" : "邮箱"}
/>
</div>
<div className="xx-settings-field">
<Button buttonType="primary" buttonSize="md" onClick={handleSave} disabled>
<label className="xx-settings-label"></label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="请输入昵称"
maxLength={20}
/>
</div>
<div className="xx-settings-field">
<Button
buttonType="primary"
buttonSize="md"
onClick={() => saveProfileMutation.mutate()}
loading={saveProfileMutation.isPending}
disabled={!displayName.trim() || !displayNameDirty}
>
</Button>
</div>
</div>
</div>
<div className="xx-settings-card">
<h3></h3>
<div className="xx-settings-wechat">
<div className="xx-settings-wechat-info">
<span className="xx-wechat-icon">💬</span>
<div>
{wechatBound ? (
<>
<strong>
{user?.wechat_nickname ? `${user.wechat_nickname}` : ""}
</strong>
<p>使</p>
</>
) : (
<>
<strong></strong>
<p>使</p>
</>
)}
</div>
</div>
<div className="xx-settings-wechat-actions">
{wechatBound ? (
<Button
buttonType="ghost"
buttonSize="md"
onClick={handleUnbind}
loading={unbindMutation.isPending}
>
</Button>
) : (
<Button buttonType="primary" buttonSize="md" onClick={handleBindWechat}>
</Button>
)}
</div>
</div>
</div>
</div>
)
}
+6
View File
@@ -6,10 +6,16 @@ import { useAuthStore } from "@/store/authStore"
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
const profileCompleted = useAuthStore((state) => state.user?.profile_completed !== false)
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
// 微信新用户未完成昵称引导时,禁止进入主界面
if (!profileCompleted) {
return <Navigate to="/welcome/wechat" replace />
}
return <>{children}</>
}
+10
View File
@@ -5,6 +5,8 @@ import Register from "@/pages/auth/Register"
import ForgotPassword from "@/pages/auth/ForgotPassword"
import ResetPassword from "@/pages/auth/ResetPassword"
import WechatCallback from "@/pages/auth/WechatCallback"
import WechatOnboarding from "@/pages/auth/WechatOnboarding"
import WechatBindCallback from "@/pages/auth/WechatBindCallback"
import { useAuthStore } from "@/store/authStore"
/** 首页路由组件:已登录跳 dashboard,未登录显示落地页 */
@@ -45,4 +47,12 @@ export const publicRoutes: RouteObject[] = [
path: "/auth/wechat/callback",
element: <WechatCallback />,
},
{
path: "/auth/wechat/bind/callback",
element: <WechatBindCallback />,
},
{
path: "/welcome/wechat",
element: <WechatOnboarding />,
},
]
+6
View File
@@ -13,6 +13,12 @@ interface User {
display_name: string
is_email_verified: boolean
email_verified: boolean
wechat_bound?: boolean
wechat_nickname?: string
avatar_url?: string
phone?: string
phone_verified?: boolean
profile_completed?: boolean
}
interface AuthState {
+124 -33
View File
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
// mock PageHead 简单mock
vi.mock("@/components/layout/PageHead", () => ({
default: ({ title, description }: { title: string; description?: string }) => (
<div data-testid="page-head">
@@ -12,51 +12,142 @@ vi.mock("@/components/layout/PageHead", () => ({
),
}))
const mockSetUser = vi.fn()
const mockInvalidate = vi.fn()
let authState: Record<string, unknown> = {
user: {
id: "1",
user_id: "1",
username: "testuser",
email: "test@example.com",
display_name: "Test User",
wechat_bound: false,
},
isAuthenticated: true,
setUser: mockSetUser,
}
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: any) => any) =>
selector({
useAuthStore: (selector: (state: unknown) => unknown) => selector(authState),
}))
const getCurrentUserMock = vi.fn(async () => authState.user as Record<string, unknown>)
const updateProfileMock = vi.fn()
const getWechatBindUrlMock = vi.fn(async () => ({
auth_url: "https://wx.example/auth",
state: "s1",
}))
const unbindWechatMock = vi.fn(async () => ({ success: true }))
vi.mock("@/api/auth", () => ({
getCurrentUser: () => getCurrentUserMock(),
updateProfile: (d: unknown) => updateProfileMock(d),
getWechatBindUrl: () => getWechatBindUrlMock(),
unbindWechat: () => unbindWechatMock(),
}))
vi.mock("antd", async () => {
const actual = await vi.importActual("antd")
return { ...actual, message: { success: vi.fn(), error: vi.fn() } }
})
import Settings from "@/pages/profile/Settings"
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const renderPage = () =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Settings />
</MemoryRouter>
</QueryClientProvider>,
)
describe("Settings Page", () => {
beforeEach(() => {
vi.clearAllMocks()
authState = {
user: {
id: "1",
user_id: "1",
username: "testuser",
email: "test@example.com",
display_name: "Test User",
is_email_verified: true,
email_verified: true,
wechat_bound: false,
},
isAuthenticated: true,
}),
}))
import Settings from "@/pages/profile/Settings"
describe("Settings Page", () => {
it("should render without crashing", () => {
render(
<MemoryRouter>
<Settings />
</MemoryRouter>,
)
expect(screen.getByText("个人设置")).toBeTruthy()
setUser: mockSetUser,
}
})
it("should display user info", () => {
render(
<MemoryRouter>
<Settings />
</MemoryRouter>,
)
it("渲染个人设置与用户信息", () => {
renderPage()
expect(screen.getByText("个人设置")).toBeTruthy()
expect(screen.getByDisplayValue("testuser")).toBeTruthy()
expect(screen.getByDisplayValue("test@example.com")).toBeTruthy()
})
it("should show save button is disabled", () => {
render(
<MemoryRouter>
<Settings />
</MemoryRouter>,
it("未绑定时显示绑定微信按钮,点击跳转微信授权", async () => {
renderPage()
expect(screen.getByText("未绑定微信")).toBeTruthy()
const btn = screen.getByText("绑定微信")
fireEvent.click(btn)
await waitFor(() => {
expect(getWechatBindUrlMock).toHaveBeenCalled()
expect(localStorage.getItem("wechat_bind_state")).toBe("s1")
})
})
it("已绑定时显示状态与解绑按钮,确认后调解绑接口", async () => {
authState.user = {
...(authState.user as object),
wechat_bound: true,
wechat_nickname: "微信昵称",
} as never
renderPage()
expect(screen.getByText(/已绑定微信/)).toBeTruthy()
fireEvent.click(
screen.getByText(
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").replace(/\s/g, "") === "解绑",
),
)
const button = screen.getByText("保存暂未开放")
expect(button).toBeTruthy()
// antd Modal.confirm 弹确认框(标题+内容均含"解绑微信",用 role=dialog 内的确认按钮)
await waitFor(() => {
expect(document.querySelector(".ant-modal-confirm")).toBeTruthy()
})
fireEvent.click(
screen.getByText(
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").includes("确定解绑"),
),
)
await waitFor(() => {
expect(unbindWechatMock).toHaveBeenCalled()
})
})
it("修改昵称后保存按钮可用,点击调用更新接口", async () => {
renderPage()
const saveBtn = screen.getByText(
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").replace(/\s/g, "") === "保存",
)
expect(saveBtn.closest("button")?.disabled).toBe(true)
fireEvent.change(screen.getByDisplayValue("Test User"), {
target: { value: "新昵称" },
})
await waitFor(() => {
expect(saveBtn.closest("button")?.disabled).toBe(false)
})
updateProfileMock.mockResolvedValueOnce({
id: "1",
display_name: "新昵称",
wechat_bound: false,
})
fireEvent.click(saveBtn)
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledWith({ display_name: "新昵称" })
})
})
})
@@ -1,79 +1,127 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
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 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
const mockAuthState = { setAuth: mockSetAuth }
// 文件级 localStorage mock(避免每个用例重复 spy 导致链式污染)
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 mockCallbackResult: Record<string, unknown> = {}
let mockCurrentUser: Record<string, unknown> = {}
let callbackShouldFail = false
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams({ code: "test_code", state: "test_state" })],
useNavigate: () => mockNavigate,
useSearchParams: () => mockSearchParams,
}
})
vi.mock("@/api/auth", () => ({
wechatCallback: vi.fn(() => new Promise(() => {})), // pending promise,保持loading
getCurrentUser: vi.fn(),
wechatCallback: vi.fn(async () => {
if (callbackShouldFail) throw new Error("fail")
return mockCallbackResult
}),
getCurrentUser: vi.fn(async () => mockCurrentUser),
normalizeUser: (u: unknown) => u,
}))
vi.mock("@/api/auth/tokenRefresh", () => ({
scheduleProactiveRefresh: vi.fn(),
cancelProactiveRefresh: vi.fn(),
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: () => ({
setAuth: vi.fn(),
}),
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }),
}))
vi.mock("@/components/auth/BindContactModal", () => ({
default: ({ open }: { open: boolean }) => (
<div data-testid="bind-contact-modal" style={{ display: open ? "block" : "none" }}>
BindContactModal
</div>
),
}))
vi.mock("antd", async () => {
const actual = await vi.importActual("antd")
return {
...actual,
message: {
success: vi.fn(),
error: vi.fn(),
},
}
})
const renderPage = () =>
render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
describe("WechatCallback Page", () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
// mock localStorage,设置wechat_state匹配,让校验通过
const store: Record<string, string> = {
wechat_state: "test_state",
vi.clearAllMocks()
callbackShouldFail = false
localStorageStore.wechat_state = "test_state"
mockCallbackResult = {
access_token: "at",
refresh_token: "rt",
is_new_user: false,
}
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => store[key] || null)
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
store[key] = val
mockCurrentUser = {
id: "u1",
display_name: "老用户",
profile_completed: true,
}
})
it("老用户登录成功跳转首页/来源页", async () => {
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
})
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
delete store[key]
expect(mockSetAuth).toHaveBeenCalled()
})
it("新用户(is_new_user)跳转昵称引导页", async () => {
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
mockCurrentUser = { id: "u2", display_name: "微信用户", profile_completed: false }
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/welcome/wechat", { replace: true })
})
})
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
expect(container).toBeTruthy()
it("is_new_user=false 但 profile_completed=false(上次中断)也跳引导页", async () => {
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: false }
mockCurrentUser = { id: "u3", display_name: "微信用户", profile_completed: false }
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/welcome/wechat", { replace: true })
})
})
it("should show loading state while processing", () => {
render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
// wechatCallback 返回 pending promise,所以应该显示 loading
expect(screen.getByText("正在登录...")).toBeTruthy()
it("state 不匹配显示安全错误", async () => {
localStorageStore.wechat_state = "other_state"
renderPage()
await waitFor(() => {
expect(screen.getByText("安全校验失败,请重新登录")).toBeTruthy()
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("接口失败显示错误提示", async () => {
callbackShouldFail = true
renderPage()
await waitFor(() => {
expect(screen.getByText("微信登录失败,请重试")).toBeTruthy()
})
})
it("处理中显示 loading", () => {
renderPage()
expect(screen.getByText("微信登录中...")).toBeTruthy()
})
})
@@ -0,0 +1,132 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import WechatOnboarding from "@/pages/auth/WechatOnboarding"
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const mockNavigate = vi.fn()
const mockSetUser = vi.fn()
let updateProfileMock = vi.fn()
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return { ...actual, useNavigate: () => mockNavigate }
})
let authState: Record<string, unknown> = {}
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector(authState),
}))
vi.mock("@/api/auth", () => ({
updateProfile: (data: { display_name: string }) => updateProfileMock(data),
}))
vi.mock("antd", async () => {
const actual = await vi.importActual("antd")
return { ...actual, message: { success: vi.fn(), error: vi.fn() } }
})
const renderPage = () =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<WechatOnboarding />
</MemoryRouter>
</QueryClientProvider>,
)
describe("WechatOnboarding 昵称引导页", () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
vi.clearAllMocks()
authState = {
isAuthenticated: true,
user: { id: "u1", display_name: "", profile_completed: false },
setUser: mockSetUser,
}
localStorage.setItem("access_token", "at")
updateProfileMock = vi.fn(async (data: { display_name: string }) => ({
id: "u1",
display_name: data.display_name,
profile_completed: true,
}))
})
it("未登录时跳转登录页", () => {
authState = {
isAuthenticated: false,
user: null,
setUser: mockSetUser,
}
localStorage.removeItem("access_token")
renderPage()
expect(mockNavigate).not.toHaveBeenCalled()
// Navigate 组件渲染即生效;这里断言页面不含昵称表单
expect(screen.queryByText("进入小虾智剪")).toBeNull()
})
it("资料已完善的用户跳 dashboard", () => {
authState = {
isAuthenticated: true,
user: { id: "u1", display_name: "已起名", profile_completed: true },
setUser: mockSetUser,
}
renderPage()
expect(screen.queryByText("进入小虾智剪")).toBeNull()
})
it("新用户可见昵称表单并能提交", async () => {
renderPage()
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledWith({ display_name: "小虾用户" })
})
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith("/app/dashboard", { replace: true })
})
})
it("昵称为空时不允许提交(表单校验)", async () => {
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: " " },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
// 等待表单校验
await waitFor(
() => {
expect(updateProfileMock).not.toHaveBeenCalled()
},
{ timeout: 1000 },
)
})
it("提交失败显示错误且不跳转", async () => {
updateProfileMock = vi.fn(async () => {
throw new Error("500")
})
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(mockNavigate).not.toHaveBeenCalled()
})
})
})