diff --git a/apps/web/src/api/auth/index.ts b/apps/web/src/api/auth/index.ts index 966c92c44..322dbe7c2 100644 --- a/apps/web/src/api/auth/index.ts +++ b/apps/web/src/api/auth/index.ts @@ -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" diff --git a/apps/web/src/api/auth/types.ts b/apps/web/src/api/auth/types.ts index 023e0fa32..0ee6e2999 100644 --- a/apps/web/src/api/auth/types.ts +++ b/apps/web/src/api/auth/types.ts @@ -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 +} diff --git a/apps/web/src/api/auth/user.ts b/apps/web/src/api/auth/user.ts index 5c345e3b5..395cb2721 100644 --- a/apps/web/src/api/auth/user.ts +++ b/apps/web/src/api/auth/user.ts @@ -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 => { + const response = await apiClient.patch("/auth/me", data) + return normalizeUser(response.data.user) +} diff --git a/apps/web/src/api/auth/wechat.ts b/apps/web/src/api/auth/wechat.ts index 1e41a8844..38da5f7ce 100644 --- a/apps/web/src/api/auth/wechat.ts +++ b/apps/web/src/api/auth/wechat.ts @@ -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 => { 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 => { + const response = await apiClient.get("/auth/wechat/bind/url") + return response.data +} + +/** + * 微信绑定完成(扫码回调后用 code 绑定到当前登录账号) + */ +export const bindWechat = async ( + code: string, + state: string, +): Promise => { + const response = await apiClient.post("/auth/wechat/bind", { code, state }) + return response.data +} + +/** + * 解绑微信 + */ +export const unbindWechat = async (): Promise => { + const response = await apiClient.delete("/auth/wechat/bind") + return response.data +} diff --git a/apps/web/src/pages/auth/WechatBindCallback.tsx b/apps/web/src/pages/auth/WechatBindCallback.tsx new file mode 100644 index 000000000..ad013cdd8 --- /dev/null +++ b/apps/web/src/pages/auth/WechatBindCallback.tsx @@ -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(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 ( +
+
+

{error}

+ +
+
+ ) + } + + return ( +
+
+ +

正在绑定微信...

+
+
+ ) +} + +export default WechatBindCallback diff --git a/apps/web/src/pages/auth/WechatCallback.tsx b/apps/web/src/pages/auth/WechatCallback.tsx index 3dc75a6e9..4186c3033 100644 --- a/apps/web/src/pages/auth/WechatCallback.tsx +++ b/apps/web/src/pages/auth/WechatCallback.tsx @@ -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(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 (
{ >
-

正在登录...

-
-
- ) - } - - if (error) { - return ( -
-
-

{error}

- +

微信登录中...

) } return ( - +
+
+

{error}

+ +
+
) } diff --git a/apps/web/src/pages/auth/WechatOnboarding.tsx b/apps/web/src/pages/auth/WechatOnboarding.tsx new file mode 100644 index 000000000..a4893b846 --- /dev/null +++ b/apps/web/src/pages/auth/WechatOnboarding.tsx @@ -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() + + const saveMutation = useMutation({ + mutationFn: (displayName: string) => updateProfile({ display_name: displayName }), + }) + + // 已登录且资料已完善的用户不该停留在引导页 + if (isAuthenticated && hasAccessToken && user?.profile_completed === true) { + return + } + // 未登录(如手动输入 URL)回登录页 + if (!isAuthenticated || !hasAccessToken) { + return + } + + 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 ( +
+
+
+
+ 🦐 + 小虾智剪 +
+

欢迎使用微信登录,请先设置您的昵称

