feat(core): 初始化项目基础架构和CMS功能模块
- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore) - 实现服务端API代理功能,支持文件、模块和服务器API转发 - 创建文章详情页、栏目文章列表页和单页内容展示页面 - 集成Ant Design Vue组件库并实现SSR样式提取功能 - 定义API响应数据结构类型和应用布局组件 - 开发开发者应用中心和文章管理页面 - 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
220
app/components/QrLogin.vue
Normal file
220
app/components/QrLogin.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user