初始版本

This commit is contained in:
2026-04-23 16:30:57 +08:00
commit 0d0683a6e6
538 changed files with 113042 additions and 0 deletions

327
app/components/QrLogin.vue Normal file
View File

@@ -0,0 +1,327 @@
<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"
class="qrcode-img"
alt="小程序码登录"
@click="refresh"
/>
<!-- 普通二维码降级方案 -->
<img
v-else-if="qrCodeDataUrl"
:src="qrCodeDataUrl"
class="qrcode-img"
alt="扫码登录"
@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 type="link" :loading="refreshing" @click="refresh">
<ReloadOutlined />
刷新二维码
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
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>