+
+ +
+ + + + + + + +
+
+
+ ) +} + +export default WechatOnboarding diff --git a/apps/web/src/pages/profile/ProfileSettings.css b/apps/web/src/pages/profile/ProfileSettings.css index de21e497b..03a35583e 100644 --- a/apps/web/src/pages/profile/ProfileSettings.css +++ b/apps/web/src/pages/profile/ProfileSettings.css @@ -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); +} diff --git a/apps/web/src/pages/profile/Settings.tsx b/apps/web/src/pages/profile/Settings.tsx index d66eea05a..7513c3bcf 100644 --- a/apps/web/src/pages/profile/Settings.tsx +++ b/apps/web/src/pages/profile/Settings.tsx @@ -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 (

个人信息

-
- ℹ️ -
- 个人资料编辑暂未开放 -

当前仅展示登录用户信息,资料修改接口接入后再开放保存。

-
-
-
@@ -42,25 +114,76 @@ const Settings: React.FC = () => {
- -
- -
- setDisplayName(e.target.value)} - placeholder="请输入显示名称" + value={user?.email && !user.email.endsWith("@wechat.local") ? user.email : ""} + disabled + placeholder={user?.email?.endsWith("@wechat.local") ? "微信账号暂未绑定邮箱" : "邮箱"} />
-
+ +
+
+ +
+

微信账号

+
+
+ 💬 +
+ {wechatBound ? ( + <> + + 已绑定微信{user?.wechat_nickname ? `(${user.wechat_nickname})` : ""} + +

可使用微信扫码登录本账号

+ + ) : ( + <> + 未绑定微信 +

绑定后可使用微信扫码快速登录

+ + )} +
+
+
+ {wechatBound ? ( + + ) : ( + + )} +
+
+
) } diff --git a/apps/web/src/router/ProtectedRoute.tsx b/apps/web/src/router/ProtectedRoute.tsx index 2c49851ff..943511cc3 100644 --- a/apps/web/src/router/ProtectedRoute.tsx +++ b/apps/web/src/router/ProtectedRoute.tsx @@ -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 } + // 微信新用户未完成昵称引导时,禁止进入主界面 + if (!profileCompleted) { + return + } + return <>{children} } diff --git a/apps/web/src/router/publicRoutes.tsx b/apps/web/src/router/publicRoutes.tsx index e610b941d..48c7245fb 100644 --- a/apps/web/src/router/publicRoutes.tsx +++ b/apps/web/src/router/publicRoutes.tsx @@ -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: , }, + { + path: "/auth/wechat/bind/callback", + element: , + }, + { + path: "/welcome/wechat", + element: , + }, ] diff --git a/apps/web/src/store/authStore.ts b/apps/web/src/store/authStore.ts index cef495665..3f6a2991c 100644 --- a/apps/web/src/store/authStore.ts +++ b/apps/web/src/store/authStore.ts @@ -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 { diff --git a/apps/web/src/test/pages/Settings.test.tsx b/apps/web/src/test/pages/Settings.test.tsx index e0614b0bf..3d0711b64 100644 --- a/apps/web/src/test/pages/Settings.test.tsx +++ b/apps/web/src/test/pages/Settings.test.tsx @@ -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 }) => (
@@ -12,51 +12,142 @@ vi.mock("@/components/layout/PageHead", () => ({ ), })) +const mockSetUser = vi.fn() +const mockInvalidate = vi.fn() +let authState: Record = { + 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) +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( + + + + + , + ) + +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( - - - , - ) - expect(screen.getByText("个人设置")).toBeTruthy() + setUser: mockSetUser, + } }) - it("should display user info", () => { - render( - - - , - ) + 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( - - - , + 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: "新昵称" }) + }) }) }) diff --git a/apps/web/src/test/pages/auth/WechatCallback.test.tsx b/apps/web/src/test/pages/auth/WechatCallback.test.tsx index fe558f930..01675db9b 100644 --- a/apps/web/src/test/pages/auth/WechatCallback.test.tsx +++ b/apps/web/src/test/pages/auth/WechatCallback.test.tsx @@ -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 = {} +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 = {} +let mockCurrentUser: Record = {} +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 }) => ( -
- BindContactModal -
- ), -})) - -vi.mock("antd", async () => { - const actual = await vi.importActual("antd") - return { - ...actual, - message: { - success: vi.fn(), - error: vi.fn(), - }, - } -}) +const renderPage = () => + render( + + + , + ) describe("WechatCallback Page", () => { + afterEach(() => { + cleanup() + }) + beforeEach(() => { - // mock localStorage,设置wechat_state匹配,让校验通过 - const store: Record = { - 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( - - - , - ) - 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( - - - , - ) - // 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() }) }) diff --git a/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx b/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx new file mode 100644 index 000000000..b93218448 --- /dev/null +++ b/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx @@ -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 = {} +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( + + + + + , + ) + +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() + }) + }) +})