feat(auth): 添加二维码登录功能- 实现了二维码登录的前端组件和API接口

- 添加了二维码登录的后端逻辑和数据库设计
- 编写了详细的使用说明和接口文档
- 提供了演示页面和测试工具
This commit is contained in:
2025-09-08 14:57:57 +08:00
parent 7b6fac7c41
commit a67162ee06
12 changed files with 2350 additions and 2 deletions

View File

@@ -0,0 +1,109 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { SERVER_API_URL } from '@/config/setting';
/**
* 二维码生成响应数据
*/
export interface QrCodeResponse {
token: string; // 二维码唯一标识token
qrCode: string; // 二维码内容
expiresIn: number; // 过期时间(秒)
}
/**
* 二维码状态响应
*/
export interface QrCodeStatusResponse {
status: 'pending' | 'scanned' | 'confirmed' | 'expired';
accessToken?: string; // 登录成功时返回的JWT token
userInfo?: any; // 用户信息
expiresIn?: number; // 剩余过期时间(秒)
}
/**
* 确认登录请求参数
*/
export interface QrLoginConfirmRequest {
token: string; // 二维码token
userId?: number; // 用户ID
platform?: string; // 登录平台
}
/**
* 生成登录二维码
*/
export async function generateQrCode(): Promise<QrCodeResponse> {
const res = await request.post<ApiResult<QrCodeResponse>>(
SERVER_API_URL + '/qr-login/generate',
{}
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message || '生成二维码失败'));
}
/**
* 检查二维码状态
*/
export async function checkQrCodeStatus(token: string): Promise<QrCodeStatusResponse> {
const res = await request.get<ApiResult<QrCodeStatusResponse>>(
SERVER_API_URL + `/qr-login/status/${token}`
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message || '检查二维码状态失败'));
}
/**
* 扫码确认登录(移动端调用)
*/
export async function confirmQrLogin(requestData: QrLoginConfirmRequest): Promise<QrCodeStatusResponse> {
const res = await request.post<ApiResult<QrCodeStatusResponse>>(
SERVER_API_URL + '/qr-login/confirm',
requestData
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message || '确认登录失败'));
}
/**
* 扫码标记(移动端扫码时调用)
*/
export async function scanQrCode(token: string): Promise<boolean> {
const res = await request.post<ApiResult<boolean>>(
SERVER_API_URL + `/qr-login/scan/${token}`
);
if (res.data.code === 0) {
return res.data.data || true;
}
return Promise.reject(new Error(res.data.message || '扫码失败'));
}
/**
* 微信小程序扫码登录确认
*/
export async function wechatMiniProgramConfirm(requestData: QrLoginConfirmRequest): Promise<QrCodeStatusResponse> {
const res = await request.post<ApiResult<QrCodeStatusResponse>>(
SERVER_API_URL + '/qr-login/wechat-confirm',
requestData
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message || '微信小程序登录确认失败'));
}

View 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>

View 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>

View File

@@ -35,6 +35,21 @@ export const routes = [
component: () => import('@/views/passport/dealer/register.vue'),
meta: { title: '邀请注册' }
},
{
path: '/qr-confirm',
component: () => import('@/views/passport/qrConfirm/index.vue'),
meta: { title: '扫码登录确认' }
},
{
path: '/qr-demo',
component: () => import('@/components/QrLogin/demo.vue'),
meta: { title: '二维码登录演示' }
},
{
path: '/qr-test',
component: () => import('@/views/test/qrLoginTest.vue'),
meta: { title: '二维码登录接口测试' }
},
// {
// path: '/forget',
// component: () => import('@/views/passport/forget/index.vue'),

View File

@@ -182,7 +182,10 @@
</a-form-item>
</template>
<template v-if="loginType == 'scan'">
二维码
<QrLogin
@loginSuccess="onQrLoginSuccess"
@loginError="onQrLoginError"
/>
</template>
</a-form>
<div class="login-copyright">
@@ -267,6 +270,7 @@ import {
} from '@ant-design/icons-vue';
import {goHomeRoute, cleanPageTabs} from '@/utils/page-tab-util';
import {login, loginBySms, getCaptcha} from '@/api/passport/login';
import QrLogin from '@/components/QrLogin/index.vue';
import {User} from '@/api/system/user/model';
import {TEMPLATE_ID, THEME_STORE_NAME} from '@/config/setting';
@@ -521,6 +525,20 @@ const onScan = () => {
}
}
/* 二维码登录成功处理 */
const onQrLoginSuccess = (token: string) => {
// 设置token到localStorage或其他存储
localStorage.setItem('access_token', token);
message.success('扫码登录成功');
cleanPageTabs();
goHome();
};
/* 二维码登录错误处理 */
const onQrLoginError = (error: string) => {
message.error(error || '扫码登录失败');
};
// const goBack = () => {
// openUrl(getDomain());
// return;

