feat(portal): 扫码登录 + 角色自动分发 (Phase 1.3 task #16)
- 新建 frontend-portal/src/api/qrcode.ts — /api/auth_qrcode/* API 适配
- 新建 frontend-portal/src/composables/useQrcodeLogin.ts — 扫码核心逻辑
- 新建 frontend-portal/src/views/QrcodeLogin.vue — Portal 扫码登录 UI
- 扫码成功后按角色自动跳:
- 只有 admin → /itadmin/
- 只有 agent → /itagent/
- admin+agent → /itportal/select(多角色)
- 默认 user → /itdesk/
- 改 frontend-portal/src/router/index.ts — 默认 / → /qrcode-login
(原 PortalSelect.vue 保留作多角色 fallback)
- 新建 docs/NGINX-DOMAIN-ROUTING.md — 运维域名分发配置模板
build: ✅ frontend-portal vue-tsc + vite build 通过
QrcodeLogin chunk 4.82 kB
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
// =============================================================================
|
||||
// IT智能服务台 — Portal 扫码登录 Composable (Phase 1.3, task #16)
|
||||
// =============================================================================
|
||||
// 说明:跟 frontend-agent/src/composables/useQrcodeLogin.ts 同款逻辑
|
||||
// Portal 端的 onSuccess 由调用方提供,通常实现"按角色跳对应端"
|
||||
// =============================================================================
|
||||
|
||||
import { ref, onUnmounted, type Ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createQrcode, pollQrcode } from '@/api/qrcode'
|
||||
import type { QrcodePollStatus } from '@/api/qrcode'
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
const COUNTDOWN_TICK_MS = 1000
|
||||
|
||||
export interface UseQrcodeLoginOptions {
|
||||
/** 登录成功回调(token, employeeId, roles)— Portal 一般这里按角色跳对应端 */
|
||||
onSuccess: (token: string, employeeId: string, roles: string[]) => void
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
export interface UseQrcodeLoginReturn {
|
||||
qrcodePngBase64: Ref<string | null>
|
||||
qrcodeUrl: Ref<string | null>
|
||||
countdown: Ref<number>
|
||||
status: Ref<QrcodePollStatus>
|
||||
scannedBy: Ref<string | null>
|
||||
loading: Ref<boolean>
|
||||
errorMessage: Ref<string | null>
|
||||
startLogin: () => Promise<void>
|
||||
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 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
|
||||
|
||||
function clearTimers(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
status.value = 'expired'
|
||||
clearTimers()
|
||||
}
|
||||
}, COUNTDOWN_TICK_MS)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (data.status === 'confirmed' && data.token && data.employee_id) {
|
||||
clearTimers()
|
||||
const roles = data.roles || ['user']
|
||||
options.onSuccess(data.token, data.employee_id, roles)
|
||||
} else if (data.status === 'expired') {
|
||||
clearTimers()
|
||||
}
|
||||
} catch (err: any) {
|
||||
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
|
||||
clearTimers()
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshQrcode(): Promise<void> {
|
||||
await startLogin()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimers()
|
||||
})
|
||||
|
||||
return {
|
||||
qrcodePngBase64,
|
||||
qrcodeUrl,
|
||||
countdown,
|
||||
status,
|
||||
scannedBy,
|
||||
loading,
|
||||
errorMessage,
|
||||
startLogin,
|
||||
refreshQrcode,
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user