Files
wecom_it_smart_desk/frontend-agent/src/views/Login.vue
T

554 lines
14 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-button
size="small"
class="back-btn"
@click="handleBackToQrCode"
>
返回扫码登录
</el-button>
<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>
<!-- 错误提示 -->
<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'
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)
/** 登录中状态 */
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) {
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
}
}
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>