feat: 新增 APP 扫码登录(PC 端二维码轮询),版本 4.8.0

This commit is contained in:
Ray.Hao
2026-07-22 18:30:46 +08:00
parent cbb150588f
commit d8c485ea0c
10 changed files with 867 additions and 297 deletions

View File

@@ -1,6 +1,28 @@
import request from "@/utils/request";
import type { CaptchaInfo, LoginRequest, LoginResult } from "./types";
/** 申请扫码票据的响应:票据字符串与有效期(秒) */
export interface QrCodeGenerateResult {
/** 一次扫码登录会话的唯一票据,由 PC 端编码进二维码 */
ticket: string;
/** 票据有效期(秒),默认 300过期后 Redis 自动清理 */
expireSeconds: number;
}
/** 轮询扫码状态的响应 */
export interface QrCodeStatusResult {
/** 当前票据 */
ticket: string;
/** 状态WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED */
status: "WAITING" | "SCANNED" | "CONFIRMED" | "LOGGED_IN" | "CANCELED" | "EXPIRED";
/** 脱敏昵称,仅 SCANNED 之后返回WAITING 为 undefined */
nickname?: string;
/** 头像 URL仅 SCANNED 之后返回 */
avatar?: string;
/** 票据剩余有效期(秒) */
expireSeconds: number;
}
const AUTH_BASE_URL = "/api/v1/auth";
const AuthAPI = {
@@ -59,6 +81,31 @@ const AuthAPI = {
method: "get",
});
},
// ============ 扫码登录 ============
qrGenerate(): Promise<QrCodeGenerateResult> {
return request<unknown, QrCodeGenerateResult>({
url: `${AUTH_BASE_URL}/qr-code/generate`,
method: "post",
});
},
qrStatus(ticket: string): Promise<QrCodeStatusResult> {
return request<unknown, QrCodeStatusResult>({
url: `${AUTH_BASE_URL}/qr-code/status`,
method: "get",
params: { ticket },
});
},
qrLogin(ticket: string): Promise<LoginResult> {
return request<unknown, LoginResult>({
url: `${AUTH_BASE_URL}/qr-code/login`,
method: "post",
data: { ticket },
});
},
};
export default AuthAPI;

View File

