feat(#558): 微信登录前端 - 授权按钮、回调页、绑定弹窗 #672

Merged
auto-approve-bot merged 2 commits from feat/558-wechat-login-frontend into develop 2026-07-21 10:47:31 +08:00
5 changed files with 407 additions and 3 deletions
+64
View File
@@ -121,3 +121,67 @@ export const verifyEmail = async (token: string): Promise<{ message: string }> =
const response = await apiClient.post("/auth/verify-email", { token })
return response.data
}
/* ========== 微信登录 ========== */
export interface WechatAuthUrlResponse {
auth_url: string
state: string
}
export interface WechatCallbackResponse {
access_token: string
refresh_token?: string | null
user_id: string
display_name: string
avatar_url: string
is_new_user: boolean
binding_complete: boolean
expires_in: number
}
export interface SendVerificationCodeRequest {
target: "email" | "phone"
value: string
purpose: "bind" | "login" | "reset_password"
}
export interface BindContactRequest {
target: "email" | "phone"
value: string
code: string
}
export interface BindContactResponse {
message: string
user: User
}
// 获取微信授权链接
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
return response.data
}
// 微信回调登录
export const wechatCallback = async (
code: string,
state: string,
): Promise<WechatCallbackResponse> => {
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
// 发送验证码
export const sendVerificationCode = async (
data: SendVerificationCodeRequest,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/send-verification-code", data)
return response.data
}
// 绑定联系方式
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
const response = await apiClient.post("/auth/bind-contact", data)
return response.data
}
@@ -0,0 +1,182 @@
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
}
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 target = activeTab
const value = target === "email" ? values.email : values.phone
const result = await bindContact({
target,
value,
code: values.code,
})
message.success("绑定成功")
onSuccess?.(result.user)
} catch (error) {
// error handled by interceptor
} finally {
setLoading(false)
}
}
return (
<Modal
title="绑定联系方式"
open={open}
onCancel={onCancel}
footer={null}
destroyOnHidden
maskClosable={false}
>
<p style={{ color: "#666", marginBottom: 16 }}></p>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as "email" | "phone")}
items={[
{
key: "email",
label: "邮箱绑定",
children: (
<Form form={form} layout="vertical">
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: "请输入邮箱" },
{ type: "email", message: "请输入有效的邮箱地址" },
]}
>
<Input placeholder="请输入邮箱地址" size="large" />
</Form.Item>
<Form.Item
name="code"
label="验证码"
rules={[{ required: true, message: "请输入验证码" }]}
>
<div style={{ display: "flex", gap: 8 }}>
<Input placeholder="请输入验证码" size="large" style={{ flex: 1 }} />
<Button
size="large"
onClick={handleSendCode}
loading={codeLoading}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</Button>
</div>
</Form.Item>
</Form>
),
},
{
key: "phone",
label: "手机绑定",
children: (
<Form form={form} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: "请输入手机号" },
{ pattern: /^1[3-9]\d{9}$/, message: "请输入有效的手机号" },
]}
>
<Input placeholder="请输入手机号" size="large" />
</Form.Item>
<Form.Item
name="code"
label="验证码"
rules={[{ required: true, message: "请输入验证码" }]}
>
<div style={{ display: "flex", gap: 8 }}>
<Input placeholder="请输入验证码" size="large" style={{ flex: 1 }} />
<Button
size="large"
onClick={handleSendCode}
loading={codeLoading}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</Button>
</div>
</Form.Item>
</Form>
),
},
]}
/>
<div style={{ marginTop: 16 }}>
<Button type="primary" block size="large" loading={loading} onClick={handleSubmit}>
</Button>
</div>
</Modal>
)
}
export default BindContactModal
+22 -3
View File
@@ -1,10 +1,11 @@
/**
* 登录页面 - V21 完全对标
*/
import React from "react"
import React, { useState } from "react"
import { Form, Input, Checkbox, message } from "antd"
import { Link, useNavigate } from "react-router-dom"
import { useLogin } from "@/hooks/useAuth"
import { getWechatAuthUrl } from "@/api/auth"
import Button from "@/components/ui/Button"
import "./Login.css"
@@ -18,6 +19,7 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const loginMutation = useLogin()
const [form] = Form.useForm()
const [wechatLoading, setWechatLoading] = useState(false)
const onFinish = async (values: LoginFormValues) => {
try {
@@ -33,6 +35,22 @@ const Login: React.FC = () => {
}
}
const handleWechatLogin = async () => {
try {
setWechatLoading(true)
const result = await getWechatAuthUrl()
// 保存 state 到 localStorage 用于回调时验证
localStorage.setItem("wechat_state", result.state)
// 跳转到微信授权页
window.location.href = result.auth_url
} catch (error) {
if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("微信登录暂不可用,请稍后重试")
} finally {
setWechatLoading(false)
}
}
return (
<div className="xx-auth-page">
<div className="xx-auth-card">
@@ -100,10 +118,11 @@ const Login: React.FC = () => {
<button
type="button"
className="xx-btn-wechat"
onClick={() => message.info("微信登录功能开发中")}
onClick={handleWechatLogin}
disabled={wechatLoading}
>
<span className="xx-wechat-icon">💬</span>
{wechatLoading ? "加载中..." : "微信登录"}
</button>
</div>
+134
View File
@@ -0,0 +1,134 @@
/**
* 微信登录回调页
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin, message } from "antd"
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import BindContactModal from "@/components/auth/BindContactModal"
const WechatCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const [loading, setLoading] = useState(true)
const [showBindModal, setShowBindModal] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
if (!code || !state) {
setError("无效的回调参数")
setLoading(false)
return
}
const handleCallback = async () => {
try {
const result = await wechatCallback(code, state)
// 保存 token
localStorage.setItem("access_token", result.access_token)
if (result.refresh_token) {
localStorage.setItem("refresh_token", result.refresh_token)
} else {
localStorage.removeItem("refresh_token")
}
// 获取用户信息
const userData = await getCurrentUser()
const user: User = normalizeUser(userData)
setAuth(user, result.access_token, result.refresh_token)
if (result.binding_complete) {
// 已绑定,直接跳转到首页
message.success("登录成功")
navigate("/app/dashboard")
} else {
// 未绑定,显示绑定弹窗
setLoading(false)
setShowBindModal(true)
}
} catch (err) {
setError("登录失败,请重试")
setLoading(false)
}
}
handleCallback()
}, [searchParams, navigate, setAuth])
const handleBindSuccess = (_user: User) => {
setShowBindModal(false)
message.success("绑定成功")
navigate("/app/dashboard")
}
const handleBindCancel = () => {
setShowBindModal(false)
navigate("/login")
}
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
return (
<BindContactModal
open={showBindModal}
onSuccess={handleBindSuccess}
onCancel={handleBindCancel}
/>
)
}
export default WechatCallback
Executable → Regular
+5
View File
@@ -9,6 +9,7 @@ import Login from "@/pages/auth/Login"
import Register from "@/pages/auth/Register"
import ForgotPassword from "@/pages/auth/ForgotPassword"
import ResetPassword from "@/pages/auth/ResetPassword"
import WechatCallback from "@/pages/auth/WechatCallback"
import HomePage from "@/pages/home/HomePage"
import { useAuthStore } from "@/store/authStore"
@@ -60,6 +61,10 @@ export const router = createBrowserRouter([
path: "/reset-password",
element: <ResetPassword />,
},
{
path: "/auth/wechat/callback",
element: <WechatCallback />,
},
{
path: "/app",
element: (