diff --git a/app/components/LoginModal.vue b/app/components/LoginModal.vue index 5023967..1491bb9 100644 --- a/app/components/LoginModal.vue +++ b/app/components/LoginModal.vue @@ -228,8 +228,15 @@ const toast = useToast() const ALIYUN_CAPTCHA_SCRIPT = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js' const ALIYUN_CAPTCHA_SCENE_ID = 'fgtt5yvc' const ALIYUN_CAPTCHA_PREFIX = '1ggr84' +const ALIYUN_CAPTCHA_WIDTH = 360 +const ALIYUN_CAPTCHA_WARMUP_MS = 2000 +const SMS_CAPTCHA_ERROR_CODE = 1020 +const SMS_COOLDOWN_SECONDS = 60 +const SMS_LAST_SENT_AT_KEY = 'ai_last_send_time' let aliyunCaptchaScriptPromise = null let captchaInstance = null +let captchaInitPromise = null +let captchaInitializedAt = 0 // Tab const activeTab = ref(isMobileClient.value ? 'phone' : 'qr') @@ -318,7 +325,6 @@ const phoneForm = reactive({ }) const countdown = ref(0) -const captchaReady = ref(false) let countdownTimer = null function shouldUseSmsCaptcha() { @@ -334,6 +340,10 @@ function cleanupAliyunCaptchaPopup() { function loadAliyunCaptchaScript() { if (!import.meta.client) return Promise.reject(new Error('验证码仅支持浏览器环境')) + window.AliyunCaptchaConfig = { + region: 'cn', + prefix: ALIYUN_CAPTCHA_PREFIX, + } if (window.initAliyunCaptcha) return Promise.resolve() if (aliyunCaptchaScriptPromise) return aliyunCaptchaScriptPromise @@ -374,55 +384,128 @@ function loadAliyunCaptchaScript() { async function initSmsCaptcha() { if (!shouldUseSmsCaptcha()) return - await loadAliyunCaptchaScript() - await nextTick() + if (captchaInstance) return captchaInstance + if (captchaInitPromise) return captchaInitPromise - const element = document.getElementById('captcha-element') - const button = document.getElementById('captcha-button') - if (!element || !button) { - throw new Error('验证码初始化失败,请刷新后重试') - } + captchaInitPromise = (async () => { + await loadAliyunCaptchaScript() + await nextTick() - if (captchaReady.value && captchaInstance) return + const element = document.getElementById('captcha-element') + const button = document.getElementById('captcha-button') + if (!element || !button) { + throw new Error('验证码初始化失败,请刷新后重试') + } - cleanupAliyunCaptchaPopup() - window.initAliyunCaptcha({ - SceneId: ALIYUN_CAPTCHA_SCENE_ID, - prefix: ALIYUN_CAPTCHA_PREFIX, - mode: 'popup', - element: '#captcha-element', - button: '#captcha-button', - captchaVerifyCallback, - onBizResultCallback, - getInstance, - slideStyle: { - width: 360, - height: 40, - }, - language: 'cn', - }) - captchaReady.value = true -} + cleanupAliyunCaptchaPopup() + + const pageWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) + const rem = pageWidth > 0 && pageWidth <= ALIYUN_CAPTCHA_WIDTH + ? Math.floor(pageWidth / ALIYUN_CAPTCHA_WIDTH * 100) / 100 + : 1 + + return await new Promise((resolve, reject) => { + let settled = false + const timeout = window.setTimeout(() => { + if (settled) return + settled = true + reject(new Error('验证码初始化超时,请刷新后重试')) + }, 15000) + + const resolveInstance = (instance) => { + if (settled) return + settled = true + window.clearTimeout(timeout) + captchaInstance = instance + resolve(instance) + } -function getInstance(instance) { - captchaInstance = instance + try { + captchaInitializedAt = Date.now() + window.initAliyunCaptcha({ + SceneId: ALIYUN_CAPTCHA_SCENE_ID, + prefix: ALIYUN_CAPTCHA_PREFIX, + mode: 'popup', + element: '#captcha-element', + button: '#captcha-button', + captchaVerifyCallback, + onBizResultCallback, + getInstance: resolveInstance, + onError: () => { + if (settled) return + settled = true + window.clearTimeout(timeout) + reject(new Error('验证码组件加载失败,请稍后重试')) + }, + autoRefresh: true, + slideStyle: { + width: ALIYUN_CAPTCHA_WIDTH, + height: 40, + }, + language: 'cn', + rem, + }) + } catch (error) { + if (settled) return + settled = true + window.clearTimeout(timeout) + reject(error) + } + }) + })() + + try { + return await captchaInitPromise + } catch (error) { + captchaInitPromise = null + captchaInstance = null + captchaInitializedAt = 0 + throw error + } } function onBizResultCallback() { // 阿里云验证码业务回调占位,短信发送结果已在 captchaVerifyCallback 中处理。 } -function startSmsCountdown() { +function getSmsCooldownSeconds() { + if (!import.meta.client) return 0 + const lastSentAt = Number(window.localStorage.getItem(SMS_LAST_SENT_AT_KEY)) + if (!Number.isFinite(lastSentAt) || lastSentAt <= 0) { + window.localStorage.removeItem(SMS_LAST_SENT_AT_KEY) + return 0 + } + + const remaining = lastSentAt + SMS_COOLDOWN_SECONDS - Math.floor(Date.now() / 1000) + return Math.min(SMS_COOLDOWN_SECONDS, Math.max(0, remaining)) +} + +function syncSmsCountdown() { clearInterval(countdownTimer) - countdown.value = 60 + countdown.value = getSmsCooldownSeconds() + if (countdown.value <= 0) { + window.localStorage.removeItem(SMS_LAST_SENT_AT_KEY) + return + } + countdownTimer = setInterval(() => { - countdown.value-- + countdown.value = getSmsCooldownSeconds() if (countdown.value <= 0) { clearInterval(countdownTimer) + window.localStorage.removeItem(SMS_LAST_SENT_AT_KEY) } }, 1000) } +function startSmsCountdown() { + window.localStorage.setItem(SMS_LAST_SENT_AT_KEY, String(Math.floor(Date.now() / 1000))) + syncSmsCountdown() +} + +function handleSmsCooldownStorage(event) { + if (event.key === SMS_LAST_SENT_AT_KEY) syncSmsCountdown() +} + async function submitSmsCode(captchaVerifyParam = '') { const payload = { phone: phoneForm.phone, @@ -439,6 +522,9 @@ async function submitSmsCode(captchaVerifyParam = '') { if (res.data?.code) { phoneForm.code = String(res.data.code) phoneForm.devCode = String(res.data.code) + } else { + phoneForm.code = '' + phoneForm.devCode = '' } startSmsCountdown() } @@ -454,9 +540,10 @@ async function captchaVerifyCallback(captchaVerifyParam) { } } catch (e) { phoneForm.error = e.message || '发送验证码失败' + const captchaFailed = Number(e?.code) === SMS_CAPTCHA_ERROR_CODE return { - captchaResult: false, - bizResult: true, + captchaResult: !captchaFailed, + bizResult: false, } } finally { phoneForm.sendingCode = false @@ -490,10 +577,22 @@ async function sendCode() { try { phoneForm.sendingCode = true - await initSmsCaptcha() + const instance = await initSmsCaptcha() + const warmupRemaining = ALIYUN_CAPTCHA_WARMUP_MS - (Date.now() - captchaInitializedAt) + if (warmupRemaining > 0) { + await new Promise(resolve => setTimeout(resolve, warmupRemaining)) + } phoneForm.sendingCode = false - await new Promise(resolve => setTimeout(resolve, 50)) - document.getElementById('captcha-button')?.click() + + if (typeof instance?.startTracelessVerification === 'function') { + instance.show?.() + instance.startTracelessVerification() + } else { + const captchaButton = document.getElementById('captcha-button') + if (captchaButton) captchaButton.click() + else if (typeof instance?.show === 'function') instance.show() + else throw new Error('验证码触发失败,请刷新后重试') + } } catch (e) { phoneForm.sendingCode = false phoneForm.error = e.message || '验证码初始化失败,请刷新后重试' @@ -540,6 +639,9 @@ function handleLoginSuccess(data) { // 初始化 onMounted(() => { reportCv('login_dialog_show', { page_key: 'login_dialog' }) + syncSmsCountdown() + window.addEventListener('storage', handleSmsCooldownStorage) + if (shouldUseSmsCaptcha()) initSmsCaptcha().catch(() => {}) if (!isMobileClient.value) refreshQR() }) @@ -555,8 +657,13 @@ watch(isMobileClient, (mobile) => { onUnmounted(() => { clearInterval(qrTimer) clearInterval(countdownTimer) + window.removeEventListener('storage', handleSmsCooldownStorage) + try { + captchaInstance?.destroyCaptcha?.() + } catch {} captchaInstance = null - captchaReady.value = false + captchaInitPromise = null + captchaInitializedAt = 0 cleanupAliyunCaptchaPopup() }) diff --git a/app/plugins/baidu-hm.client.js b/app/plugins/baidu-hm.client.js new file mode 100644 index 0000000..f390d41 --- /dev/null +++ b/app/plugins/baidu-hm.client.js @@ -0,0 +1,25 @@ +const BAIDU_HM_ID = '1cafaa94863718681ae361aa0b209ce7' + +export default defineNuxtPlugin(() => { + window._hmt = window._hmt || [] + + useHead({ + script: [ + { + key: 'baidu-hm', + src: `https://hm.baidu.com/hm.js?${BAIDU_HM_ID}`, + async: true, + }, + ], + }) + + const router = useRouter() + let lastFullPath = router.currentRoute.value.fullPath + + router.afterEach((to, _from, failure) => { + if (failure || to.fullPath === lastFullPath) return + + lastFullPath = to.fullPath + window._hmt.push(['_trackPageview', to.fullPath]) + }) +})