Răsfoiți Sursa

主要实现了 OAuth 第三方登录功能(特别是 Google 登录)

urbanu 1 lună în urmă
părinte
comite
033ffe9f6a
5 a modificat fișierele cu 397 adăugiri și 38 ștergeri
  1. 27 0
      src/api/auth.ts
  2. 34 0
      src/typings/env.d.ts
  3. 4 4
      src/utils/http/index.ts
  4. 149 32
      src/views/login/index.vue
  5. 183 2
      src/views/register/index.vue

+ 27 - 0
src/api/auth.ts

@@ -96,3 +96,30 @@ export function requestGetAuthUrl(redirectUri: string): Promise<CommonResponse<{
     params: { redirectUri }
   });
 }
+
+// OAuth登录参数
+interface OAuthLoginParams {
+  provider: 'google' | 'zalo' | 'telegram';
+  openId: string;
+  nickname?: string;
+  avatar?: string;
+  email?: string;
+  inviteCode?: string;
+}
+
+// OAuth登录
+export function requestOAuthLogin(data: OAuthLoginParams): Promise<CommonResponse<{ token: string; user: UserInfo; isNew?: boolean }>> {
+  return http.request({
+    url: "/api/v1/dt/auth/oauth/login",
+    method: "post",
+    data
+  });
+}
+
+// 获取OAuth配置
+export function requestOAuthConfig(): Promise<CommonResponse<{ googleClientId?: string; zaloAppId?: string }>> {
+  return http.request({
+    url: "/api/v1/dt/config/oauth",
+    method: "get"
+  });
+}

+ 34 - 0
src/typings/env.d.ts

@@ -1 +1,35 @@
 /// <reference types="vite/client" />
+
+// Google Identity Services 类型声明
+interface GoogleAccountsId {
+  initialize: (config: {
+    client_id: string;
+    callback: (response: { credential: string }) => void;
+    auto_select?: boolean;
+    cancel_on_tap_outside?: boolean;
+  }) => void;
+  prompt: (callback?: (notification: {
+    isNotDisplayed: () => boolean;
+    isSkippedMoment: () => boolean;
+  }) => void) => void;
+  renderButton: (element: HTMLElement, options: any) => void;
+}
+
+interface GoogleAccountsOAuth2 {
+  initCodeClient: (config: {
+    client_id: string;
+    scope: string;
+    callback: (response: { code?: string }) => void;
+  }) => void;
+}
+
+interface GoogleAccounts {
+  id: GoogleAccountsId;
+  oauth2: GoogleAccountsOAuth2;
+}
+
+interface Window {
+  google?: {
+    accounts: GoogleAccounts;
+  };
+}

+ 4 - 4
src/utils/http/index.ts

@@ -1,13 +1,13 @@
 import Axios, { type AxiosInstance, type AxiosError, type AxiosResponse, type AxiosRequestConfig } from "axios";
 import { ContentTypeEnum } from "@/enums/requestEnum.js";
 import { toast } from "vue-sonner";
-import { jumpAuthorizePage } from "../utils";
 
 const tokenExpired = () => {
   localStorage.removeItem("token");
-  setTimeout(() => {
-    jumpAuthorizePage();
-  }, 1000);
+  // 直接跳转到登录页面
+  if (!window.location.hash.includes('/login')) {
+    window.location.href = '/#/login';
+  }
 };
 
 // 默认 axios 实例请求配置

+ 149 - 32
src/views/login/index.vue

@@ -125,15 +125,27 @@
       <!-- 第三方登录 -->
       <div class="third-party-section">
         <div class="divider">
-          <span>{{ $t('auth.thirdPartyLogin') }}</span>
+          <span>{{ $t('auth.or') || '或' }}</span>
         </div>
