index.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044
  1. <template>
  2. <div class="login-page">
  3. <!-- Logo区域 -->
  4. <div class="logo-section">
  5. <div class="logo">
  6. <img src="@/assets/images/common/logo.svg" alt="Vitiens" class="logo-img" />
  7. </div>
  8. <div class="title">Vitiens</div>
  9. <div class="subtitle">{{ $t('home.taskCenter') }}</div>
  10. </div>
  11. <!-- 登录表单 -->
  12. <div class="form-section">
  13. <!-- 登录方式切换 -->
  14. <div class="login-tabs">
  15. <div
  16. class="tab-item"
  17. :class="{ active: loginType === 'phone' }"
  18. @click="loginType = 'phone'"
  19. >
  20. {{ $t('auth.phoneLogin') }}
  21. </div>
  22. <div
  23. class="tab-item"
  24. :class="{ active: loginType === 'email' }"
  25. @click="loginType = 'email'"
  26. >
  27. {{ $t('auth.emailLogin') }}
  28. </div>
  29. </div>
  30. <!-- 手机号登录 -->
  31. <div v-if="loginType === 'phone'" class="form-content">
  32. <div class="input-group">
  33. <van-field
  34. v-model="phoneForm.phone"
  35. type="tel"
  36. :placeholder="$t('auth.pleaseInputPhone')"
  37. clearable
  38. >
  39. <template #left-icon>
  40. <div class="area-code" @click="showAreaCode = true">
  41. +{{ areaCode }}
  42. <van-icon name="arrow-down" />
  43. </div>
  44. </template>
  45. </van-field>
  46. </div>
  47. <div class="input-group">
  48. <van-field
  49. v-model="phoneForm.password"
  50. :type="showPassword ? 'text' : 'password'"
  51. :placeholder="$t('auth.pleaseInputPassword')"
  52. >
  53. <template #right-icon>
  54. <van-icon
  55. :name="showPassword ? 'eye-o' : 'closed-eye'"
  56. @click="showPassword = !showPassword"
  57. />
  58. </template>
  59. </van-field>
  60. </div>
  61. <div class="forgot-password" @click="goForgotPassword">
  62. {{ $t('auth.forgotPassword') }}
  63. </div>
  64. <van-button
  65. class="login-btn"
  66. type="primary"
  67. block
  68. :loading="loading"
  69. @click="handlePhoneLogin"
  70. >
  71. {{ $t('auth.login') }}
  72. </van-button>
  73. </div>
  74. <!-- 邮箱登录 -->
  75. <div v-if="loginType === 'email'" class="form-content">
  76. <div class="input-group">
  77. <van-field
  78. v-model="emailForm.email"
  79. type="email"
  80. :placeholder="$t('auth.pleaseInputEmail')"
  81. clearable
  82. >
  83. <template #left-icon>
  84. <van-icon name="envelop-o" />
  85. </template>
  86. </van-field>
  87. </div>
  88. <div class="input-group">
  89. <van-field
  90. v-model="emailForm.password"
  91. :type="showPassword ? 'text' : 'password'"
  92. :placeholder="$t('auth.pleaseInputPassword')"
  93. >
  94. <template #right-icon>
  95. <van-icon
  96. :name="showPassword ? 'eye-o' : 'closed-eye'"
  97. @click="showPassword = !showPassword"
  98. />
  99. </template>
  100. </van-field>
  101. </div>
  102. <div class="forgot-password" @click="goForgotPassword">
  103. {{ $t('auth.forgotPassword') }}
  104. </div>
  105. <van-button
  106. class="login-btn"
  107. type="primary"
  108. block
  109. :loading="loading"
  110. @click="handleEmailLogin"
  111. >
  112. {{ $t('auth.login') }}
  113. </van-button>
  114. </div>
  115. <!-- 第三方登录 -->
  116. <div class="third-party-section">
  117. <div class="divider">
  118. <span>{{ $t('auth.or') || '或' }}</span>
  119. </div>
  120. <!-- Google 登录按钮 -->
  121. <div class="oauth-btn google-btn" :class="{ loading: googleLoading }" @click="handleGoogleLogin">
  122. <svg class="google-icon" viewBox="0 0 24 24" width="20" height="20">
  123. <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"/>
  124. <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"/>
  125. <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"/>
  126. <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"/>
  127. </svg>
  128. <span>{{ $t('auth.googleLogin') || '使用 Google 登录' }}</span>
  129. <van-loading v-if="googleLoading" size="16px" color="#fff" />
  130. </div>
  131. <!-- Telegram 登录按钮 -->
  132. <div class="oauth-btn telegram-btn" @click="handleTelegramLogin" v-if="telegramBotName">
  133. <svg class="telegram-icon" viewBox="0 0 24 24" width="20" height="20">
  134. <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"/>
  135. </svg>
  136. <span>{{ $t('auth.telegramLogin') || 'Telegram 登录' }}</span>
  137. </div>
  138. <!-- Zalo 登录按钮 -->
  139. <div class="oauth-btn zalo-btn" @click="handleZaloLogin">
  140. <svg class="zalo-icon" viewBox="0 0 24 24" width="20" height="20">
  141. <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"/>
  142. </svg>
  143. <span>{{ $t('auth.zaloLogin') || '使用 Zalo 登录' }}</span>
  144. </div>
  145. <!-- Facebook 登录按钮 -->
  146. <div class="oauth-btn facebook-btn" :class="{ loading: facebookLoading }" @click="handleFacebookLogin" v-if="facebookAppId">
  147. <svg class="facebook-icon" viewBox="0 0 24 24" width="20" height="20">
  148. <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"/>
  149. </svg>
  150. <span>{{ $t('auth.facebookLogin') || 'Facebook 登录' }}</span>
  151. <van-loading v-if="facebookLoading" size="16px" color="#fff" />
  152. </div>
  153. <!-- TikTok 登录按钮 -->
  154. <div class="oauth-btn tiktok-btn" :class="{ loading: tiktokLoading }" @click="handleTiktokLogin" v-if="tiktokClientKey">
  155. <svg class="tiktok-icon" viewBox="0 0 24 24" width="20" height="20">
  156. <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"/>
  157. <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"/>
  158. </svg>
  159. <span>{{ $t('auth.tiktokLogin') || 'TikTok 登录' }}</span>
  160. <van-loading v-if="tiktokLoading" size="16px" color="#fff" />
  161. </div>
  162. </div>
  163. <!-- 注册入口 -->
  164. <div class="register-entry">
  165. {{ $t('auth.noAccount') }}
  166. <span class="link" @click="goRegister">{{ $t('auth.goRegister') }}</span>
  167. </div>
  168. </div>
  169. <!-- 区号选择弹窗 -->
  170. <van-action-sheet
  171. v-model:show="showAreaCode"
  172. :actions="areaCodeActions"
  173. @select="onAreaCodeSelect"
  174. cancel-text="Cancel"
  175. close-on-click-action
  176. />
  177. </div>
  178. </template>
  179. <script setup lang="ts">
  180. import { ref, reactive, onMounted, onUnmounted } from 'vue';
  181. import { useRouter } from 'vue-router';
  182. import { useI18n } from 'vue-i18n';
  183. import { toast } from 'vue-sonner';
  184. import { requestLogin, requestSendCode, requestOAuthLogin, requestOAuthConfig } from '@/api/auth';
  185. import { useUserStore } from '@/store/modules/userStore';
  186. const { t } = useI18n();
  187. const router = useRouter();
  188. const userStore = useUserStore();
  189. const googleLoading = ref(false);
  190. const googleClientId = ref('');
  191. const telegramLoading = ref(false);
  192. const telegramBotName = ref('');
  193. const telegramBotId = ref('');
  194. const zaloLoading = ref(false);
  195. const zaloAppId = ref('');
  196. const tiktokLoading = ref(false);
  197. const tiktokClientKey = ref('');
  198. const facebookLoading = ref(false);
  199. const facebookAppId = ref('');
  200. const loginType = ref<'phone' | 'email'>('phone');
  201. const showPassword = ref(false);
  202. const loading = ref(false);
  203. const showAreaCode = ref(false);
  204. const areaCode = ref('84'); // 越南区号
  205. const phoneForm = reactive({
  206. phone: '',
  207. password: ''
  208. });
  209. const emailForm = reactive({
  210. email: '',
  211. password: ''
  212. });
  213. const areaCodes = [
  214. { text: '+84 Vietnam', value: '84' },
  215. { text: '+62 Indonesia', value: '62' },
  216. { text: '+86 China', value: '86' },
  217. { text: '+1 USA', value: '1' }
  218. ];
  219. const areaCodeActions = areaCodes.map(item => ({
  220. name: item.text,
  221. value: item.value
  222. }));
  223. const onAreaCodeSelect = (action: { name: string; value: string }) => {
  224. areaCode.value = action.value;
  225. showAreaCode.value = false;
  226. };
  227. const handlePhoneLogin = async () => {
  228. if (!phoneForm.phone) {
  229. toast.error(t('auth.pleaseInputPhone'));
  230. return;
  231. }
  232. if (!phoneForm.password) {
  233. toast.error(t('auth.pleaseInputPassword'));
  234. return;
  235. }
  236. loading.value = true;
  237. try {
  238. const res = await requestLogin({
  239. type: 'phone',
  240. account: phoneForm.phone,
  241. password: phoneForm.password,
  242. areaCode: areaCode.value
  243. });
  244. if (res.code === 200) {
  245. localStorage.setItem('token', res.data.token);
  246. userStore.setUserInfo(res.data.userInfo);
  247. toast.success(t('auth.loginSuccess'));
  248. router.replace('/home');
  249. }
  250. } finally {
  251. loading.value = false;
  252. }
  253. };
  254. const handleEmailLogin = async () => {
  255. if (!emailForm.email) {
  256. toast.error(t('auth.pleaseInputEmail'));
  257. return;
  258. }
  259. if (!emailForm.password) {
  260. toast.error(t('auth.pleaseInputPassword'));
  261. return;
  262. }
  263. loading.value = true;
  264. try {
  265. const res = await requestLogin({
  266. type: 'email',
  267. account: emailForm.email,
  268. password: emailForm.password
  269. });
  270. if (res.code === 200) {
  271. localStorage.setItem('token', res.data.token);
  272. userStore.setUserInfo(res.data.userInfo);
  273. toast.success(t('auth.loginSuccess'));
  274. router.replace('/home');
  275. }
  276. } finally {
  277. loading.value = false;
  278. }
  279. };
  280. // 获取OAuth配置并初始化
  281. const initOAuth = async () => {
  282. try {
  283. // 从后端获取OAuth配置
  284. const configRes = await requestOAuthConfig();
  285. if (configRes.code === 200) {
  286. // 初始化 Google Sign-In
  287. if (configRes.data.googleClientId) {
  288. googleClientId.value = configRes.data.googleClientId;
  289. const script = document.createElement('script');
  290. script.src = 'https://accounts.google.com/gsi/client';
  291. script.async = true;
  292. script.defer = true;
  293. script.onload = () => {
  294. if (window.google && googleClientId.value) {
  295. window.google.accounts.id.initialize({
  296. client_id: googleClientId.value,
  297. callback: handleGoogleCallback,
  298. auto_select: false,
  299. cancel_on_tap_outside: true
  300. });
  301. }
  302. };
  303. document.head.appendChild(script);
  304. }
  305. // 初始化 Telegram Login
  306. if (configRes.data.telegramBotName) {
  307. telegramBotName.value = configRes.data.telegramBotName;
  308. telegramBotId.value = configRes.data.telegramBotId || '';
  309. }
  310. // 初始化 Zalo Login
  311. if (configRes.data.zaloAppId) {
  312. zaloAppId.value = configRes.data.zaloAppId;
  313. initZaloLogin();
  314. }
  315. // 初始化 TikTok Login
  316. if (configRes.data.tiktokClientKey) {
  317. tiktokClientKey.value = configRes.data.tiktokClientKey;
  318. initTiktokLogin();
  319. }
  320. // 初始化 Facebook Login
  321. if (configRes.data.facebookAppId) {
  322. facebookAppId.value = configRes.data.facebookAppId;
  323. initFacebookSDK(configRes.data.facebookAppId);
  324. }
  325. }
  326. } catch (error) {
  327. console.warn('Failed to load OAuth config:', error);
  328. }
  329. };
  330. // 点击 Telegram 登录按钮 - 直接打开 Telegram OAuth 弹窗
  331. const handleTelegramLogin = () => {
  332. if (!telegramBotId.value) {
  333. toast.error(t('auth.telegramNotConfigured') || 'Telegram 登录未配置');
  334. return;
  335. }
  336. // 支持传入完整 bot token (如 123456:ABC) 或纯数字 ID
  337. const botId = telegramBotId.value.split(':')[0];
  338. const origin = encodeURIComponent(window.location.origin);
  339. const authUrl = `https://oauth.telegram.org/auth?bot_id=${botId}&origin=${origin}&request_access=write`;
  340. const width = 550;
  341. const height = 470;
  342. const left = Math.round((screen.width - width) / 2);
  343. const top = Math.round((screen.height - height) / 2);
  344. const popup = window.open(
  345. authUrl,
  346. 'telegram_oauth',
  347. `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=no`
  348. );
  349. // 监听来自 Telegram OAuth 弹窗的 postMessage
  350. const onMessage = (event: MessageEvent) => {
  351. if (event.origin !== 'https://oauth.telegram.org') return;
  352. try {
  353. let data = event.data;
  354. // Telegram 可能发送 JSON 字符串或对象
  355. if (typeof data === 'string') {
  356. try { data = JSON.parse(data); } catch { return; }
  357. }
  358. if (data?.event === 'auth_result' && data?.result) {
  359. window.removeEventListener('message', onMessage);
  360. handleTelegramCallback(data.result);
  361. }
  362. } catch (e) {
  363. console.error('Telegram auth message error:', e);
  364. }
  365. };
  366. window.addEventListener('message', onMessage);
  367. // 如果弹窗被关闭,清理监听
  368. const checkClosed = setInterval(() => {
  369. if (popup?.closed) {
  370. clearInterval(checkClosed);
  371. window.removeEventListener('message', onMessage);
  372. }
  373. }, 500);
  374. };
  375. // Telegram 登录回调
  376. const handleTelegramCallback = async (user: any) => {
  377. if (!user || !user.id) {
  378. toast.error(t('auth.telegramLoginFailed') || 'Telegram 登录失败');
  379. return;
  380. }
  381. telegramLoading.value = true;
  382. try {
  383. const res = await requestOAuthLogin({
  384. provider: 'telegram',
  385. openId: String(user.id),
  386. nickname: user.first_name + (user.last_name ? ' ' + user.last_name : ''),
  387. avatar: user.photo_url || ''
  388. });
  389. if (res.code === 200) {
  390. localStorage.setItem('token', res.data.token);
  391. userStore.setUserInfo(res.data.user);
  392. toast.success(t('auth.loginSuccess'));
  393. router.replace('/home');
  394. }
  395. } catch (error) {
  396. console.error('Telegram login error:', error);
  397. toast.error(t('auth.telegramLoginFailed') || 'Telegram 登录失败');
  398. } finally {
  399. telegramLoading.value = false;
  400. }
  401. };
  402. // Google 登录回调处理
  403. const handleGoogleCallback = async (response: any) => {
  404. if (!response.credential) {
  405. toast.error('Google 登录失败');
  406. return;
  407. }
  408. googleLoading.value = true;
  409. try {
  410. // 解析 JWT token 获取用户信息
  411. const payload = JSON.parse(atob(response.credential.split('.')[1]));
  412. // 调用后端 OAuth 登录接口
  413. const res = await requestOAuthLogin({
  414. provider: 'google',
  415. openId: payload.sub, // Google 用户唯一 ID
  416. nickname: payload.name,
  417. avatar: payload.picture,
  418. email: payload.email
  419. });
  420. if (res.code === 200) {
  421. localStorage.setItem('token', res.data.token);
  422. userStore.setUserInfo(res.data.user);
  423. toast.success(t('auth.loginSuccess'));
  424. router.replace('/home');
  425. }
  426. } catch (error) {
  427. console.error('Google login error:', error);
  428. toast.error('Google 登录失败');
  429. } finally {
  430. googleLoading.value = false;
  431. }
  432. };
  433. const handleGoogleLogin = () => {
  434. if (!googleClientId.value) {
  435. toast.error('Google 登录未配置');
  436. return;
  437. }
  438. // 使用 OAuth 2.0 隐式授权流程,通过弹窗方式
  439. const redirectUri = window.location.origin + '/google-callback.html';
  440. const scope = 'openid email profile';
  441. 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()}`;
  442. // 打开弹窗
  443. const width = 500;
  444. const height = 600;
  445. const left = (screen.width - width) / 2;
  446. const top = (screen.height - height) / 2;
  447. const popup = window.open(authUrl, 'google-login', `width=${width},height=${height},left=${left},top=${top}`);
  448. // 监听弹窗返回
  449. const timer = setInterval(() => {
  450. if (popup?.closed) {
  451. clearInterval(timer);
  452. googleLoading.value = false;
  453. }
  454. }, 500);
  455. googleLoading.value = true;
  456. };
  457. // 监听 Google 弹窗回传的消息
  458. const onGoogleMessage = async (event: MessageEvent) => {
  459. if (event.origin !== window.location.origin) return;
  460. if (event.data?.type !== 'google-login' || !event.data?.idToken) return;
  461. googleLoading.value = true;
  462. try {
  463. const payload = JSON.parse(atob(event.data.idToken.split('.')[1]));
  464. const res = await requestOAuthLogin({
  465. provider: 'google',
  466. openId: payload.sub,
  467. nickname: payload.name,
  468. avatar: payload.picture,
  469. email: payload.email
  470. });
  471. if (res.code === 200) {
  472. localStorage.setItem('token', res.data.token);
  473. userStore.setUserInfo(res.data.user);
  474. toast.success(t('auth.loginSuccess'));
  475. router.replace('/home');
  476. }
  477. } catch (error) {
  478. console.error('Google login error:', error);
  479. toast.error('Google 登录失败');
  480. } finally {
  481. googleLoading.value = false;
  482. }
  483. };
  484. // 组件挂载时初始化 OAuth
  485. onMounted(() => {
  486. window.addEventListener('message', onGoogleMessage);
  487. // 先检查 Zalo/TikTok 回调
  488. initZaloLogin();
  489. initTiktokLogin();
  490. // 再初始化 OAuth 配置
  491. initOAuth();
  492. });
  493. onUnmounted(() => {
  494. window.removeEventListener('message', onGoogleMessage);
  495. });
  496. // 生成随机字符串
  497. const generateRandomString = (length: number): string => {
  498. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  499. let result = '';
  500. for (let i = 0; i < length; i++) {
  501. result += chars.charAt(Math.floor(Math.random() * chars.length));
  502. }
  503. return result;
  504. };
  505. // 生成 PKCE code verifier 和 code challenge
  506. const generatePKCE = async () => {
  507. const codeVerifier = generateRandomString(43);
  508. const encoder = new TextEncoder();
  509. const data = encoder.encode(codeVerifier);
  510. const digest = await crypto.subtle.digest('SHA-256', data);
  511. const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
  512. .replace(/\+/g, '-')
  513. .replace(/\//g, '_')
  514. .replace(/=+$/, '');
  515. return { codeVerifier, codeChallenge: base64 };
  516. };
  517. // 初始化 Zalo 登录
  518. const initZaloLogin = () => {
  519. // 从 sessionStorage 读取 Zalo OAuth 回调的 code
  520. // (由 index.html 中的脚本在页面加载时保存)
  521. const code = sessionStorage.getItem('zalo_oauth_code');
  522. const codeVerifier = sessionStorage.getItem('zalo_code_verifier');
  523. console.log('Zalo initZaloLogin - code:', code ? 'exists' : 'null', 'codeVerifier:', codeVerifier ? 'exists' : 'null');
  524. if (code && codeVerifier) {
  525. // 清除保存的参数
  526. sessionStorage.removeItem('zalo_oauth_code');
  527. sessionStorage.removeItem('zalo_oauth_state');
  528. sessionStorage.removeItem('zalo_code_verifier');
  529. // 使用 code 获取用户信息
  530. handleZaloCallback(code, codeVerifier);
  531. } else if (code && !codeVerifier) {
  532. // 有 code 但没有 codeVerifier,可能是用户直接访问了带 code 的 URL
  533. console.warn('Zalo OAuth code found but no codeVerifier, clearing...');
  534. sessionStorage.removeItem('zalo_oauth_code');
  535. sessionStorage.removeItem('zalo_oauth_state');
  536. }
  537. };
  538. // Zalo 登录回调处理
  539. const handleZaloCallback = async (code: string, codeVerifier: string) => {
  540. zaloLoading.value = true;
  541. try {
  542. // 调用后端接口,让后端用 code 换取 access_token 和用户信息
  543. const res = await requestOAuthLogin({
  544. provider: 'zalo',
  545. openId: code, // 先传 code,后端会处理
  546. nickname: '',
  547. avatar: '',
  548. extra: codeVerifier // 传递 code_verifier 给后端
  549. });
  550. if (res.code === 200) {
  551. localStorage.setItem('token', res.data.token);
  552. userStore.setUserInfo(res.data.user);
  553. toast.success(t('auth.loginSuccess'));
  554. router.replace('/home');
  555. } else {
  556. toast.error(res.msg || t('auth.zaloLoginFailed') || 'Zalo 登录失败');
  557. }
  558. } catch (error) {
  559. console.error('Zalo login error:', error);
  560. toast.error(t('auth.zaloLoginFailed') || 'Zalo 登录失败');
  561. } finally {
  562. zaloLoading.value = false;
  563. }
  564. };
  565. // 触发 Zalo 登录
  566. const handleZaloLogin = async () => {
  567. if (!zaloAppId.value) {
  568. toast.error(t('auth.zaloNotConfigured') || 'Zalo 登录未配置');
  569. return;
  570. }
  571. zaloLoading.value = true;
  572. try {
  573. // 生成 PKCE
  574. const { codeVerifier, codeChallenge } = await generatePKCE();
  575. // 保存 code_verifier 到 sessionStorage
  576. sessionStorage.setItem('zalo_code_verifier', codeVerifier);
  577. // 构建 Zalo OAuth URL
  578. // 对于 hash 路由,使用不带 hash 的路径作为回调地址
  579. // Zalo 回调后 URL 会变成: https://domain.com/login?code=xxx
  580. // 然后我们在 initZaloLogin 中处理这个参数
  581. const redirectUri = encodeURIComponent(window.location.origin + '/login');
  582. const state = generateRandomString(16);
  583. console.log('Zalo login - redirectUri:', window.location.origin + '/login');
  584. const authUrl = `https://oauth.zaloapp.com/v4/permission?app_id=${zaloAppId.value}&redirect_uri=${redirectUri}&code_challenge=${codeChallenge}&state=${state}`;
  585. // 跳转到 Zalo 授权页面
  586. window.location.href = authUrl;
  587. } catch (error) {
  588. console.error('Zalo login error:', error);
  589. toast.error('Zalo 登录失败');
  590. zaloLoading.value = false;
  591. }
  592. };
  593. // 初始化 TikTok 登录(检查回调参数)
  594. const initTiktokLogin = () => {
  595. // 路由守卫已将 TikTok 回调参数从 URL 存入 sessionStorage
  596. const code = sessionStorage.getItem('tiktok_oauth_code');
  597. const redirectUri = sessionStorage.getItem('tiktok_redirect_uri');
  598. if (code && redirectUri) {
  599. sessionStorage.removeItem('tiktok_oauth_code');
  600. sessionStorage.removeItem('tiktok_oauth_state');
  601. sessionStorage.removeItem('tiktok_redirect_uri');
  602. handleTiktokCallback(code, redirectUri);
  603. }
  604. };
  605. // TikTok 登录回调处理
  606. const handleTiktokCallback = async (code: string, redirectUri: string) => {
  607. tiktokLoading.value = true;
  608. try {
  609. const res = await requestOAuthLogin({
  610. provider: 'tiktok',
  611. openId: code,
  612. nickname: '',
  613. avatar: '',
  614. extra: redirectUri
  615. });
  616. if (res.code === 200) {
  617. localStorage.setItem('token', res.data.token);
  618. userStore.setUserInfo(res.data.user);
  619. toast.success(t('auth.loginSuccess'));
  620. router.replace('/home');
  621. } else {
  622. toast.error(res.msg || t('auth.tiktokLoginFailed') || 'TikTok 登录失败');
  623. }
  624. } catch (error) {
  625. console.error('TikTok login error:', error);
  626. toast.error(t('auth.tiktokLoginFailed') || 'TikTok 登录失败');
  627. } finally {
  628. tiktokLoading.value = false;
  629. }
  630. };
  631. // 触发 TikTok 登录
  632. const handleTiktokLogin = () => {
  633. if (!tiktokClientKey.value) {
  634. toast.error(t('auth.tiktokNotConfigured') || 'TikTok 登录未配置');
  635. return;
  636. }
  637. const redirectUri = window.location.origin + '/login';
  638. sessionStorage.setItem('tiktok_redirect_uri', redirectUri);
  639. const state = 'tiktok_' + generateRandomString(16);
  640. 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}`;
  641. window.location.href = authUrl;
  642. };
  643. // 初始化 Facebook SDK
  644. const initFacebookSDK = (appId: string) => {
  645. // 避免重复加载
  646. if (document.getElementById('facebook-jssdk')) return;
  647. window.fbAsyncInit = function() {
  648. window.FB.init({
  649. appId: appId,
  650. cookie: true,
  651. xfbml: false,
  652. version: 'v19.0'
  653. });
  654. };
  655. const script = document.createElement('script');
  656. script.id = 'facebook-jssdk';
  657. script.src = 'https://connect.facebook.net/en_US/sdk.js';
  658. script.async = true;
  659. script.defer = true;
  660. document.head.appendChild(script);
  661. };
  662. // 触发 Facebook 登录
  663. const handleFacebookLogin = () => {
  664. if (!facebookAppId.value) {
  665. toast.error(t('auth.facebookNotConfigured') || 'Facebook 登录未配置');
  666. return;
  667. }
  668. if (!window.FB) {
  669. toast.error('Facebook SDK 加载中,请稍后再试');
  670. return;
  671. }
  672. facebookLoading.value = true;
  673. window.FB.login((response: any) => {
  674. if (response.authResponse) {
  675. const accessToken = response.authResponse.accessToken;
  676. // 获取用户信息
  677. window.FB.api('/me', { fields: 'id,name,email,picture.width(200)' }, (userInfo: any) => {
  678. handleFacebookCallback(userInfo, accessToken);
  679. });
  680. } else {
  681. facebookLoading.value = false;
  682. }
  683. }, { scope: 'public_profile,email' });
  684. };
  685. // Facebook 登录回调
  686. const handleFacebookCallback = async (userInfo: any, accessToken: string) => {
  687. try {
  688. const res = await requestOAuthLogin({
  689. provider: 'facebook',
  690. openId: userInfo.id,
  691. nickname: userInfo.name || '',
  692. avatar: userInfo.picture?.data?.url || '',
  693. email: userInfo.email || '',
  694. extra: accessToken
  695. });
  696. if (res.code === 200) {
  697. localStorage.setItem('token', res.data.token);
  698. userStore.setUserInfo(res.data.user);
  699. toast.success(t('auth.loginSuccess'));
  700. router.replace('/home');
  701. } else {
  702. toast.error(res.msg || t('auth.facebookLoginFailed') || 'Facebook 登录失败');
  703. }
  704. } catch (error) {
  705. console.error('Facebook login error:', error);
  706. toast.error(t('auth.facebookLoginFailed') || 'Facebook 登录失败');
  707. } finally {
  708. facebookLoading.value = false;
  709. }
  710. };
  711. const goForgotPassword = () => {
  712. router.push('/forgot-password');
  713. };
  714. const goRegister = () => {
  715. router.push('/register');
  716. };
  717. </script>
  718. <style lang="scss" scoped>
  719. .login-page {
  720. min-height: 100vh;
  721. background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
  722. padding: min(6vw, 30px) min(5.333vw, 25.6px) min(10vw, 48px);
  723. max-width: 500px;
  724. margin: 0 auto;
  725. }
  726. .logo-section {
  727. text-align: center;
  728. margin-bottom: min(5.333vw, 25.6px);
  729. .logo {
  730. width: min(17.067vw, 80px);
  731. height: min(17.067vw, 80px);
  732. margin: 0 auto min(2.667vw, 12.8px);
  733. border-radius: min(4.267vw, 20.48px);
  734. display: flex;
  735. align-items: center;
  736. justify-content: center;
  737. overflow: hidden;
  738. .logo-img {
  739. width: 100%;
  740. height: 100%;
  741. }
  742. }
  743. .title {
  744. font-size: min(5.867vw, 28px);
  745. font-weight: bold;
  746. color: #fff;
  747. margin-bottom: min(1.067vw, 5.12px);
  748. }
  749. .subtitle {
  750. font-size: min(3.733vw, 17.92px);
  751. color: rgba(255, 255, 255, 0.6);
  752. }
  753. }
  754. .form-section {
  755. background: rgba(255, 255, 255, 0.05);
  756. border-radius: min(4.267vw, 20.48px);
  757. padding: min(5.333vw, 25.6px);
  758. }
  759. .login-tabs {
  760. display: flex;
  761. margin-bottom: min(5.333vw, 25.6px);
  762. background: rgba(255, 255, 255, 0.1);
  763. border-radius: min(2.133vw, 10.24px);
  764. padding: min(1.067vw, 5.12px);
  765. .tab-item {
  766. flex: 1;
  767. text-align: center;
  768. padding: min(2.667vw, 12.8px) 0;
  769. color: rgba(255, 255, 255, 0.6);
  770. font-size: min(3.733vw, 17.92px);
  771. border-radius: min(1.6vw, 7.68px);
  772. cursor: pointer;
  773. transition: all 0.3s;
  774. &.active {
  775. background: #ffc300;
  776. color: #000;
  777. font-weight: 500;
  778. }
  779. }
  780. }
  781. .form-content {
  782. .input-group {
  783. margin-bottom: min(3.2vw, 15.36px);
  784. :deep(.van-field) {
  785. background: rgba(255, 255, 255, 0.1);
  786. border-radius: min(2.133vw, 10.24px);
  787. padding: min(3.2vw, 15.36px) min(4.267vw, 20.48px);
  788. .van-field__control {
  789. color: #fff;
  790. font-size: min(3.733vw, 17.92px);
  791. &::placeholder {
  792. color: rgba(255, 255, 255, 0.4);
  793. }
  794. }
  795. .van-icon {
  796. color: rgba(255, 255, 255, 0.6);
  797. font-size: min(4.267vw, 20.48px);
  798. }
  799. }
  800. .area-code {
  801. display: flex;
  802. align-items: center;
  803. gap: min(1.067vw, 5.12px);
  804. color: #fff;
  805. font-size: min(3.733vw, 17.92px);
  806. padding-right: min(3.2vw, 15.36px);
  807. border-right: 1px solid rgba(255, 255, 255, 0.2);
  808. margin-right: min(3.2vw, 15.36px);
  809. cursor: pointer;
  810. }
  811. }
  812. }
  813. .forgot-password {
  814. text-align: right;
  815. color: #ffc300;
  816. font-size: min(3.467vw, 16.64px);
  817. margin-bottom: min(5.333vw, 25.6px);
  818. cursor: pointer;
  819. }
  820. .login-btn {
  821. height: min(12.8vw, 61.44px);
  822. border-radius: min(2.133vw, 10.24px);
  823. font-size: min(4.267vw, 20.48px);
  824. font-weight: 500;
  825. background: linear-gradient(135deg, #ffc300 0%, #ff9500 100%);
  826. border: none;
  827. }
  828. .third-party-section {
  829. margin-top: min(6.4vw, 30.72px);
  830. .divider {
  831. display: flex;
  832. align-items: center;
  833. margin-bottom: min(4.267vw, 20.48px);
  834. &::before,
  835. &::after {
  836. content: '';
  837. flex: 1;
  838. height: 1px;
  839. background: rgba(255, 255, 255, 0.2);
  840. }
  841. span {
  842. padding: 0 min(4.267vw, 20.48px);
  843. color: rgba(255, 255, 255, 0.4);
  844. font-size: min(3.2vw, 15.36px);
  845. }
  846. }
  847. .oauth-btn {
  848. display: flex;
  849. align-items: center;
  850. justify-content: center;
  851. gap: min(2.667vw, 12.8px);
  852. height: min(12.8vw, 61.44px);
  853. border-radius: min(2.133vw, 10.24px);
  854. font-size: min(4vw, 19.2px);
  855. font-weight: 500;
  856. cursor: pointer;
  857. transition: all 0.3s;
  858. margin-bottom: min(3.2vw, 15.36px);
  859. &.loading {
  860. pointer-events: none;
  861. opacity: 0.7;
  862. }
  863. .google-icon, .zalo-icon, .telegram-icon, .tiktok-icon, .facebook-icon {
  864. flex-shrink: 0;
  865. }
  866. &.facebook-btn {
  867. background: #1877F2;
  868. color: #fff;
  869. &:active {
  870. background: #1565C0;
  871. }
  872. }
  873. &.google-btn {
  874. background: #fff;
  875. color: #333;
  876. border: 1px solid rgba(255, 255, 255, 0.2);
  877. &:active {
  878. background: #f5f5f5;
  879. }
  880. }
  881. &.zalo-btn {
  882. background: #0068FF;
  883. color: #fff;
  884. &:active {
  885. background: #0055cc;
  886. }
  887. }
  888. &.telegram-btn {
  889. background: #0088cc;
  890. color: #fff;
  891. &:active {
  892. background: #0077b5;
  893. }
  894. }
  895. &.tiktok-btn {
  896. background: #000;
  897. color: #fff;
  898. &:active {
  899. background: #333;
  900. }
  901. }
  902. }
  903. }
  904. .register-entry {
  905. text-align: center;
  906. margin-top: min(5.333vw, 25.6px);
  907. color: rgba(255, 255, 255, 0.6);
  908. font-size: min(3.733vw, 17.92px);
  909. .link {
  910. color: #ffc300;
  911. margin-left: min(1.067vw, 5.12px);
  912. cursor: pointer;
  913. }
  914. }
  915. </style>