327 lines
8.4 KiB
Vue
327 lines
8.4 KiB
Vue
|
|
<!-- =============================================================================
|
|||
|
|
// 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>
|