- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore) - 实现服务端API代理功能,支持文件、模块和服务器API转发 - 创建文章详情页、栏目文章列表页和单页内容展示页面 - 集成Ant Design Vue组件库并实现SSR样式提取功能 - 定义API响应数据结构类型和应用布局组件 - 开发开发者应用中心和文章管理页面 - 实现CMS导航菜单获取和多租户切换功能
253 lines
5.8 KiB
Vue
253 lines
5.8 KiB
Vue
<template>
|
||
<div class="page">
|
||
<a-card :bordered="false" class="card">
|
||
<div class="header">
|
||
<div class="app">
|
||
<img :src="appLogo" class="logo" alt="logo" />
|
||
<h3 class="name">{{ appName }}</h3>
|
||
</div>
|
||
<p class="tip">确认登录到 Web 端?</p>
|
||
</div>
|
||
|
||
<div v-if="userInfo" class="user">
|
||
<a-avatar :size="64" :src="userInfo.avatar">
|
||
<template v-if="!userInfo.avatar" #icon><UserOutlined /></template>
|
||
</a-avatar>
|
||
<div class="user-text">
|
||
<h4 class="username">{{ userInfo.nickname || userInfo.username }}</h4>
|
||
<p class="phone">{{ userInfo.phone || userInfo.mobile }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="device">
|
||
<div class="row"><span class="label">登录设备:</span><span class="value">{{ deviceInfo.browser }} {{ deviceInfo.version }}</span></div>
|
||
<div class="row"><span class="label">操作系统:</span><span class="value">{{ deviceInfo.os }}</span></div>
|
||
<div class="row"><span class="label">IP 地址:</span><span class="value">{{ deviceInfo.ip }}</span></div>
|
||
<div class="row"><span class="label">登录时间:</span><span class="value">{{ formatTime(new Date()) }}</span></div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<a-button size="large" class="cancel" :loading="cancelLoading" @click="handleCancel">取消登录</a-button>
|
||
<a-button type="primary" size="large" class="confirm" :loading="confirmLoading" @click="handleConfirm">确认登录</a-button>
|
||
</div>
|
||
|
||
<div class="security">
|
||
<ExclamationCircleOutlined class="warn" />
|
||
<span>请确认是您本人操作,如非本人操作请点击“取消登录”</span>
|
||
</div>
|
||
</a-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { ExclamationCircleOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||
import { confirmQrLogin, scanQrCode, type QrLoginConfirmRequest } from '@/api/passport/qrLogin'
|
||
import { getUserInfo } from '@/api/layout'
|
||
import { getToken } from '@/utils/token-util'
|
||
import type { User } from '@/api/system/user/model'
|
||
|
||
definePageMeta({ layout: 'blank' })
|
||
|
||
const route = useRoute()
|
||
|
||
const qrCodeKey = computed(() => String(route.query.qrCodeKey || ''))
|
||
const userInfo = ref<User | null>(null)
|
||
const confirmLoading = ref(false)
|
||
const cancelLoading = ref(false)
|
||
|
||
const appName = ref('桂乐淘')
|
||
const appLogo = ref('/favicon.ico')
|
||
|
||
const deviceInfo = ref({
|
||
browser: 'Mobile',
|
||
version: '',
|
||
os: 'Unknown',
|
||
ip: '-'
|
||
})
|
||
|
||
function formatTime(date: Date) {
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
})
|
||
}
|
||
|
||
async function fetchUser() {
|
||
const token = getToken()
|
||
if (!token) {
|
||
message.error('请先登录')
|
||
await navigateTo('/login')
|
||
return false
|
||
}
|
||
try {
|
||
userInfo.value = await getUserInfo()
|
||
return true
|
||
} catch (error: unknown) {
|
||
console.error(error)
|
||
message.error('获取用户信息失败')
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function markScanned() {
|
||
if (!qrCodeKey.value) return
|
||
try {
|
||
await scanQrCode(qrCodeKey.value)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
if (!qrCodeKey.value) return message.error('二维码参数错误')
|
||
confirmLoading.value = true
|
||
try {
|
||
if (!userInfo.value?.userId) return message.error('用户信息获取失败')
|
||
const requestData: QrLoginConfirmRequest = {
|
||
token: qrCodeKey.value,
|
||
userId: Number(userInfo.value.userId),
|
||
platform: 'web'
|
||
}
|
||
await confirmQrLogin(requestData)
|
||
message.success('登录确认成功')
|
||
setTimeout(() => backOrHome(), 1200)
|
||
} catch (e: unknown) {
|
||
message.error(e instanceof Error ? e.message : '确认登录失败')
|
||
} finally {
|
||
confirmLoading.value = false
|
||
}
|
||
}
|
||
|
||
function backOrHome() {
|
||
if (import.meta.client && window.history.length > 1) {
|
||
window.history.back()
|
||
return
|
||
}
|
||
navigateTo('/')
|
||
}
|
||
|
||
function handleCancel() {
|
||
cancelLoading.value = true
|
||
try {
|
||
message.info('已取消登录')
|
||
setTimeout(() => backOrHome(), 800)
|
||
} finally {
|
||
cancelLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (!qrCodeKey.value) {
|
||
message.error('二维码参数错误')
|
||
await navigateTo('/login')
|
||
return
|
||
}
|
||
const ok = await fetchUser()
|
||
if (!ok) return
|
||
await markScanned()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px 16px;
|
||
background: #f5f5f5;
|
||
}
|
||
.card {
|
||
width: 480px;
|
||
max-width: 100%;
|
||
border-radius: 12px;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.header {
|
||
text-align: center;
|
||
padding: 8px 0 12px;
|
||
}
|
||
.app {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
}
|
||
.logo {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 10px;
|
||
}
|
||
.name {
|
||
margin: 0;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
}
|
||
.tip {
|
||
margin: 10px 0 0;
|
||
color: #6b7280;
|
||
}
|
||
.user {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 14px 4px;
|
||
}
|
||
.user-text {
|
||
flex: 1;
|
||
}
|
||
.username {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
.phone {
|
||
margin: 4px 0 0;
|
||
color: #6b7280;
|
||
}
|
||
.device {
|
||
padding: 12px 4px 4px;
|
||
border-top: 1px solid #f0f0f0;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 6px 0;
|
||
gap: 12px;
|
||
}
|
||
.label {
|
||
color: #6b7280;
|
||
}
|
||
.value {
|
||
color: #111827;
|
||
font-weight: 500;
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 16px 4px 6px;
|
||
}
|
||
.cancel,
|
||
.confirm {
|
||
flex: 1;
|
||
}
|
||
.security {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 4px 2px;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
.warn {
|
||
color: #faad14;
|
||
}
|
||
</style>
|