Files
template-nuxt4/app/components/QrLogin.vue
2026-04-29 01:33:33 +08:00

328 lines
8.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="qr-login">
<div class="qr-box">
<div v-if="status === 'loading'" class="qr-state">
<a-spin size="large" />
<p class="muted">正在生成二维码</p>
</div>
<!-- 微信扫码登录 -->
<div v-else-if="status === 'active'" class="qr-state">
<!-- 小程序码优先展示Base64格式 -->
<img
v-if="miniprogramQrCode"
:src="miniprogramQrCode"
alt="小程序码登录"
class="qrcode-img"
@click="refresh"
/>
<!-- 普通二维码降级方案 -->
<img
v-else-if="qrCodeDataUrl"
:src="qrCodeDataUrl"
alt="扫码登录"
class="qrcode-img"
@click="refresh"
/>
<div v-else class="qrcode-placeholder">
<a-spin size="large" />
</div>
<p class="tip">请使用微信扫一扫扫码后自动登录</p>
<p class="muted">有效期{{ formatCountdown(expireSeconds) }}</p>
</div>
<div v-else-if="status === 'scanned'" class="qr-state">
<CheckCircleOutlined class="icon ok" />
<p class="ok-text">已识别扫码请在手机上确认登录</p>
</div>
<div v-else-if="status === 'expired'" class="qr-state">
<ClockCircleOutlined class="icon bad" />
<p class="bad-text">二维码已过期</p>
<a-button type="primary" @click="refresh">刷新二维码</a-button>
</div>
<div v-else-if="status === 'error'" class="qr-state">
<ExclamationCircleOutlined class="icon bad" />
<p class="bad-text">{{ errorMessage || '生成二维码失败' }}</p>
<a-button type="primary" @click="refresh">重新生成</a-button>
</div>
</div>
<div v-if="status === 'active'" class="actions">
<a-button :loading="refreshing" type="link" @click="refresh">
<ReloadOutlined />
刷新二维码
</a-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
ReloadOutlined
} from '@ant-design/icons-vue'
import {
checkQrCodeStatus,
generateQrCode,
type QrCodeResponse,
type QrCodeStatusResponse
} from '@/api/passport/qrLogin'
import { generateQrCodeDataUrl } from '@/composables/useQRCode'
const emit = defineEmits<{
(e: 'loginSuccess', data: QrCodeStatusResponse): void
(e: 'loginError', error: string): void
}>()
const router = useRouter()
const qrCodeDataUrl = ref('') // 前端生成的base64二维码图片
const miniprogramQrCode = ref('') // 后端返回的小程序码Base64图片优先使用
const token = ref('')
const status = ref<'loading' | 'active' | 'scanned' | 'expired' | 'error'>('loading')
const expireSeconds = ref(0)
const errorMessage = ref('')
const refreshing = ref(false)
let statusTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
function stopTimers() {
if (statusTimer) clearInterval(statusTimer)
if (countdownTimer) clearInterval(countdownTimer)
statusTimer = null
countdownTimer = null
}
// 启动轮询(小程序码和普通二维码共用)
function startStatusPolling() {
// 状态轮询
statusTimer = setInterval(async () => {
try {
const current = await checkQrCodeStatus(token.value)
if (current.expiresIn !== undefined) expireSeconds.value = current.expiresIn
if (current.status === 'scanned') {
status.value = 'scanned'
return
}
if (current.status === 'bind_phone') {
stopTimers()
message.info(current.message || '检测到新用户,请先绑定手机号')
await router.replace(`/bind-phone?token=${token.value}`)
return
}
if (current.status === 'expired') {
status.value = 'expired'
stopTimers()
return
}
if (current.status === 'confirmed') {
stopTimers()
emit('loginSuccess', current)
}
} catch {
// ignore polling errors, keep polling
}
}, 2000)
// 倒计时
countdownTimer = setInterval(() => {
expireSeconds.value = Math.max(0, expireSeconds.value - 1)
if (expireSeconds.value <= 0) {
status.value = 'expired'
stopTimers()
}
}, 1000)
}
function formatCountdown(seconds: number) {
const m = Math.floor(seconds / 60)
const s = Math.max(0, seconds % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
if (typeof error === 'object' && error && 'message' in error) {
const msg = (error as { message?: unknown }).message
if (typeof msg === 'string') return msg
}
try {
return String(error)
} catch {
return ''
}
}
async function init() {
if (!import.meta.client) return
stopTimers()
status.value = 'loading'
errorMessage.value = ''
qrCodeDataUrl.value = ''
try {
const response: QrCodeResponse = await generateQrCode()
token.value = response.token
expireSeconds.value = response.expiresIn || 300
// 优先使用后端返回的小程序码Base64图片扫码后直接打开小程序
if (response.miniprogramQrCode) {
miniprogramQrCode.value = response.miniprogramQrCode
console.info('使用小程序码Base64长度: {}', miniprogramQrCode.value.length)
status.value = 'active'
// 小程序码模式下不需要前端生成二维码,启动轮询即可
startStatusPolling()
return
}
// 小程序码不存在,降级到普通二维码
miniprogramQrCode.value = ''
console.warn('后端未返回小程序码,降级到普通二维码')
// 获取二维码内容
// 优先级wechatScanUrlH5页面微信能识别> qrCodeContent如果是http开头> 降级URL
// 不使用 qrCodeContent因为它是 websopy:// 自定义协议,微信无法识别
let qrContent = '';
if (response.wechatScanUrl) {
// 优先使用 wechatScanUrlH5 扫码页面,微信能打开)
qrContent = response.wechatScanUrl
console.info('使用微信扫码URL:', qrContent)
} else if (response.qrCodeContent && response.qrCodeContent.startsWith('http')) {
// 如果 qrCodeContent 是 http 开头,也可以使用
qrContent = response.qrCodeContent
console.info('使用 qrCodeContent:', qrContent)
} else {
// 降级使用当前域名构建扫码URL
const currentOrigin = window.location.origin
qrContent = `${currentOrigin}/wx-scan?token=${response.token}`
console.info('使用降级扫码URL:', qrContent)
}
// 前端直接生成base64二维码不再依赖后端返回的图片URL
try {
qrCodeDataUrl.value = await generateQrCodeDataUrl(qrContent, {
width: 280,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
})
} catch (e) {
console.error('生成二维码失败:', e)
throw new Error('生成二维码失败,请刷新重试')
}
status.value = 'active'
// 启动轮询
startStatusPolling()
} catch (error: unknown) {
status.value = 'error'
errorMessage.value = getErrorMessage(error) || '生成二维码失败'
message.error(errorMessage.value)
emit('loginError', errorMessage.value)
}
}
async function refresh() {
refreshing.value = true
try {
await init()
} finally {
refreshing.value = false
}
}
onMounted(() => {
init()
})
onUnmounted(() => {
stopTimers()
})
</script>
<style scoped>
.qr-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0 12px;
}
.qr-box {
width: 100%;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
/* 二维码样式 */
.qrcode-img {
width: 200px;
height: 200px;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.qrcode-img:hover {
transform: scale(1.02);
}
.qrcode-placeholder {
width: 200px;
height: 200px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.tip {
color: #111827;
margin: 0;
font-size: 14px;
}
.muted {
color: #6b7280;
margin: 0;
font-size: 12px;
}
.actions {
margin-top: 10px;
}
.icon {
font-size: 48px;
}
.ok {
color: #16a34a;
}
.bad {
color: #ef4444;
}
.ok-text {
margin: 0;
color: #16a34a;
}
.bad-text {
margin: 0;
color: #ef4444;
}
</style>