Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eba067088e | |||
| ab6a2ffb08 | |||
| 3be0bc68a0 | |||
| 1883d9ba06 | |||
| 2b883b5468 | |||
| 5e0c2f2654 | |||
| 550b6cb73c | |||
| 6c5e76d32e | |||
| b1e85b68df | |||
| 21a66be8a6 | |||
| 4a848247fa | |||
| af38a296c5 | |||
| f021ff2a74 | |||
| 0fc133798b | |||
| 61117e50bc | |||
| ad902105f5 | |||
| f83c96bb77 |
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-83
@@ -1,84 +1,20 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react"
|
import React from "react"
|
||||||
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
import { Modal, Tabs, Form, Input, Button } from "antd"
|
||||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
import { useBindContactForm } from "./hooks/useBindContactForm"
|
||||||
|
import type { BindContactModalProps } from "./types"
|
||||||
interface BindContactModalProps {
|
|
||||||
open: boolean
|
|
||||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
|
||||||
onCancel?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||||
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
const {
|
||||||
const [form] = Form.useForm()
|
activeTab,
|
||||||
const [loading, setLoading] = useState(false)
|
setActiveTab,
|
||||||
const [codeLoading, setCodeLoading] = useState(false)
|
form,
|
||||||
const [countdown, setCountdown] = useState(0)
|
loading,
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
codeLoading,
|
||||||
|
countdown,
|
||||||
useEffect(() => {
|
codeDisabled,
|
||||||
if (countdown > 0) {
|
handleSendCode,
|
||||||
timerRef.current = setInterval(() => {
|
handleSubmit,
|
||||||
setCountdown((prev) => prev - 1)
|
} = useBindContactForm({ open, onSuccess, onCancel })
|
||||||
}, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -111,7 +47,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
<Input placeholder="请输入邮箱地址" size="large" />
|
<Input placeholder="请输入邮箱地址" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="code"
|
name="email_code"
|
||||||
label="验证码"
|
label="验证码"
|
||||||
rules={[{ required: true, message: "请输入验证码" }]}
|
rules={[{ required: true, message: "请输入验证码" }]}
|
||||||
>
|
>
|
||||||
@@ -121,7 +57,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={handleSendCode}
|
onClick={handleSendCode}
|
||||||
loading={codeLoading}
|
loading={codeLoading}
|
||||||
disabled={countdown > 0}
|
disabled={codeDisabled}
|
||||||
>
|
>
|
||||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -146,7 +82,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
<Input placeholder="请输入手机号" size="large" />
|
<Input placeholder="请输入手机号" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="code"
|
name="phone_code"
|
||||||
label="验证码"
|
label="验证码"
|
||||||
rules={[{ required: true, message: "请输入验证码" }]}
|
rules={[{ required: true, message: "请输入验证码" }]}
|
||||||
>
|
>
|
||||||
@@ -156,7 +92,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={handleSendCode}
|
onClick={handleSendCode}
|
||||||
loading={codeLoading}
|
loading={codeLoading}
|
||||||
disabled={countdown > 0}
|
disabled={codeDisabled}
|
||||||
>
|
>
|
||||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -178,3 +114,4 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default BindContactModal
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user