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")