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:
Simon
2026-07-08 21:54:57 +08:00
parent 6f0fbbb066
commit 400ce3ddcb
27 changed files with 2822 additions and 312 deletions
+129
View File
@@ -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>
+14
View File
@@ -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 },
},
]
// --------------------------------------------------------------------------
+20 -2
View File
@@ -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
+58 -8
View File
@@ -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')
+450
View File
@@ -0,0 +1,450 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席端个人设置页 (T04)
// =============================================================================
// 说明:坐席端个人设置页面,包含「OTP 二次验证」管理面板。
// 功能:
// - 查询 OTP 绑定状态(getOtpStatus
// - 已绑定时显示状态 + 解绑按钮
// - 未绑定时显示警告 + 绑定按钮(复用 OtpBindPanel
//
// 依赖:
// - @/api/otpgetOtpStatus / unbindOtp
// - @/components/OtpBindPanel.vue:可复用的 OTP 首次绑定面板
// - @/stores/agentuseAgentStore(获取 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>