- 添加 .editorconfig 文件统一代码风格 - 添加 .env.development 和 .env.example 环境配置文件 - 添加 .eslintignore 和 .eslintrc.js 代码检查配置 - 添加 .gitignore 版本控制忽略文件配置 - 添加 .prettierignore 格式化忽略配置 - 添加隐私协议HTML文件 - 添加API密钥管理组件基础结构
326 lines
7.7 KiB
Vue
326 lines
7.7 KiB
Vue
<template>
|
||
<div class="qr-login-container">
|
||
<div class="qr-code-wrapper">
|
||
<!-- 二维码显示区域 -->
|
||
<div v-if="qrCodeStatus === 'loading'" class="qr-loading">
|
||
<a-spin size="large" />
|
||
<p class="loading-text">正在生成二维码...</p>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="qrCodeStatus === 'active'"
|
||
class="qr-active cursor-pointer"
|
||
>
|
||
<ele-qr-code-svg
|
||
:value="qrCodeData"
|
||
:size="200"
|
||
@click="refreshQrCode"
|
||
/>
|
||
<p class="qr-tip">请使用手机APP或小程序扫码登录</p>
|
||
<p class="qr-expire-tip">二维码有效期:{{ formatTime(expireTime) }}</p>
|
||
</div>
|
||
|
||
<div v-else-if="qrCodeStatus === 'scanned'" class="qr-scanned">
|
||
<div class="scanned-icon">
|
||
<CheckCircleOutlined style="font-size: 48px; color: #52c41a" />
|
||
</div>
|
||
<p class="scanned-text">扫码成功,请在手机上确认登录</p>
|
||
</div>
|
||
|
||
<div v-else-if="qrCodeStatus === 'expired'" class="qr-expired">
|
||
<div class="expired-icon">
|
||
<ClockCircleOutlined style="font-size: 48px; color: #ff4d4f" />
|
||
</div>
|
||
<p class="expired-text">二维码已过期</p>
|
||
<a-button type="primary" @click="refreshQrCode">刷新二维码</a-button>
|
||
</div>
|
||
|
||
<div v-else-if="qrCodeStatus === 'error'" class="qr-error">
|
||
<div class="error-icon">
|
||
<ExclamationCircleOutlined style="font-size: 48px; color: #ff4d4f" />
|
||
</div>
|
||
<p class="error-text">{{ errorMessage || '生成二维码失败' }}</p>
|
||
<a-button type="primary" @click="refreshQrCode">重新生成</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 刷新按钮 -->
|
||
<div class="qr-actions" v-if="qrCodeStatus === 'active'">
|
||
<a-button type="link" @click="refreshQrCode" :loading="refreshing">
|
||
<ReloadOutlined /> 刷新二维码
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||
import { message } from 'ant-design-vue';
|
||
import {
|
||
CheckCircleOutlined,
|
||
ClockCircleOutlined,
|
||
ExclamationCircleOutlined,
|
||
ReloadOutlined
|
||
} from '@ant-design/icons-vue';
|
||
import {
|
||
generateQrCode,
|
||
checkQrCodeStatus,
|
||
type QrCodeResponse,
|
||
QrCodeStatusResponse
|
||
} from '@/api/passport/qrLogin';
|
||
|
||
// 定义组件事件
|
||
const emit = defineEmits<{
|
||
(e: 'loginSuccess', data: QrCodeStatusResponse): void;
|
||
(e: 'loginError', error: string): void;
|
||
}>();
|
||
|
||
// 响应式数据
|
||
const qrCodeData = ref<string>('');
|
||
const qrCodeToken = ref<string>('');
|
||
const qrCodeStatus = ref<
|
||
'loading' | 'active' | 'scanned' | 'expired' | 'error'
|
||
>('loading');
|
||
const expireTime = ref<number>(0);
|
||
const errorMessage = ref<string>('');
|
||
const refreshing = ref<boolean>(false);
|
||
|
||
// 定时器
|
||
let statusCheckTimer: number | null = null;
|
||
let expireTimer: number | null = null;
|
||
|
||
// 计算属性:格式化剩余时间
|
||
const formatTime = computed(() => {
|
||
return (seconds: number) => {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = seconds % 60;
|
||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||
};
|
||
});
|
||
|
||
/**
|
||
* 生成二维码
|
||
*/
|
||
const generateQrCodeData = async () => {
|
||
try {
|
||
qrCodeStatus.value = 'loading';
|
||
const response: QrCodeResponse = await generateQrCode();
|
||
|
||
// 后端返回的qrCode是二维码内容,我们需要构造完整的URL
|
||
const baseUrl = window.location.origin;
|
||
const qrCodeUrl = `${baseUrl}/qr-confirm?qrCodeKey=${response.token}`;
|
||
|
||
qrCodeData.value = qrCodeUrl;
|
||
qrCodeToken.value = response.token;
|
||
qrCodeStatus.value = 'active';
|
||
expireTime.value = response.expiresIn || 300; // 默认5分钟过期
|
||
|
||
// 开始检查二维码状态
|
||
startStatusCheck();
|
||
// 开始倒计时
|
||
startExpireCountdown();
|
||
} catch (error: any) {
|
||
qrCodeStatus.value = 'error';
|
||
errorMessage.value = error.message || '生成二维码失败';
|
||
message.error(errorMessage.value);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 开始检查二维码状态
|
||
*/
|
||
const startStatusCheck = () => {
|
||
if (statusCheckTimer) {
|
||
clearInterval(statusCheckTimer);
|
||
}
|
||
|
||
statusCheckTimer = window.setInterval(async () => {
|
||
try {
|
||
const status = await checkQrCodeStatus(qrCodeToken.value);
|
||
|
||
switch (status.status) {
|
||
case 'scanned':
|
||
qrCodeStatus.value = 'scanned';
|
||
break;
|
||
case 'confirmed':
|
||
// 登录成功
|
||
if (status.tenantId) {
|
||
localStorage.setItem('TenantId', `${status.tenantId}`);
|
||
}
|
||
qrCodeStatus.value = 'active';
|
||
stopAllTimers();
|
||
emit('loginSuccess', status);
|
||
break;
|
||
case 'expired':
|
||
qrCodeStatus.value = 'expired';
|
||
stopAllTimers();
|
||
break;
|
||
case 'pending':
|
||
// 继续等待
|
||
break;
|
||
}
|
||
|
||
// 更新剩余时间
|
||
if (status.expiresIn !== undefined) {
|
||
expireTime.value = status.expiresIn;
|
||
}
|
||
} catch (error: any) {
|
||
console.error('检查二维码状态失败:', error);
|
||
// 继续检查,不中断流程
|
||
}
|
||
}, 2000); // 每2秒检查一次
|
||
};
|
||
|
||
/**
|
||
* 开始过期倒计时
|
||
*/
|
||
const startExpireCountdown = () => {
|
||
if (expireTimer) {
|
||
clearInterval(expireTimer);
|
||
}
|
||
|
||
expireTimer = window.setInterval(() => {
|
||
if (expireTime.value <= 0) {
|
||
qrCodeStatus.value = 'expired';
|
||
stopAllTimers();
|
||
return;
|
||
}
|
||
expireTime.value--;
|
||
}, 1000);
|
||
};
|
||
|
||
/**
|
||
* 停止所有定时器
|
||
*/
|
||
const stopAllTimers = () => {
|
||
if (statusCheckTimer) {
|
||
clearInterval(statusCheckTimer);
|
||
statusCheckTimer = null;
|
||
}
|
||
if (expireTimer) {
|
||
clearInterval(expireTimer);
|
||
expireTimer = null;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 刷新二维码
|
||
*/
|
||
const refreshQrCode = async () => {
|
||
refreshing.value = true;
|
||
stopAllTimers();
|
||
|
||
try {
|
||
await generateQrCodeData();
|
||
} finally {
|
||
refreshing.value = false;
|
||
}
|
||
};
|
||
|
||
// 组件挂载时生成二维码
|
||
onMounted(() => {
|
||
generateQrCodeData();
|
||
});
|
||
|
||
// 组件卸载时清理定时器
|
||
onUnmounted(() => {
|
||
stopAllTimers();
|
||
});
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.qr-login-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 20px;
|
||
min-height: 300px;
|
||
}
|
||
|
||
.qr-code-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 250px;
|
||
width: 100%;
|
||
}
|
||
|
||
.qr-loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
|
||
.loading-text {
|
||
color: #666;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.qr-active {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
|
||
.qr-tip {
|
||
color: #333;
|
||
font-size: 14px;
|
||
margin: 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.qr-expire-tip {
|
||
color: #999;
|
||
font-size: 12px;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.qr-scanned {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
|
||
.scanned-text {
|
||
color: #52c41a;
|
||
font-size: 14px;
|
||
margin: 0;
|
||
text-align: center;
|
||
}
|
||
}
|
||
|
||
.qr-expired {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
|
||
.expired-text {
|
||
color: #ff4d4f;
|
||
font-size: 14px;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.qr-error {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
|
||
.error-text {
|
||
color: #ff4d4f;
|
||
font-size: 14px;
|
||
margin: 0;
|
||
text-align: center;
|
||
}
|
||
}
|
||
|
||
.qr-actions {
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|