View File

@@ -0,0 +1,371 @@
<template>
<div class="qr-confirm-container">
<a-card :bordered="false" class="confirm-card">
<!-- 头部信息 -->
<div class="header-info">
<div class="app-info">
<img :src="appLogo" alt="应用图标" class="app-logo" />
<h3 class="app-name">{{ appName }}</h3>
</div>
<p class="confirm-tip">确认登录到Web端管理后台</p>
</div>
<!-- 用户信息 -->
<div class="user-info" v-if="userInfo">
<a-avatar :size="64" :src="userInfo.avatar">
<template v-if="!userInfo.avatar" #icon>
<UserOutlined />
</template>
</a-avatar>
<div class="user-details">
<h4 class="username">{{ userInfo.nickname || userInfo.username }}</h4>
<p class="user-phone">{{ userInfo.phone || userInfo.mobile }}</p>
</div>
</div>
<!-- 设备信息 -->
<div class="device-info">
<div class="info-item">
<span class="label">登录设备</span>
<span class="value">{{ deviceInfo.browser }} {{ deviceInfo.version }}</span>
</div>
<div class="info-item">
<span class="label">操作系统</span>
<span class="value">{{ deviceInfo.os }}</span>
</div>
<div class="info-item">
<span class="label">IP地址</span>
<span class="value">{{ deviceInfo.ip }}</span>
</div>
<div class="info-item">
<span class="label">登录时间</span>
<span class="value">{{ formatTime(new Date()) }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<a-button
size="large"
@click="handleCancel"
:loading="cancelLoading"
class="cancel-btn"
>
取消登录
</a-button>
<a-button
type="primary"
size="large"
@click="handleConfirm"
:loading="confirmLoading"
class="confirm-btn"
>
确认登录
</a-button>
</div>
<!-- 安全提示 -->
<div class="security-tip">
<ExclamationCircleOutlined style="color: #faad14; margin-right: 8px;" />
<span>请确认是您本人操作如非本人操作请点击"取消登录"</span>
</div>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { UserOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue';
import { confirmQrLogin, scanQrCode, type QrLoginConfirmRequest } from '@/api/passport/qrLogin';
import { getUserInfo } from '@/api/system/user';
import { getToken } from '@/utils/token-util';
const route = useRoute();
const router = useRouter();
// 响应式数据
const qrCodeKey = ref<string>('');
const userInfo = ref<any>(null);
const confirmLoading = ref<boolean>(false);
const cancelLoading = ref<boolean>(false);
// 应用信息
const appName = ref<string>('唐九运售电云');
const appLogo = ref<string>('/logo.png');
// 设备信息(这里可以从后端获取或前端检测)
const deviceInfo = ref({
browser: 'Chrome',
version: '120.0',
os: 'Windows 10',
ip: '192.168.1.100'
});
/**
* 格式化时间
*/
const formatTime = (date: Date): string => {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
/**
* 获取用户信息
*/
const fetchUserInfo = async () => {
try {
const token = getToken();
if (!token) {
message.error('请先登录');
router.push('/login');
return;
}
const info = await getUserInfo();
userInfo.value = info;
} catch (error: any) {
console.error('获取用户信息失败:', error);
message.error('获取用户信息失败');
}
};
/**
* 标记二维码已扫描
*/
const markScanned = async () => {
try {
if (!qrCodeKey.value) return;
await scanQrCode(qrCodeKey.value);
} catch (error: any) {
console.error('标记扫描失败:', error);
}
};
/**
* 确认登录
*/
const handleConfirm = async () => {
if (!qrCodeKey.value) {
message.error('二维码参数错误');
return;
}
confirmLoading.value = true;
try {
if (!userInfo.value || !userInfo.value.userId) {
message.error('用户信息获取失败');
return;
}
const requestData: QrLoginConfirmRequest = {
token: qrCodeKey.value,
userId: userInfo.value.userId,
platform: 'web'
};
await confirmQrLogin(requestData);
message.success('登录确认成功');
// 可以跳转到成功页面或关闭页面
setTimeout(() => {
// 如果是在APP内的webview中可以调用原生方法关闭页面
if (window.history.length > 1) {
router.go(-1);
} else {
router.push('/');
}
}, 1500);
} catch (error: any) {
message.error(error.message || '确认登录失败');
} finally {
confirmLoading.value = false;
}
};
/**
* 取消登录
*/
const handleCancel = async () => {
cancelLoading.value = true;
try {
// 直接返回,不调用后端接口
message.info('已取消登录');
setTimeout(() => {
if (window.history.length > 1) {
router.go(-1);
} else {
router.push('/');
}
}, 1000);
} catch (error: any) {
message.error(error.message || '取消登录失败');
} finally {
cancelLoading.value = false;
}
};
// 组件挂载时初始化
onMounted(async () => {
// 从URL参数获取二维码key
qrCodeKey.value = route.query.qrCodeKey as string;
if (!qrCodeKey.value) {
message.error('二维码参数错误');
router.push('/');
return;
}
// 获取用户信息
await fetchUserInfo();
// 标记二维码已扫描
await markScanned();
});
</script>
<style lang="less" scoped>
.qr-confirm-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.confirm-card {
width: 100%;
max-width: 400px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.header-info {
text-align: center;
margin-bottom: 24px;
.app-info {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 16px;
.app-logo {
width: 48px;
height: 48px;
border-radius: 8px;
margin-bottom: 8px;
}
.app-name {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 600;
}
}
.confirm-tip {
color: #666;
font-size: 14px;
margin: 0;
}
}
.user-info {
display: flex;
align-items: center;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 24px;
.user-details {
margin-left: 16px;
flex: 1;
.username {
margin: 0 0 4px 0;
color: #333;
font-size: 16px;
font-weight: 500;
}
.user-phone {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.device-info {
margin-bottom: 24px;
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.label {
color: #666;
font-size: 14px;
}
.value {
color: #333;
font-size: 14px;
font-weight: 500;
}
}
}
.action-buttons {
display: flex;
gap: 12px;
margin-bottom: 16px;
.cancel-btn,
.confirm-btn {
flex: 1;
height: 44px;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
}
.cancel-btn {
border-color: #d9d9d9;
color: #666;
}
}
.security-tip {
display: flex;
align-items: center;
padding: 12px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 6px;
font-size: 12px;
color: #666;
}
</style>

View File

@@ -0,0 +1,323 @@
<template>
<div class="qr-login-test">
<a-card title="二维码登录接口测试" :bordered="false">
<a-space direction="vertical" style="width: 100%;" size="large">
<!-- 生成二维码测试 -->
<a-card size="small" title="1. 生成二维码">
<a-button type="primary" @click="testGenerateQrCode" :loading="generateLoading">
生成二维码
</a-button>
<div v-if="qrCodeData" style="margin-top: 16px;">
<a-descriptions :column="1" size="small">
<a-descriptions-item label="Token">{{ qrCodeData.token }}</a-descriptions-item>
<a-descriptions-item label="二维码内容">{{ qrCodeData.qrCode }}</a-descriptions-item>
<a-descriptions-item label="过期时间">{{ qrCodeData.expiresIn }}</a-descriptions-item>
</a-descriptions>
<div style="margin-top: 16px;">
<ele-qr-code-svg :value="qrCodeUrl" :size="200" />
</div>
</div>
</a-card>
<!-- 检查状态测试 -->
<a-card size="small" title="2. 检查二维码状态">
<a-space>
<a-button @click="testCheckStatus" :loading="checkLoading" :disabled="!qrCodeData">
检查状态
</a-button>
<a-switch
v-model:checked="autoCheck"
checked-children="自动检查"
un-checked-children="手动检查"
@change="onAutoCheckChange"
/>
</a-space>
<div v-if="statusData" style="margin-top: 16px;">
<a-descriptions :column="1" size="small">
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(statusData.status)">
{{ getStatusText(statusData.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="剩余时间" v-if="statusData.expiresIn">
{{ statusData.expiresIn }}
</a-descriptions-item>
<a-descriptions-item label="访问令牌" v-if="statusData.accessToken">
{{ statusData.accessToken.substring(0, 50) }}...
</a-descriptions-item>
</a-descriptions>
</div>
</a-card>
<!-- 扫码标记测试 -->
<a-card size="small" title="3. 扫码标记">
<a-button @click="testScanQrCode" :loading="scanLoading" :disabled="!qrCodeData">
模拟扫码
</a-button>
</a-card>
<!-- 确认登录测试 -->
<a-card size="small" title="4. 确认登录">
<a-space>
<a-input-number
v-model:value="testUserId"
placeholder="用户ID"
:min="1"
style="width: 120px;"
/>
<a-button @click="testConfirmLogin" :loading="confirmLoading" :disabled="!qrCodeData">
确认登录
</a-button>
</a-space>
</a-card>
<!-- 操作日志 -->
<a-card size="small" title="操作日志">
<div class="log-container">
<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>
</a-space>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onUnmounted } from 'vue';
import { message } from 'ant-design-vue';
import {
generateQrCode,
checkQrCodeStatus,
scanQrCode,
confirmQrLogin,
type QrCodeResponse,
type QrCodeStatusResponse,
type QrLoginConfirmRequest
} from '@/api/passport/qrLogin';
// 响应式数据
const generateLoading = ref(false);
const checkLoading = ref(false);
const scanLoading = ref(false);
const confirmLoading = ref(false);
const autoCheck = ref(false);
const testUserId = ref<number>(1);
const qrCodeData = ref<QrCodeResponse | null>(null);
const statusData = ref<QrCodeStatusResponse | null>(null);
const logs = ref<Array<{time: string, message: string, type: string}>>([]);
// 自动检查定时器
let autoCheckTimer: number | null = null;
// 计算二维码URL
const qrCodeUrl = computed(() => {
if (!qrCodeData.value) return '';
const baseUrl = window.location.origin;
return `${baseUrl}/qr-confirm?qrCodeKey=${qrCodeData.value.token}`;
});
// 添加日志
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 > 100) {
logs.value = logs.value.slice(0, 100);
}
};
// 获取状态颜色
const getStatusColor = (status: string) => {
const colors = {
pending: 'blue',
scanned: 'orange',
confirmed: 'green',
expired: 'red'
};
return colors[status] || 'default';
};
// 获取状态文本
const getStatusText = (status: string) => {
const texts = {
pending: '等待扫码',
scanned: '已扫码',
confirmed: '已确认',
expired: '已过期'
};
return texts[status] || status;
};
// 测试生成二维码
const testGenerateQrCode = async () => {
generateLoading.value = true;
try {
const response = await generateQrCode();
qrCodeData.value = response;
statusData.value = null;
addLog(`生成二维码成功: ${response.token}`, 'success');
message.success('生成二维码成功');
} catch (error: any) {
addLog(`生成二维码失败: ${error.message}`, 'error');
message.error(error.message);
} finally {
generateLoading.value = false;
}
};
// 测试检查状态
const testCheckStatus = async () => {
if (!qrCodeData.value) return;
checkLoading.value = true;
try {
const response = await checkQrCodeStatus(qrCodeData.value.token);
statusData.value = response;
addLog(`检查状态成功: ${response.status}`, 'info');
} catch (error: any) {
addLog(`检查状态失败: ${error.message}`, 'error');
message.error(error.message);
} finally {
checkLoading.value = false;
}
};
// 测试扫码标记
const testScanQrCode = async () => {
if (!qrCodeData.value) return;
scanLoading.value = true;
try {
await scanQrCode(qrCodeData.value.token);
addLog('扫码标记成功', 'success');
message.success('扫码标记成功');
// 自动检查状态
setTimeout(() => {
testCheckStatus();
}, 1000);
} catch (error: any) {
addLog(`扫码标记失败: ${error.message}`, 'error');
message.error(error.message);
} finally {
scanLoading.value = false;
}
};
// 测试确认登录
const testConfirmLogin = async () => {
if (!qrCodeData.value || !testUserId.value) return;
confirmLoading.value = true;
try {
const requestData: QrLoginConfirmRequest = {
token: qrCodeData.value.token,
userId: testUserId.value,
platform: 'web'
};
const response = await confirmQrLogin(requestData);
statusData.value = response;
addLog(`确认登录成功: ${response.status}`, 'success');
message.success('确认登录成功');
} catch (error: any) {
addLog(`确认登录失败: ${error.message}`, 'error');
message.error(error.message);
} finally {
confirmLoading.value = false;
}
};
// 自动检查状态变化
const onAutoCheckChange = (checked: boolean) => {
if (checked) {
autoCheckTimer = window.setInterval(() => {
if (qrCodeData.value) {
testCheckStatus();
}
}, 3000);
addLog('开启自动检查状态', 'info');
} else {
if (autoCheckTimer) {
clearInterval(autoCheckTimer);
autoCheckTimer = null;
}
addLog('关闭自动检查状态', 'info');
}
};
// 组件卸载时清理定时器
onUnmounted(() => {
if (autoCheckTimer) {
clearInterval(autoCheckTimer);
}
});
// 初始化日志
addLog('二维码登录接口测试页面已加载', 'info');
</script>
<style lang="less" scoped>
.qr-login-test {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.log-container {
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;
}
}
</style>