@@ -26,6 +26,14 @@ export const useUserStore = defineStore("user", () => {
AuthStorage.setTokens(accessToken, refreshToken, rememberMe.value);
}
/**
* 扫码登录:用票据换取会话令牌
*/
async function loginByQrCode(ticket: string): Promise<void> {
const { accessToken, refreshToken } = await AuthAPI.qrLogin(ticket);
AuthStorage.setTokens(accessToken, refreshToken, false);
}
let refreshPromise: Promise<void> | null = null;
/**
@@ -111,6 +119,7 @@ export const useUserStore = defineStore("user", () => {
rememberMe,
isLoggedIn: () => !!AuthStorage.getAccessToken(),
login,
loginByQrCode,
logout,
getUserInfo,
resetAllState,

View File

@@ -0,0 +1,236 @@
<template>
<div class="qr-login">
<h2 class="qr-login__title">扫码登录</h2>
<p class="qr-login__desc">使用 youlai-app 扫描下方二维码登录</p>
<div class="qr-login__board">
<div v-if="state === 'loading'" class="qr-login__mask">
<el-icon class="is-loading"><Loading /></el-icon>
<span>二维码生成中...</span>
</div>
<div v-else-if="state === 'scanned'" class="qr-login__mask qr-login__mask--info">
<el-avatar :size="56" :src="avatar || undefined">
<el-icon><User /></el-icon>
</el-avatar>
<span class="qr-login__mask-text">{{ nickname }} 已扫码</span>
<span class="qr-login__mask-hint">请在手机上确认登录</span>
</div>
<div v-else-if="state === 'expired'" class="qr-login__mask">
<el-icon :size="28"><CircleClose /></el-icon>
<span>二维码已过期</span>
<el-button type="primary" link @click="start">点击刷新</el-button>
</div>
<div v-else-if="state === 'canceled'" class="qr-login__mask">
<el-icon :size="28"><CircleClose /></el-icon>
<span>已取消登录</span>
<el-button type="primary" link @click="start">重新生成</el-button>
</div>
<canvas v-show="state === 'waiting'" ref="canvasRef" class="qr-login__canvas" />
</div>
<div class="qr-login__footer">
<el-button text @click="emit('switch', 'login')">
<el-icon><ArrowLeft /></el-icon>
返回账号登录
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowLeft, CircleClose, Loading, User } from "@element-plus/icons-vue";
import QRCode from "qrcode";
import AuthAPI, { type QrCodeStatusResult } from "@/api/auth";
import { useUserStore } from "@/stores";
import router from "@/router";
import { useRoute } from "vue-router";
import { onBeforeUnmount, onMounted, ref } from "vue";
// 界面状态机loading(生成中) → waiting(待扫码) → scanned(已扫码待确认)
// → done(已登录) / expired(过期或已使用) / canceled(APP 取消)
type State = "loading" | "waiting" | "scanned" | "expired" | "canceled" | "done";
const emit = defineEmits<{ (e: "switch", mode: "login"): void }>();
const userStore = useUserStore();
const route = useRoute();
// 二维码画布元素引用renderQrCode 把 ticket 绘进它
const canvasRef = ref<HTMLCanvasElement>();
// 当前界面状态,驱动模板中遮罩层的显隐
const state = ref<State>("loading");
// 扫码用户脱敏昵称与头像scanned 状态展示
const nickname = ref("");
const avatar = ref("");
// 当前票据,轮询与登录换令牌都依赖它
let ticket = "";
// 轮询定时器句柄,组件卸载或停止轮询时清空
let pollTimer: ReturnType<typeof setTimeout> | null = null;
// 轮询间隔(毫秒)
const QR_POLL_INTERVAL = 2000;
// 申请票据并渲染二维码,开始轮询
async function start() {
stopPolling();
state.value = "loading";
nickname.value = "";
avatar.value = "";
try {
const res = await AuthAPI.qrGenerate();
ticket = res.ticket;
await renderQrCode(ticket);
state.value = "waiting";
startPolling();
} catch {
state.value = "expired";
}
}
async function renderQrCode(payload: string) {
if (!canvasRef.value) return;
await QRCode.toCanvas(canvasRef.value, payload, { width: 220, margin: 1 });
}
function startPolling() {
stopPolling();
pollTimer = setTimeout(pollOnce, QR_POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
async function pollOnce() {
if (!ticket) return;
try {
const res = await AuthAPI.qrStatus(ticket);
handleStatus(res);
} catch {
startPolling();
}
}
// 根据后端返回状态推进界面与下一步动作
function handleStatus(res: QrCodeStatusResult) {
switch (res.status) {
case "WAITING":
state.value = "waiting";
startPolling();
break;
case "SCANNED":
state.value = "scanned";
nickname.value = res.nickname || "";
avatar.value = res.avatar || "";
startPolling();
break;
case "CONFIRMED":
doLogin();
break;
case "CANCELED":
state.value = "canceled";
break;
case "LOGGED_IN":
case "EXPIRED":
default:
state.value = "expired";
break;
}
}
async function doLogin() {
state.value = "done";
stopPolling();
try {
await userStore.loginByQrCode(ticket);
const redirectPath = (route.query.redirect as string) || "/";
await router.push(decodeURIComponent(redirectPath));
} catch {
state.value = "expired";
}
}
onMounted(start);
onBeforeUnmount(stopPolling);
</script>
<style lang="scss" scoped>
.qr-login {
width: 100%;
&__title {
margin: 0 0 4px;
font-size: 34px;
font-weight: 750;
color: var(--el-text-color-primary);
}
&__desc {
margin: 8px 0 24px;
font-size: 14px;
color: var(--el-text-color-placeholder);
}
&__board {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 240px;
height: 240px;
margin: 0 auto;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
}
&__canvas {
display: block;
border-radius: 8px;
}
&__mask {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
padding: 16px;
font-size: 13px;
color: var(--el-text-color-regular);
background: rgb(255 255 255 / 90%);
backdrop-filter: blur(2px);
&--info {
gap: 4px;
}
}
&__mask-text {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__mask-hint {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
&__footer {
display: flex;
justify-content: center;
margin-top: 20px;
}
}
</style>

View File

@@ -52,7 +52,14 @@
<div class="login-card">
<div class="login-card__inner">
<transition name="fade-slide" mode="out-in">
<div v-if="component === 'login'" key="login" class="login-card__form">
<QrCodeLogin
v-if="component === 'qrcode'"
key="qrcode"
class="login-card__form"
@switch="component = 'login'"
/>
<div v-else-if="component === 'login'" key="login" class="login-card__form">
<h2 class="login-card__title">欢迎回来</h2>
<p class="login-card__desc">请完成身份验证后进入系统</p>
@@ -126,7 +133,7 @@
<div class="login-alt">
<div class="login-alt__divider">其他登录方式</div>
<div class="login-alt__buttons">
<button class="login-alt__btn">
<button class="login-alt__btn" @click="component = 'qrcode'">
<span class="login-alt__icon i-svg:qr-code" />
扫码登录
</button>
@@ -166,11 +173,12 @@ import { AuthStorage } from "@/utils/auth";
import { appConfig } from "@/settings";
import ThemeSwitch from "@/components/ThemeSwitch/index.vue";
import ResetPwd from "./components/ResetPwd.vue";
import QrCodeLogin from "./components/QrCodeLogin.vue";
import logo from "@/assets/images/logo.png";
const userStore = useUserStore();
const route = useRoute();
const component = ref<"login" | "resetPwd">("login");
const component = ref<"login" | "resetPwd" | "qrcode">("login");
const loginFormRef = ref<FormInstance>();
const loading = ref(false);