WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
@@ -1,11 +1,25 @@
|
||||
<!-- =============================================================================
|
||||
// IT智能服务台 — 坐席登录页 (v1.7, 2026-07-05)
|
||||
// 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">
|
||||
@@ -13,8 +27,8 @@
|
||||
<p>坐席工作台</p>
|
||||
</div>
|
||||
|
||||
<!-- 企微扫码登录(二维码) -->
|
||||
<div v-if="!showPasswordLogin" class="qr-login">
|
||||
<!-- 扫码登录面板(默认展示) -->
|
||||
<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>
|
||||
@@ -41,12 +55,12 @@
|
||||
@click="showPasswordLogin = true"
|
||||
>
|
||||
<span>🔐</span>
|
||||
账号密码+OTP
|
||||
账号密码登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码+OTP 登录表单 -->
|
||||
<div v-else class="password-login">
|
||||
<!-- 账号密码登录表单 -->
|
||||
<div v-if="showPasswordLogin" class="password-login">
|
||||
<el-button
|
||||
size="small"
|
||||
class="back-btn"
|
||||
@@ -137,13 +151,20 @@ 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 showPasswordLogin = ref(false)
|
||||
/** 测试环境标识 */
|
||||
const isTestEnv = import.meta.env.DEV || window.location.hostname.includes('localhost')
|
||||
|
||||
/** 登录面板显示控制 */
|
||||
const showQrLoginPanel = ref(true) // 默认展示扫码登录
|
||||
const showPasswordLogin = ref(false) // 是否显示账号密码登录
|
||||
|
||||
/** 企微二维码 */
|
||||
const qrCode = ref('')
|
||||
@@ -180,65 +201,64 @@ const rules: FormRules = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企微登录二维码
|
||||
* 获取企微登录二维码(CTRT: apiClient 直接返回 inner data)
|
||||
*/
|
||||
async function fetchQrCode(): Promise<void> {
|
||||
qrLoading.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const response = await fetch(`${baseUrl}/auth_qrcode/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
const response = await apiClient.post('/auth_qrcode/create')
|
||||
const result = response
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取二维码失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 0 && (result.data?.qrcode_url || result.data?.qrcode_png_base64)) {
|
||||
// qrcode_png_base64 是纯 base64,需要添加 data:image 前缀才能被 img 标签渲染
|
||||
qrCode.value = result.data.qrcode_png_base64
|
||||
? `data:image/png;base64,${result.data.qrcode_png_base64}`
|
||||
: result.data.qrcode_url
|
||||
currentTicket = result.data.ticket
|
||||
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(result.message || '获取二维码失败')
|
||||
throw new Error('获取二维码失败:响应数据为空')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取企微二维码失败:', error)
|
||||
errorMsg.value = error instanceof Error ? error.message : '获取二维码失败'
|
||||
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 baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const response = await fetch(`${baseUrl}/auth_qrcode/poll/${currentTicket}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
const response = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
|
||||
const result = response
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
const { status, token } = result.data
|
||||
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()
|
||||
@@ -247,21 +267,17 @@ async function pollQrCode(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询扫码状态失败:', error)
|
||||
console.warn('轮询扫码状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
/** 启动轮询 */
|
||||
function startPolling(): void {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(pollQrCode, 2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
*/
|
||||
/** 停止轮询 */
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
@@ -269,16 +285,22 @@ function stopPolling(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回扫码登录
|
||||
*/
|
||||
/** 返回扫码登录 */
|
||||
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)
|
||||
@@ -303,6 +325,7 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
connectWebSocket()
|
||||
router.push('/workspace')
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message === 'require_otp') {
|
||||
@@ -319,8 +342,66 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchQrCode()
|
||||
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(() => {
|
||||
@@ -329,7 +410,23 @@ onUnmounted(() => {
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user