| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044 |
- <template>
- <div class="login-page">
- <!-- Logo区域 -->
- <div class="logo-section">
- <div class="logo">
- <img src="@/assets/images/common/logo.svg" alt="Vitiens" class="logo-img" />
- </div>
- <div class="title">Vitiens</div>
- <div class="subtitle">{{ $t('home.taskCenter') }}</div>
- </div>
- <!-- 登录表单 -->
- <div class="form-section">
- <!-- 登录方式切换 -->
- <div class="login-tabs">
- <div
- class="tab-item"
- :class="{ active: loginType === 'phone' }"
- @click="loginType = 'phone'"
- >
- {{ $t('auth.phoneLogin') }}
- </div>
- <div
- class="tab-item"
- :class="{ active: loginType === 'email' }"
- @click="loginType = 'email'"
- >
- {{ $t('auth.emailLogin') }}
- </div>
- </div>
- <!-- 手机号登录 -->
- <div v-if="loginType === 'phone'" class="form-content">
- <div class="input-group">
- <van-field
- v-model="phoneForm.phone"
- type="tel"
- :placeholder="$t('auth.pleaseInputPhone')"
- clearable
- >
- <template #left-icon>
- <div class="area-code" @click="showAreaCode = true">
- +{{ areaCode }}
- <van-icon name="arrow-down" />
- </div>
- </template>
- </van-field>
- </div>
- <div class="input-group">
- <van-field
- v-model="phoneForm.password"
- :type="showPassword ? 'text' : 'password'"
- :placeholder="$t('auth.pleaseInputPassword')"
- >
- <template #right-icon>
- <van-icon
- :name="showPassword ? 'eye-o' : 'closed-eye'"
- @click="showPassword = !showPassword"
- />
- </template>
- </van-field>
- </div>
- <div class="forgot-password" @click="goForgotPassword">
- {{ $t('auth.forgotPassword') }}
- </div>
- <van-button
- class="login-btn"
- type="primary"
- block
- :loading="loading"
- @click="handlePhoneLogin"
- >
- {{ $t('auth.login') }}
- </van-button>
- </div>
- <!-- 邮箱登录 -->
- <div v-if="loginType === 'email'" class="form-content">
- <div class="input-group">
- <van-field
- v-model="emailForm.email"
- type="email"
- :placeholder="$t('auth.pleaseInputEmail')"
- clearable
- >
- <template #left-icon>
- <van-icon name="envelop-o" />
- </template>
- </van-field>
- </div>
- <div class="input-group">
- <van-field
- v-model="emailForm.password"
- :type="showPassword ? 'text' : 'password'"
- :placeholder="$t('auth.pleaseInputPassword')"
- >
- <template #right-icon>
- <van-icon
- :name="showPassword ? 'eye-o' : 'closed-eye'"
- @click="showPassword = !showPassword"
- />
- </template>
- </van-field>
- </div>
- <div class="forgot-password" @click="goForgotPassword">
- {{ $t('auth.forgotPassword') }}
- </div>
- <van-button
- class="login-btn"
- type="primary"
- block
- :loading="loading"
- @click="handleEmailLogin"
- >
- {{ $t('auth.login') }}
- </van-button>
- </div>
- <!-- 第三方登录 -->
- <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>
- <!-- Telegram 登录按钮 -->
- <div class="oauth-btn telegram-btn" @click="handleTelegramLogin" v-if="telegramBotName">
- <svg class="telegram-icon" viewBox="0 0 24 24" width="20" height="20">
- <path fill="#fff" 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.telegramLogin') || 'Telegram 登录' }}</span>
- </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>
- <!-- Facebook 登录按钮 -->
- <div class="oauth-btn facebook-btn" :class="{ loading: facebookLoading }" @click="handleFacebookLogin" v-if="facebookAppId">
- <svg class="facebook-icon" viewBox="0 0 24 24" width="20" height="20">
- <path fill="#fff" d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
- </svg>
- <span>{{ $t('auth.facebookLogin') || 'Facebook 登录' }}</span>
- <van-loading v-if="facebookLoading" size="16px" color="#fff" />
- </div>
- <!-- TikTok 登录按钮 -->
- <div class="oauth-btn tiktok-btn" :class="{ loading: tiktokLoading }" @click="handleTiktokLogin" v-if="tiktokClientKey">
- <svg class="tiktok-icon" viewBox="0 0 24 24" width="20" height="20">
- <path fill="#25F4EE" d="M16.6 5.82s.51.5 0 0A4.278 4.278 0 0 1 15.54 3h-3.09v12.4a2.592 2.592 0 0 1-2.59 2.5c-1.42 0-2.6-1.16-2.6-2.6 0-1.72 1.66-3.01 3.37-2.48V9.66c-3.45-.46-6.47 2.22-6.47 5.64 0 3.33 2.76 5.7 5.69 5.7 3.14 0 5.69-2.55 5.69-5.7V9.01a7.35 7.35 0 0 0 4.3 1.38V7.3s-1.88.09-3.24-1.48z"/>
- <path fill="#FE2C55" d="M17.6 5.82s.51.5 0 0A4.278 4.278 0 0 1 16.54 3h-3.09v12.4a2.592 2.592 0 0 1-2.59 2.5c-1.42 0-2.6-1.16-2.6-2.6 0-1.72 1.66-3.01 3.37-2.48V9.66c-3.45-.46-6.47 2.22-6.47 5.64 0 3.33 2.76 5.7 5.69 5.7 3.14 0 5.69-2.55 5.69-5.7V9.01a7.35 7.35 0 0 0 4.3 1.38V7.3s-1.88.09-3.24-1.48z"/>
- </svg>
- <span>{{ $t('auth.tiktokLogin') || 'TikTok 登录' }}</span>
- <van-loading v-if="tiktokLoading" size="16px" color="#fff" />
- </div>
- </div>
- <!-- 注册入口 -->
- <div class="register-entry">
- {{ $t('auth.noAccount') }}
- <span class="link" @click="goRegister">{{ $t('auth.goRegister') }}</span>
- </div>
- </div>
- <!-- 区号选择弹窗 -->
- <van-action-sheet
- v-model:show="showAreaCode"
- :actions="areaCodeActions"
- @select="onAreaCodeSelect"
- cancel-text="Cancel"
- close-on-click-action
- />
- </div>
- </template>
- <script setup lang="ts">
- import { ref, reactive, onMounted, onUnmounted } from 'vue';
- import { useRouter } from 'vue-router';
- import { useI18n } from 'vue-i18n';
- import { toast } from 'vue-sonner';
- 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 telegramLoading = ref(false);
- const telegramBotName = ref('');
- const telegramBotId = ref('');
- const zaloLoading = ref(false);
- const zaloAppId = ref('');
- const tiktokLoading = ref(false);
- const tiktokClientKey = ref('');
- const facebookLoading = ref(false);
- const facebookAppId = ref('');
- const loginType = ref<'phone' | 'email'>('phone');
- const showPassword = ref(false);
- const loading = ref(false);
- const showAreaCode = ref(false);
- const areaCode = ref('84'); // 越南区号
- const phoneForm = reactive({
- phone: '',
- password: ''
- });
- const emailForm = reactive({
- email: '',
- password: ''
- });
- const areaCodes = [
- { text: '+84 Vietnam', value: '84' },
- { text: '+62 Indonesia', value: '62' },
- { text: '+86 China', value: '86' },
- { text: '+1 USA', value: '1' }
- ];
- const areaCodeActions = areaCodes.map(item => ({
- name: item.text,
- value: item.value
- }));
- const onAreaCodeSelect = (action: { name: string; value: string }) => {
- areaCode.value = action.value;
- showAreaCode.value = false;
- };
- const handlePhoneLogin = async () => {
- if (!phoneForm.phone) {
- toast.error(t('auth.pleaseInputPhone'));
- return;
- }
- if (!phoneForm.password) {
- toast.error(t('auth.pleaseInputPassword'));
- return;
- }
- loading.value = true;
- try {
- const res = await requestLogin({
- type: 'phone',
- account: phoneForm.phone,
- password: phoneForm.password,
- areaCode: areaCode.value
- });
- if (res.code === 200) {
- localStorage.setItem('token', res.data.token);
- userStore.setUserInfo(res.data.userInfo);
- toast.success(t('auth.loginSuccess'));
- router.replace('/home');
- }
- } finally {
- loading.value = false;
- }
- };
- const handleEmailLogin = async () => {
- if (!emailForm.email) {
- toast.error(t('auth.pleaseInputEmail'));
- return;
- }
- if (!emailForm.password) {
- toast.error(t('auth.pleaseInputPassword'));
- return;
- }
- loading.value = true;
- try {
- const res = await requestLogin({
- type: 'email',
- account: emailForm.email,
- password: emailForm.password
- });
- if (res.code === 200) {
- localStorage.setItem('token', res.data.token);
- userStore.setUserInfo(res.data.userInfo);
- toast.success(t('auth.loginSuccess'));
- router.replace('/home');
- }
- } finally {
- loading.value = false;
- }
- };
- // 获取OAuth配置并初始化
- const initOAuth = async () => {
- try {
- // 从后端获取OAuth配置
- const configRes = await requestOAuthConfig();
- if (configRes.code === 200) {
- // 初始化 Google Sign-In
- if (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);
- }
- // 初始化 Telegram Login
- if (configRes.data.telegramBotName) {
- telegramBotName.value = configRes.data.telegramBotName;
- telegramBotId.value = configRes.data.telegramBotId || '';
- }
- // 初始化 Zalo Login
- if (configRes.data.zaloAppId) {
- zaloAppId.value = configRes.data.zaloAppId;
- initZaloLogin();
- }
- // 初始化 TikTok Login
- if (configRes.data.tiktokClientKey) {
- tiktokClientKey.value = configRes.data.tiktokClientKey;
- initTiktokLogin();
- }
- // 初始化 Facebook Login
- if (configRes.data.facebookAppId) {
- facebookAppId.value = configRes.data.facebookAppId;
- initFacebookSDK(configRes.data.facebookAppId);
- }
- }
- } catch (error) {
- console.warn('Failed to load OAuth config:', error);
- }
- };
- // 点击 Telegram 登录按钮 - 直接打开 Telegram OAuth 弹窗
- const handleTelegramLogin = () => {
- if (!telegramBotId.value) {
- toast.error(t('auth.telegramNotConfigured') || 'Telegram 登录未配置');
- return;
- }
- // 支持传入完整 bot token (如 123456:ABC) 或纯数字 ID
- const botId = telegramBotId.value.split(':')[0];
- const origin = encodeURIComponent(window.location.origin);
- const authUrl = `https://oauth.telegram.org/auth?bot_id=${botId}&origin=${origin}&request_access=write`;
- const width = 550;
- const height = 470;
- const left = Math.round((screen.width - width) / 2);
- const top = Math.round((screen.height - height) / 2);
- const popup = window.open(
- authUrl,
- 'telegram_oauth',
- `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=no`
- );
- // 监听来自 Telegram OAuth 弹窗的 postMessage
- const onMessage = (event: MessageEvent) => {
- if (event.origin !== 'https://oauth.telegram.org') return;
- try {
- let data = event.data;
- // Telegram 可能发送 JSON 字符串或对象
- if (typeof data === 'string') {
- try { data = JSON.parse(data); } catch { return; }
- }
- if (data?.event === 'auth_result' && data?.result) {
- window.removeEventListener('message', onMessage);
- handleTelegramCallback(data.result);
- }
- } catch (e) {
- console.error('Telegram auth message error:', e);
- }
- };
- window.addEventListener('message', onMessage);
- // 如果弹窗被关闭,清理监听
- const checkClosed = setInterval(() => {
- if (popup?.closed) {
- clearInterval(checkClosed);
- window.removeEventListener('message', onMessage);
- }
- }, 500);
- };
- // Telegram 登录回调
- const handleTelegramCallback = async (user: any) => {
- if (!user || !user.id) {
- toast.error(t('auth.telegramLoginFailed') || 'Telegram 登录失败');
- return;
- }
- telegramLoading.value = true;
- try {
- const res = await requestOAuthLogin({
- provider: 'telegram',
- openId: String(user.id),
- nickname: user.first_name + (user.last_name ? ' ' + user.last_name : ''),
- avatar: user.photo_url || ''
- });
- 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('Telegram login error:', error);
- toast.error(t('auth.telegramLoginFailed') || 'Telegram 登录失败');
- } finally {
- telegramLoading.value = false;
- }
- };
- // 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 = () => {
- if (!googleClientId.value) {
- toast.error('Google 登录未配置');
- return;
- }
- // 使用 OAuth 2.0 隐式授权流程,通过弹窗方式
- const redirectUri = window.location.origin + '/google-callback.html';
- const scope = 'openid email profile';
- const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId.value}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=${encodeURIComponent(scope)}&nonce=${Date.now()}`;
- // 打开弹窗
- const width = 500;
- const height = 600;
- const left = (screen.width - width) / 2;
- const top = (screen.height - height) / 2;
- const popup = window.open(authUrl, 'google-login', `width=${width},height=${height},left=${left},top=${top}`);
- // 监听弹窗返回
- const timer = setInterval(() => {
- if (popup?.closed) {
- clearInterval(timer);
- googleLoading.value = false;
- }
- }, 500);
- googleLoading.value = true;
- };
- // 监听 Google 弹窗回传的消息
- const onGoogleMessage = async (event: MessageEvent) => {
- if (event.origin !== window.location.origin) return;
- if (event.data?.type !== 'google-login' || !event.data?.idToken) return;
- googleLoading.value = true;
- try {
- const payload = JSON.parse(atob(event.data.idToken.split('.')[1]));
- const res = await requestOAuthLogin({
- provider: 'google',
- openId: payload.sub,
- 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;
- }
- };
- // 组件挂载时初始化 OAuth
- onMounted(() => {
- window.addEventListener('message', onGoogleMessage);
- // 先检查 Zalo/TikTok 回调
- initZaloLogin();
- initTiktokLogin();
- // 再初始化 OAuth 配置
- initOAuth();
- });
- onUnmounted(() => {
- window.removeEventListener('message', onGoogleMessage);
- });
- // 生成随机字符串
- const generateRandomString = (length: number): string => {
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
- let result = '';
- for (let i = 0; i < length; i++) {
- result += chars.charAt(Math.floor(Math.random() * chars.length));
- }
- return result;
- };
- // 生成 PKCE code verifier 和 code challenge
- const generatePKCE = async () => {
- const codeVerifier = generateRandomString(43);
- const encoder = new TextEncoder();
- const data = encoder.encode(codeVerifier);
- const digest = await crypto.subtle.digest('SHA-256', data);
- const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/, '');
- return { codeVerifier, codeChallenge: base64 };
- };
- // 初始化 Zalo 登录
- const initZaloLogin = () => {
- // 从 sessionStorage 读取 Zalo OAuth 回调的 code
- // (由 index.html 中的脚本在页面加载时保存)
- const code = sessionStorage.getItem('zalo_oauth_code');
- const codeVerifier = sessionStorage.getItem('zalo_code_verifier');
- console.log('Zalo initZaloLogin - code:', code ? 'exists' : 'null', 'codeVerifier:', codeVerifier ? 'exists' : 'null');
- if (code && codeVerifier) {
- // 清除保存的参数
- sessionStorage.removeItem('zalo_oauth_code');
- sessionStorage.removeItem('zalo_oauth_state');
- sessionStorage.removeItem('zalo_code_verifier');
- // 使用 code 获取用户信息
- handleZaloCallback(code, codeVerifier);
- } else if (code && !codeVerifier) {
- // 有 code 但没有 codeVerifier,可能是用户直接访问了带 code 的 URL
- console.warn('Zalo OAuth code found but no codeVerifier, clearing...');
- sessionStorage.removeItem('zalo_oauth_code');
- sessionStorage.removeItem('zalo_oauth_state');
- }
- };
- // Zalo 登录回调处理
- const handleZaloCallback = async (code: string, codeVerifier: string) => {
- zaloLoading.value = true;
- try {
- // 调用后端接口,让后端用 code 换取 access_token 和用户信息
- const res = await requestOAuthLogin({
- provider: 'zalo',
- openId: code, // 先传 code,后端会处理
- nickname: '',
- avatar: '',
- extra: codeVerifier // 传递 code_verifier 给后端
- });
- if (res.code === 200) {
- localStorage.setItem('token', res.data.token);
- userStore.setUserInfo(res.data.user);
- toast.success(t('auth.loginSuccess'));
- router.replace('/home');
- } else {
- toast.error(res.msg || t('auth.zaloLoginFailed') || 'Zalo 登录失败');
- }
- } catch (error) {
- console.error('Zalo login error:', error);
- toast.error(t('auth.zaloLoginFailed') || 'Zalo 登录失败');
- } finally {
- zaloLoading.value = false;
- }
- };
- // 触发 Zalo 登录
- const handleZaloLogin = async () => {
- if (!zaloAppId.value) {
- toast.error(t('auth.zaloNotConfigured') || 'Zalo 登录未配置');
- return;
- }
- zaloLoading.value = true;
- try {
- // 生成 PKCE
- const { codeVerifier, codeChallenge } = await generatePKCE();
- // 保存 code_verifier 到 sessionStorage
- sessionStorage.setItem('zalo_code_verifier', codeVerifier);
- // 构建 Zalo OAuth URL
- // 对于 hash 路由,使用不带 hash 的路径作为回调地址
- // Zalo 回调后 URL 会变成: https://domain.com/login?code=xxx
- // 然后我们在 initZaloLogin 中处理这个参数
- const redirectUri = encodeURIComponent(window.location.origin + '/login');
- const state = generateRandomString(16);
- console.log('Zalo login - redirectUri:', window.location.origin + '/login');
- const authUrl = `https://oauth.zaloapp.com/v4/permission?app_id=${zaloAppId.value}&redirect_uri=${redirectUri}&code_challenge=${codeChallenge}&state=${state}`;
- // 跳转到 Zalo 授权页面
- window.location.href = authUrl;
- } catch (error) {
- console.error('Zalo login error:', error);
- toast.error('Zalo 登录失败');
- zaloLoading.value = false;
- }
- };
- // 初始化 TikTok 登录(检查回调参数)
- const initTiktokLogin = () => {
- // 路由守卫已将 TikTok 回调参数从 URL 存入 sessionStorage
- const code = sessionStorage.getItem('tiktok_oauth_code');
- const redirectUri = sessionStorage.getItem('tiktok_redirect_uri');
- if (code && redirectUri) {
- sessionStorage.removeItem('tiktok_oauth_code');
- sessionStorage.removeItem('tiktok_oauth_state');
- sessionStorage.removeItem('tiktok_redirect_uri');
- handleTiktokCallback(code, redirectUri);
- }
- };
- // TikTok 登录回调处理
- const handleTiktokCallback = async (code: string, redirectUri: string) => {
- tiktokLoading.value = true;
- try {
- const res = await requestOAuthLogin({
- provider: 'tiktok',
- openId: code,
- nickname: '',
- avatar: '',
- extra: redirectUri
- });
- if (res.code === 200) {
- localStorage.setItem('token', res.data.token);
- userStore.setUserInfo(res.data.user);
- toast.success(t('auth.loginSuccess'));
- router.replace('/home');
- } else {
- toast.error(res.msg || t('auth.tiktokLoginFailed') || 'TikTok 登录失败');
- }
- } catch (error) {
- console.error('TikTok login error:', error);
- toast.error(t('auth.tiktokLoginFailed') || 'TikTok 登录失败');
- } finally {
- tiktokLoading.value = false;
- }
- };
- // 触发 TikTok 登录
- const handleTiktokLogin = () => {
- if (!tiktokClientKey.value) {
- toast.error(t('auth.tiktokNotConfigured') || 'TikTok 登录未配置');
- return;
- }
- const redirectUri = window.location.origin + '/login';
- sessionStorage.setItem('tiktok_redirect_uri', redirectUri);
- const state = 'tiktok_' + generateRandomString(16);
- const authUrl = `https://www.tiktok.com/v2/auth/authorize/?client_key=${tiktokClientKey.value}&scope=user.info.basic&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`;
- window.location.href = authUrl;
- };
- // 初始化 Facebook SDK
- const initFacebookSDK = (appId: string) => {
- // 避免重复加载
- if (document.getElementById('facebook-jssdk')) return;
- window.fbAsyncInit = function() {
- window.FB.init({
- appId: appId,
- cookie: true,
- xfbml: false,
- version: 'v19.0'
- });
- };
- const script = document.createElement('script');
- script.id = 'facebook-jssdk';
- script.src = 'https://connect.facebook.net/en_US/sdk.js';
- script.async = true;
- script.defer = true;
- document.head.appendChild(script);
- };
- // 触发 Facebook 登录
- const handleFacebookLogin = () => {
- if (!facebookAppId.value) {
- toast.error(t('auth.facebookNotConfigured') || 'Facebook 登录未配置');
- return;
- }
- if (!window.FB) {
- toast.error('Facebook SDK 加载中,请稍后再试');
- return;
- }
- facebookLoading.value = true;
- window.FB.login((response: any) => {
- if (response.authResponse) {
- const accessToken = response.authResponse.accessToken;
- // 获取用户信息
- window.FB.api('/me', { fields: 'id,name,email,picture.width(200)' }, (userInfo: any) => {
- handleFacebookCallback(userInfo, accessToken);
- });
- } else {
- facebookLoading.value = false;
- }
- }, { scope: 'public_profile,email' });
- };
- // Facebook 登录回调
- const handleFacebookCallback = async (userInfo: any, accessToken: string) => {
- try {
- const res = await requestOAuthLogin({
- provider: 'facebook',
- openId: userInfo.id,
- nickname: userInfo.name || '',
- avatar: userInfo.picture?.data?.url || '',
- email: userInfo.email || '',
- extra: accessToken
- });
- if (res.code === 200) {
- localStorage.setItem('token', res.data.token);
- userStore.setUserInfo(res.data.user);
- toast.success(t('auth.loginSuccess'));
- router.replace('/home');
- } else {
- toast.error(res.msg || t('auth.facebookLoginFailed') || 'Facebook 登录失败');
- }
- } catch (error) {
- console.error('Facebook login error:', error);
- toast.error(t('auth.facebookLoginFailed') || 'Facebook 登录失败');
- } finally {
- facebookLoading.value = false;
- }
- };
- const goForgotPassword = () => {
- router.push('/forgot-password');
- };
- const goRegister = () => {
- router.push('/register');
- };
- </script>
- <style lang="scss" scoped>
- .login-page {
- min-height: 100vh;
- background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
- padding: min(6vw, 30px) min(5.333vw, 25.6px) min(10vw, 48px);
- max-width: 500px;
- margin: 0 auto;
- }
- .logo-section {
- text-align: center;
- margin-bottom: min(5.333vw, 25.6px);
- .logo {
- width: min(17.067vw, 80px);
- height: min(17.067vw, 80px);
- margin: 0 auto min(2.667vw, 12.8px);
- border-radius: min(4.267vw, 20.48px);
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- .logo-img {
- width: 100%;
- height: 100%;
- }
- }
- .title {
- font-size: min(5.867vw, 28px);
- font-weight: bold;
- color: #fff;
- margin-bottom: min(1.067vw, 5.12px);
- }
- .subtitle {
- font-size: min(3.733vw, 17.92px);
- color: rgba(255, 255, 255, 0.6);
- }
- }
- .form-section {
- background: rgba(255, 255, 255, 0.05);
- border-radius: min(4.267vw, 20.48px);
- padding: min(5.333vw, 25.6px);
- }
- .login-tabs {
- display: flex;
- margin-bottom: min(5.333vw, 25.6px);
- background: rgba(255, 255, 255, 0.1);
- border-radius: min(2.133vw, 10.24px);
- padding: min(1.067vw, 5.12px);
- .tab-item {
- flex: 1;
- text-align: center;
- padding: min(2.667vw, 12.8px) 0;
- color: rgba(255, 255, 255, 0.6);
- font-size: min(3.733vw, 17.92px);
- border-radius: min(1.6vw, 7.68px);
- cursor: pointer;
- transition: all 0.3s;
- &.active {
- background: #ffc300;
- color: #000;
- font-weight: 500;
- }
- }
- }
- .form-content {
- .input-group {
- margin-bottom: min(3.2vw, 15.36px);
- :deep(.van-field) {
- background: rgba(255, 255, 255, 0.1);
- border-radius: min(2.133vw, 10.24px);
- padding: min(3.2vw, 15.36px) min(4.267vw, 20.48px);
- .van-field__control {
- color: #fff;
- font-size: min(3.733vw, 17.92px);
- &::placeholder {
- color: rgba(255, 255, 255, 0.4);
- }
- }
- .van-icon {
- color: rgba(255, 255, 255, 0.6);
- font-size: min(4.267vw, 20.48px);
- }
- }
- .area-code {
- display: flex;
- align-items: center;
- gap: min(1.067vw, 5.12px);
- color: #fff;
- font-size: min(3.733vw, 17.92px);
- padding-right: min(3.2vw, 15.36px);
- border-right: 1px solid rgba(255, 255, 255, 0.2);
- margin-right: min(3.2vw, 15.36px);
- cursor: pointer;
- }
- }
- }
- .forgot-password {
- text-align: right;
- color: #ffc300;
- font-size: min(3.467vw, 16.64px);
- margin-bottom: min(5.333vw, 25.6px);
- cursor: pointer;
- }
- .login-btn {
- height: min(12.8vw, 61.44px);
- border-radius: min(2.133vw, 10.24px);
- font-size: min(4.267vw, 20.48px);
- font-weight: 500;
- background: linear-gradient(135deg, #ffc300 0%, #ff9500 100%);
- border: none;
- }
- .third-party-section {
- margin-top: min(6.4vw, 30.72px);
- .divider {
- display: flex;
- align-items: center;
- margin-bottom: min(4.267vw, 20.48px);
- &::before,
- &::after {
- content: '';
- flex: 1;
- height: 1px;
- background: rgba(255, 255, 255, 0.2);
- }
- span {
- padding: 0 min(4.267vw, 20.48px);
- color: rgba(255, 255, 255, 0.4);
- font-size: min(3.2vw, 15.36px);
- }
- }
- .oauth-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: min(2.667vw, 12.8px);
- height: min(12.8vw, 61.44px);
- border-radius: min(2.133vw, 10.24px);
- font-size: min(4vw, 19.2px);
- font-weight: 500;
- cursor: pointer;
- transition: all 0.3s;
- margin-bottom: min(3.2vw, 15.36px);
- &.loading {
- pointer-events: none;
- opacity: 0.7;
- }
- .google-icon, .zalo-icon, .telegram-icon, .tiktok-icon, .facebook-icon {
- flex-shrink: 0;
- }
- &.facebook-btn {
- background: #1877F2;
- color: #fff;
- &:active {
- background: #1565C0;
- }
- }
- &.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;
- }
- }
- &.telegram-btn {
- background: #0088cc;
- color: #fff;
- &:active {
- background: #0077b5;
- }
- }
- &.tiktok-btn {
- background: #000;
- color: #fff;
- &:active {
- background: #333;
- }
- }
- }
- }
- .register-entry {
- text-align: center;
- margin-top: min(5.333vw, 25.6px);
- color: rgba(255, 255, 255, 0.6);
- font-size: min(3.733vw, 17.92px);
- .link {
- color: #ffc300;
- margin-left: min(1.067vw, 5.12px);
- cursor: pointer;
- }
- }
- </style>
|