8c609e72ba
- 新建 src/api/qrcode.ts — 后端 /api/auth_qrcode/* API 适配层
- 新建 src/composables/useQrcodeLogin.ts — 扫码登录核心逻辑
(create → poll 2s 间隔 → 120s 倒计时 → 状态机 waiting/scanned/confirmed/expired)
- 重写 src/views/Login.vue — 企微扫码 UI 替代原用户名表单
- 展示后端返回的二维码 PNG(base64)
- 倒计时 + 自动过期
- 扫码成功后跳 /workspace
- 管理员 OTP 场景预留按钮(Phase 2.4 集成)
build: ✅ vue-tsc + vite build 通过 (Login chunk 4.91 kB)
215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
// =============================================================================
|
|
// 企微IT智能服务台 — 扫码登录 Composable (Phase 1.2)
|
|
// =============================================================================
|
|
// 说明:封装扫码登录核心逻辑(create → poll → 倒计时 → 确认)
|
|
// 用法:
|
|
// const { qrcodeUrl, qrcodePngBase64, status, countdown, otpRequired,
|
|
// startLogin, confirmLogin, refreshQrcode } = useQrcodeLogin(onSuccess)
|
|
//
|
|
// 流程:
|
|
// 1. startLogin() → 调 /auth_qrcode/create 拿 ticket + 二维码 → 启动轮询
|
|
// 2. 后端返回 scanned → 状态切换为"已扫码,请在手机上确认"
|
|
// 3. 后端返回 confirmed + token → 调 onSuccess(token, employee_id, roles)
|
|
// 4. 倒计时 0 → 自动 refreshQrcode() 重新生成
|
|
//
|
|
// 配合 task #14 (后端 auth_qrcode.py) 使用
|
|
// =============================================================================
|
|
|
|
import { ref, onUnmounted, type Ref } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { createQrcode, pollQrcode } from '@/api/qrcode'
|
|
import type { QrcodePollStatus } from '@/api/qrcode'
|
|
|
|
/** 轮询间隔(毫秒)— 2 秒,跟后端 ticket TTL 120s 匹配 */
|
|
const POLL_INTERVAL_MS = 2000
|
|
|
|
/** 倒计时精度(毫秒)— 1 秒刷新一次显示 */
|
|
const COUNTDOWN_TICK_MS = 1000
|
|
|
|
/** useQrcodeLogin 配置项 */
|
|
export interface UseQrcodeLoginOptions {
|
|
/** 登录成功回调(token, employeeId, roles) */
|
|
onSuccess: (token: string, employeeId: string, roles: string[]) => void
|
|
/** 登录失败回调(可选,默认用 ElMessage.error) */
|
|
onError?: (message: string) => void
|
|
}
|
|
|
|
/** useQrcodeLogin 返回值 */
|
|
export interface UseQrcodeLoginReturn {
|
|
/** 二维码图片 base64(后端生成 PNG 时) */
|
|
qrcodePngBase64: Ref<string | null>
|
|
/** 二维码扫码 URL(后端没返回 base64 时,前端可自己用 qrcode 库渲染) */
|
|
qrcodeUrl: Ref<string | null>
|
|
/** 剩余有效期(秒),0 表示已过期 */
|
|
countdown: Ref<number>
|
|
/** 当前扫码状态 */
|
|
status: Ref<QrcodePollStatus>
|
|
/** 是否需要 OTP 验证(管理员场景) */
|
|
otpRequired: Ref<boolean>
|
|
/** 已扫码的员工姓名(给 UI 提示用) */
|
|
scannedBy: Ref<string | null>
|
|
/** 加载中(创建二维码时) */
|
|
loading: Ref<boolean>
|
|
/** 错误信息(给 UI 显示) */
|
|
errorMessage: Ref<string | null>
|
|
/** 开始扫码登录(create ticket + 启动轮询) */
|
|
startLogin: () => Promise<void>
|
|
/** 刷新二维码(ticket 过期时) */
|
|
refreshQrcode: () => Promise<void>
|
|
/** 停止轮询(组件卸载时自动调用) */
|
|
stopPolling: () => void
|
|
}
|
|
|
|
export function useQrcodeLogin(options: UseQrcodeLoginOptions): UseQrcodeLoginReturn {
|
|
// --------------------------------------------------------------------------
|
|
// 响应式状态
|
|
// --------------------------------------------------------------------------
|
|
const qrcodePngBase64 = ref<string | null>(null)
|
|
const qrcodeUrl = ref<string | null>(null)
|
|
const countdown = ref<number>(0)
|
|
const status = ref<QrcodePollStatus>('waiting')
|
|
const otpRequired = ref<boolean>(false)
|
|
const scannedBy = ref<string | null>(null)
|
|
const loading = ref<boolean>(false)
|
|
const errorMessage = ref<string | null>(null)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 内部状态(不暴露给外部)
|
|
// --------------------------------------------------------------------------
|
|
let ticket: string | null = null
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
let expiresAt: number | null = null // 时间戳(毫秒)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 工具:清理所有 timer
|
|
// --------------------------------------------------------------------------
|
|
function clearTimers(): void {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
if (countdownTimer) {
|
|
clearInterval(countdownTimer)
|
|
countdownTimer = null
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 工具:启动倒计时(每秒刷新 countdown)
|
|
// --------------------------------------------------------------------------
|
|
function startCountdown(): void {
|
|
if (countdownTimer) clearInterval(countdownTimer)
|
|
countdownTimer = setInterval(() => {
|
|
if (!expiresAt) {
|
|
countdown.value = 0
|
|
return
|
|
}
|
|
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000))
|
|
countdown.value = remaining
|
|
if (remaining === 0 && status.value === 'waiting') {
|
|
// 二维码过期但还没扫 → 标记 expired,停止轮询
|
|
status.value = 'expired'
|
|
stopPolling()
|
|
}
|
|
}, COUNTDOWN_TICK_MS)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 工具:启动轮询(每 2s 调 poll)
|
|
// --------------------------------------------------------------------------
|
|
function startPolling(): void {
|
|
if (pollTimer) clearInterval(pollTimer)
|
|
pollTimer = setInterval(async () => {
|
|
if (!ticket) return
|
|
try {
|
|
const data = await pollQrcode(ticket)
|
|
status.value = data.status
|
|
scannedBy.value = data.name || null
|
|
otpRequired.value = !!data.require_otp
|
|
|
|
if (data.status === 'confirmed' && data.token && data.employee_id) {
|
|
// 登录成功
|
|
stopPolling()
|
|
const roles = data.roles || ['agent']
|
|
options.onSuccess(data.token, data.employee_id, roles)
|
|
} else if (data.status === 'expired') {
|
|
stopPolling()
|
|
}
|
|
} catch (err: any) {
|
|
// 轮询失败不打断 UI(下次轮询会重试)
|
|
console.warn('[useQrcodeLogin] poll error:', err)
|
|
}
|
|
}, POLL_INTERVAL_MS)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 公开方法:停止轮询
|
|
// --------------------------------------------------------------------------
|
|
function stopPolling(): void {
|
|
clearTimers()
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 公开方法:开始扫码登录
|
|
// --------------------------------------------------------------------------
|
|
async function startLogin(): Promise<void> {
|
|
if (loading.value) return
|
|
loading.value = true
|
|
errorMessage.value = null
|
|
stopPolling() // 清旧 timer
|
|
|
|
try {
|
|
const data = await createQrcode()
|
|
ticket = data.ticket
|
|
qrcodeUrl.value = data.qrcode_url
|
|
qrcodePngBase64.value = data.qrcode_png_base64 || null
|
|
countdown.value = data.expires_in
|
|
expiresAt = Date.now() + data.expires_in * 1000
|
|
status.value = 'waiting'
|
|
scannedBy.value = null
|
|
otpRequired.value = false
|
|
|
|
startCountdown()
|
|
startPolling()
|
|
} catch (err: any) {
|
|
const msg = err?.message || '生成二维码失败'
|
|
errorMessage.value = msg
|
|
if (options.onError) {
|
|
options.onError(msg)
|
|
} else {
|
|
ElMessage.error(msg)
|
|
}
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 公开方法:刷新二维码(ticket 过期后用户点"刷新"按钮)
|
|
// --------------------------------------------------------------------------
|
|
async function refreshQrcode(): Promise<void> {
|
|
await startLogin()
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 生命周期:组件卸载时清理 timer
|
|
// --------------------------------------------------------------------------
|
|
onUnmounted(() => {
|
|
stopPolling()
|
|
})
|
|
|
|
return {
|
|
qrcodePngBase64,
|
|
qrcodeUrl,
|
|
countdown,
|
|
status,
|
|
otpRequired,
|
|
scannedBy,
|
|
loading,
|
|
errorMessage,
|
|
startLogin,
|
|
refreshQrcode,
|
|
stopPolling,
|
|
}
|
|
} |