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

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