Files
wecom_it_smart_desk/frontend-agent/src/views/Login.vue
T
Simon bea288e414 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
2026-07-11 23:13:10 +08:00

478 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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智能服务台 — 坐席端统一登录页
// =============================================================================
// 说明:统一认证登录页面
// - 企微内:自动跳转 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">
<div class="login-logo-row">
<span class="logo-block">IT</span>
<h1 class="title-gradient">智能IT服务台</h1>
</div>
<p>坐席工作台</p>
</div>
<!-- 扫码登录面板 -->
<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="qrCodeUrl" class="qr-code">
<img :src="qrCodeUrl" 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>
<p v-if="scanStatus === 'scanned'" class="scan-tip">
<el-icon><CircleCheckFilled /></el-icon> 已扫码请在手机确认
</p>
</div>
<!-- 加载状态 -->
<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, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading, CircleCheckFilled } from '@element-plus/icons-vue'
import { getQrcode, getScanStatus, handleOAuthCallback, type QrcodeResponse, type ScanStatusResponse } from '@/api/auth'
import { useAgentStore } from '@/stores/agent'
const router = useRouter()
const agentStore = useAgentStore()
// --------------------------------------------------------------------------
// 状态
// --------------------------------------------------------------------------
/** 测试环境标识 */
const isTestEnv = import.meta.env.DEV || window.location.hostname.includes('localhost')
/** 是否显示扫码面板 */
const showQrPanel = ref(true)
/** 二维码 URL */
const qrCodeUrl = ref('')
/** 二维码加载中 */
const qrLoading = ref(false)
/** 扫码状态 */
const scanStatus = ref<'waiting' | 'scanned' | ''>('')
/** 全局加载状态 */
const loading = ref(false)
/** 加载提示文字 */
const loadingText = ref('请稍候...')
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 当前扫码登录票据 */
let currentTicket = ''
// --------------------------------------------------------------------------
// 方法
// --------------------------------------------------------------------------
/**
* 检测是否在企微环境
*/
function isWecomEnv(): boolean {
if (typeof navigator === 'undefined') return false
return /wxwork/i.test(navigator.userAgent)
}
/**
* 获取扫码登录二维码
*/
async function fetchQrCode(): Promise<void> {
qrLoading.value = true
scanStatus.value = 'waiting'
try {
const data: QrcodeResponse = await getQrcode()
qrCodeUrl.value = `data:image/png;base64,${data.qrcode_png_base64}`
currentTicket = data.ticket
startPolling()
} catch (error) {
console.error('获取二维码失败:', error)
ElMessage.error('获取二维码失败,请重试')
} finally {
qrLoading.value = false
}
}
/**
* 轮询扫码状态
*/
async function pollScanStatus(): Promise<void> {
if (!currentTicket) return
try {
const result: ScanStatusResponse = await getScanStatus(currentTicket)
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(pollScanStatus, 2000)
}
/**
* 停止轮询
*/
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/**
* 处理登录成功
*/
async function handleLoginSuccess(token: string, userInfo?: any): Promise<void> {
loading.value = true
loadingText.value = '登录中...'
try {
// 检查是否需要账号绑定
if (userInfo?.is_new_user) {
// 跳转绑定页面,传递用户信息
router.push({
name: 'Bind',
query: {
userid: userInfo.userid,
name: userInfo.name,
},
})
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('登录成功')
router.push('/workspace')
} 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
}
// 登录成功
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)
}
// 降级:本地构造授权URL
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
if (!corpId) {
console.warn('未配置 VITE_WECOM_CORP_ID,无法跳转授权')
return
}
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(() => {
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, #07C160 0%, #06AD56 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;
}
/* 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: #ffffff;
margin: 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: var(--bg-tertiary, #f5f5f5);
border-radius: 12px;
margin-bottom: 16px;
}
.qr-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--text-tertiary, #909399);
}
.qr-code img {
width: 180px;
height: 180px;
}
.qr-error {
text-align: center;
color: var(--error-color, #f56c6c);
}
.qr-hint {
font-size: 14px;
color: var(--text-tertiary, #909399);
margin: 0;
}
.scan-tip {
display: flex;
align-items: center;
gap: 4px;
color: var(--success-color, #07c160);
font-size: 14px;
margin-top: 12px;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 40px 0;
color: var(--text-secondary, #606266);
}
</style>