diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 4b4f9c891..b671db6fb 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -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 => { + const response = await apiClient.get("/auth/wechat/url") + return response.data +} + +// 微信回调登录 +export const wechatCallback = async ( + code: string, + state: string, +): Promise => { + 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 => { + const response = await apiClient.post("/auth/bind-contact", data) + return response.data +} diff --git a/apps/web/src/components/auth/BindContactModal.tsx b/apps/web/src/components/auth/BindContactModal.tsx new file mode 100644 index 000000000..4cfb89724 --- /dev/null +++ b/apps/web/src/components/auth/BindContactModal.tsx @@ -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 = ({ 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 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 ( + +

为了保障账号安全,请绑定您的邮箱或手机号

+ + setActiveTab(key as "email" | "phone")} + items={[ + { + key: "email", + label: "邮箱绑定", + children: ( +
+ + + + +
+ + +
+
+
+ ), + }, + { + key: "phone", + label: "手机绑定", + children: ( +
+ + + + +
+ + +
+
+
+ ), + }, + ]} + /> + +
+ +
+
+ ) +} + +export default BindContactModal diff --git a/apps/web/src/pages/auth/Login.tsx b/apps/web/src/pages/auth/Login.tsx old mode 100755 new mode 100644 index 39fca3304..6a4e22abb --- a/apps/web/src/pages/auth/Login.tsx +++ b/apps/web/src/pages/auth/Login.tsx @@ -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 (
@@ -100,10 +118,11 @@ const Login: React.FC = () => {
diff --git a/apps/web/src/pages/auth/WechatCallback.tsx b/apps/web/src/pages/auth/WechatCallback.tsx new file mode 100644 index 000000000..41d46886e --- /dev/null +++ b/apps/web/src/pages/auth/WechatCallback.tsx @@ -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(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 ( +
+
+ +

正在登录...

+
+
+ ) + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ) + } + + return ( + + ) +} + +export default WechatCallback diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx old mode 100755 new mode 100644 index 037a1fc6c..670c915ce --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -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: , }, + { + path: "/auth/wechat/callback", + element: , + }, { path: "/app", element: (