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:
Simon
2026-06-21 01:06:47 +08:00
parent 8c609e72ba
commit c3899594d0
5 changed files with 797 additions and 4 deletions
+47
View File
@@ -0,0 +1,47 @@
// =============================================================================
// IT智能服务台 — Portal 扫码登录 API 适配层 (Phase 1.3, task #16)
// =============================================================================
// 说明:复用 backend/app/api/auth_qrcode.py 接口(Phase 1.1)
// Portal 是统一入口,扫码成功后根据用户角色自动跳到对应端:
// - 只有 user 角色 → /itdesk/(H5)
// - 只有 agent 角色 → /itagent/(坐席工作台)
// - 只有 admin 角色 → /itadmin/(管理后台)
// - 多角色 → /itportal/select(角色选择页)
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
export interface QrcodeCreateData {
ticket: string
qrcode_url: string
expires_in: number
expires_at: string
qrcode_png_base64?: string
}
export type QrcodePollStatus = 'waiting' | 'scanned' | 'confirmed' | 'expired'
export interface QrcodePollData {
status: QrcodePollStatus
employee_id?: string
name?: string
token?: string
roles?: string[]
}
/**
* 生成登录二维码
*/
export async function createQrcode(): Promise<QrcodeCreateData> {
const response: AxiosResponse = await apiClient.post('/auth_qrcode/create')
return response.data.data
}
/**
* 轮询扫码状态
*/
export async function pollQrcode(ticket: string): Promise<QrcodePollData> {
const response: AxiosResponse = await apiClient.get(`/auth_qrcode/poll/${ticket}`)
return response.data.data
}
@@ -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,
}
}
+14 -4
View File
@@ -9,12 +9,22 @@ import { createRouter, createWebHistory } from 'vue-router'
// 路由配置
const routes = [
{
// 根路径重定向到角色选择页
// 根路径重定向到扫码登录页(Phase 1.3 task #16)
// 原 PortalSelect.vue 保留作为多角色用户的 fallback
path: '/',
redirect: '/select',
redirect: '/qrcode-login',
},
{
// 角色选择页
// 扫码登录页(主入口,Phase 1.3 新增)
path: '/qrcode-login',
name: 'QrcodeLogin',
component: () => import('@/views/QrcodeLogin.vue'),
meta: {
title: '扫码登录',
},
},
{
// 角色选择页(多角色用户扫码成功后的 fallback,保留)
path: '/select',
name: 'PortalSelect',
component: () => import('@/views/PortalSelect.vue'),
@@ -35,7 +45,7 @@ const routes = [
// 404 页面
path: '/:pathMatch(.*)*',
name: 'NotFound',
redirect: '/select',
redirect: '/qrcode-login',
},
]
+327
View File
@@ -0,0 +1,327 @@
<!-- =============================================================================
// IT智能服务台 — Portal 扫码登录页 (Phase 1.3, task #16)
// =============================================================================
// 说明:替代原 PortalSelect.vue 的"企微 OAuth + 角色选择"流程
// 新流程:Portal 显示二维码 → 员工扫码 → 后端 confirm → 按角色自动跳到对应端
//
// 角色分发规则(扫码成功后):
// 只有 admin → /itadmin/(管理后台)
// 只有 agent → /itagent/(坐席工作台)
// admin + agent → /itportal/select(让用户选)
// 默认 user → /itdesk/(H5 员工端)
//
// nginx 域名分发建议配置见 docs/NGINX-DOMAIN-ROUTING.md
// ============================================================================= -->
<template>
<div class="qrcode-login-page">
<div class="qrcode-login-card">
<!-- 头部 -->
<div class="header">
<h1 class="title">🛠 IT智能服务台</h1>
<p class="subtitle">扫码登录</p>
</div>
<!-- 二维码区 -->
<div class="qrcode-section">
<!-- 加载中 -->
<div v-if="loading && !qrcodePngBase64" class="qrcode-placeholder">
<el-icon class="is-loading" :size="48"><Loading /></el-icon>
<p>正在生成二维码</p>
</div>
<!-- 二维码图片 -->
<img
v-else-if="qrcodePngBase64"
:src="`data:image/png;base64,${qrcodePngBase64}`"
alt="登录二维码"
class="qrcode-image"
:class="{ 'qrcode-expired': status === 'expired' }"
/>
<!-- 错误状态 -->
<div v-else-if="errorMessage" class="qrcode-error">
<el-icon :size="48" color="#ef4444"><CircleCloseFilled /></el-icon>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="refreshQrcode">重试</el-button>
</div>
</div>
<!-- 状态文字 -->
<div class="status-section">
<div v-if="status === 'waiting' && countdown > 0" class="status-waiting">
<p class="status-main">
<el-icon><Iphone /></el-icon>
请用<span class="highlight">企业微信</span>扫描二维码
</p>
<p class="status-sub">
二维码 <span class="countdown">{{ countdown }}</span> 秒后过期
</p>
</div>
<div v-else-if="status === 'scanned'" class="status-scanned">
<p class="status-main">
<el-icon color="#67c23a"><Check /></el-icon>
扫码成功
</p>
<p class="status-sub">
{{ scannedBy || '员工' }},请在手机上点<span class="highlight">"确认登录"</span>
</p>
</div>
<div v-else-if="status === 'confirmed'" class="status-confirmed">
<el-icon :size="32" color="#67c23a"><Loading /></el-icon>
<p>登录成功,正在跳转</p>
</div>
<div v-else-if="status === 'expired'" class="status-expired">
<p class="status-main">
<el-icon color="#e6a23c"><Warning /></el-icon>
二维码已过期
</p>
<el-button type="primary" @click="refreshQrcode">刷新二维码</el-button>
</div>
</div>
<!-- 底部 -->
<div class="footer">
<p class="footer-text">
扫码后系统会根据您的角色自动跳转到对应工作台
</p>
<p class="footer-sub">
坐席/管理员/H5 多端入口统一管理
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useQrcodeLogin } from '@/composables/useQrcodeLogin'
// ============================================================================
// 角色 URL 映射(跟 backend/app/api/portal.py _get_role_url 保持一致)
// ============================================================================
const ROLE_URLS = {
user: '/itdesk/',
agent: '/itagent/',
admin: '/itadmin/',
}
// ============================================================================
// 角色分发逻辑
// ============================================================================
/**
* 按角色决定跳到哪个端
* 规则:
* - 只有 admin → /itadmin/
* - 只有 agent → /itagent/
* - admin + agent → /itportal/select(用户多角色,给选择页)
* - 默认 user → /itdesk/
*/
function dispatchToRole(roles: string[]): string {
const hasAgent = roles.includes('agent')
const hasAdmin = roles.includes('admin')
// 多角色:让用户在 PortalSelect 选择
if (hasAdmin && hasAgent) {
return '/itportal/select'
}
if (hasAdmin) {
return ROLE_URLS.admin
}
if (hasAgent) {
return ROLE_URLS.agent
}
// 默认 user
return ROLE_URLS.user
}
/**
* 登录成功回调
* 1. 存 token 到 localStorage(双 key: portal_token + 各端 token,跨端共享)
* 2. 按角色自动跳到对应端(整页跳,因为跨基础路径)
*/
function handleLoginSuccess(token: string, _employeeId: string, roles: string[]): void {
// 存 token 到所有可能的 key(避免各端读不到)
localStorage.setItem('portal_token', token)
localStorage.setItem('agent_token', token)
localStorage.setItem('admin_token', token)
localStorage.setItem('agent_user_id', _employeeId)
// 按角色跳
const targetUrl = dispatchToRole(roles)
const separator = targetUrl.includes('?') ? '&' : '?'
const finalUrl = `${targetUrl}${separator}token=${encodeURIComponent(token)}`
ElMessage.success('登录成功')
// 短暂延迟让用户看到"登录成功"提示
setTimeout(() => {
window.location.href = finalUrl
}, 500)
}
const {
qrcodePngBase64,
countdown,
status,
scannedBy,
loading,
errorMessage,
startLogin,
refreshQrcode,
stopPolling,
} = useQrcodeLogin({
onSuccess: handleLoginSuccess,
onError: (msg) => ElMessage.error(msg),
})
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
startLogin()
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.qrcode-login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
padding: 24px;
}
.qrcode-login-card {
width: 100%;
max-width: 460px;
background: rgba(30, 41, 59, 0.85);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 40px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(71, 85, 105, 0.5);
}
.header {
text-align: center;
margin-bottom: 32px;
}
.title {
font-size: 28px;
font-weight: 700;
color: #f1f5f9;
margin: 0 0 8px 0;
}
.subtitle {
font-size: 14px;
color: #94a3b8;
margin: 0;
}
.qrcode-section {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
margin-bottom: 24px;
}
.qrcode-image {
width: 200px;
height: 200px;
border-radius: 8px;
background: white;
padding: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
transition: opacity 0.3s;
}
.qrcode-expired {
opacity: 0.3;
filter: grayscale(100%);
}
.qrcode-placeholder,
.qrcode-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #94a3b8;
}
.status-section {
text-align: center;
margin-bottom: 24px;
min-height: 80px;
}
.status-main {
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 8px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.status-sub {
font-size: 13px;
color: #94a3b8;
margin: 0;
line-height: 1.5;
}
.status-confirmed {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #67c23a;
}
.countdown {
color: #60a5fa;
font-weight: 600;
font-family: monospace;
}
.highlight {
color: #60a5fa;
font-weight: 600;
}
.footer {
text-align: center;
color: #64748b;
font-size: 12px;
border-top: 1px solid rgba(71, 85, 105, 0.5);
padding-top: 16px;
}
.footer-text {
margin: 4px 0;
}
.footer-sub {
margin: 4px 0;
font-size: 11px;
color: #475569;
}
</style>