diff --git a/frontend-agent/src/api/qrcode.ts b/frontend-agent/src/api/qrcode.ts new file mode 100644 index 0000000..b0839ba --- /dev/null +++ b/frontend-agent/src/api/qrcode.ts @@ -0,0 +1,80 @@ +// ============================================================================= +// 企微IT智能服务台 — 扫码登录 API 适配层 (Phase 1.2) +// ============================================================================= +// 说明:封装 /api/auth_qrcode/* 4 个端点 +// 对应后端: backend/app/api/auth_qrcode.py (Phase 1.1, task #14) +// +// 4 个端点: +// POST /auth_qrcode/create 坐席端:生成二维码 ticket +// GET /auth_qrcode/poll/{ticket} 坐席端:轮询扫码状态 +// POST /auth_qrcode/scan 企微 OAuth 回调:写扫码状态(后端内部用) +// POST /auth_qrcode/confirm 员工扫码后,在手机上确认登录(后端内部用) +// +// 前端只用前 2 个(create + poll),后 2 个是后端内部流程,不直接调。 +// ============================================================================= + +import apiClient from './index' +import type { AxiosResponse } from 'axios' + +// -------------------------------------------------------------------------- +// TypeScript 类型定义 — 与后端 schema/qrcode.py 保持一致 +// -------------------------------------------------------------------------- + +/** create 响应:坐席端拿到二维码 + ticket */ +export interface QrcodeCreateData { + /** 二维码标识(UUID),用于轮询 */ + ticket: string + /** 企微 OAuth 扫码 URL(员工用企微扫这个 URL) */ + qrcode_url: string + /** 二维码有效期(秒),固定 120 */ + expires_in: number + /** 过期时间戳(ISO 8601) */ + expires_at: string + /** 二维码 PNG 图片 base64(可选,如果后端没生成则前端需要 qrcode 库渲染 qrcode_url) */ + qrcode_png_base64?: string +} + +/** poll 响应:扫码状态 */ +export type QrcodePollStatus = 'waiting' | 'scanned' | 'confirmed' | 'expired' + +export interface QrcodePollData { + /** 当前状态 */ + status: QrcodePollStatus + /** 已扫码时的员工信息 */ + employee_id?: string + /** 已扫码时的员工姓名 */ + name?: string + /** 已确认时的 token(confirmed 状态才有) */ + token?: string + /** 已确认时的角色列表 */ + roles?: string[] + /** 是否需要 OTP 验证(管理员扫码登录时) */ + require_otp?: boolean +} + +// -------------------------------------------------------------------------- +// API 函数 +// -------------------------------------------------------------------------- + +/** + * 生成登录二维码 + * 坐席端进入登录页时调用,拿到 ticket + 二维码图片 + * + * @returns 二维码信息 + */ +export async function createQrcode(): Promise { + const response: AxiosResponse = await apiClient.post('/auth_qrcode/create') + return response.data.data +} + +/** + * 轮询扫码状态 + * 坐席端每 2 秒调一次,直到 status='confirmed' 拿到 token + * + * @param ticket - 二维码标识 + * @returns 扫码状态 + */ +export async function pollQrcode(ticket: string): Promise { + const response: AxiosResponse = await apiClient.get(`/auth_qrcode/poll/${ticket}`) + return response.data.data +} \ No newline at end of file diff --git a/frontend-agent/src/composables/useQrcodeLogin.ts b/frontend-agent/src/composables/useQrcodeLogin.ts new file mode 100644 index 0000000..a04de17 --- /dev/null +++ b/frontend-agent/src/composables/useQrcodeLogin.ts @@ -0,0 +1,215 @@ +// ============================================================================= +// 企微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 + /** 二维码扫码 URL(后端没返回 base64 时,前端可自己用 qrcode 库渲染) */ + qrcodeUrl: Ref + /** 剩余有效期(秒),0 表示已过期 */ + countdown: Ref + /** 当前扫码状态 */ + status: Ref + /** 是否需要 OTP 验证(管理员场景) */ + otpRequired: Ref + /** 已扫码的员工姓名(给 UI 提示用) */ + scannedBy: Ref + /** 加载中(创建二维码时) */ + loading: Ref + /** 错误信息(给 UI 显示) */ + errorMessage: Ref + /** 开始扫码登录(create ticket + 启动轮询) */ + startLogin: () => Promise + /** 刷新二维码(ticket 过期时) */ + refreshQrcode: () => Promise + /** 停止轮询(组件卸载时自动调用) */ + stopPolling: () => void +} + +export function useQrcodeLogin(options: UseQrcodeLoginOptions): UseQrcodeLoginReturn { + // -------------------------------------------------------------------------- + // 响应式状态 + // -------------------------------------------------------------------------- + const qrcodePngBase64 = ref(null) + const qrcodeUrl = ref(null) + const countdown = ref(0) + const status = ref('waiting') + const otpRequired = ref(false) + const scannedBy = ref(null) + const loading = ref(false) + const errorMessage = ref(null) + + // -------------------------------------------------------------------------- + // 内部状态(不暴露给外部) + // -------------------------------------------------------------------------- + let ticket: string | null = null + let pollTimer: ReturnType | null = null + let countdownTimer: ReturnType | 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 { + 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 { + await startLogin() + } + + // -------------------------------------------------------------------------- + // 生命周期:组件卸载时清理 timer + // -------------------------------------------------------------------------- + onUnmounted(() => { + stopPolling() + }) + + return { + qrcodePngBase64, + qrcodeUrl, + countdown, + status, + otpRequired, + scannedBy, + loading, + errorMessage, + startLogin, + refreshQrcode, + stopPolling, + } +} \ No newline at end of file diff --git a/frontend-agent/src/views/Login.vue b/frontend-agent/src/views/Login.vue index 63740da..d93daa4 100644 --- a/frontend-agent/src/views/Login.vue +++ b/frontend-agent/src/views/Login.vue @@ -1,80 +1,121 @@