WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+149 -52
View File
@@ -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;