feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复

== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
+270 -396
View File
@@ -1,41 +1,38 @@
<!-- =============================================================================
// IT智能服务台 — 坐席登录页 (三端认证重构 AUTH-09 + CTRT)
// 企微IT智能服务台 — 坐席端统一登录页
// =============================================================================
// 说明: 坐席工作台登录,仅保留两种受控登录方式(决策见 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。
// 说明:统一认证登录页面
// - 企微内:自动跳转 OAuth2 授权
// - 企微外:展示扫码登录页面,轮询扫码状态
// - 支持账号绑定(互联企业用户)
// ============================================================================= -->
<template>
<div class="login-page">
<!-- 测试环境标识 - 右上角覆盖层 -->
<!-- 测试环境标识 -->
<div v-if="isTestEnv" class="test-env-badge">
🔧 测试环境
</div>
<div class="login-card">
<!-- 标题区 -->
<!-- 标题区logo-block + 渐变文字 TopBar 统一 -->
<div class="login-title">
<h1>🛠 IT智能服务台</h1>
<div class="login-logo-row">
<span class="logo-block">IT</span>
<h1 class="title-gradient">智能IT服务台</h1>
</div>
<p>坐席工作台</p>
</div>
<!-- 扫码登录面板默认展示 -->
<div v-if="showQrLoginPanel" class="qr-login">
<!-- 扫码登录面板 -->
<div v-if="showQrPanel" 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 v-else-if="qrCodeUrl" class="qr-code">
<img :src="qrCodeUrl" alt="企微扫码登录" />
</div>
<div v-else class="qr-error">
<p>获取二维码失败</p>
@@ -43,250 +40,134 @@
</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>
<p v-if="scanStatus === 'scanned'" class="scan-tip">
<el-icon><CircleCheckFilled /></el-icon> 已扫码请在手机确认
</p>
</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 v-if="loading" class="loading-container">
<el-icon class="is-loading" size="32"><Loading /></el-icon>
<p>{{ loadingText }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, 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 { Loading, CircleCheckFilled } from '@element-plus/icons-vue'
import { getQrcode, getScanStatus, handleOAuthCallback, type QrcodeResponse, type ScanStatusResponse } from '@/api/auth'
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 showQrPanel = ref(true)
/** 企微二维码 */
const qrCode = ref('')
/** 二维码 URL */
const qrCodeUrl = ref('')
/** 二维码加载中 */
const qrLoading = ref(false)
/** 登录表单数据 */
const loginForm = reactive({
userId: '',
password: '',
otpCode: '',
})
/** 扫码状态 */
const scanStatus = ref<'waiting' | 'scanned' | ''>('')
/** 是否需要 OTP 验证 */
const requireOtp = ref(false)
/** 全局加载状态 */
const loading = 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>('')
/** 加载提示文字 */
const loadingText = ref('请稍候...')
/** 轮询定时器 */
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' },
],
// --------------------------------------------------------------------------
// 方法
// --------------------------------------------------------------------------
/**
* 检测是否在企微环境
*/
function isWecomEnv(): boolean {
if (typeof navigator === 'undefined') return false
return /wxwork/i.test(navigator.userAgent)
}
/**
* 获取企微登录二维码CTRT: apiClient 直接返回 inner data
* 获取扫码登录二维码
*/
async function fetchQrCode(): Promise<void> {
qrLoading.value = true
errorMsg.value = ''
scanStatus.value = 'waiting'
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('获取二维码失败:响应数据为空')
}
const data: QrcodeResponse = await getQrcode()
qrCodeUrl.value = `data:image/png;base64,${data.qrcode_png_base64}`
currentTicket = data.ticket
startPolling()
} catch (error) {
console.error('获取企微二维码失败:', error)
if (!errorMsg.value) {
errorMsg.value = error instanceof Error ? error.message : '获取二维码失败,请重试'
}
console.error('获取二维码失败:', error)
ElMessage.error('获取二维码失败,请重试')
} finally {
qrLoading.value = false
}
}
/**
* 轮询扫码状态CTRT: apiClient 直接返回 inner data
* 轮询扫码状态
*/
async function pollQrCode(): Promise<void> {
async function pollScanStatus(): Promise<void> {
if (!currentTicket) return
try {
const response = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
const result = response
const result: ScanStatusResponse = await getScanStatus(currentTicket)
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()
}
if (result.status === 'scanned') {
scanStatus.value = 'scanned'
} else if (result.status === 'confirmed' && result.token) {
stopPolling()
await handleLoginSuccess(result.token, result.user_info)
} else if (result.status === 'expired') {
stopPolling()
ElMessage.warning('二维码已过期,请重新扫码')
fetchQrCode()
} else if (result.status === 'cancelled') {
stopPolling()
ElMessage.info('扫码已取消')
scanStatus.value = 'waiting'
fetchQrCode()
}
} catch (error) {
console.warn('轮询扫码状态失败:', error)
}
}
/** 启动轮询 */
/**
* 启动轮询
*/
function startPolling(): void {
stopPolling()
pollTimer = setInterval(pollQrCode, 2000)
pollTimer = setInterval(pollScanStatus, 2000)
}
/** 停止轮询 */
/**
* 停止轮询
*/
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
@@ -294,164 +175,159 @@ 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)
if (!valid) return
logging.value = true
errorMsg.value = ''
async function handleLoginSuccess(token: string, userInfo?: any): Promise<void> {
loading.value = true
loadingText.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
// 检查是否需要账号绑定
if (userInfo?.is_new_user) {
// 跳转绑定页面,传递用户信息
router.push({
name: 'Bind',
query: {
userid: userInfo.userid,
name: userInfo.name,
},
})
return
}
if (result && result.require_otp) {
requireOtp.value = true
loginForm.otpCode = ''
ElMessage.info('请输入 OTP 验证码')
logging.value = false
return
// 保存 token
localStorage.setItem('agent_token', token)
agentStore.token = token
if (userInfo) {
localStorage.setItem('agent_user_id', userInfo.userid)
agentStore.agentUserId = userInfo.userid
agentStore.agentInfo = {
user_id: userInfo.userid,
name: userInfo.name,
status: 'online',
}
}
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
} catch (error) {
console.error('登录失败:', error)
ElMessage.error('登录失败,请重试')
} finally {
loading.value = false
}
}
/**
* 处理 OAuth2 回调
*/
async function processOAuthCallback(code: string, state?: string): Promise<void> {
loading.value = true
loadingText.value = '授权中...'
try {
const result = await handleOAuthCallback(code, state)
if (result.is_new_user) {
// 需要账号绑定
router.push({
name: 'Bind',
query: {
userid: result.user_info.userid,
name: result.user_info.name,
},
})
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
// 登录成功
localStorage.setItem('agent_token', result.token)
agentStore.token = result.token
localStorage.setItem('agent_user_id', result.user_info.userid)
agentStore.agentUserId = result.user_info.userid
agentStore.agentInfo = {
user_id: result.user_info.userid,
name: result.user_info.name,
status: 'online',
}
ElMessage.success('登录成功')
router.push('/workspace')
} catch (error) {
console.error('OAuth回调处理失败:', error)
ElMessage.error('授权失败,请重试')
} finally {
loading.value = false
}
}
/**
* 跳转到 OAuth2 授权页面
*/
async function redirectToOAuth(): Promise<void> {
const currentRedirectUri = window.location.origin + '/itagent/'
try {
const { getOAuthAuthorizeUrl } = await import('@/api/auth')
const data = await getOAuthAuthorizeUrl(currentRedirectUri)
if (data.authorize_url) {
window.location.href = data.authorize_url
return
}
} catch (error) {
console.warn('从后端获取授权URL失败,尝试本地构造:', error)
}
// 2. 如果有 code,跳转到后端 OAuth 回调
if (code) {
console.log('[Login] 检测到 OAuth code,跳转授权...')
window.location.replace(`/api/auth_wecom/sso/callback?code=${code}&state=`)
// 降级:本地构造授权URL
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
if (!corpId) {
console.warn('未配置 VITE_WECOM_CORP_ID,无法跳转授权')
return
}
// 3. 默认展示扫码登录(AUTH-09:移除 JS-SDK 免密与智能检测自动跳转)
showQrLogin()
const redirectUri = encodeURIComponent(currentRedirectUri)
const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${corpId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect`
window.location.href = oauthUrl
}
// --------------------------------------------------------------------------
// 生命周期
// --------------------------------------------------------------------------
onMounted(async () => {
// 检查 URL 中的 code 参数(OAuth2 回调)
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const state = urlParams.get('state')
if (code) {
// 有 OAuth2 code,处理回调
await processOAuthCallback(code, state || undefined)
return
}
// 检测企微环境
if (isWecomEnv()) {
loading.value = true
loadingText.value = '正在跳转企微授权...'
try {
// 企微内,跳转 OAuth2 授权
await redirectToOAuth()
} catch (error) {
console.error('跳转授权失败:', error)
// 降级显示扫码登录
showQrPanel.value = true
fetchQrCode()
} finally {
loading.value = false
}
} else {
// 企微外,显示扫码登录
showQrPanel.value = true
fetchQrCode()
}
})
onUnmounted(() => {
@@ -460,7 +336,7 @@ onUnmounted(() => {
</script>
<style scoped>
/* 测试环境标识 - 左上角覆盖层 */
/* 测试环境标识 */
.test-env-badge {
position: absolute;
top: 16px;
@@ -472,7 +348,7 @@ onUnmounted(() => {
border-radius: 4px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.login-page {
@@ -481,7 +357,7 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #07C160 0%, #06AD56 100%);
padding: 24px;
}
@@ -499,11 +375,37 @@ onUnmounted(() => {
margin-bottom: 32px;
}
.login-title h1 {
/* logo + 标题水平排列 */
.login-logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 8px;
}
/* IT logo 方块 — 复用 TopBar 样式 */
.logo-block {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 7px;
background: rgba(255, 255, 255, 0.95);
color: #07C160;
font-size: 16px;
font-weight: 800;
letter-spacing: -0.5px;
flex-shrink: 0;
}
/* 渐变文字 — 复用 TopBar 样式 */
.title-gradient {
font-size: 24px;
font-weight: 700;
color: var(--text-primary, #303133);
margin: 0 0 8px 0;
color: #ffffff;
margin: 0;
}
.login-title p {
@@ -512,6 +414,7 @@ onUnmounted(() => {
margin: 0;
}
/* 扫码登录 */
.qr-login {
display: flex;
flex-direction: column;
@@ -524,9 +427,9 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
background: var(--bg-tertiary, #f5f5f5);
border-radius: 12px;
margin-bottom: 12px;
margin-bottom: 16px;
}
.qr-loading {
@@ -534,7 +437,7 @@ onUnmounted(() => {
flex-direction: column;
align-items: center;
gap: 8px;
color: #909399;
color: var(--text-tertiary, #909399);
}
.qr-code img {
@@ -544,60 +447,31 @@ onUnmounted(() => {
.qr-error {
text-align: center;
color: #f56c6c;
color: var(--error-color, #f56c6c);
}
.qr-hint {
font-size: 14px;
color: #909399;
margin: 0 0 24px 0;
color: var(--text-tertiary, #909399);
margin: 0;
}
.divider {
.scan-tip {
display: flex;
align-items: center;
width: 100%;
margin: 0 0 24px 0;
color: #909399;
gap: 4px;
color: var(--success-color, #07c160);
font-size: 14px;
margin-top: 12px;
}
.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;
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 40px 0;
color: var(--text-secondary, #606266);
}
</style>