Files
wecom_it_smart_desk/frontend-agent/src/views/Login.vue
T
Simon 8c609e72ba feat(agent): 扫码登录前端 UI (Phase 1.2 task #15)
- 新建 src/api/qrcode.ts — 后端 /api/auth_qrcode/* API 适配层
- 新建 src/composables/useQrcodeLogin.ts — 扫码登录核心逻辑
  (create → poll 2s 间隔 → 120s 倒计时 → 状态机 waiting/scanned/confirmed/expired)
- 重写 src/views/Login.vue — 企微扫码 UI 替代原用户名表单
  - 展示后端返回的二维码 PNG(base64)
  - 倒计时 + 自动过期
  - 扫码成功后跳 /workspace
  - 管理员 OTP 场景预留按钮(Phase 2.4 集成)

build:  vue-tsc + vite build 通过 (Login chunk 4.91 kB)
2026-06-21 00:46:50 +08:00

354 lines
9.1 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.
<!-- =============================================================================
// 企微IT智能服务台 — 坐席扫码登录页 (Phase 1.2, task #15)
// =============================================================================
// 说明:重写自原"用户名 + 姓名表单"登录,改为"企微扫码登录"
//
// 流程:
// 1. 进入页面 → useQrcodeLogin.startLogin() 调后端 /auth_qrcode/create
// 2. 展示二维码 + 倒计时(120s)
// 3. 员工用企微扫 → 后端状态 → scanned → UI 切换"已扫码,请在手机上确认"
// 4. 员工在手机上点确认 → 后端 confirm → 拿到 token → 写 localStorage → 跳 /workspace
// 5. 倒计时归 0 → 自动停止轮询,UI 显示"已过期",用户点"刷新二维码"
//
// 管理员场景(扫码确认后 require_otp=true)→ 当前版本暂未集成 OTP 输入框
// OTP 二次认证由 task #17 (Phase 2.1 后端 MFA) + task #20 (Phase 2.4 前端 MFA UI) 负责
// 短期方案:管理员走 /itportal/ 入口(那边有 OTP UI)
// ============================================================================= -->
<template>
<div class="login-page">
<div class="login-card">
<!-- 标题区 -->
<div class="login-title">
<h1>🛠 IT智能服务台</h1>
<p>坐席工作台 · 扫码登录</p>
</div>
<!-- 二维码区 -->
<div class="qrcode-section">
<!-- 加载中 -->
<div v-if="loading && !qrcodePngBase64" class="qrcode-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
<p>正在生成二维码</p>
</div>
<!-- 二维码图片(base64 PNG) -->
<img
v-else-if="qrcodePngBase64"
:src="`data:image/png;base64,${qrcodePngBase64}`"
alt="登录二维码"
class="qrcode-image"
:class="{ 'qrcode-expired': status === 'expired' }"
/>
<!-- 降级:后端没返回 base64,显示 qrcode_url 提示用户手动复制 -->
<div v-else-if="qrcodeUrl" class="qrcode-fallback">
<p class="qrcode-fallback-hint">请复制以下链接到企业微信打开:</p>
<el-input
:model-value="qrcodeUrl"
readonly
type="textarea"
:rows="3"
class="qrcode-fallback-url"
/>
<p class="qrcode-fallback-tip">
(前端暂未集成二维码渲染库,后端应返回 base64 PNG)
</p>
</div>
<!-- 错误状态 -->
<div v-else-if="errorMessage" class="qrcode-error">
<el-icon><CircleClose /></el-icon>
<p>{{ errorMessage }}</p>
</div>
</div>
<!-- 状态文字 -->
<div class="status-section">
<!-- 等待扫码 -->
<div v-if="status === 'waiting' && countdown > 0" class="status-waiting">
<p class="status-main">
<el-icon><Iphone /></el-icon>
请用<span class="highlight">企业微信</span>扫描二维码
</p>
<p class="status-sub">
二维码 <span class="countdown">{{ countdown }}</span> 秒后过期
</p>
</div>
<!-- 已扫码,等待员工在手机上确认 -->
<div v-else-if="status === 'scanned'" class="status-scanned">
<p class="status-main">
<el-icon color="#67c23a"><Check /></el-icon>
扫码成功
</p>
<p class="status-sub" v-if="scannedBy">
{{ scannedBy }},请在手机上点<span class="highlight">"确认登录"</span>
</p>
<p class="status-sub" v-else>
请在手机上点<span class="highlight">"确认登录"</span>
</p>
<el-button
v-if="otpRequired"
type="primary"
class="otp-button"
@click="handleOtpConfirm"
>
输入 OTP 动态码
</el-button>
</div>
<!-- 已过期 -->
<div v-else-if="status === 'expired'" class="status-expired">
<p class="status-main">
<el-icon color="#e6a23c"><Warning /></el-icon>
二维码已过期
</p>
<el-button type="primary" class="refresh-button" @click="refreshQrcode">
刷新二维码
</el-button>
</div>
</div>
<!-- 底部提示 -->
<div class="login-hint">
<p>登录即表示同意IT智能服务台使用规范</p>
<p class="login-hint-sub">
首次使用请确保已在企业微信中完成认证
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useQrcodeLogin } from '@/composables/useQrcodeLogin'
// ============================================================================
// 状态
// ============================================================================
const router = useRouter()
/**
* 扫码登录成功回调
* 1. 存 token 到 localStorage(双 key: agent_token + portal_token,跨端共享)
* 2. 跳转到 /workspace
*/
function handleLoginSuccess(token: string, _employeeId: string, _roles: string[]): void {
localStorage.setItem('agent_token', token)
localStorage.setItem('portal_token', token)
ElMessage.success('登录成功')
router.push('/workspace')
}
const {
qrcodePngBase64,
qrcodeUrl,
countdown,
status,
otpRequired,
scannedBy,
loading,
errorMessage,
startLogin,
refreshQrcode,
stopPolling,
} = useQrcodeLogin({
onSuccess: handleLoginSuccess,
onError: (msg) => ElMessage.error(msg),
})
/**
* 管理员 OTP 输入按钮(暂未实现完整流程,提示用户去 /itportal/)
* Phase 2.4 完成后这里跳到 OTP 输入弹窗
*/
function handleOtpConfirm(): void {
ElMessage.info('管理员 OTP 二次认证:请前往 /itportal/ 完成(Phase 2.4 即将上线)')
}
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
// 进入页面立即生成二维码
startLogin()
})
onUnmounted(() => {
// 离开页面停止轮询(防止内存泄漏)
stopPolling()
})
</script>
<style scoped>
/* 登录页面容器:全屏居中 */
.login-page {
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: 440px;
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;
}
/* 二维码区 */
.qrcode-section {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
margin-bottom: 24px;
}
.qrcode-image {
width: 200px;
height: 200px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: opacity 0.3s;
}
.qrcode-expired {
opacity: 0.3;
filter: grayscale(100%);
}
.qrcode-placeholder,
.qrcode-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--text-tertiary, #909399);
}
.qrcode-placeholder .el-icon,
.qrcode-error .el-icon {
font-size: 48px;
}
.qrcode-fallback {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.qrcode-fallback-hint {
font-size: 13px;
color: var(--text-regular, #606266);
margin: 0;
}
.qrcode-fallback-url {
font-size: 11px;
font-family: monospace;
}
.qrcode-fallback-tip {
font-size: 11px;
color: var(--text-placeholder, #c0c4cc);
margin: 0;
text-align: center;
}
/* 状态区 */
.status-section {
text-align: center;
margin-bottom: 24px;
min-height: 80px;
}
.status-main {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #303133);
margin: 0 0 8px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.status-sub {
font-size: 13px;
color: var(--text-tertiary, #909399);
margin: 0;
line-height: 1.5;
}
.status-waiting .status-main .el-icon {
font-size: 20px;
}
.countdown {
color: #409eff;
font-weight: 600;
font-family: monospace;
}
.highlight {
color: #409eff;
font-weight: 600;
}
.refresh-button,
.otp-button {
margin-top: 16px;
width: 100%;
}
/* 底部提示 */
.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;
}
.login-hint p {
margin: 4px 0;
}
.login-hint-sub {
font-size: 11px;
color: var(--text-placeholder, #c0c4cc);
}
</style>