From b27ff1323d1c70ec00d7febaee7a27f2dd68ff3b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 6 Sep 2026 11:19:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(#1718):=20=E6=98=B5=E7=A7=B0=E9=A1=B5?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=8C=89=E9=92=AE=E5=8A=A0=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E9=98=B2=E8=BF=9E=E7=82=B9=E5=AE=88=E5=8D=AB=EF=BC=88=E8=BF=9E?= =?UTF-8?q?=E7=82=B9=E8=87=B4=20PATCH/me=20=E9=87=8D=E5=A4=8D=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=EF=BC=89=E3=80=81=E8=A1=A5=20/vite.svg=20favicon=20?= =?UTF-8?q?=E6=B6=88=E9=99=A4=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/vite.svg | 4 ++ apps/web/src/pages/auth/WechatOnboarding.tsx | 19 ++++++-- .../test/pages/auth/WechatOnboarding.test.tsx | 48 +++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 apps/web/public/vite.svg diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg new file mode 100644 index 000000000..7f7809d44 --- /dev/null +++ b/apps/web/public/vite.svg @@ -0,0 +1,4 @@ + + + 🦐 + diff --git a/apps/web/src/pages/auth/WechatOnboarding.tsx b/apps/web/src/pages/auth/WechatOnboarding.tsx index 0b237243f..38c62f345 100644 --- a/apps/web/src/pages/auth/WechatOnboarding.tsx +++ b/apps/web/src/pages/auth/WechatOnboarding.tsx @@ -2,11 +2,12 @@ * 微信新用户昵称引导页 * 新微信用户首次登录后强制填写昵称,完成后才进入主界面 */ -import React from "react" +import React, { useRef } 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 { getErrorMessage, isErrorMsgShown } from "@/api/errors" import { useAuthStore } from "@/store/authStore" import Button from "@/components/ui/Button" import "./Login.css" @@ -22,6 +23,10 @@ const WechatOnboarding: React.FC = () => { const user = useAuthStore((state) => state.user) const hasAccessToken = Boolean(localStorage.getItem("access_token")) const [form] = Form.useForm() + // 同步防连点守卫:antd loading 要等 React 重渲染后才禁用按钮, + // 连点两次时第一次的 mutation 刚触发、重渲染未发生,第二次 click 仍会进来 + // (截图里 PATCH /me 405 出现两次就是连点导致的重复提交) + const submittingRef = useRef(false) const saveMutation = useMutation({ mutationFn: (displayName: string) => updateProfile({ display_name: displayName }), @@ -37,6 +42,8 @@ const WechatOnboarding: React.FC = () => { } const onFinish = async (values: OnboardingFormValues) => { + if (submittingRef.current) return + submittingRef.current = true try { const updated = await saveMutation.mutateAsync(values.display_name.trim()) // 后端返回的 profile_completed 以最新资料为准,前端同步标记完善 @@ -45,9 +52,14 @@ const WechatOnboarding: React.FC = () => { const redirect = localStorage.getItem("login_redirect") || "/app/dashboard" localStorage.removeItem("login_redirect") navigate(redirect, { replace: true }) - } catch { - message.error("保存失败,请重试") + } catch (err) { + // 透传后端真实原因(如接口异常/校验失败);拦截器已弹过的不重复弹 + if (!isErrorMsgShown(err)) { + message.error(`昵称保存失败:${getErrorMessage(err, "请稍后重试")}`) + } + submittingRef.current = false } + // 成功时页面跳走,不复位 } return ( @@ -89,6 +101,7 @@ const WechatOnboarding: React.FC = () => { buttonSize="lg" htmlType="submit" loading={saveMutation.isPending} + disabled={saveMutation.isPending} style={{ width: "100%" }} > {saveMutation.isPending ? "保存中..." : "进入小虾智剪"} diff --git a/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx b/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx index 41c4fcfa9..548d7f52e 100644 --- a/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx +++ b/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx @@ -122,6 +122,54 @@ describe("WechatOnboarding 昵称引导页", () => { ) }) + it("连点提交按钮只触发一次请求(防重复提交)", async () => { + // mutation 挂起不立即完成,模拟慢网络下连续双击 + let resolveSubmit: (v: unknown) => void = () => {} + updateProfileMock = vi.fn( + () => + new Promise((resolve) => { + resolveSubmit = resolve + }), + ) + renderPage() + fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), { + target: { value: "小虾用户" }, + }) + const btn = screen.getByText("进入小虾智剪") + fireEvent.click(btn) + // 第一次点击后立即再点(此时重渲染/loading 可能还没生效) + fireEvent.click(btn) + fireEvent.click(btn) + await waitFor(() => { + expect(updateProfileMock).toHaveBeenCalledTimes(1) + }) + // 释放挂起的 Promise,避免泄漏 + resolveSubmit({ id: "u1", display_name: "小虾用户", profile_completed: true }) + }) + + it("提交失败后守卫复位,允许再次提交", async () => { + updateProfileMock = vi + .fn() + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 500, data: { detail: "服务内部错误" } }, + }) + .mockResolvedValueOnce({ id: "u1", display_name: "小虾用户", profile_completed: true }) + renderPage() + fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), { + target: { value: "小虾用户" }, + }) + fireEvent.click(screen.getByText("进入小虾智剪")) + await waitFor(() => { + expect(updateProfileMock).toHaveBeenCalledTimes(1) + }) + // 失败后再点一次,应能重新提交 + fireEvent.click(screen.getByText("进入小虾智剪")) + await waitFor(() => { + expect(updateProfileMock).toHaveBeenCalledTimes(2) + }) + }) + it("提交失败显示错误且不跳转", async () => { updateProfileMock = vi.fn(async () => { throw new Error("500")