Files
wecom_it_smart_desk/frontend-admin/src/views/Login.vue
T
Simon 400ce3ddcb feat: OTP首次绑定 + 三端登录修复 + 管理端权限修复 (2026-07-08)
OTP首次绑定:
- 新增统一 OTP 路由 /auth/otp-* (otp.py + router.py)
- 坐席端 OTP 绑定面板 (OtpBindPanel.vue)
- 管理端 OTP 管理列表 (MfaManage.vue)
- agent_login 签发半认证 token 支持首次绑定流程

三端登录修复:
- 坐席/管理端去掉'返回扫码登录'按钮
- 管理端改为二维码始终可见+轮询扫码状态
- 员工端 /itdesk/ 改为 alias 直接服务 H5 (不再301重定向)
- docker-compose 添加 h5 volume 挂载

管理端权限修复:
- 扫码登录改用 get_user_roles() 替代写死 roles=['agent']
- get_user_roles() 增加 agents.role 回退
- 新增 GET /admin/roles/user-roles 端点
- 角色管理页加载用户角色分配数据

文档更新:
- OTP PRD + 系统设计文档
- 故障排查手册 v1.1 (新增6案例)
- nginx 生产基准配置
2026-07-08 21:54:57 +08:00

670 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
=============================================================================
IT智能服务台 管理员登录页 (v1.2, 2026-07-06)
=============================================================================
说明智能检测企微登录状态提供三种登录方式
- PRD v1.5 §4.5: 企微免密登录/企微扫码登录/账号密码+OTP
- 复用坐席端登录 APIPOST /api/agents/login
- 企微免密登录检测到企微登录账号且有管理员角色免密直接进入
-->
<template>
<div class="login-page">
<!-- 背景装饰 -->
<div class="login-bg">
<div class="login-bg-gradient"></div>
</div>
<!-- 登录卡片 -->
<div class="login-card">
<!-- Logo -->
<div class="login-logo">
<div class="login-logo-icon">
<el-icon :size="28"><Headset /></el-icon>
</div>
<h1 class="login-title">IT智能服务台</h1>
<p class="login-subtitle">管理后台</p>
</div>
<!-- 企微免密登录仅企微内管理员可见 -->
<div v-if="wecomUserId" style="text-align: center; margin-bottom: 12px;">
<el-button
type="primary"
size="large"
:loading="wecomQuickLoading"
@click="handleWecomQuickLogin"
class="option-btn"
style="width: 100%;"
>
<el-icon><Key /></el-icon>
企微免密登录{{ wecomUserId }}
</el-button>
</div>
<!-- 企微扫码登录面板始终可见与坐席端一致 -->
<div class="qr-login">
<div class="qr-container">
<div v-if="qrLoading" class="qr-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>加载中...</p>
</div>
<div v-else-if="qrCode" class="qr-code">
<img :src="qrCode" alt="企微扫码登录" />
</div>
<div v-else class="qr-error">
<p>获取二维码失败</p>
<el-button size="small" @click="fetchQrCode">重试</el-button>
</div>
</div>
<p class="qr-hint">请使用企业微信扫码登录</p>
<!-- 分隔线 -->
<div class="divider">
<span>其他登录方式</span>
</div>
<el-button
size="large"
class="password-login-btn"
@click="showPasswordPanel = true"
>
<span>🔐</span>
账号密码登录
</el-button>
</div>
<!-- 账号密码登录表单与二维码同时展示无需返回按钮 -->
<div v-if="showPasswordPanel" class="password-login">
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
label-position="top"
@submit.prevent="handleLogin"
>
<el-form-item label="账号" prop="userId">
<el-input
v-model="loginForm.userId"
placeholder="请输入账号(如 sxn"
size="large"
:prefix-icon="User"
@keydown.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
:prefix-icon="Lock"
show-password
@keydown.enter="handleLogin"
/>
</el-form-item>
<!-- OTP 输入区: 仅在 requireOtp 时显示 -->
<el-form-item v-if="requireOtp" label="OTP 验证码" prop="otpCode">
<el-input
v-model="loginForm.otpCode"
placeholder="请输入 Google Authenticator 验证码"
size="large"
:prefix-icon="Key"
maxlength="6"
@keydown.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="adminStore.logging"
:disabled="adminStore.logging"
@click="handleLogin"
class="login-btn"
>
{{ adminStore.logging ? '登录中...' : (requireOtp ? '验证 OTP' : '登录管理后台') }}
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 错误提示 -->
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="true"
@close="errorMsg = ''"
style="margin-top: 16px"
/>
<!-- 提示信息 -->
<div class="login-tips">
<el-icon :size="14"><InfoFilled /></el-icon>
<span>仅限组长admin角色登录管理后台</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAdminStore } from '@/stores/admin'
import type { FormInstance, FormRules } from 'element-plus'
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
// ==========================================================================
// Store
// ==========================================================================
const router = useRouter()
const adminStore = useAdminStore()
// ==========================================================================
// 表单状态
// ==========================================================================
/** 表单引用 */
const formRef = ref<FormInstance>()
/** 登录表单数据 */
const loginForm = reactive({
userId: '',
password: '',
otpCode: '',
})
/** 是否需要 OTP 验证 */
const requireOtp = ref(false)
/** 错误信息 */
const errorMsg = ref<string>('')
// ==========================================================================
// 企微智能检测相关状态(PRD v1.5 §4.5
// ==========================================================================
/** 是否显示账号密码登录 */
const showPasswordPanel = ref(false)
/** 企微用户ID(用于免密登录) */
const wecomUserId = ref('')
/** 企微快捷登录loading */
const wecomQuickLoading = ref(false)
/** 企微二维码 */
const qrCode = ref('')
/** 二维码加载中 */
const qrLoading = ref(false)
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 当前二维码票据 */
let currentTicket = ''
/** 企微检测中 */
const wecomChecking = ref(false)
/** 表单校验规则 */
const rules: FormRules = {
userId: [
{ required: true, message: '请输入账号', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
otpCode: [
{ required: true, message: '请输入 OTP 验证码', trigger: 'blur' },
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
],
}
// ==========================================================================
// 方法
// ==========================================================================
/**
* 检测企微客户端登录状态(PRD v1.5 §4.5
* 页面加载时自动检测
*/
async function checkWecomClient(): Promise<void> {
wecomChecking.value = true
try {
// 首先尝试使用 UA 检测
const isWxWork = /wxwork/i.test(navigator.userAgent)
// 不在企微环境
if (!isWxWork) {
wecomChecking.value = false
return
}
// 在企微客户端中,尝试使用企微 JS-SDK
if (typeof (window as any).wx !== 'undefined') {
try {
// 获取当前页面 URL
const currentUrl = window.location.href.split('#')[0]
// 调用后端接口获取 JS-SDK 签名配置
const apiClient = (await import('@/api')).default
const configResponse = await apiClient.get('/wecom/jsapi-config', {
params: { url: currentUrl }
})
const jsConfig: any = configResponse
console.log('[Admin Login] 获取到企微 JS-SDK 配置:', jsConfig)
// 使用 wx.config 配置企微 JS-SDK
await new Promise<void>((resolve) => {
(window as any).wx.config({
beta: true,
debug: false,
appId: jsConfig.corp_id,
timestamp: jsConfig.timestamp,
nonceStr: jsConfig.nonce_str,
signature: jsConfig.signature,
jsApiList: ['agentConfig']
})
resolve()
})
// 使用 wx.agentConfig 获取当前用户信息
const userInfo = await new Promise<any>((resolve, reject) => {
(window as any).wx.agentConfig({
corpid: jsConfig.corp_id,
agentid: jsConfig.agent_id,
timestamp: jsConfig.timestamp,
nonceStr: jsConfig.nonce_str,
signature: jsConfig.signature,
success: (res: any) => resolve(res),
fail: (res: any) => reject(new Error(res.errMsg || 'agentConfig 失败'))
})
})
// 获取用户ID
const userId = userInfo.userId
if (userId) {
console.log('[Admin Login] 获取到企微用户 ID:', userId)
wecomUserId.value = userId
// 检测角色
const roleResponse = await apiClient.get('/wecom/check-role', {
params: { userid: userId }
})
const roleData: any = roleResponse
console.log('[Admin Login] 用户角色:', roleData)
// 如果是管理员,显示免密登录选项
if (roleData.role === 'admin') {
// 企微内管理员可免密登录(按钮将显示在顶部)
} else {
// 非管理员,使用二维码和密码登录
}
}
} catch (e) {
// 检测失败,显示普通登录
console.warn('[Admin Login] 企微 JS-SDK 检测失败:', e)
}
} else {
// 没有企微 JS-SDK
console.log('[Admin Login] 无企微 JS-SDK,使用默认登录')
}
} catch (error) {
console.error('企微客户端检测失败:', error)
} finally {
wecomChecking.value = false
}
}
/**
* 企微免密登录
*/
async function handleWecomQuickLogin(): Promise<void> {
wecomQuickLoading.value = true
try {
const apiClient = (await import('@/api')).default
// 拦截器已统一返回 inner dataCTRT-03),response 即 {token, employee_id, name, ...}
const result: any = await apiClient.post('/auth_wecom/jsdk-login', {
userid: wecomUserId.value,
login_source: 'wecom_jsdk_admin'
})
if (result?.token) {
const { token, employee_id, name } = result
// 存储 token
localStorage.setItem('admin_token', token)
if (employee_id) {
localStorage.setItem('admin_user_id', employee_id)
}
// 存储到 store
adminStore.token = token
adminStore.adminUserId = employee_id
ElMessage.success('登录成功')
router.push('/')
} else {
throw new Error('免密登录失败')
}
} catch (error) {
console.error('企微免密登录失败:', error)
ElMessage.error('免密登录失败,请使用其他方式')
} finally {
wecomQuickLoading.value = false
}
}
/**
* 获取扫码登录二维码
*/
async function fetchQrCode(): Promise<void> {
qrLoading.value = true
try {
const apiClient = (await import('@/api')).default
const result: any = await apiClient.post('/auth_qrcode/create')
if (result.qrcode_png_base64) {
qrCode.value = `data:image/png;base64,${result.qrcode_png_base64}`
} else if (result.qrcode_url) {
qrCode.value = result.qrcode_url
}
// 保存票据并启动轮询
if (result.ticket) {
currentTicket = result.ticket
startPolling()
}
} catch (error) {
console.error('获取二维码失败:', error)
} finally {
qrLoading.value = false
}
}
/**
* 轮询扫码状态
*/
async function pollQrCode(): Promise<void> {
if (!currentTicket) return
try {
const apiClient = (await import('@/api')).default
const result: any = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
if (!result) return
const { status, token, employee_id, name } = result
if (status === 'confirmed' && token) {
stopPolling()
localStorage.setItem('admin_token', token)
if (employee_id) localStorage.setItem('admin_user_id', employee_id)
adminStore.token = token
adminStore.adminUserId = employee_id
ElMessage.success('扫码登录成功')
router.push('/')
} else if (status === 'expired') {
stopPolling()
qrCode.value = ''
ElMessage.warning('二维码已过期,请刷新')
}
} catch (error) {
console.warn('轮询扫码状态失败:', error)
}
}
/** 启动轮询(每 2 秒) */
function startPolling(): void {
stopPolling()
pollTimer = setInterval(pollQrCode, 2000)
}
/** 停止轮询 */
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/**
* 处理登录
*/
async function handleLogin(): Promise<void> {
// 表单校验
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
errorMsg.value = ''
try {
await adminStore.login(
loginForm.userId.trim(),
loginForm.password,
loginForm.otpCode.trim() || undefined
)
} catch (error: unknown) {
// 检查是否需要 OTP 验证
if (error instanceof Error && error.message === 'require_otp') {
requireOtp.value = true
loginForm.otpCode = ''
ElMessage.info('请输入 OTP 验证码')
return
}
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
errorMsg.value = errMsg
}
}
// 页面加载时检测企微客户端状态 + 获取二维码
onMounted(() => {
checkWecomClient()
fetchQrCode()
})
// 页面卸载时停止轮询
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
/* 登录页面容器 */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: var(--bg-primary);
}
/* 背景装饰 */
.login-bg {
position: absolute;
inset: 0;
overflow: hidden;
}
.login-bg-gradient {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at center, rgba(59, 130, 246, 0.08) 0%, transparent 60%);
animation: rotate 30s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 登录卡片 */
.login-card {
position: relative;
z-index: 1;
width: 400px;
padding: 40px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
/* Logo */
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: white;
margin: 0 auto 16px;
}
.login-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px;
}
.login-subtitle {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
/* 登录按钮 */
.login-btn {
width: 100%;
margin-top: 8px;
}
/* 提示信息 */
.login-tips {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 20px;
font-size: 12px;
color: var(--text-muted);
}
/* 登录方式选择 */
.login-options {
text-align: center;
padding: 20px 0;
}
.login-options-title {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.login-options-btns {
display: flex;
flex-direction: column;
gap: 12px;
}
.option-btn {
width: 100%;
height: 44px;
font-size: 14px;
}
/* 扫码登录 */
.qr-login {
text-align: center;
padding: 20px 0;
}
.qr-title {
font-size: 16px;
color: var(--text-primary);
margin-bottom: 16px;
}
.qr-code {
width: 180px;
height: 180px;
margin: 0 auto 16px;
background: var(--bg-secondary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
max-width: 100%;
max-height: 100%;
}
.qr-loading {
color: var(--text-muted);
}
.qr-hint {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 12px;
}
/* 二维码容器 */
.qr-container {
margin-bottom: 12px;
}
.qr-error {
color: var(--text-muted);
text-align: center;
padding: 20px 0;
}
/* 分隔线 */
.divider {
display: flex;
align-items: center;
margin: 16px 0;
color: var(--text-muted);
font-size: 13px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--border);
}
.divider span {
padding: 0 12px;
}
/* 账号密码登录按钮 */
.password-login-btn {
width: 100%;
}
/* 账号密码登录面板 */
.password-login {
margin-top: 8px;
}
</style>