diff --git a/apps/web/src/components/auth/BindContactModal/hooks/useBindContactForm.ts b/apps/web/src/components/auth/BindContactModal/hooks/useBindContactForm.ts new file mode 100644 index 000000000..16ce271c3 --- /dev/null +++ b/apps/web/src/components/auth/BindContactModal/hooks/useBindContactForm.ts @@ -0,0 +1,97 @@ +import { useState, useEffect } from "react" +import { Form, message } from "antd" +import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth" +import { useCountdown } from "./useCountdown" + +type ContactType = "email" | "phone" + +interface UseBindContactFormOptions { + open: boolean + onSuccess?: (user: BindContactResponse["user"]) => void + onCancel?: () => void +} + +/** + * 绑定联系方式表单 Hook + * 封装表单状态、验证码发送、提交绑定等业务逻辑 + */ +export const useBindContactForm = ({ open, onSuccess }: UseBindContactFormOptions) => { + const [activeTab, setActiveTab] = useState("email") + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [codeLoading, setCodeLoading] = useState(false) + const { countdown, isRunning, start: startCountdown, reset: resetCountdown } = useCountdown(60) + + /* ── 打开时重置表单 ── */ + useEffect(() => { + if (open) { + form.resetFields() + resetCountdown() + } + }, [open, form, resetCountdown]) + + /* ── 发送验证码 ── */ + const handleSendCode = async () => { + try { + const fieldName = activeTab === "email" ? "email" : "phone" + const value = form.getFieldValue(fieldName) + if (!value) { + message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号") + return + } + setCodeLoading(true) + await sendVerificationCode({ + target: activeTab, + value, + purpose: "bind", + }) + message.success("验证码已发送") + startCountdown() + } catch { + // error handled by interceptor + } finally { + setCodeLoading(false) + } + } + + /* ── 提交绑定 ── */ + const handleSubmit = async () => { + // 1. 表单验证(失败时 Ant Design Form 会自动展示错误信息) + let values + try { + values = await form.validateFields() + } catch { + return + } + + // 2. 调用绑定接口 + setLoading(true) + try { + const payload = + activeTab === "email" + ? { email: values.email, email_code: values.email_code } + : { phone: values.phone, phone_code: values.phone_code } + + const result = await bindContact(payload) + + message.success("绑定成功") + onSuccess?.(result.user) + } catch { + // API 错误由全局拦截器统一处理提示 + } finally { + setLoading(false) + } + } + + return { + activeTab, + setActiveTab, + form, + loading, + codeLoading, + countdown, + codeDisabled: isRunning, + handleSendCode, + handleSubmit, + } +} diff --git a/apps/web/src/components/auth/BindContactModal/hooks/useCountdown.ts b/apps/web/src/components/auth/BindContactModal/hooks/useCountdown.ts new file mode 100644 index 000000000..ffa35d5fc --- /dev/null +++ b/apps/web/src/components/auth/BindContactModal/hooks/useCountdown.ts @@ -0,0 +1,39 @@ +import { useState, useEffect, useRef, useCallback } from "react" + +/** + * 倒计时 Hook + * 用于验证码发送等场景的倒计时控制 + */ +export const useCountdown = (initialSeconds = 60) => { + const [countdown, setCountdown] = useState(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (countdown > 0) { + timerRef.current = setInterval(() => { + setCountdown((prev) => prev - 1) + }, 1000) + } else if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } + return () => { + if (timerRef.current) clearInterval(timerRef.current) + } + }, [countdown]) + + const start = useCallback(() => { + setCountdown(initialSeconds) + }, [initialSeconds]) + + const reset = useCallback(() => { + setCountdown(0) + }, []) + + return { + countdown, + isRunning: countdown > 0, + start, + reset, + } +} diff --git a/apps/web/src/components/auth/BindContactModal.tsx b/apps/web/src/components/auth/BindContactModal/index.tsx similarity index 58% rename from apps/web/src/components/auth/BindContactModal.tsx rename to apps/web/src/components/auth/BindContactModal/index.tsx index c600657de..2e64b4141 100644 --- a/apps/web/src/components/auth/BindContactModal.tsx +++ b/apps/web/src/components/auth/BindContactModal/index.tsx @@ -1,84 +1,20 @@ -import React, { useState, useEffect, useRef } from "react" -import { Modal, Tabs, Form, Input, Button, message } from "antd" -import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth" - -interface BindContactModalProps { - open: boolean - onSuccess?: (user: BindContactResponse["user"]) => void - onCancel?: () => void -} +import React from "react" +import { Modal, Tabs, Form, Input, Button } from "antd" +import { useBindContactForm } from "./hooks/useBindContactForm" +import type { BindContactModalProps } from "./types" const BindContactModal: React.FC = ({ open, onSuccess, onCancel }) => { - const [activeTab, setActiveTab] = useState<"email" | "phone">("email") - const [form] = Form.useForm() - const [loading, setLoading] = useState(false) - const [codeLoading, setCodeLoading] = useState(false) - const [countdown, setCountdown] = useState(0) - const timerRef = useRef | null>(null) - - useEffect(() => { - if (countdown > 0) { - timerRef.current = setInterval(() => { - setCountdown((prev) => prev - 1) - }, 1000) - } else if (timerRef.current) { - clearInterval(timerRef.current) - timerRef.current = null - } - return () => { - if (timerRef.current) clearInterval(timerRef.current) - } - }, [countdown]) - - useEffect(() => { - if (open) { - form.resetFields() - setCountdown(0) - } - }, [open, form]) - - const handleSendCode = async () => { - try { - const value = form.getFieldValue(activeTab === "email" ? "email" : "phone") - if (!value) { - message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号") - return - } - setCodeLoading(true) - await sendVerificationCode({ - target: activeTab, - value, - purpose: "bind", - }) - message.success("验证码已发送") - setCountdown(60) - } catch (error) { - // error handled by interceptor - } finally { - setCodeLoading(false) - } - } - - const handleSubmit = async () => { - try { - const values = await form.validateFields() - setLoading(true) - - const payload = - activeTab === "email" - ? { email: values.email, email_code: values.code } - : { phone: values.phone, phone_code: values.code } - - const result = await bindContact(payload) - - message.success("绑定成功") - onSuccess?.(result.user) - } catch (error) { - // error handled by interceptor - } finally { - setLoading(false) - } - } + const { + activeTab, + setActiveTab, + form, + loading, + codeLoading, + countdown, + codeDisabled, + handleSendCode, + handleSubmit, + } = useBindContactForm({ open, onSuccess, onCancel }) return ( = ({ open, onSuccess, on @@ -121,7 +57,7 @@ const BindContactModal: React.FC = ({ open, onSuccess, on size="large" onClick={handleSendCode} loading={codeLoading} - disabled={countdown > 0} + disabled={codeDisabled} > {countdown > 0 ? `${countdown}s 后重发` : "发送验证码"} @@ -146,7 +82,7 @@ const BindContactModal: React.FC = ({ open, onSuccess, on @@ -156,7 +92,7 @@ const BindContactModal: React.FC = ({ open, onSuccess, on size="large" onClick={handleSendCode} loading={codeLoading} - disabled={countdown > 0} + disabled={codeDisabled} > {countdown > 0 ? `${countdown}s 后重发` : "发送验证码"} @@ -178,3 +114,4 @@ const BindContactModal: React.FC = ({ open, onSuccess, on } export default BindContactModal +export type { BindContactModalProps } from "./types" diff --git a/apps/web/src/components/auth/BindContactModal/types.ts b/apps/web/src/components/auth/BindContactModal/types.ts new file mode 100644 index 000000000..569bbb69a --- /dev/null +++ b/apps/web/src/components/auth/BindContactModal/types.ts @@ -0,0 +1,14 @@ +import type { BindContactResponse } from "@/api/auth" + +/** BindContactModal 组件属性 */ +export interface BindContactModalProps { + /** 是否打开 */ + open: boolean + /** 绑定成功回调 */ + onSuccess?: (user: BindContactResponse["user"]) => void + /** 取消回调 */ + onCancel?: () => void +} + +/** 联系方式类型 */ +export type ContactType = "email" | "phone" diff --git a/apps/web/src/test/components/auth/BindContactModal.test.tsx b/apps/web/src/test/components/auth/BindContactModal.test.tsx new file mode 100644 index 000000000..eeb201d0a --- /dev/null +++ b/apps/web/src/test/components/auth/BindContactModal.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest" +import BindContactModal from "@/components/auth/BindContactModal" +import type { BindContactModalProps, ContactType } from "@/components/auth/BindContactModal/types" +import { useBindContactForm } from "@/components/auth/BindContactModal/hooks/useBindContactForm" +import { useCountdown } from "@/components/auth/BindContactModal/hooks/useCountdown" + +describe("BindContactModal", () => { + it("主组件可正常导入", () => { + expect(BindContactModal).toBeDefined() + }) + + it("Hook 可正常导入", () => { + expect(useBindContactForm).toBeDefined() + expect(useCountdown).toBeDefined() + }) + + it("类型导出正常", () => { + const props: BindContactModalProps = { open: false } + expect(props.open).toBe(false) + + const contactType: ContactType = "email" + expect(contactType).toBe("email") + }) +})