diff --git a/app/components/LoginModal.vue b/app/components/LoginModal.vue index aef1e8f..5023967 100644 --- a/app/components/LoginModal.vue +++ b/app/components/LoginModal.vue @@ -153,17 +153,27 @@ :disabled="phoneForm.sendingCode || countdown > 0" :class="[ 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer', - countdown > 0 + phoneForm.sendingCode || countdown > 0 ? 'text-[var(--c-94a3b8)] bg-[var(--c-f1f5f9)] cursor-not-allowed' : 'text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)] hover:bg-[var(--c-e0f0ff)]', ]" @click="sendCode" > - {{ countdown > 0 ? `${countdown}s` : '获取验证码' }} + {{ phoneForm.sendingCode ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }} + +
+ +开发环境验证码:{{ phoneForm.devCode }}(已自动填入) @@ -215,6 +225,12 @@ const { isMobileClient } = useClientDevice() const { reportCv } = usePvcv() 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' +let aliyunCaptchaScriptPromise = null +let captchaInstance = null + // Tab const activeTab = ref(isMobileClient.value ? 'phone' : 'qr') @@ -302,45 +318,188 @@ const phoneForm = reactive({ }) const countdown = ref(0) +const captchaReady = ref(false) let countdownTimer = null -// 发送验证码 -async function sendCode() { - if (!phoneForm.phone || phoneForm.phone.length !== 11) { - phoneForm.error = '请输入正确的11位手机号' - return +function shouldUseSmsCaptcha() { + return import.meta.client && !import.meta.dev +} + +function cleanupAliyunCaptchaPopup() { + if (!import.meta.client) return + ;['aliyunCaptcha-mask', 'aliyunCaptcha-window-popup'].forEach((id) => { + document.getElementById(id)?.remove() + }) +} + +function loadAliyunCaptchaScript() { + if (!import.meta.client) return Promise.reject(new Error('验证码仅支持浏览器环境')) + if (window.initAliyunCaptcha) return Promise.resolve() + if (aliyunCaptchaScriptPromise) return aliyunCaptchaScriptPromise + + aliyunCaptchaScriptPromise = new Promise((resolve, reject) => { + const existed = document.querySelector(`script[src="${ALIYUN_CAPTCHA_SCRIPT}"]`) + const script = existed || document.createElement('script') + const timeout = setTimeout(() => { + aliyunCaptchaScriptPromise = null + reject(new Error('验证码组件加载超时,请刷新后重试')) + }, 15000) + + const done = () => { + clearTimeout(timeout) + if (window.initAliyunCaptcha) resolve() + else { + aliyunCaptchaScriptPromise = null + reject(new Error('验证码组件加载失败')) + } + } + const fail = () => { + clearTimeout(timeout) + aliyunCaptchaScriptPromise = null + reject(new Error('验证码组件加载失败,请刷新后重试')) + } + + script.addEventListener('load', done, { once: true }) + script.addEventListener('error', fail, { once: true }) + + if (!existed) { + script.src = ALIYUN_CAPTCHA_SCRIPT + script.async = true + document.head.appendChild(script) + } + }) + + return aliyunCaptchaScriptPromise +} + +async function initSmsCaptcha() { + if (!shouldUseSmsCaptcha()) return + await loadAliyunCaptchaScript() + await nextTick() + + const element = document.getElementById('captcha-element') + const button = document.getElementById('captcha-button') + if (!element || !button) { + throw new Error('验证码初始化失败,请刷新后重试') + } + + if (captchaReady.value && captchaInstance) return + + 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 +} + +function getInstance(instance) { + captchaInstance = instance +} + +function onBizResultCallback() { + // 阿里云验证码业务回调占位,短信发送结果已在 captchaVerifyCallback 中处理。 +} + +function startSmsCountdown() { + clearInterval(countdownTimer) + countdown.value = 60 + countdownTimer = setInterval(() => { + countdown.value-- + if (countdown.value <= 0) { + clearInterval(countdownTimer) + } + }, 1000) +} + +async function submitSmsCode(captchaVerifyParam = '') { + const payload = { + phone: phoneForm.phone, + type: 3, + } + if (captchaVerifyParam) payload.captchaVerifyParam = captchaVerifyParam + + const res = await post('/common/sendcode', payload) + if (res.code !== 0) { + throw new Error(res.msg || '发送验证码失败') } + + // 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试 + if (res.data?.code) { + phoneForm.code = String(res.data.code) + phoneForm.devCode = String(res.data.code) + } + startSmsCountdown() +} + +async function captchaVerifyCallback(captchaVerifyParam) { phoneForm.error = '' phoneForm.sendingCode = true - try { - const res = await post('/common/sendcode', { - phone: phoneForm.phone, - type: 3, - }) - if (res.code === 0) { - // 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试 - if (res.data?.code) { - phoneForm.code = String(res.data.code) - phoneForm.devCode = String(res.data.code) - } - countdown.value = 60 - countdownTimer = setInterval(() => { - countdown.value-- - if (countdown.value <= 0) { - clearInterval(countdownTimer) - } - }, 1000) - } else { - phoneForm.error = res.msg || '发送验证码失败' + await submitSmsCode(captchaVerifyParam) + return { + captchaResult: true, + bizResult: true, } } catch (e) { phoneForm.error = e.message || '发送验证码失败' + return { + captchaResult: false, + bizResult: true, + } } finally { phoneForm.sendingCode = false } } +function validatePhone() { + if (!phoneForm.phone || phoneForm.phone.length !== 11) { + phoneForm.error = '请输入正确的11位手机号' + return false + } + return true +} + +// 发送验证码 +async function sendCode() { + if (!validatePhone()) return + phoneForm.error = '' + + if (!shouldUseSmsCaptcha()) { + phoneForm.sendingCode = true + try { + await submitSmsCode() + } catch (e) { + phoneForm.error = e.message || '发送验证码失败' + } finally { + phoneForm.sendingCode = false + } + return + } + + try { + phoneForm.sendingCode = true + await initSmsCaptcha() + phoneForm.sendingCode = false + await new Promise(resolve => setTimeout(resolve, 50)) + document.getElementById('captcha-button')?.click() + } catch (e) { + phoneForm.sendingCode = false + phoneForm.error = e.message || '验证码初始化失败,请刷新后重试' + } +} + // 手机号登录 async function loginByPhone() { if (!phoneForm.phone || !phoneForm.code) return @@ -396,5 +555,8 @@ watch(isMobileClient, (mobile) => { onUnmounted(() => { clearInterval(qrTimer) clearInterval(countdownTimer) + captchaInstance = null + captchaReady.value = false + cleanupAliyunCaptchaPopup() }) diff --git a/app/pages/index.vue b/app/pages/index.vue index 9ec2891..112f793 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -195,7 +195,15 @@