-        <div class="third-party-icons">
-          <div class="icon-item google" @click="handleGoogleLogin">
-            <van-icon name="passed" />
-          </div>
-          <div class="icon-item zalo" @click="handleZaloLogin">
-            <van-icon name="chat-o" />
-          </div>
+
+        <!-- Google 登录按钮 -->
+        <div class="oauth-btn google-btn" :class="{ loading: googleLoading }" @click="handleGoogleLogin">
+          <svg class="google-icon" viewBox="0 0 24 24" width="20" height="20">
+            <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
+            <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
+            <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
+            <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
+          </svg>
+          <span>{{ $t('auth.googleLogin') || '使用 Google 登录' }}</span>
+          <van-loading v-if="googleLoading" size="16px" color="#fff" />
+        </div>
+
+        <!-- Zalo 登录按钮 -->
+        <div class="oauth-btn zalo-btn" @click="handleZaloLogin">
+          <svg class="zalo-icon" viewBox="0 0 24 24" width="20" height="20">
+            <path fill="#0068FF" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5.46 7.12l-1.67 6.14c-.13.46-.47.58-.95.36l-2.62-1.93-1.26 1.22c-.14.14-.26.26-.53.26l.19-2.67 4.84-4.37c.21-.19-.05-.29-.32-.12l-5.99 3.78-2.58-.81c-.56-.17-.57-.56.12-.83l10.1-3.9c.46-.17.87.11.67.87z"/>
+          </svg>
+          <span>{{ $t('auth.zaloLogin') || '使用 Zalo 登录' }}</span>
         </div>
       </div>
 
@@ -157,16 +169,18 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive } from 'vue';
+import { ref, reactive, onMounted } from 'vue';
 import { useRouter } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { toast } from 'vue-sonner';
-import { requestLogin, requestSendCode } from '@/api/auth';
+import { requestLogin, requestSendCode, requestOAuthLogin, requestOAuthConfig } from '@/api/auth';
 import { useUserStore } from '@/store/modules/userStore';
 
 const { t } = useI18n();
 const router = useRouter();
 const userStore = useUserStore();
+const googleLoading = ref(false);
+const googleClientId = ref('');
 
 const loginType = ref<'phone' | 'email'>('phone');
 const showPassword = ref(false);
@@ -253,11 +267,103 @@ const handleEmailLogin = async () => {
   }
 };
 
+// 获取OAuth配置并初始化 Google Sign-In
+const initGoogleSignIn = async () => {
+  try {
+    // 从后端获取OAuth配置
+    const configRes = await requestOAuthConfig();
+    if (configRes.code === 200 && configRes.data.googleClientId) {
+      googleClientId.value = configRes.data.googleClientId;
+
+      // 动态加载 Google Identity Services SDK
+      const script = document.createElement('script');
+      script.src = 'https://accounts.google.com/gsi/client';
+      script.async = true;
+      script.defer = true;
+      script.onload = () => {
+        if (window.google && googleClientId.value) {
+          window.google.accounts.id.initialize({
+            client_id: googleClientId.value,
+            callback: handleGoogleCallback,
+            auto_select: false,
+            cancel_on_tap_outside: true
+          });
+        }
+      };
+      document.head.appendChild(script);
+    }
+  } catch (error) {
+    console.warn('Failed to load OAuth config:', error);
+  }
+};
+
+// Google 登录回调处理
+const handleGoogleCallback = async (response: any) => {
+  if (!response.credential) {
+    toast.error('Google 登录失败');
+    return;
+  }
+
+  googleLoading.value = true;
+  try {
+    // 解析 JWT token 获取用户信息
+    const payload = JSON.parse(atob(response.credential.split('.')[1]));
+
+    // 调用后端 OAuth 登录接口
+    const res = await requestOAuthLogin({
+      provider: 'google',
+      openId: payload.sub, // Google 用户唯一 ID
+      nickname: payload.name,
+      avatar: payload.picture,
+      email: payload.email
+    });
+
+    if (res.code === 200) {
+      localStorage.setItem('token', res.data.token);
+      userStore.setUserInfo(res.data.user);
+      toast.success(t('auth.loginSuccess'));
+      router.replace('/home');
+    }
+  } catch (error) {
+    console.error('Google login error:', error);
+    toast.error('Google 登录失败');
+  } finally {
+    googleLoading.value = false;
+  }
+};
+
 const handleGoogleLogin = () => {
-  // TODO: Google OAuth登录
-  toast.info('Google Login - Coming Soon');
+  if (!googleClientId.value) {
+    toast.error('Google 登录未配置');
+    return;
+  }
+
+  if (window.google) {
+    // 使用 Google One Tap 或弹窗登录
+    window.google.accounts.id.prompt((notification: any) => {
+      if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
+        // 如果 One Tap 不显示,使用传统弹窗方式
+        window.google.accounts.oauth2.initCodeClient({
+          client_id: googleClientId.value,
+          scope: 'email profile',
+          callback: (response: any) => {
+            if (response.code) {
+              // 这里可以用 code 换取 token,但我们使用 ID Token 方式更简单
+            }
+          }
+        });
+      }
+    });
+  } else {
+    toast.error('Google SDK 加载失败');
+  }
 };
 
