feat: OTP首次绑定 + 三端登录修复 + 管理端权限修复 (2026-07-08)
OTP首次绑定: - 新增统一 OTP 路由 /auth/otp-* (otp.py + router.py) - 坐席端 OTP 绑定面板 (OtpBindPanel.vue) - 管理端 OTP 管理列表 (MfaManage.vue) - agent_login 签发半认证 token 支持首次绑定流程 三端登录修复: - 坐席/管理端去掉'返回扫码登录'按钮 - 管理端改为二维码始终可见+轮询扫码状态 - 员工端 /itdesk/ 改为 alias 直接服务 H5 (不再301重定向) - docker-compose 添加 h5 volume 挂载 管理端权限修复: - 扫码登录改用 get_user_roles() 替代写死 roles=['agent'] - get_user_roles() 增加 agents.role 回退 - 新增 GET /admin/roles/user-roles 端点 - 角色管理页加载用户角色分配数据 文档更新: - OTP PRD + 系统设计文档 - 故障排查手册 v1.1 (新增6案例) - nginx 生产基准配置
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — OTP 绑定 API 适配层 (T03)
|
||||
// =============================================================================
|
||||
// 说明:封装 /auth/otp-* 端点,供坐席端登录绑定面板使用
|
||||
// 对应后端:backend/app/api/auth.py (OTP 绑定/验证/状态/解绑)
|
||||
//
|
||||
// 端点:
|
||||
// POST /auth/otp-bind — 生成 secret + 二维码(首次绑定)
|
||||
// POST /auth/otp-verify — 输入 OTP 完成绑定/验证
|
||||
// GET /auth/otp-status — 查询 OTP 绑定状态
|
||||
// POST /auth/otp-unbind — 解绑 OTP(需当前 OTP 码)
|
||||
//
|
||||
// 响应契约(CTRT):apiClient 拦截器已自动解包 inner data,
|
||||
// 前端直接消费返回字段,无需访问 .data / .data.data。
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TypeScript 类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** POST /auth/otp-bind 响应 */
|
||||
export interface OtpBindData {
|
||||
/** TOTP 共享密钥(base32) */
|
||||
secret: string
|
||||
/** otpauth:// URI */
|
||||
otpauth_url: string
|
||||
/** 二维码 PNG base64(不含 data: 前缀) */
|
||||
qr_code_base64: string
|
||||
}
|
||||
|
||||
/** POST /auth/otp-verify 请求体 */
|
||||
export interface OtpVerifyRequest {
|
||||
/** 6 位 OTP 动态码 */
|
||||
otp_code: string
|
||||
}
|
||||
|
||||
/** POST /auth/otp-verify 响应 */
|
||||
export interface OtpVerifyData {
|
||||
/** 验证是否通过 */
|
||||
verified: boolean
|
||||
/** 登录 token(首次绑定成功后返回) */
|
||||
token?: string
|
||||
/** 用户 ID */
|
||||
user_id?: string
|
||||
/** 用户姓名 */
|
||||
name?: string
|
||||
/** 用户角色 */
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** GET /auth/otp-status 响应 */
|
||||
export interface OtpStatusData {
|
||||
/** 是否已绑定 */
|
||||
bound: boolean
|
||||
/** 是否已启用 */
|
||||
enabled: boolean
|
||||
/** 最近一次验证成功时间(ISO 8601,可空) */
|
||||
last_verified_at?: string | null
|
||||
}
|
||||
|
||||
/** POST /auth/otp-unbind 请求体 */
|
||||
export interface OtpUnbindRequest {
|
||||
/** 6 位 OTP 动态码(解绑前需验证当前 OTP) */
|
||||
otp_code: string
|
||||
}
|
||||
|
||||
/** POST /auth/otp-unbind 响应 */
|
||||
export interface OtpUnbindData {
|
||||
/** 解绑是否成功 */
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 1) 绑定 OTP — 生成 secret + 二维码
|
||||
* 坐席首次登录时调用,获取 TOTP 密钥和二维码
|
||||
*
|
||||
* 注意:此端点要求先通过账密验证(后端会校验临时凭证)
|
||||
*
|
||||
* @returns OTP 绑定信息(secret + otpauth_url + base64 PNG)
|
||||
*/
|
||||
export async function bindOtp(): Promise<OtpBindData> {
|
||||
const response: AxiosResponse = await apiClient.post('/auth/otp-bind')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) 验证 OTP 并完成绑定
|
||||
* 用户扫码后输入 6 位验证码,验证通过后完成绑定
|
||||
* 如果是登录流程中的首次绑定,返回 token 等登录信息
|
||||
*
|
||||
* @param otpCode - 6 位 OTP 动态码
|
||||
* @returns 验证结果(verified + 可选的登录 token)
|
||||
*/
|
||||
export async function verifyOtp(otpCode: string): Promise<OtpVerifyData> {
|
||||
const body: OtpVerifyRequest = { otp_code: otpCode }
|
||||
const response: AxiosResponse = await apiClient.post('/auth/otp-verify', body)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) 查询 OTP 绑定状态
|
||||
* 用于路由守卫或设置页判断当前用户的 OTP 状态
|
||||
*
|
||||
* @returns OTP 状态(bound + enabled + last_verified_at)
|
||||
*/
|
||||
export async function getOtpStatus(): Promise<OtpStatusData> {
|
||||
const response: AxiosResponse = await apiClient.get('/auth/otp-status')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 4) 解绑 OTP
|
||||
* 用户主动关闭 OTP,需先输入当前 OTP 码确认
|
||||
*
|
||||
* @param otpCode - 6 位 OTP 动态码(防误操作)
|
||||
* @returns 解绑结果
|
||||
*/
|
||||
export async function unbindOtp(otpCode: string): Promise<OtpUnbindData> {
|
||||
const body: OtpUnbindRequest = { otp_code: otpCode }
|
||||
const response: AxiosResponse = await apiClient.post('/auth/otp-unbind', body)
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
<!-- =============================================================================
|
||||
// IT智能服务台 — OTP 首次绑定面板 (T03)
|
||||
// =============================================================================
|
||||
// 说明:坐席首次登录时展示的 OTP 绑定面板,嵌入登录卡片内。
|
||||
// 功能:
|
||||
// - 展示二维码(供 Authenticator 扫码)
|
||||
// - 展示 secret 密钥(手动输入备用)
|
||||
// - 6 位验证码输入 + 验证并完成绑定
|
||||
// - P0 阶段不显示"暂不绑定"按钮(P1 再加)
|
||||
//
|
||||
// Props: userId, name
|
||||
// Emits: bind-success(token, userId, name, role), cancel
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="otp-bind-panel">
|
||||
<!-- 标题 -->
|
||||
<div class="bind-title">
|
||||
<h2>🔐 首次登录 — 绑定 OTP 二次验证</h2>
|
||||
<p v-if="name" class="bind-subtitle">欢迎,{{ name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="bind-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>正在生成密钥...</p>
|
||||
</div>
|
||||
|
||||
<!-- 绑定内容 -->
|
||||
<div v-else class="bind-content">
|
||||
<!-- 二维码 -->
|
||||
<div class="qr-section">
|
||||
<div class="qr-wrapper">
|
||||
<img
|
||||
v-if="qrCodeBase64"
|
||||
:src="'data:image/png;base64,' + qrCodeBase64"
|
||||
alt="OTP 二维码"
|
||||
class="qr-image"
|
||||
/>
|
||||
<div v-else class="qr-placeholder">
|
||||
<el-icon :size="48"><PictureFilled /></el-icon>
|
||||
<p>二维码加载失败</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-hint">请使用 Google Authenticator 或 Microsoft Authenticator 扫码</p>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="divider-line">
|
||||
<span>或手动输入密钥</span>
|
||||
</div>
|
||||
|
||||
<!-- Secret 密钥展示 -->
|
||||
<div class="secret-section">
|
||||
<div class="secret-display">
|
||||
<code class="secret-text">{{ secret }}</code>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="copied ? 'success' : 'default'"
|
||||
class="copy-btn"
|
||||
@click="copySecret"
|
||||
>
|
||||
{{ copied ? '✅ 已复制' : '📋 复制' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div class="verify-section">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="verifyForm"
|
||||
:rules="verifyRules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleVerify"
|
||||
>
|
||||
<el-form-item label="验证码" prop="otpCode">
|
||||
<el-input
|
||||
v-model="verifyForm.otpCode"
|
||||
placeholder="请输入 6 位验证码"
|
||||
size="large"
|
||||
maxlength="6"
|
||||
show-word-limit
|
||||
@keydown.enter="handleVerify"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="bind-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="verifying"
|
||||
:disabled="verifying || verifyForm.otpCode.length !== 6"
|
||||
class="verify-btn"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ verifying ? '验证中...' : '验证并完成绑定' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- P0: 不显示"暂不绑定"按钮(P1 再加) -->
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="errorMsg = ''"
|
||||
class="bind-error"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Loading, PictureFilled } from '@element-plus/icons-vue'
|
||||
import { bindOtp, verifyOtp } from '@/api/otp'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props & Emits
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const props = defineProps<{
|
||||
/** 用户 ID */
|
||||
userId: string
|
||||
/** 用户姓名 */
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 绑定成功事件 */
|
||||
(e: 'bind-success', token: string, userId: string, name: string, role: string): void
|
||||
/** 取消绑定事件 */
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 状态
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/** 加载中(获取二维码) */
|
||||
const loading = ref(true)
|
||||
|
||||
/** 验证中 */
|
||||
const verifying = ref(false)
|
||||
|
||||
/** 二维码 base64 */
|
||||
const qrCodeBase64 = ref('')
|
||||
|
||||
/** TOTP 密钥 */
|
||||
const secret = ref('')
|
||||
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref<string>('')
|
||||
|
||||
/** 复制状态 */
|
||||
const copied = ref(false)
|
||||
|
||||
/** 验证码表单 */
|
||||
const verifyForm = reactive({
|
||||
otpCode: '',
|
||||
})
|
||||
|
||||
/** 验证码校验规则 */
|
||||
const verifyRules: FormRules = {
|
||||
otpCode: [
|
||||
{ required: true, message: '请输入 6 位验证码', trigger: 'blur' },
|
||||
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^\d{6}$/,
|
||||
message: '验证码只能包含数字',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOtpBind()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取 OTP 绑定信息(密钥 + 二维码)
|
||||
*/
|
||||
async function fetchOtpBind(): Promise<void> {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const data = await bindOtp()
|
||||
secret.value = data.secret
|
||||
qrCodeBase64.value = data.qr_code_base64
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '获取绑定信息失败'
|
||||
console.error('[OtpBindPanel] 获取绑定信息失败:', error)
|
||||
errorMsg.value = errMsg
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制密钥到剪贴板
|
||||
*/
|
||||
async function copySecret(): Promise<void> {
|
||||
if (!secret.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret.value)
|
||||
copied.value = true
|
||||
ElMessage.success('密钥已复制到剪贴板')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch {
|
||||
// 降级方案:使用传统方式复制
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = secret.value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
copied.value = true
|
||||
ElMessage.success('密钥已复制到剪贴板')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制密钥')
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 OTP 并完成绑定
|
||||
*/
|
||||
async function handleVerify(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
verifying.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const data = await verifyOtp(verifyForm.otpCode.trim())
|
||||
|
||||
if (data.verified) {
|
||||
ElMessage.success('OTP 绑定成功')
|
||||
emit(
|
||||
'bind-success',
|
||||
data.token || '',
|
||||
data.user_id || props.userId,
|
||||
data.name || props.name,
|
||||
data.role || ''
|
||||
)
|
||||
} else {
|
||||
// verified=false:验证码错误,不抛异常,提示用户重试
|
||||
errorMsg.value = '验证码错误,请重新输入'
|
||||
verifyForm.otpCode = ''
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '验证失败,请重试'
|
||||
console.error('[OtpBindPanel] 验证失败:', error)
|
||||
errorMsg.value = errMsg
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
企微浅色扁平风格(accent #07C160)
|
||||
========================================================================== */
|
||||
|
||||
.otp-bind-panel {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ---- 标题 ---- */
|
||||
.bind-title {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.bind-title h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bind-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #606266);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- 加载 ---- */
|
||||
.bind-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 0;
|
||||
color: var(--text-tertiary, #909399);
|
||||
}
|
||||
|
||||
.bind-loading .el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.bind-loading p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- 二维码区域 ---- */
|
||||
.qr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e4e7ed;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-placeholder, #c0c4cc);
|
||||
}
|
||||
|
||||
.qr-placeholder p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- 分隔线 ---- */
|
||||
.divider-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--text-placeholder, #c0c4cc);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.divider-line::before,
|
||||
.divider-line::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e4e7ed;
|
||||
}
|
||||
|
||||
.divider-line span {
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- 密钥区域 ---- */
|
||||
.secret-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.secret-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.secret-text {
|
||||
flex: 1;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
letter-spacing: 1px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 验证码输入 ---- */
|
||||
.verify-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.verify-section .el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #606266);
|
||||
}
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.bind-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.verify-btn {
|
||||
width: 100%;
|
||||
background-color: #07c160;
|
||||
border-color: #07c160;
|
||||
}
|
||||
|
||||
.verify-btn:hover,
|
||||
.verify-btn:focus {
|
||||
background-color: #06ad56;
|
||||
border-color: #06ad56;
|
||||
}
|
||||
|
||||
.verify-btn:active {
|
||||
background-color: #059a4d;
|
||||
border-color: #059a4d;
|
||||
}
|
||||
|
||||
/* ---- 错误提示 ---- */
|
||||
.bind-error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -57,6 +57,20 @@ const routes = [
|
||||
component: () => import('@/views/automation/SessionWorkbench.vue'),
|
||||
meta: { title: '自动化会话', requiresAuth: true },
|
||||
},
|
||||
// Tier1 知识库迭代 — 独立审批队列(D7)
|
||||
{
|
||||
path: '/approval-queue',
|
||||
name: 'ApprovalQueue',
|
||||
component: () => import('@/views/ApprovalQueue.vue'),
|
||||
meta: { title: '独立审批队列', requiresAuth: true },
|
||||
},
|
||||
// T04 — 坐席端个人设置页(含 OTP 管理)
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { title: '个人设置', requiresAuth: true },
|
||||
},
|
||||
]
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -83,7 +83,6 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
|
||||
// 登录前先清除旧数据,避免切换账号时显示旧用户信息
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(PORTAL_TOKEN_KEY)
|
||||
localStorage.removeItem(AGENT_USER_ID_KEY)
|
||||
token.value = null
|
||||
agentUserId.value = null
|
||||
@@ -91,7 +90,26 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
|
||||
const data = await apiLogin(inputUserId, password, otpCode)
|
||||
|
||||
// 检查是否需要 OTP 验证
|
||||
// 检查是否需要 OTP 首次绑定(优先级高于 require_otp)
|
||||
if ('require_otp_bind' in data && data.require_otp_bind) {
|
||||
// 保存半认证 token(BUG-001 修复后新增),供后续 otp-bind/otp-verify 携带鉴权
|
||||
if (data.token) {
|
||||
token.value = data.token
|
||||
agentUserId.value = data.user_id
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
localStorage.setItem(AGENT_USER_ID_KEY, data.user_id)
|
||||
}
|
||||
// 不抛异常,返回绑定信息供 Login.vue 展示 OtpBindPanel
|
||||
logging.value = false
|
||||
return {
|
||||
require_otp_bind: true,
|
||||
user_id: data.user_id,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要 OTP 验证(已绑定用户二次验证)
|
||||
if ('require_otp' in data && data.require_otp) {
|
||||
// 返回 data,让 Login.vue 处理 require_otp
|
||||
logging.value = false
|
||||
|
||||
@@ -59,15 +59,8 @@
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录表单 -->
|
||||
<!-- 账号密码登录表单(与二维码同时展示,无需返回按钮) -->
|
||||
<div v-if="showPasswordLogin" class="password-login">
|
||||
<el-button
|
||||
size="small"
|
||||
class="back-btn"
|
||||
@click="handleBackToQrCode"
|
||||
>
|
||||
← 返回扫码登录
|
||||
</el-button>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
@@ -125,6 +118,15 @@
|
||||
</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"
|
||||
@@ -153,6 +155,7 @@ 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'
|
||||
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { connect: connectWebSocket } = useWebSocket()
|
||||
@@ -180,6 +183,12 @@ const loginForm = reactive({
|
||||
/** 是否需要 OTP 验证 */
|
||||
const requireOtp = 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)
|
||||
|
||||
@@ -316,6 +325,20 @@ async function handleLogin(): Promise<void> {
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
if (result && result.require_otp) {
|
||||
requireOtp.value = true
|
||||
loginForm.otpCode = ''
|
||||
@@ -342,6 +365,33 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端个人设置页 (T04)
|
||||
// =============================================================================
|
||||
// 说明:坐席端个人设置页面,包含「OTP 二次验证」管理面板。
|
||||
// 功能:
|
||||
// - 查询 OTP 绑定状态(getOtpStatus)
|
||||
// - 已绑定时显示状态 + 解绑按钮
|
||||
// - 未绑定时显示警告 + 绑定按钮(复用 OtpBindPanel)
|
||||
//
|
||||
// 依赖:
|
||||
// - @/api/otp:getOtpStatus / unbindOtp
|
||||
// - @/components/OtpBindPanel.vue:可复用的 OTP 首次绑定面板
|
||||
// - @/stores/agent:useAgentStore(获取 userId / agentName)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h1 class="page-title">个人设置</h1>
|
||||
|
||||
<!-- ==========================================================================
|
||||
OTP 二次验证面板
|
||||
========================================================================== -->
|
||||
<el-card class="settings-card otp-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">🔐 OTP 二次验证</span>
|
||||
<el-tag
|
||||
:type="otpStatus.bound ? 'success' : 'warning'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ otpStatus.bound ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="statusLoading" class="status-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>正在查询状态...</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-else class="otp-content">
|
||||
<!-- 已绑定状态 -->
|
||||
<template v-if="otpStatus.bound">
|
||||
<div class="otp-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">绑定状态</span>
|
||||
<span class="info-value">
|
||||
<el-icon class="check-icon"><CircleCheckFilled /></el-icon>
|
||||
OTP 二次验证已启用
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="otpStatus.last_verified_at" class="info-row">
|
||||
<span class="info-label">最近验证</span>
|
||||
<span class="info-value info-time">
|
||||
{{ formatTime(otpStatus.last_verified_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="otp-actions">
|
||||
<p class="action-hint">
|
||||
解绑后将关闭二次验证保护,建议保持开启以增强账户安全。
|
||||
</p>
|
||||
<el-button
|
||||
type="danger"
|
||||
:loading="unbinding"
|
||||
:disabled="unbinding"
|
||||
@click="handleUnbind"
|
||||
>
|
||||
{{ unbinding ? '解绑中...' : '解绑 OTP' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 未绑定状态 -->
|
||||
<template v-else>
|
||||
<div class="otp-warning">
|
||||
<el-icon class="warning-icon"><WarningFilled /></el-icon>
|
||||
<div class="warning-text">
|
||||
<p class="warning-title">您的账户尚未绑定 OTP 二次验证</p>
|
||||
<p class="warning-desc">
|
||||
绑定后每次登录需输入动态验证码,有效防止账户被盗。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="otp-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="bind-btn"
|
||||
@click="showBindDialog = true"
|
||||
>
|
||||
立即绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ==========================================================================
|
||||
绑定 OTP 弹窗
|
||||
========================================================================== -->
|
||||
<el-dialog
|
||||
v-model="showBindDialog"
|
||||
title="绑定 OTP 二次验证"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<OtpBindPanel
|
||||
:user-id="agentStore.userId"
|
||||
:name="agentStore.agentName"
|
||||
@bind-success="onBindSuccess"
|
||||
@cancel="showBindDialog = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Loading, CircleCheckFilled, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { getOtpStatus, unbindOtp } from '@/api/otp'
|
||||
import type { OtpStatusData } from '@/api/otp'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 状态
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** OTP 绑定状态 */
|
||||
const otpStatus = reactive<OtpStatusData>({
|
||||
bound: false,
|
||||
enabled: false,
|
||||
last_verified_at: null,
|
||||
})
|
||||
|
||||
/** 状态加载中 */
|
||||
const statusLoading = ref(true)
|
||||
|
||||
/** 解绑中 */
|
||||
const unbinding = ref(false)
|
||||
|
||||
/** 是否显示绑定弹窗 */
|
||||
const showBindDialog = ref(false)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOtpStatus()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 查询 OTP 绑定状态
|
||||
*/
|
||||
async function fetchOtpStatus(): Promise<void> {
|
||||
statusLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await getOtpStatus()
|
||||
otpStatus.bound = data.bound
|
||||
otpStatus.enabled = data.enabled
|
||||
otpStatus.last_verified_at = data.last_verified_at ?? null
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '获取 OTP 状态失败'
|
||||
console.error('[Settings] 获取 OTP 状态失败:', error)
|
||||
ElMessage.error(errMsg)
|
||||
} finally {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑 OTP
|
||||
* 弹窗输入当前 6 位 OTP 验证码,验证通过后解绑
|
||||
*/
|
||||
async function handleUnbind(): Promise<void> {
|
||||
try {
|
||||
// ElMessageBox.prompt 弹出输入框
|
||||
const { value: otpCode } = await ElMessageBox.prompt(
|
||||
'请输入当前 6 位 OTP 验证码以确认解绑',
|
||||
'解绑 OTP 二次验证',
|
||||
{
|
||||
confirmButtonText: '确认解绑',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '请输入 6 位验证码',
|
||||
inputType: 'text',
|
||||
inputValidator: (val: string) => {
|
||||
if (!val || val.trim().length !== 6) {
|
||||
return '请输入 6 位验证码'
|
||||
}
|
||||
if (!/^\d{6}$/.test(val.trim())) {
|
||||
return '验证码只能包含数字'
|
||||
}
|
||||
return true
|
||||
},
|
||||
// 危险操作样式
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
// 输入框限制
|
||||
inputErrorMessage: '验证码格式不正确',
|
||||
}
|
||||
)
|
||||
|
||||
// 用户取消了
|
||||
if (otpCode === undefined) return
|
||||
|
||||
unbinding.value = true
|
||||
|
||||
const data = await unbindOtp(otpCode.trim())
|
||||
|
||||
if (data.success) {
|
||||
ElMessage.success('OTP 已解绑,二次验证已关闭')
|
||||
// 刷新状态
|
||||
await fetchOtpStatus()
|
||||
} else {
|
||||
ElMessage.error('解绑失败,请重试')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// ElMessageBox 取消时会抛出 'cancel' 字符串
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
|
||||
const errMsg = error instanceof Error ? error.message : '解绑失败,请重试'
|
||||
console.error('[Settings] 解绑 OTP 失败:', error)
|
||||
ElMessage.error(errMsg)
|
||||
} finally {
|
||||
unbinding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定成功回调
|
||||
*/
|
||||
async function onBindSuccess(): Promise<void> {
|
||||
showBindDialog.value = false
|
||||
ElMessage.success('OTP 二次验证绑定成功')
|
||||
// 刷新状态
|
||||
await fetchOtpStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 8601 时间为本地可读格式
|
||||
*/
|
||||
function formatTime(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
企微浅色扁平风格(accent #07C160)
|
||||
========================================================================== */
|
||||
|
||||
.settings-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 48px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 24px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- 卡片 ---- */
|
||||
.settings-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.settings-card :deep(.el-card__header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.settings-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
}
|
||||
|
||||
/* ---- 加载 ---- */
|
||||
.status-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 0;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #909399);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-loading .el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ---- OTP 信息 ---- */
|
||||
.otp-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
min-width: 72px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #606266);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #303133);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #07c160;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-time {
|
||||
color: var(--text-tertiary, #909399);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 警告区域 ---- */
|
||||
.otp-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fdf6ec;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #faecd8;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: #e6a23c;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.warning-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #606266);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- 操作区域 ---- */
|
||||
.otp-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 绑定按钮 — 企微绿 */
|
||||
.bind-btn {
|
||||
background-color: #07c160;
|
||||
border-color: #07c160;
|
||||
}
|
||||
|
||||
.bind-btn:hover,
|
||||
.bind-btn:focus {
|
||||
background-color: #06ad56;
|
||||
border-color: #06ad56;
|
||||
}
|
||||
|
||||
.bind-btn:active {
|
||||
background-color: #059a4d;
|
||||
border-color: #059a4d;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user