|
- <template>
- <div
- class="fixed inset-0 z-[999] flex items-center justify-center"
- role="dialog"
- aria-modal="true"
- aria-labelledby="login-modal-title"
- >
- <!-- 遮罩 -->
- <div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="$emit('close')" />
-
- <!-- 弹窗 -->
- <div class="relative w-full max-w-md mx-4 bg-[var(--c-ffffff)] rounded-2xl shadow-2xl overflow-hidden">
- <!-- 关闭按钮 -->
- <button
- type="button"
- aria-label="关闭登录弹窗"
- class="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center rounded-full text-[var(--c-94a3b8)] hover:text-[var(--c-475569)] hover:bg-[var(--c-f1f5f9)] transition-colors cursor-pointer"
- @click="$emit('close')"
- >
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
-
- <!-- 标题 -->
- <div class="px-8 pt-8 pb-4 text-center">
- <img src="/logo.png" alt="AI在线" class="mx-auto mb-3 h-12 w-12 rounded-xl shadow-[0_6px_16px_var(--sh-11-140-255-220)]">
- <h2 id="login-modal-title" class="text-xl font-bold text-[var(--c-172033)]">登录 AI 在线</h2>
- <p class="mt-1 text-sm text-[var(--c-64748b)]">解放你的生产力</p>
- </div>
-
- <!-- Tab 切换 -->
- <div class="px-8">
- <div class="flex border-b border-[var(--c-e2e8f0)]">
- <button
- v-if="!isMobileClient"
- :class="[
- 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
- activeTab === 'qr'
- ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
- : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
- ]"
- @click="activeTab = 'qr'"
- >
- 微信扫码登录
- </button>
- <button
- :class="[
- 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
- activeTab === 'phone'
- ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
- : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
- ]"
- @click="activeTab = 'phone'"
- >
- 手机号登录
- </button>
- </div>
- </div>
-
- <!-- 微信扫码登录 -->
- <div v-if="!isMobileClient && activeTab === 'qr'" class="px-8 py-6">
- <div class="flex flex-col items-center">
- <!-- 二维码区域 -->
- <div
- class="relative w-48 h-48 rounded-xl border border-[var(--c-e2e8f0)] flex items-center justify-center bg-[var(--c-ffffff)]"
- >
- <!-- 加载中 -->
- <div v-if="qrLoading" class="flex flex-col items-center gap-2">
- <svg class="w-8 h-8 text-[var(--c-0b8cff)] animate-spin" fill="none" viewBox="0 0 24 24">
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
- </svg>
- <span class="text-xs text-[var(--c-94a3b8)]">获取二维码...</span>
- </div>
-
- <!-- 二维码生成失败:首次请求未拿到有效 ticket/url -->
- <button
- v-else-if="qrFailed"
- type="button"
- class="group flex cursor-pointer flex-col items-center gap-2.5 px-4 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sh-11-140-255-180)]"
- aria-label="二维码生成失败,点击重新生成"
- @click="refreshQR"
- >
- <span class="flex size-11 items-center justify-center rounded-full bg-[var(--c-fff1f0)] text-[var(--c-f04438)]">
- <UIcon name="i-lucide-circle-alert" class="size-6" />
- </span>
- <span class="text-sm font-bold text-[var(--c-172033)]">二维码生成失败</span>
- <span class="text-xs leading-5 text-[var(--c-94a3b8)]">{{ qrErrorMessage }}</span>
- <span class="text-xs font-bold text-[var(--c-0b8cff)] group-hover:underline">点击重新生成</span>
- </button>
-
- <!-- 二维码过期 -->
- <button
- v-else-if="qrExpired"
- type="button"
- class="flex cursor-pointer flex-col items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sh-11-140-255-180)]"
- aria-label="二维码已过期,点击刷新"
- @click="refreshQR"
- >
- <svg class="w-10 h-10 text-[var(--c-cbd5e1)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4v1m6 11h2m-6 0h-2m4-8h2m-8 0H6m5 5h2m-4 0h2m2 4h-2m2-4h2" />
- </svg>
- <span class="text-sm text-[var(--c-94a3b8)]">二维码已过期</span>
- <span class="text-xs font-bold text-[var(--c-0b8cff)]">点击刷新</span>
- </button>
-
- <!-- 二维码 -->
- <vue-qr
- v-else-if="qrData.url"
- :text="qrData.url"
- :size="180"
- :margin="8"
- :logo-image="qrLogo"
- :logo-size="36"
- />
- </div>
-
- <p class="mt-4 text-sm text-[var(--c-64748b)] text-center">
- 请使用微信扫描二维码完成登录
- </p>
- </div>
- </div>
-
- <!-- 手机号登录 -->
- <div v-if="activeTab === 'phone'" class="px-8 py-6">
- <div class="space-y-4">
- <!-- 手机号 -->
- <div>
- <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">手机号</label>
- <input
- v-model="phoneForm.phone"
- type="tel"
- maxlength="11"
- placeholder="请输入手机号"
- class="w-full px-3 py-2.5 text-sm border border-[var(--c-e2e8f0)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--sh-11-140-255-180)] focus:border-[var(--c-0b8cff)] transition-colors"
- @input="phoneForm.phone = phoneForm.phone.replace(/\D/g, '')"
- />
- </div>
-
- <!-- 验证码 -->
- <div>
- <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">验证码</label>
- <div class="flex gap-3">
- <input
- v-model="phoneForm.code"
- type="text"
- maxlength="6"
- placeholder="请输入验证码"
- class="flex-1 px-3 py-2.5 text-sm border border-[var(--c-e2e8f0)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--sh-11-140-255-180)] focus:border-[var(--c-0b8cff)] transition-colors"
- />
- <button
- :disabled="phoneForm.sendingCode || countdown > 0"
- :class="[
- 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer',
- 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"
- >
- {{ phoneForm.sendingCode ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
- </button>
- </div>
- </div>
-
- <!-- 阿里云验证码挂载点:生产环境发送登录短信前触发,开发环境仍走后端 mock -->
- <div id="captcha-element" class="absolute size-px overflow-hidden opacity-0" aria-hidden="true" />
- <button
- id="captcha-button"
- type="button"
- class="absolute size-px overflow-hidden opacity-0"
- tabindex="-1"
- aria-hidden="true"
- />
-
- <!-- 开发环境提示(后端未发短信,直接返回验证码) -->
- <p v-if="phoneForm.devCode" class="text-xs text-[var(--c-94a3b8)]">
- 开发环境验证码:<span class="font-bold text-[var(--c-0b8cff)]">{{ phoneForm.devCode }}</span>(已自动填入)
- </p>
-
- <!-- 错误提示 -->
- <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p>
-
- <!-- 登录按钮 -->
- <button
- :disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code"
- class="w-full py-2.5 text-sm font-semibold text-white bg-[var(--c-0b8cff)] hover:bg-[var(--c-087dde)] disabled:bg-[var(--c-cbd5e1)] disabled:cursor-not-allowed rounded-lg transition-colors cursor-pointer"
- @click="loginByPhone"
- >
- <span v-if="phoneForm.loading" class="inline-flex items-center gap-2">
- <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
- </svg>
- 登录中...
- </span>
- <span v-else>登录</span>
- </button>
- </div>
- </div>
-
- <!-- 底部协议 -->
- <div class="px-8 pb-6 text-center">
- <p class="text-xs text-[var(--c-94a3b8)]">
- 登录即表示同意
- <NuxtLink to="/terms" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《服务条款》</NuxtLink>
- 和
- <NuxtLink to="/privacy" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《隐私政策》</NuxtLink>
- </p>
- </div>
- </div>
- </div>
- </template>
-
- <script setup>
- import VueQr from 'vue-qr'
-
- const emit = defineEmits(['close', 'login-success'])
-
- const { post } = useApi()
- const { setLogin } = useUser()
- const { inviteCode, withInvite, clearInvite } = useInvite()
- 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'
- 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')
-
- // ===== 微信扫码 =====
- const qrLoading = ref(true)
- const qrExpired = ref(false)
- const qrFailed = ref(false)
- const qrErrorMessage = ref('')
- const qrData = ref({ ticket: '', url: '' })
- const qrLogo = '' // 可替换为 logo URL
- let qrTimer = null
-
- // 获取二维码
- async function refreshQR() {
- if (isMobileClient.value) return
- clearInterval(qrTimer)
- qrLoading.value = true
- qrExpired.value = false
- qrFailed.value = false
- qrErrorMessage.value = ''
- qrData.value = { ticket: '', url: '' }
-
- try {
- // 邀请码必须在创建二维码 Ticket 时写入,扫码回调才能完成邀请归因。
- const res = await post('/user/getloginqr', withInvite())
- if (res.code === 0 && res.data?.ticket && res.data?.url) {
- qrData.value = res.data
- qrLoading.value = false
- startPolling()
- } else {
- qrLoadError()
- }
- } catch (error) {
- qrLoadError(error)
- }
- }
-
- function qrLoadError(error) {
- clearInterval(qrTimer)
- qrLoading.value = false
- qrExpired.value = false
- qrFailed.value = true
- const message = String(error?.message || '')
- qrErrorMessage.value = /access[_ ]?token|登录凭证/i.test(message)
- ? '未能获取微信登录凭证,请稍后重试'
- : '暂时无法生成二维码,请稍后重试'
- }
-
- function markQrExpired() {
- clearInterval(qrTimer)
- qrLoading.value = false
- qrFailed.value = false
- qrExpired.value = true
- }
-
- // 轮询扫码结果
- function startPolling() {
- clearInterval(qrTimer)
- qrTimer = setInterval(async () => {
- try {
- const res = await post('/user/scanquery', { ticket: qrData.value.ticket })
- if (res.code === 0 && res.data?.token) {
- clearInterval(qrTimer)
- handleLoginSuccess(res.data)
- } else if (res.code === 1010) {
- // 二维码过期
- markQrExpired()
- }
- } catch (error) {
- // useApi 会把非 0 业务码抛出;1010 需要在异常分支识别为真正过期。
- if (Number(error?.code) === 1010) markQrExpired()
- // 其他短暂轮询失败不中断
- }
- }, 1000)
- }
-
- // ===== 手机号登录 =====
- const phoneForm = reactive({
- phone: '',
- code: '',
- sendingCode: false,
- loading: false,
- error: '',
- devCode: '', // 开发环境后端直接返回的验证码,仅用于提示
- })
-
- const countdown = ref(0)
- let countdownTimer = null
-
- 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('验证码仅支持浏览器环境'))
- window.AliyunCaptchaConfig = {
- region: 'cn',
- prefix: ALIYUN_CAPTCHA_PREFIX,
- }
- 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
- if (captchaInstance) return captchaInstance
- if (captchaInitPromise) return captchaInitPromise
-
- captchaInitPromise = (async () => {
- await loadAliyunCaptchaScript()
- await nextTick()
-
- const element = document.getElementById('captcha-element')
- const button = document.getElementById('captcha-button')
- if (!element || !button) {
- throw new Error('验证码初始化失败,请刷新后重试')
- }
-
- 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)
- }
-
- 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 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 = getSmsCooldownSeconds()
- if (countdown.value <= 0) {
- window.localStorage.removeItem(SMS_LAST_SENT_AT_KEY)
- return
- }
-
- countdownTimer = setInterval(() => {
- 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,
- 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)
- } else {
- phoneForm.code = ''
- phoneForm.devCode = ''
- }
- startSmsCountdown()
- }
-
- async function captchaVerifyCallback(captchaVerifyParam) {
- phoneForm.error = ''
- phoneForm.sendingCode = true
- try {
- await submitSmsCode(captchaVerifyParam)
- return {
- captchaResult: true,
- bizResult: true,
- }
- } catch (e) {
- phoneForm.error = e.message || '发送验证码失败'
- const captchaFailed = Number(e?.code) === SMS_CAPTCHA_ERROR_CODE
- return {
- captchaResult: !captchaFailed,
- bizResult: false,
- }
- } 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
- 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
-
- 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 || '验证码初始化失败,请刷新后重试'
- }
- }
-
- // 手机号登录
- async function loginByPhone() {
- if (!phoneForm.phone || !phoneForm.code) return
- phoneForm.error = ''
- phoneForm.loading = true
-
- try {
- const res = await post('/user/login_phone', withInvite({
- phone: phoneForm.phone,
- code: phoneForm.code,
- }))
- if (res.code === 0 && res.data?.token) {
- handleLoginSuccess(res.data)
- } else {
- phoneForm.error = res.msg || '登录失败'
- }
- } catch (e) {
- phoneForm.error = e.message || '登录失败'
- } finally {
- phoneForm.loading = false
- }
- }
-
- // ===== 公共 =====
- function handleLoginSuccess(data) {
- const invited = Boolean(inviteCode.value)
- setLogin(data)
- clearInvite()
- reportCv('login_success', { page_key: 'login_dialog' })
- toast.add({
- title: invited && data.is_register ? '登录并领取成功' : '登录成功',
- description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '',
- color: 'success',
- })
- emit('login-success', data)
- }
-
- // 初始化
- onMounted(() => {
- reportCv('login_dialog_show', { page_key: 'login_dialog' })
- syncSmsCountdown()
- window.addEventListener('storage', handleSmsCooldownStorage)
- if (shouldUseSmsCaptcha()) initSmsCaptcha().catch(() => {})
- if (!isMobileClient.value) refreshQR()
- })
-
- watch(isMobileClient, (mobile) => {
- if (mobile) {
- clearInterval(qrTimer)
- activeTab.value = 'phone'
- } else if (activeTab.value === 'qr' && !qrData.value.url) {
- refreshQR()
- }
- })
-
- onUnmounted(() => {
- clearInterval(qrTimer)
- clearInterval(countdownTimer)
- window.removeEventListener('storage', handleSmsCooldownStorage)
- try {
- captchaInstance?.destroyCaptcha?.()
- } catch {}
- captchaInstance = null
- captchaInitPromise = null
- captchaInitializedAt = 0
- cleanupAliyunCaptchaPopup()
- })
- </script>
|