+// 组件挂载时初始化 Google Sign-In
+onMounted(() => {
+  initGoogleSignIn();
+});
+
 const handleZaloLogin = () => {
   // TODO: Zalo OAuth登录
   toast.info('Zalo Login - Coming Soon');
@@ -418,33 +524,44 @@ const goRegister = () => {
     }
   }
 
-  .third-party-icons {
+  .oauth-btn {
     display: flex;
+    align-items: center;
     justify-content: center;
-    gap: 32px;
+    gap: 10px;
+    height: 48px;
+    border-radius: 8px;
+    font-size: 15px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.3s;
+    margin-bottom: 12px;
 
-    .icon-item {
-      width: 48px;
-      height: 48px;
-      border-radius: 50%;
-      background: rgba(255, 255, 255, 0.1);
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      cursor: pointer;
+    &.loading {
+      pointer-events: none;
+      opacity: 0.7;
+    }
 
-      .van-icon {
-        font-size: 24px;
-      }
+    .google-icon, .zalo-icon {
+      flex-shrink: 0;
+    }
+
+    &.google-btn {
+      background: #fff;
+      color: #333;
+      border: 1px solid rgba(255, 255, 255, 0.2);
 
-      &.google {
-        background: rgba(234, 67, 53, 0.2);
-        .van-icon { color: #ea4335; }
+      &:active {
+        background: #f5f5f5;
       }
+    }
+
+    &.zalo-btn {
+      background: #0068FF;
+      color: #fff;
 
-      &.zalo {
-        background: rgba(0, 136, 204, 0.2);
-        .van-icon { color: #0088cc; }
+      &:active {
+        background: #0055cc;
       }
     }
   }

+ 183 - 2
src/views/register/index.vue

@@ -210,6 +210,33 @@
         {{ $t('auth.register') }}
       </van-button>
 
+      <!-- 第三方登录 -->
+      <div class="third-party-section">
+        <div class="divider">
+          <span>{{ $t('auth.or') || '或' }}</span>
+        </div>
+
+        <!-- Google 登录按钮 -->
+        <div class="oauth-btn google-btn" :class="{ loading: googleLoading }" @click="handleGoogleLogin">
+          <svg class="google-icon" viewBox="0 0 24 24" width="20" height="20">
+            <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
+            <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
+            <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
+            <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
+          </svg>
+          <span>{{ $t('auth.googleLogin') || '使用 Google 登录' }}</span>
+          <van-loading v-if="googleLoading" size="16px" color="#fff" />
+        </div>
+
+        <!-- Zalo 登录按钮 -->
+        <div class="oauth-btn zalo-btn" @click="handleZaloLogin">
+          <svg class="zalo-icon" viewBox="0 0 24 24" width="20" height="20">
+            <path fill="#0068FF" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5.46 7.12l-1.67 6.14c-.13.46-.47.58-.95.36l-2.62-1.93-1.26 1.22c-.14.14-.26.26-.53.26l.19-2.67 4.84-4.37c.21-.19-.05-.29-.32-.12l-5.99 3.78-2.58-.81c-.56-.17-.57-.56.12-.83l10.1-3.9c.46-.17.87.11.67.87z"/>
+          </svg>
+          <span>{{ $t('auth.zaloLogin') || '使用 Zalo 登录' }}</span>
+        </div>
+      </div>
+
       <!-- 登录入口 -->
       <div class="login-entry">
         {{ $t('auth.hasAccount') }}
@@ -230,15 +257,19 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive } from 'vue';
+import { ref, reactive, onMounted } from 'vue';
 import { useRouter, useRoute } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { toast } from 'vue-sonner';
-import { requestRegister, requestSendCode } from '@/api/auth';
+import { requestRegister, requestSendCode, requestOAuthLogin, requestOAuthConfig } from '@/api/auth';
+import { useUserStore } from '@/store/modules/userStore';
 
 const { t } = useI18n();
 const router = useRouter();
 const route = useRoute();
+const userStore = useUserStore();
+const googleLoading = ref(false);
+const googleClientId = ref('');
 
 const registerType = ref<'phone' | 'email'>('phone');
 const showPassword = ref(false);
@@ -396,6 +427,90 @@ const showTerms = () => {
 const showPrivacy = () => {
   // TODO: 显示隐私政策
 };
+
+// 获取OAuth配置并初始化 Google Sign-In
+const initGoogleSignIn = async () => {
+  try {
+    const configRes = await requestOAuthConfig();
+    if (configRes.code === 200 && configRes.data.googleClientId) {
+      googleClientId.value = configRes.data.googleClientId;
+
+      const script = document.createElement('script');
+      script.src = 'https://accounts.google.com/gsi/client';
+      script.async = true;
+      script.defer = true;
+      script.onload = () => {
+        if (window.google && googleClientId.value) {
+          window.google.accounts.id.initialize({
+            client_id: googleClientId.value,
+            callback: handleGoogleCallback,
+            auto_select: false,
+            cancel_on_tap_outside: true
+          });
+        }
+      };
+      document.head.appendChild(script);
+    }
+  } catch (error) {
+    console.warn('Failed to load OAuth config:', error);
+  }
+};
+
+// Google 登录回调处理
+const handleGoogleCallback = async (response: any) => {
+  if (!response.credential) {
+    toast.error('Google 登录失败');
+    return;
+  }
+
+  googleLoading.value = true;
+  try {
+    const payload = JSON.parse(atob(response.credential.split('.')[1]));
+    const inviteCode = phoneForm.inviteCode || emailForm.inviteCode || (route.query.smid as string) || '';
+
+    const res = await requestOAuthLogin({
+      provider: 'google',
+      openId: payload.sub,
+      nickname: payload.name,
+      avatar: payload.picture,
+      email: payload.email,
+      inviteCode
+    });
+
+    if (res.code === 200) {
+      localStorage.setItem('token', res.data.token);
+      userStore.setUserInfo(res.data.user);
+      toast.success(t('auth.loginSuccess'));
+      router.replace('/home');
+    }
+  } catch (error) {
+    console.error('Google login error:', error);
+    toast.error('Google 登录失败');
+  } finally {
+    googleLoading.value = false;
+  }
+};
+
+const handleGoogleLogin = () => {
+  if (!googleClientId.value) {
+    toast.error('Google 登录未配置');
+    return;
+  }
+
+  if (window.google) {
+    window.google.accounts.id.prompt();
+  } else {
+    toast.error('Google SDK 加载失败');
+  }
+};
+
+const handleZaloLogin = () => {
+  toast.info('Zalo Login - Coming Soon');
+};
+
+onMounted(() => {
+  initGoogleSignIn();
+});
 </script>
 
 <style lang="scss" scoped>
@@ -535,6 +650,72 @@ const showPrivacy = () => {
   }
 }
 
+.third-party-section {
+  margin-top: 24px;
+
+  .divider {
+    display: flex;
+    align-items: center;
+    margin-bottom: 16px;
+
+    &::before,
+    &::after {
+      content: '';
+      flex: 1;
+      height: 1px;
+      background: rgba(255, 255, 255, 0.2);
+    }
+
+    span {
+      padding: 0 16px;
+      color: rgba(255, 255, 255, 0.4);
+      font-size: 12px;
+    }
+  }
+
+  .oauth-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 10px;
+    height: 48px;
+    border-radius: 8px;
+    font-size: 15px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.3s;
+    margin-bottom: 12px;
+
+    &.loading {
+      pointer-events: none;
+      opacity: 0.7;
+    }
+
+    .google-icon, .zalo-icon {
+      flex-shrink: 0;
+    }
+
+    &.google-btn {
+      background: #fff;
+      color: #333;
+      border: 1px solid rgba(255, 255, 255, 0.2);
+
+      &:active {
+        background: #f5f5f5;
+      }
+    }
+
+    &.zalo-btn {
+      background: #0068FF;
+      color: #fff;
+
+      &:active {
+        background: #0055cc;
+      }
+    }
+  }
+}
+
 .login-entry {
   text-align: center;
   margin-top: 24px;