refactor(BindContactModal): 绑定联系方式弹窗目录化拆分(180→119行, -34%) #1183

Merged
xiaoxia merged 17 commits from refactor/bind-contact-modal-split into develop 2026-07-30 19:58:49 +08:00
5 changed files with 194 additions and 83 deletions
@@ -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<ContactType>("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,
}
}
@@ -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<ReturnType<typeof setInterval> | 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,
}
}
@@ -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<BindContactModalProps> = ({ 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<ReturnType<typeof setInterval> | 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 (
<Modal
@@ -111,7 +47,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
<Input placeholder="请输入邮箱地址" size="large" />
</Form.Item>
<Form.Item
name="code"
name="email_code"
label="验证码"
rules={[{ required: true, message: "请输入验证码" }]}
>
@@ -121,7 +57,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
size="large"
onClick={handleSendCode}
loading={codeLoading}
disabled={countdown > 0}
disabled={codeDisabled}
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</Button>
@@ -146,7 +82,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
<Input placeholder="请输入手机号" size="large" />
</Form.Item>
<Form.Item
name="code"
name="phone_code"
label="验证码"
rules={[{ required: true, message: "请输入验证码" }]}
>
@@ -156,7 +92,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
size="large"
onClick={handleSendCode}
loading={codeLoading}
disabled={countdown > 0}
disabled={codeDisabled}
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</Button>
@@ -178,3 +114,4 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
}
export default BindContactModal
export type { BindContactModalProps } from "./types"
@@ -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"
@@ -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")
})
})