feat(auth): 添加二维码登录功能- 实现了二维码登录的前端组件和API接口
- 添加了二维码登录的后端逻辑和数据库设计 - 编写了详细的使用说明和接口文档 - 提供了演示页面和测试工具
This commit is contained in:
258
src/components/QrLogin/demo.vue
Normal file
258
src/components/QrLogin/demo.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="qr-login-demo">
|
||||
<a-card title="二维码登录演示" :bordered="false">
|
||||
<div class="demo-content">
|
||||
<!-- 二维码登录组件 -->
|
||||
<QrLogin
|
||||
@loginSuccess="handleLoginSuccess"
|
||||
@loginError="handleLoginError"
|
||||
/>
|
||||
|
||||
<!-- 演示控制面板 -->
|
||||
<div class="demo-controls">
|
||||
<h4>演示控制</h4>
|
||||
<a-space direction="vertical" style="width: 100%;">
|
||||
<a-button @click="simulateScanned" type="primary" ghost>
|
||||
模拟扫码
|
||||
</a-button>
|
||||
<a-button @click="simulateConfirmed" type="primary">
|
||||
模拟确认登录
|
||||
</a-button>
|
||||
<a-button @click="simulateExpired" type="default">
|
||||
模拟过期
|
||||
</a-button>
|
||||
<a-button @click="simulateError" danger>
|
||||
模拟错误
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 状态显示 -->
|
||||
<div class="demo-status">
|
||||
<h4>当前状态</h4>
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="二维码Key">
|
||||
{{ currentQrKey || '未生成' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(currentStatus)">
|
||||
{{ getStatusText(currentStatus) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="剩余时间">
|
||||
{{ remainingTime }}秒
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 日志显示 -->
|
||||
<a-card title="操作日志" :bordered="false" style="margin-top: 16px;">
|
||||
<div class="demo-logs">
|
||||
<div
|
||||
v-for="(log, index) in logs"
|
||||
:key="index"
|
||||
class="log-item"
|
||||
:class="log.type"
|
||||
>
|
||||
<span class="log-time">{{ log.time }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import QrLogin from './index.vue';
|
||||
|
||||
// 响应式数据
|
||||
const currentQrKey = ref<string>('');
|
||||
const currentStatus = ref<string>('loading');
|
||||
const remainingTime = ref<number>(300);
|
||||
const logs = ref<Array<{time: string, message: string, type: string}>>([]);
|
||||
|
||||
// 添加日志
|
||||
const addLog = (message: string, type: 'info' | 'success' | 'error' | 'warning' = 'info') => {
|
||||
const now = new Date();
|
||||
const time = now.toLocaleTimeString();
|
||||
logs.value.unshift({ time, message, type });
|
||||
|
||||
// 限制日志数量
|
||||
if (logs.value.length > 50) {
|
||||
logs.value = logs.value.slice(0, 50);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
loading: 'blue',
|
||||
active: 'green',
|
||||
scanned: 'orange',
|
||||
expired: 'red',
|
||||
error: 'red'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const texts = {
|
||||
loading: '正在生成',
|
||||
active: '等待扫码',
|
||||
scanned: '已扫码',
|
||||
expired: '已过期',
|
||||
error: '生成失败'
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
// 处理登录成功
|
||||
const handleLoginSuccess = (token: string) => {
|
||||
addLog(`登录成功,获得token: ${token.substring(0, 20)}...`, 'success');
|
||||
message.success('二维码登录成功!');
|
||||
};
|
||||
|
||||
// 处理登录错误
|
||||
const handleLoginError = (error: string) => {
|
||||
addLog(`登录失败: ${error}`, 'error');
|
||||
message.error(`登录失败: ${error}`);
|
||||
};
|
||||
|
||||
// 模拟扫码
|
||||
const simulateScanned = () => {
|
||||
currentStatus.value = 'scanned';
|
||||
addLog('模拟用户扫码', 'info');
|
||||
message.info('模拟扫码成功');
|
||||
};
|
||||
|
||||
// 模拟确认登录
|
||||
const simulateConfirmed = () => {
|
||||
const mockToken = 'mock_token_' + Date.now();
|
||||
handleLoginSuccess(mockToken);
|
||||
};
|
||||
|
||||
// 模拟过期
|
||||
const simulateExpired = () => {
|
||||
currentStatus.value = 'expired';
|
||||
remainingTime.value = 0;
|
||||
addLog('模拟二维码过期', 'warning');
|
||||
message.warning('二维码已过期');
|
||||
};
|
||||
|
||||
// 模拟错误
|
||||
const simulateError = () => {
|
||||
currentStatus.value = 'error';
|
||||
addLog('模拟生成二维码失败', 'error');
|
||||
handleLoginError('网络连接失败');
|
||||
};
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
addLog('二维码登录演示组件已加载', 'info');
|
||||
|
||||
// 模拟生成二维码
|
||||
setTimeout(() => {
|
||||
currentQrKey.value = 'demo_qr_' + Date.now();
|
||||
currentStatus.value = 'active';
|
||||
addLog(`生成二维码成功,Key: ${currentQrKey.value}`, 'success');
|
||||
|
||||
// 开始倒计时
|
||||
const timer = setInterval(() => {
|
||||
if (remainingTime.value > 0) {
|
||||
remainingTime.value--;
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
if (currentStatus.value === 'active') {
|
||||
simulateExpired();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.qr-login-demo {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px 200px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-status {
|
||||
h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-logs {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #999;
|
||||
margin-right: 8px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.info .log-message {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&.success .log-message {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.error .log-message {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&.warning .log-message {
|
||||
color: #faad14;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
src/components/QrLogin/index.vue
Normal file
310
src/components/QrLogin/index.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<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">
|
||||
<ele-qr-code-svg :value="qrCodeData" :size="200" />
|
||||
<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 } from '@/api/passport/qrLogin';
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'loginSuccess', token: string): 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':
|
||||
// 登录成功
|
||||
qrCodeStatus.value = 'active';
|
||||
stopAllTimers();
|
||||
emit('loginSuccess', status.accessToken || '');
|
||||
message.success('登录成功');
|
||||
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>
|
||||
Reference in New Issue
Block a user