Files
wecom_it_smart_desk/frontend-agent/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

604 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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智能服务台 — 坐席登录页 (三端认证重构 AUTH-09 + CTRT)
// =============================================================================
// 说明: 坐席工作台登录,仅保留两种受控登录方式(决策见 system_design.md):
// - ① 企微扫码登录(/auth_qrcode/create + /auth_qrcode/poll 轮询)
// - ② 账号密码 + OTP 二次验证(POST /agents/login,所有登录强制 OTP
//
// 已移除(AUTH-09):
// - 企微 JS-SDK "免密登录" 分支(checkWecomClient / handleWecomQuickLogin / autoLoginWithWecomUser
// - "智能检测自动跳转" 分支(isInWecom 自动跳转 sso/init
// 理由:统一安全水位,所有登录均需经过扫码或账密+OTP,不再有免密直入通道。
//
// 响应契约(CTRT-01/02):apiClient 拦截器已统一返回 inner data
// 故此页面直接读取 response 字段,不再访问 response.data / response.data.data。
// ============================================================================= -->
<template>
<div class="login-page">
<!-- 测试环境标识 - 右上角覆盖层 -->
<div v-if="isTestEnv" class="test-env-badge">
🔧 测试环境
</div>
<div class="login-card">
<!-- 标题区 -->
<div class="login-title">
<h1>🛠 IT智能服务台</h1>
<p>坐席工作台</p>
</div>
<!-- 扫码登录面板默认展示 -->
<div v-if="showQrLoginPanel" 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="showPasswordLogin = true"
>
<span>🔐</span>
账号密码登录
</el-button>
</div>
<!-- 账号密码登录表单与二维码同时展示无需返回按钮 -->
<div v-if="showPasswordLogin" 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 输入区: 仅在 require_otp 时显示 -->
<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="logging"
:disabled="logging"
@click="handleLogin"
class="login-btn"
>
{{ logging ? '登录中...' : (requireOtp ? '验证 OTP' : '账号密码登录') }}
</el-button>
</el-form-item>
</el-form>
</div>
<!-- OTP 首次绑定面板 -->
<OtpBindPanel
v-if="requireOtpBind"
:user-id="otpBindUser.user_id"
:name="otpBindUser.name"
@bind-success="onBindSuccess"
@cancel="onBindCancel"
/>
<!-- 错误提示 -->
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="true"
@close="errorMsg = ''"
style="margin-top: 16px"
/>
<!-- 提示信息 -->
<div class="login-hint">
<p>登录即表示同意IT智能服务台使用规范</p>
</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 type { FormInstance, FormRules } from 'element-plus'
import { User, Lock, Key, Loading } from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
import { useWebSocket } from '@/composables/useWebSocket'
import apiClient from '@/api/index'
import OtpBindPanel from '@/components/OtpBindPanel.vue'
const router = useRouter()
const { connect: connectWebSocket } = useWebSocket()
const agentStore = useAgentStore()
const formRef = ref<FormInstance>()
/** 测试环境标识 */
const isTestEnv = import.meta.env.DEV || window.location.hostname.includes('localhost')
/** 登录面板显示控制 */
const showQrLoginPanel = ref(true) // 默认展示扫码登录
const showPasswordLogin = ref(false) // 是否显示账号密码登录
/** 企微二维码 */
const qrCode = ref('')
const qrLoading = ref(false)
/** 登录表单数据 */
const loginForm = reactive({
userId: '',
password: '',
otpCode: '',
})
/** 是否需要 OTP 验证 */
const requireOtp = ref(false)
/** 是否需要 OTP 首次绑定 */
const requireOtpBind = ref(false)
/** OTP 绑定用户信息(来自 require_otp_bind 响应) */
const otpBindUser = ref<{ user_id: string; name: string; role: string } | null>(null)
/** 登录中状态 */
const logging = ref(false)
/** 错误信息 */
const errorMsg = ref<string>('')
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
let currentTicket = ''
/** 表单校验规则 */
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' },
],
}
/**
* 获取企微登录二维码(CTRT: apiClient 直接返回 inner data
*/
async function fetchQrCode(): Promise<void> {
qrLoading.value = true
errorMsg.value = ''
try {
const response = await apiClient.post('/auth_qrcode/create')
const result = response
if (result?.qrcode_url || result?.qrcode_png_base64) {
qrCode.value = result.qrcode_png_base64
? `data:image/png;base64,${result.qrcode_png_base64}`
: result.qrcode_url
currentTicket = result.ticket
startPolling()
} else {
throw new Error('获取二维码失败:响应数据为空')
}
} catch (error) {
console.error('获取企微二维码失败:', error)
if (!errorMsg.value) {
errorMsg.value = error instanceof Error ? error.message : '获取二维码失败,请重试'
}
} finally {
qrLoading.value = false
}
}
/**
* 轮询扫码状态(CTRT: apiClient 直接返回 inner data
*/
async function pollQrCode(): Promise<void> {
if (!currentTicket) return
try {
const response = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
const result = response
if (result) {
const { status, token, employee_id, name } = result
if (status === 'confirmed' && token) {
stopPolling()
localStorage.setItem('agent_token', token)
if (employee_id) {
localStorage.setItem('agent_user_id', employee_id)
}
const agentStore = useAgentStore()
agentStore.token = token
if (employee_id) {
agentStore.agentUserId = employee_id
}
if (name) {
agentStore.agentInfo = { user_id: employee_id, name, status: 'online' }
}
ElMessage.success('登录成功')
connectWebSocket()
router.push('/workspace')
} else if (status === 'expired') {
stopPolling()
ElMessage.warning('二维码已过期,请重新获取')
fetchQrCode()
}
}
} catch (error) {
console.warn('轮询扫码状态失败:', error)
}
}
/** 启动轮询 */
function startPolling(): void {
stopPolling()
pollTimer = setInterval(pollQrCode, 2000)
}
/** 停止轮询 */
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** 返回扫码登录 */
function handleBackToQrCode(): void {
showPasswordLogin.value = false
showQrLoginPanel.value = true
fetchQrCode()
}
/** 显示扫码登录面板 */
function showQrLogin(): void {
showQrLoginPanel.value = true
showPasswordLogin.value = false
fetchQrCode()
}
/**
* 账号密码 + OTP 登录
*/
async function handleLogin(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
logging.value = true
errorMsg.value = ''
try {
const result = await agentStore.login(
loginForm.userId.trim(),
loginForm.password,
loginForm.otpCode.trim() || undefined
)
if (result && result.require_otp_bind) {
// 首次登录需绑定 OTP:隐藏表单,展示绑定面板
requireOtpBind.value = true
otpBindUser.value = {
user_id: result.user_id,
name: result.name,
role: result.role,
}
showQrLoginPanel.value = false
showPasswordLogin.value = false
logging.value = false
return
}
if (result && result.require_otp) {
requireOtp.value = true
loginForm.otpCode = ''
ElMessage.info('请输入 OTP 验证码')
logging.value = false
return
}
ElMessage.success('登录成功')
connectWebSocket()
router.push('/workspace')
} catch (error: unknown) {
if (error instanceof Error && error.message === 'require_otp') {
requireOtp.value = true
loginForm.otpCode = ''
ElMessage.info('请输入 OTP 验证码')
logging.value = false
return
}
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
errorMsg.value = errMsg
} finally {
logging.value = false
}
}
/**
* OTP 绑定成功回调
* 保存 token 并跳转到工作台
*/
function onBindSuccess(token: string, userId: string, name: string, role: string): void {
localStorage.setItem('agent_token', token)
localStorage.setItem('agent_user_id', userId)
agentStore.token = token
agentStore.agentUserId = userId
agentStore.agentInfo = { user_id: userId, name, status: 'online' }
ElMessage.success('OTP 绑定成功,已登录')
connectWebSocket()
router.push('/workspace')
}
/**
* OTP 绑定取消回调
* 返回扫码登录面板
*/
function onBindCancel(): void {
requireOtpBind.value = false
otpBindUser.value = null
showQrLoginPanel.value = true
}
onMounted(async () => {
// === OAuth 重定向计数清除 ===
const existingToken = localStorage.getItem('agent_token')
if (existingToken) {
console.log('[Login] 检测到已有 token,清除 OAuth 重定向计数')
localStorage.removeItem('oauth_redirect_count')
}
// === OAuth2.0 自动登录流程(参考ITSM系统)===
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const ssoToken = urlParams.get('sso_token')
// 1. 如果有 SSO token,说明已经通过 OAuth 登录成功(CTRT: 直接读取 inner data
if (ssoToken) {
console.log('[Login] 检测到 SSO token,验证身份...')
logging.value = true
try {
const response = await apiClient.get('/auth_wecom/sso/verify', {
params: { sso_token: ssoToken },
})
const result = response
// 拦截器已保证成功即有效数据
if (result) {
const { user_id, name, role } = result
const token = ssoToken
localStorage.setItem('agent_token', token)
localStorage.setItem('agent_user_id', user_id)
agentStore.token = token
agentStore.agentUserId = user_id
agentStore.agentInfo = { user_id, name, status: 'online' }
window.history.replaceState({}, '', window.location.pathname)
ElMessage.success('登录成功')
connectWebSocket()
router.push('/workspace')
return
}
} catch (error) {
console.error('SSO token 验证失败:', error)
window.history.replaceState({}, '', window.location.pathname)
} finally {
logging.value = false
}
}
// 2. 如果有 code,跳转到后端 OAuth 回调
if (code) {
console.log('[Login] 检测到 OAuth code,跳转授权...')
window.location.replace(`/api/auth_wecom/sso/callback?code=${code}&state=`)
return
}
// 3. 默认展示扫码登录(AUTH-09:移除 JS-SDK 免密与智能检测自动跳转)
showQrLogin()
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
/* 测试环境标识 - 左上角覆盖层 */
.test-env-badge {
position: absolute;
top: 16px;
left: 16px;
z-index: 100;
background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.login-page {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 24px;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--bg-secondary, #ffffff);
border-radius: 16px;
padding: 40px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
.login-title {
text-align: center;
margin-bottom: 32px;
}
.login-title h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary, #303133);
margin: 0 0 8px 0;
}
.login-title p {
font-size: 14px;
color: var(--text-tertiary, #909399);
margin: 0;
}
.qr-login {
display: flex;
flex-direction: column;
align-items: center;
}
.qr-container {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 12px;
margin-bottom: 12px;
}
.qr-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #909399;
}
.qr-code img {
width: 180px;
height: 180px;
}
.qr-error {
text-align: center;
color: #f56c6c;
}
.qr-hint {
font-size: 14px;
color: #909399;
margin: 0 0 24px 0;
}
.divider {
display: flex;
align-items: center;
width: 100%;
margin: 0 0 24px 0;
color: #909399;
font-size: 14px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #e4e7ed;
}
.divider span {
padding: 0 16px;
}
.password-login-btn,
.login-btn {
width: 100%;
}
.password-login {
padding-top: 8px;
}
.back-btn {
margin-bottom: 16px;
}
.login-hint {
text-align: center;
color: var(--text-placeholder, #c0c4cc);
font-size: 12px;
line-height: 1.6;
border-top: 1px solid var(--border-color-lighter, #ebeef5);
padding-top: 16px;
margin-top: 24px;
}
.login-hint p {
margin: 4px 0;
}
</style>