Files
wecom_it_smart_desk/frontend-agent/src/composables/useQrcodeLogin.ts
T
Simon 8c609e72ba feat(agent): 扫码登录前端 UI (Phase 1.2 task #15)
- 新建 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)
2026-06-21 00:46:50 +08:00

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,
}
}