- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore) - 实现服务端API代理功能,支持文件、模块和服务器API转发 - 创建文章详情页、栏目文章列表页和单页内容展示页面 - 集成Ant Design Vue组件库并实现SSR样式提取功能 - 定义API响应数据结构类型和应用布局组件 - 开发开发者应用中心和文章管理页面 - 实现CMS导航菜单获取和多租户切换功能
221 lines
5.3 KiB
Vue
221 lines
5.3 KiB
Vue
<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">
|
||
<a-qrcode :value="qrCodeUrl" :size="200" @click="refresh" />
|
||
<p class="tip">请使用手机 APP 或小程序扫码登录</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'
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'loginSuccess', data: QrCodeStatusResponse): void
|
||
(e: 'loginError', error: string): void
|
||
}>()
|
||
|
||
const qrCodeUrl = ref('')
|
||
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 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 = ''
|
||
try {
|
||
const response: QrCodeResponse = await generateQrCode()
|
||
token.value = response.token
|
||
expireSeconds.value = response.expiresIn || 300
|
||
qrCodeUrl.value = `${window.location.origin}/qr-confirm?qrCodeKey=${encodeURIComponent(
|
||
response.token
|
||
)}`
|
||
status.value = 'active'
|
||
|
||
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 === '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)
|
||
} 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;
|
||
}
|
||
.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>
|