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