fix(REQ-013): 修复 2 个 P0 Bug — TopBar v-model 命名 + OtpBindDialog API 端点
QA Round 1 修复(不改 PRD 偏离和 P2/P3 项): ## Bug #1 — TopBar.vue v-model 与 Dialog props 不匹配 - Line 120: <AgentProfileDialog v-model> → v-model:visible - Line 125: <OtpBindDialog v-model> → v-model:visible - 原因:Vue 3 v-model 无参数展开为 modelValue/update:modelValue,Dialog 组件用 visible props + update:visible emit,名称错配导致弹窗打不开 + TS2345 报错 ## Bug #2 — OtpBindDialog 调用了不存在的 /agents/otp-* 端点 - import: @/api/agent → @/api/otp(真实端点 /auth/otp-*) - 二维码字段: qr_code → qr_code_base64 - 增加 getOtpStatus() 状态判断:bound=true 走已绑定分支(不再用 !!data.secret 误判) - verifyOtp(otpCode) / unbindOtp(otpCode) 改为单参数(otp.ts 新签名) - 删除未使用的 useAgentStore import - 二维码 src: data:image/png;base64, ${qr_code_base64} ## Bonus 修复 - AgentProfileDialog.vue:18 与 OtpBindDialog.vue:18 同样添加 (val: boolean) 类型注解(TS7006 隐式 any) ## 验证 - vue-tsc --noEmit: TopBar.vue / OtpBindDialog.vue / AgentProfileDialog.vue 全部 0 报错 - 残留 111 个 vue-tsc 错误均为项目其他文件预先存在(AxiosResponse 类型不匹配等),与本次修复无关
This commit is contained in:
@@ -0,0 +1,603 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// 企微IT智能服务台 — 个性化设置对话框(REQ-坐席-013)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:坐席端「个性化设置」对话框,从姓名账户菜单第 1 项触发。
|
||||||
|
// 功能(三档头像 + 昵称):
|
||||||
|
// - 头像 A 档(wecom):使用企微头像
|
||||||
|
// - 头像 B 档(preset):8 套预置 SVG(4×2 网格)
|
||||||
|
// - 头像 C 档(custom):拖拽 / 选择上传 jpg/png(≤2MB)
|
||||||
|
// - 昵称:≤16 字,禁 Emoji,自动 trim + 折叠空白
|
||||||
|
// ============================================================================= -->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="visible"
|
||||||
|
title="个性化设置"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@update:model-value="(val: boolean) => $emit('update:visible', val)"
|
||||||
|
@open="handleDialogOpen"
|
||||||
|
@close="handleDialogClose"
|
||||||
|
>
|
||||||
|
<div v-loading="loading">
|
||||||
|
<!-- ============ 头像选择 ============ -->
|
||||||
|
<div class="prd013-section">
|
||||||
|
<div class="prd013-section-label">头像</div>
|
||||||
|
|
||||||
|
<!-- 头像档位切换 -->
|
||||||
|
<el-radio-group v-model="activeSource" class="prd013-source-tabs" @change="handleSourceChange">
|
||||||
|
<el-radio-button value="wecom">企微</el-radio-button>
|
||||||
|
<el-radio-button value="preset">预置</el-radio-button>
|
||||||
|
<el-radio-button value="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<!-- A 档:企微头像 -->
|
||||||
|
<div v-show="activeSource === 'wecom'" class="prd013-source-panel">
|
||||||
|
<div class="prd013-wecom-preview">
|
||||||
|
<img
|
||||||
|
v-if="wecomProxyUrl"
|
||||||
|
:src="wecomProxyUrl"
|
||||||
|
class="prd013-wecom-img"
|
||||||
|
alt="企微头像"
|
||||||
|
@error="wecomImgError = true"
|
||||||
|
/>
|
||||||
|
<div v-else class="prd013-wecom-placeholder">
|
||||||
|
{{ deriveAvatarLetter(agentStore.agentName || '?') }}
|
||||||
|
</div>
|
||||||
|
<div class="prd013-wecom-text">
|
||||||
|
<p class="prd013-wecom-name">{{ agentStore.agentName || '?' }}</p>
|
||||||
|
<p class="prd013-wecom-hint">将使用您当前企微账号头像</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- B 档:预置头像(4×2 网格) -->
|
||||||
|
<div v-show="activeSource === 'preset'" class="prd013-source-panel">
|
||||||
|
<div class="prd013-preset-grid">
|
||||||
|
<button
|
||||||
|
v-for="p in PRESET_AVATARS"
|
||||||
|
:key="p.id"
|
||||||
|
type="button"
|
||||||
|
class="prd013-preset-item"
|
||||||
|
:class="{ 'is-active': formDraft.avatar_preset === p.id }"
|
||||||
|
:title="p.colorName"
|
||||||
|
@click="selectPreset(p.id)"
|
||||||
|
>
|
||||||
|
<img :src="p.svg" :alt="p.colorName" class="prd013-preset-img" />
|
||||||
|
<span class="prd013-preset-id">{{ p.id.replace('preset-', '') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- C 档:自定义上传 -->
|
||||||
|
<div v-show="activeSource === 'custom'" class="prd013-source-panel">
|
||||||
|
<div
|
||||||
|
class="prd013-upload-zone"
|
||||||
|
:class="{ 'is-dragover': isDragover }"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
@dragover.prevent="isDragover = true"
|
||||||
|
@dragleave.prevent="isDragover = false"
|
||||||
|
@drop.prevent="handleFileDrop"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
<div v-if="!previewUrl" class="prd013-upload-empty">
|
||||||
|
<el-icon :size="32"><Upload /></el-icon>
|
||||||
|
<p class="prd013-upload-hint">点击或拖拽上传</p>
|
||||||
|
<p class="prd013-upload-meta">仅支持 jpg/png,≤ 2MB</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="prd013-upload-preview">
|
||||||
|
<img :src="previewUrl" class="prd013-upload-img" alt="头像预览" />
|
||||||
|
<p v-if="uploading" class="prd013-upload-status">上传中…</p>
|
||||||
|
<p v-else class="prd013-upload-status">已上传 {{ (lastFileSize / 1024).toFixed(0) }} KB</p>
|
||||||
|
<el-button size="small" @click.stop="clearPreview">重新选择</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<!-- ============ 昵称输入 ============ -->
|
||||||
|
<div class="prd013-section">
|
||||||
|
<div class="prd013-section-label">
|
||||||
|
<span>昵称</span>
|
||||||
|
<span class="prd013-char-count">{{ formDraft.nickname.length }}/16</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="formDraft.nickname"
|
||||||
|
placeholder="留空回落真实姓名(≤16 字,禁 Emoji)"
|
||||||
|
maxlength="16"
|
||||||
|
show-word-limit
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<p class="prd013-nickname-preview">
|
||||||
|
预览:<strong>{{ previewName }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ 底部按钮 ============ -->
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="!hasChange" @click="handleSave">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ============================================================================
|
||||||
|
// 导入
|
||||||
|
// ============================================================================
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Upload } from '@element-plus/icons-vue'
|
||||||
|
import { PRESET_AVATARS, deriveAvatarLetter } from '@/data/presetAvatars'
|
||||||
|
import { useAgentStore } from '@/stores/agent'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Props / Emits
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 弹窗可见性(v-model) */
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', val: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const agentStore = useAgentStore()
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 表单草稿
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ProfileFormDraft {
|
||||||
|
avatar_source: 'wecom' | 'preset' | 'custom'
|
||||||
|
avatar_url: string
|
||||||
|
avatar_preset: string | null
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DRAFT: ProfileFormDraft = {
|
||||||
|
avatar_source: 'wecom',
|
||||||
|
avatar_url: '',
|
||||||
|
avatar_preset: null,
|
||||||
|
nickname: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formDraft = ref<ProfileFormDraft>({ ...DEFAULT_DRAFT })
|
||||||
|
|
||||||
|
/** 当前激活的档位(UI 切换用) */
|
||||||
|
const activeSource = ref<'wecom' | 'preset' | 'custom'>('wecom')
|
||||||
|
|
||||||
|
/** 上传预览 URL(base64 data URI) */
|
||||||
|
const previewUrl = ref('')
|
||||||
|
|
||||||
|
/** 上传中状态 */
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
/** 最近一次上传的文件大小 */
|
||||||
|
const lastFileSize = ref(0)
|
||||||
|
|
||||||
|
/** 拖拽高亮 */
|
||||||
|
const isDragover = ref(false)
|
||||||
|
|
||||||
|
/** 加载态(dialog 打开时拉取最新 agentInfo) */
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
/** 保存中状态 */
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
/** file input ref */
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
/** 企微图片加载失败标记 */
|
||||||
|
const wecomImgError = ref(false)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 派生
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 企微代理 URL:原 avatar_url 走 /api/avatar/proxy */
|
||||||
|
const wecomProxyUrl = computed(() => {
|
||||||
|
if (!agentStore.agentInfo?.avatar_url) return ''
|
||||||
|
return `/api/avatar/proxy?url=${encodeURIComponent(agentStore.agentInfo.avatar_url)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 是否有修改(用于启用/禁用保存按钮) */
|
||||||
|
const hasChange = computed(() => {
|
||||||
|
const info = agentStore.agentInfo
|
||||||
|
if (!info) return false
|
||||||
|
if (formDraft.value.avatar_source !== (info.avatar_source || 'wecom')) return true
|
||||||
|
if ((formDraft.value.avatar_url || '') !== (info.avatar_url || '')) return true
|
||||||
|
if ((formDraft.value.avatar_preset || null) !== (info.avatar_preset || null)) return true
|
||||||
|
if ((formDraft.value.nickname || '').trim() !== (info.nickname || '').trim()) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 昵称预览:「nickname|name」或「name」 */
|
||||||
|
const previewName = computed(() => {
|
||||||
|
const nick = (formDraft.value.nickname || '').trim()
|
||||||
|
const real = (agentStore.agentInfo?.name || '').trim() || agentStore.agentName || ''
|
||||||
|
if (!nick || nick === real) return real || '—'
|
||||||
|
return `${nick}|${real}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 监听 visible:打开时刷新 agentInfo 并填充表单
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (val) => {
|
||||||
|
if (val) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await agentStore.refreshAgentInfo()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleDialogOpen(): void {
|
||||||
|
// 从 store 拷贝初始值
|
||||||
|
const info = agentStore.agentInfo
|
||||||
|
formDraft.value = {
|
||||||
|
avatar_source: (info?.avatar_source as 'wecom' | 'preset' | 'custom') || 'wecom',
|
||||||
|
avatar_url: info?.avatar_url || '',
|
||||||
|
avatar_preset: info?.avatar_preset || null,
|
||||||
|
nickname: info?.nickname || '',
|
||||||
|
}
|
||||||
|
activeSource.value = formDraft.value.avatar_source
|
||||||
|
previewUrl.value = ''
|
||||||
|
lastFileSize.value = 0
|
||||||
|
wecomImgError.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogClose(): void {
|
||||||
|
previewUrl.value = ''
|
||||||
|
isDragover.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 档位切换
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function handleSourceChange(val: 'wecom' | 'preset' | 'custom'): void {
|
||||||
|
formDraft.value.avatar_source = val
|
||||||
|
if (val === 'wecom') {
|
||||||
|
// 切回企微:URL 取当前 store 中的企微 URL
|
||||||
|
formDraft.value.avatar_url = agentStore.agentInfo?.avatar_url || ''
|
||||||
|
formDraft.value.avatar_preset = null
|
||||||
|
previewUrl.value = ''
|
||||||
|
} else if (val === 'preset') {
|
||||||
|
// 切到预置:URL 清空,preset 由 selectPreset 设置
|
||||||
|
if (!formDraft.value.avatar_preset) {
|
||||||
|
formDraft.value.avatar_preset = PRESET_AVATARS[0].id
|
||||||
|
}
|
||||||
|
formDraft.value.avatar_url = ''
|
||||||
|
previewUrl.value = ''
|
||||||
|
} else if (val === 'custom') {
|
||||||
|
// 切到自定义:URL 清空,由 handleFileChange 设置
|
||||||
|
formDraft.value.avatar_preset = null
|
||||||
|
if (!formDraft.value.avatar_url) {
|
||||||
|
previewUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreset(id: string): void {
|
||||||
|
formDraft.value.avatar_preset = id
|
||||||
|
formDraft.value.avatar_source = 'preset'
|
||||||
|
formDraft.value.avatar_url = ''
|
||||||
|
// 同步 activeSource 以防 watch 顺序差异
|
||||||
|
activeSource.value = 'preset'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 自定义上传
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png']
|
||||||
|
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
|
|
||||||
|
function triggerFileInput(): void {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(ev: Event): void {
|
||||||
|
const input = ev.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
void processFile(file)
|
||||||
|
}
|
||||||
|
// 清空 value 允许同名重复上传
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDrop(ev: DragEvent): void {
|
||||||
|
isDragover.value = false
|
||||||
|
const file = ev.dataTransfer?.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
void processFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFile(file: File): Promise<void> {
|
||||||
|
// 1. MIME 嗅探
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
ElMessage.error('仅支持 jpg/png 格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 2. 大小校验
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
ElMessage.error(`文件 ${(file.size / 1024 / 1024).toFixed(1)}MB 超过 2MB 限制`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size === 0) {
|
||||||
|
ElMessage.error('文件为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 客户端预览(FileReader)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
previewUrl.value = (e.target?.result as string) || ''
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
|
||||||
|
// 4. 立即上传(不等保存)
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const res = await agentStore.uploadAvatar(file)
|
||||||
|
formDraft.value.avatar_source = 'custom'
|
||||||
|
formDraft.value.avatar_url = res.avatar_url
|
||||||
|
formDraft.value.avatar_preset = null
|
||||||
|
activeSource.value = 'custom'
|
||||||
|
lastFileSize.value = file.size
|
||||||
|
ElMessage.success('头像上传成功')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const code = (e as { code?: number })?.code
|
||||||
|
if (code === 1511) ElMessage.error('文件超过 2MB 限制')
|
||||||
|
else if (code === 1510) ElMessage.error('仅支持 jpg/png 格式')
|
||||||
|
else if (code === 1513) ElMessage.error('文件内容与扩展名不符')
|
||||||
|
else if (code === 1520) ElMessage.error('头像存储失败,请联系管理员')
|
||||||
|
else ElMessage.error('头像上传失败')
|
||||||
|
// 保留预览供重试
|
||||||
|
lastFileSize.value = file.size
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreview(): void {
|
||||||
|
previewUrl.value = ''
|
||||||
|
lastFileSize.value = 0
|
||||||
|
formDraft.value.avatar_url = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 保存 / 取消
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
if (!hasChange.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await agentStore.updateProfile({
|
||||||
|
avatar_source: formDraft.value.avatar_source,
|
||||||
|
avatar_url: formDraft.value.avatar_url,
|
||||||
|
avatar_preset: formDraft.value.avatar_preset,
|
||||||
|
nickname: formDraft.value.nickname.trim() || null,
|
||||||
|
})
|
||||||
|
ElMessage.success('个性化设置已保存')
|
||||||
|
emit('update:visible', false)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存个性化设置失败:', e)
|
||||||
|
ElMessage.error('保存失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(): Promise<void> {
|
||||||
|
if (hasChange.value) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'当前修改未保存,确定要关闭吗?',
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定关闭',
|
||||||
|
cancelButtonText: '继续编辑',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return // 用户选择继续编辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ===== 整体 ===== */
|
||||||
|
.prd013-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.prd013-section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.prd013-char-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 档位切换 ===== */
|
||||||
|
.prd013-source-tabs {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.prd013-source-panel {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== A 档:企微预览 ===== */
|
||||||
|
.prd013-wecom-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.prd013-wecom-img,
|
||||||
|
.prd013-wecom-placeholder {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.prd013-wecom-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.prd013-wecom-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.prd013-wecom-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== B 档:预置网格 ===== */
|
||||||
|
.prd013-preset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.prd013-preset-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.prd013-preset-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
.prd013-preset-item.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-accent-soft, rgba(83, 74, 183, 0.08));
|
||||||
|
}
|
||||||
|
.prd013-preset-img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.prd013-preset-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== C 档:上传区 ===== */
|
||||||
|
.prd013-upload-zone {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.prd013-upload-zone:hover,
|
||||||
|
.prd013-upload-zone.is-dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-accent-soft, rgba(83, 74, 183, 0.04));
|
||||||
|
}
|
||||||
|
.prd013-upload-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.prd013-upload-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.prd013-upload-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.prd013-upload-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.prd013-upload-img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.prd013-upload-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 昵称预览 ===== */
|
||||||
|
.prd013-nickname-preview {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.prd013-nickname-preview strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// 企微IT智能服务台 — OTP 二次验证弹窗(REQ-坐席-013 从 TopBar 迁移)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:从原 TopBar.vue 迁移出来的 OTP 弹窗组件。
|
||||||
|
// 入口现在由姓名账户菜单第 2 项触发,逻辑完全保留。
|
||||||
|
// 流程:
|
||||||
|
// 1. 打开 → 先 getOtpStatus() 查绑定状态
|
||||||
|
// 2. 已绑定 (bound=true) → 显示状态 + 解绑表单(需输入 6 位 OTP 验证)
|
||||||
|
// 3. 未绑定 (bound=false) → bindOtp 取二维码 → 6 位验证码 → verifyOtp 启用
|
||||||
|
// ============================================================================= -->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="visible"
|
||||||
|
title="OTP二次验证设置"
|
||||||
|
width="400px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@update:model-value="(val: boolean) => $emit('update:visible', val)"
|
||||||
|
>
|
||||||
|
<div v-if="initialLoading" v-loading="initialLoading" style="min-height: 200px;"></div>
|
||||||
|
<div v-else-if="otpBindData">
|
||||||
|
<!-- 已绑定状态 -->
|
||||||
|
<template v-if="isOtpBound">
|
||||||
|
<el-result icon="success" title="OTP已绑定">
|
||||||
|
<template #sub-title>
|
||||||
|
<p>当前账号已绑定OTP二次验证</p>
|
||||||
|
<p style="color: var(--text-tertiary); font-size: 12px;">
|
||||||
|
密钥:{{ otpBindData.secret }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</el-result>
|
||||||
|
<!-- 解绑前需输入当前 OTP 码 -->
|
||||||
|
<el-divider>解绑验证(输入当前 6 位 OTP 码)</el-divider>
|
||||||
|
<el-input v-model="otpInputCode" placeholder="输入6位OTP码" maxlength="6" style="width: 200px;" />
|
||||||
|
<el-button type="danger" style="margin-top: 12px;" :loading="submitting" @click="handleUnbindOtp">
|
||||||
|
解绑OTP
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<!-- 未绑定状态:显示二维码 -->
|
||||||
|
<template v-else>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<p style="margin-bottom: 16px;">请使用身份验证器(如Google Authenticator)扫码绑定</p>
|
||||||
|
<img
|
||||||
|
:src="`data:image/png;base64,${otpBindData.qr_code_base64}`"
|
||||||
|
alt="OTP二维码"
|
||||||
|
style="width: 200px; height: 200px; margin: 0 auto;"
|
||||||
|
/>
|
||||||
|
<el-divider>或手动输入密钥</el-divider>
|
||||||
|
<el-input v-model="otpBindData.secret" readonly>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="copyToClipboard(otpBindData.secret)">复制</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-divider>验证启用</el-divider>
|
||||||
|
<el-input v-model="otpInputCode" placeholder="输入6位OTP码" maxlength="6" style="width: 200px;" />
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
style="margin-top: 12px;"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="handleVerifyOtp"
|
||||||
|
>
|
||||||
|
验证并启用
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ============================================================================
|
||||||
|
// 导入
|
||||||
|
// ============================================================================
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
// 真实端点位于 /src/frontend-agent/src/api/otp.ts(对应后端 /auth/otp-*)
|
||||||
|
// Bug #2 修复:原 TopBar 调用的是 /agents/otp-*(不存在),已废弃
|
||||||
|
import { bindOtp, verifyOtp, unbindOtp, getOtpStatus } from '@/api/otp'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Props / Emits
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 弹窗可见性(v-model:visible) */
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', val: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 状态
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** OTP 绑定数据(二维码 base64 + secret + otpauth_url) */
|
||||||
|
const otpBindData = ref<{ qr_code_base64: string; secret: string; otpauth_url: string } | null>(null)
|
||||||
|
|
||||||
|
/** 用户输入的 OTP 码 */
|
||||||
|
const otpInputCode = ref('')
|
||||||
|
|
||||||
|
/** 初次拉取状态 / 提交中状态 */
|
||||||
|
const initialLoading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
/** 是否已绑定 OTP(由 getOtpStatus().bound 决定) */
|
||||||
|
const isOtpBound = ref(false)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 监听 visible:每次打开时先查状态,再决定走"已绑定"或"未绑定"分支
|
||||||
|
// ============================================================================
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
void loadInitialState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 方法
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制到剪贴板
|
||||||
|
*/
|
||||||
|
async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取绑定状态:决定走"已绑定解绑"分支还是"首次绑定"分支
|
||||||
|
*/
|
||||||
|
async function loadInitialState(): Promise<void> {
|
||||||
|
initialLoading.value = true
|
||||||
|
otpInputCode.value = ''
|
||||||
|
try {
|
||||||
|
const status = await getOtpStatus()
|
||||||
|
isOtpBound.value = !!status.bound
|
||||||
|
if (status.bound) {
|
||||||
|
// 已绑定:仅显示状态(不解码 secret,因为 getOtpStatus 不返回 secret)
|
||||||
|
// 模板里 otpBindData.secret 会显示空 — 视情况可隐藏该行
|
||||||
|
otpBindData.value = { qr_code_base64: '', secret: '', otpauth_url: '' }
|
||||||
|
} else {
|
||||||
|
// 未绑定:拉取 bind 数据(生成新 secret + 二维码)
|
||||||
|
const data = await bindOtp()
|
||||||
|
otpBindData.value = data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取OTP状态失败:', error)
|
||||||
|
ElMessage.error('获取OTP状态失败')
|
||||||
|
emit('update:visible', false)
|
||||||
|
} finally {
|
||||||
|
initialLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证并启用 OTP(首次绑定场景)
|
||||||
|
*/
|
||||||
|
async function handleVerifyOtp(): Promise<void> {
|
||||||
|
if (!otpInputCode.value || otpInputCode.value.length < 6) {
|
||||||
|
ElMessage.warning('请输入6位OTP码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await verifyOtp(otpInputCode.value)
|
||||||
|
ElMessage.success('OTP验证成功,已启用二次验证')
|
||||||
|
emit('update:visible', false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OTP验证失败:', error)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑 OTP(已绑定场景,需先输入当前 6 位 OTP 验证)
|
||||||
|
*/
|
||||||
|
async function handleUnbindOtp(): Promise<void> {
|
||||||
|
if (!otpInputCode.value || otpInputCode.value.length < 6) {
|
||||||
|
ElMessage.warning('请输入当前 6 位 OTP 码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定要解绑OTP吗?解绑后将不再需要二次验证。',
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定解绑',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
submitting.value = true
|
||||||
|
await unbindOtp(otpInputCode.value)
|
||||||
|
ElMessage.success('OTP已解绑')
|
||||||
|
emit('update:visible', false)
|
||||||
|
} catch (error) {
|
||||||
|
// ElMessageBox 取消:error === 'cancel' 或 'close'
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('解绑OTP失败:', error)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<!-- =============================================================================
|
<!-- =============================================================================
|
||||||
// 企微IT智能服务台 — 顶栏组件
|
// 企微IT智能服务台 — 顶栏组件(REQ-坐席-013 三段式重构)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// 说明:独立顶栏组件,从 Workspace.vue 顶部栏抽离
|
// 说明:从原 4 控件「主题 / 姓名+状态 / 登出 / 助手」重构为
|
||||||
// 包含:Logo + 标题 + 主题切换开关 + 坐席状态 + 登出
|
// 4 控件「主题 / 状态按钮(下拉含退出) / 姓名账户菜单(下拉含个性化设置+OTP) / 助手」
|
||||||
|
// 删除原独立登出按钮与 OTP 入口,OTP 弹窗迁移到 OtpBindDialog 组件。
|
||||||
// ============================================================================= -->
|
// ============================================================================= -->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
<span class="subtitle">· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理</span>
|
<span class="subtitle">· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:主题切换开关 + 坐席状态 + 登出 -->
|
<!-- 右侧:主题切换 + 状态按钮 + 姓名账户菜单 + 助手 -->
|
||||||
<div class="top-bar-right">
|
<div class="top-bar-right">
|
||||||
<!-- 主题切换开关(☀️ 滑轨 🌙,匹配原型v5.3) -->
|
<!-- 主题切换开关(☀️ 滑轨 🌙,匹配原型v5.3) -->
|
||||||
<div
|
<div
|
||||||
@@ -33,38 +34,74 @@
|
|||||||
<span class="switch-icon">🌙</span>
|
<span class="switch-icon">🌙</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 坐席状态切换 -->
|
<!-- 🆕 REQ-坐席-013 状态按钮(替换原状态下拉) -->
|
||||||
<el-dropdown trigger="click" @command="handleStatusChange">
|
<el-dropdown
|
||||||
<span style="cursor: pointer; display: flex; align-items: center; gap: 4px;">
|
trigger="click"
|
||||||
<el-tag :type="statusTagType" size="small" effect="dark">
|
class="prd013-status-dropdown-anchor"
|
||||||
{{ statusLabel }}
|
@command="handleStatusChange"
|
||||||
</el-tag>
|
>
|
||||||
<span class="agent-name">{{ agentStore.agentName }}</span>
|
<button
|
||||||
<el-icon><ArrowDown /></el-icon>
|
class="prd013-status-btn"
|
||||||
</span>
|
:class="`is-${agentStore.agentStatus}`"
|
||||||
|
aria-label="状态按钮,菜单"
|
||||||
|
>
|
||||||
|
<span class="prd013-status-dot" :class="`dot-${agentStore.agentStatus}`"></span>
|
||||||
|
<span class="prd013-status-label">{{ statusLabel }}</span>
|
||||||
|
<el-icon class="prd013-caret"><ArrowDown /></el-icon>
|
||||||
|
</button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu class="prd013-status-dropdown">
|
||||||
<el-dropdown-item command="online">
|
<el-dropdown-item command="online">
|
||||||
🟢 在线 — 接收新会话
|
<span class="prd013-dot dot-online"></span>在线 — 接收新会话
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item command="busy">
|
<el-dropdown-item command="busy">
|
||||||
🟡 忙碌 — 不接新会话
|
<span class="prd013-dot dot-busy"></span>忙碌 — 不接新会话
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item command="offline">
|
<el-dropdown-item command="offline">
|
||||||
⚪ 离线 — 不接收任何会话
|
<span class="prd013-dot dot-offline"></span>离线 — 不接收任何会话
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item divided command="otp">
|
<el-dropdown-item divided command="logout" class="prd013-logout-item">
|
||||||
🔐 OTP二次验证
|
<el-icon><SwitchButton /></el-icon>退出登录
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
|
||||||
<!-- 登出按钮 -->
|
<!-- 🆕 REQ-坐席-013 姓名账户菜单 -->
|
||||||
<el-button text type="danger" @click="handleLogout">
|
<el-dropdown
|
||||||
<el-icon><SwitchButton /></el-icon>
|
trigger="click"
|
||||||
登出
|
class="prd013-name-dropdown-anchor"
|
||||||
</el-button>
|
@command="handleAccountMenu"
|
||||||
|
>
|
||||||
|
<button class="prd013-name-btn" aria-label="账户菜单,菜单">
|
||||||
|
<img
|
||||||
|
v-if="agentStore.displayAvatarUrl"
|
||||||
|
:src="agentStore.displayAvatarUrl"
|
||||||
|
class="prd013-name-avatar-img"
|
||||||
|
alt="坐席头像"
|
||||||
|
@error="nameAvatarError = true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="prd013-name-avatar-letter"
|
||||||
|
:style="{ background: presetAvatar.bgGradient, color: presetAvatar.letterColor }"
|
||||||
|
>
|
||||||
|
{{ deriveAvatarLetter(agentStore.displayName || '?') }}
|
||||||
|
</span>
|
||||||
|
<span class="prd013-name-text">{{ agentStore.displayName }}</span>
|
||||||
|
<el-icon class="prd013-caret"><ArrowDown /></el-icon>
|
||||||
|
</button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu class="prd013-name-dropdown">
|
||||||
|
<el-dropdown-item command="profile">
|
||||||
|
<el-icon><Setting /></el-icon>个性化设置(头像、昵称)
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="otp">
|
||||||
|
<el-icon><Lock /></el-icon>OTP 二次验证
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
<!-- 小屏幕下显示/隐藏助手面板 -->
|
<!-- 小屏幕下显示/隐藏助手面板 -->
|
||||||
<el-button
|
<el-button
|
||||||
@@ -76,51 +113,17 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ==================================================================== -->
|
<!-- ==================================================================== -->
|
||||||
<!-- OTP 设置对话框 -->
|
<!-- 个性化设置对话框(REQ-坐席-013 新增) -->
|
||||||
<!-- ==================================================================== -->
|
<!-- ==================================================================== -->
|
||||||
<el-dialog
|
<AgentProfileDialog v-model:visible="profileDialogVisible" />
|
||||||
v-model="otpDialogVisible"
|
|
||||||
title="OTP二次验证设置"
|
<!-- ==================================================================== -->
|
||||||
width="400px"
|
<!-- OTP 设置对话框(REQ-坐席-013 从原 TopBar 迁移) -->
|
||||||
:close-on-click-modal="false"
|
<!-- ==================================================================== -->
|
||||||
>
|
<OtpBindDialog v-model:visible="otpDialogVisible" />
|
||||||
<div v-if="otpLoading" v-loading="otpLoading" style="min-height: 200px;"></div>
|
</header>
|
||||||
<div v-else-if="otpBindData">
|
|
||||||
<!-- 已绑定状态 -->
|
|
||||||
<template v-if="isOtpBound">
|
|
||||||
<el-result icon="success" title="OTP已绑定">
|
|
||||||
<template #sub-title>
|
|
||||||
<p>当前账号已绑定OTP二次验证</p>
|
|
||||||
<p style="color: var(--text-tertiary); font-size: 12px;">
|
|
||||||
密钥:{{ otpBindData.secret }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</el-result>
|
|
||||||
<el-button type="danger" @click="handleUnbindOtp">解绑OTP</el-button>
|
|
||||||
</template>
|
|
||||||
<!-- 未绑定状态:显示二维码 -->
|
|
||||||
<template v-else>
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<p style="margin-bottom: 16px;">请使用身份验证器(如Google Authenticator)扫码绑定</p>
|
|
||||||
<img :src="otpBindData.qr_code" alt="OTP二维码" style="width: 200px; height: 200px; margin: 0 auto;" />
|
|
||||||
<el-divider>或手动输入密钥</el-divider>
|
|
||||||
<el-input v-model="otpBindData.secret" readonly>
|
|
||||||
<template #append>
|
|
||||||
<el-button @click="copyToClipboard(otpBindData.secret)">复制</el-button>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
<el-divider>验证启用</el-divider>
|
|
||||||
<el-input v-model="otpInputCode" placeholder="输入6位OTP码" maxlength="6" style="width: 200px;" />
|
|
||||||
<el-button type="primary" style="margin-top: 12px;" @click="handleVerifyOtp">
|
|
||||||
验证并启用
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -129,11 +132,14 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { ArrowDown, SwitchButton, Setting, Lock, Operation } from '@element-plus/icons-vue'
|
||||||
import { useAgentStore } from '@/stores/agent'
|
import { useAgentStore } from '@/stores/agent'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { bindOtp, verifyOtp, unbindOtp } from '@/api/agent'
|
import AgentProfileDialog from '@/components/agent/AgentProfileDialog.vue'
|
||||||
|
import OtpBindDialog from '@/components/agent/OtpBindDialog.vue'
|
||||||
|
import { deriveAvatarLetter, findPresetById } from '@/data/presetAvatars'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 事件
|
// 事件
|
||||||
@@ -158,98 +164,14 @@ const conversationStore = useConversationStore()
|
|||||||
/** WebSocket 组合式函数 */
|
/** WebSocket 组合式函数 */
|
||||||
const { disconnect: disconnectWs } = useWebSocket()
|
const { disconnect: disconnectWs } = useWebSocket()
|
||||||
|
|
||||||
// ============================================================================
|
/** 🆕 REQ-坐席-013:个性化设置对话框可见性 */
|
||||||
// OTP 双因素认证
|
const profileDialogVisible = ref(false)
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** OTP 对话框可见性 */
|
/** 🆕 REQ-坐席-013:OTP 对话框可见性(迁移自原 TopBar) */
|
||||||
const otpDialogVisible = ref(false)
|
const otpDialogVisible = ref(false)
|
||||||
|
|
||||||
/** OTP 绑定数据(二维码和密钥) */
|
/** 🆕 姓名头像加载失败标记(避免无限 error 循环) */
|
||||||
const otpBindData = ref<{ qr_code: string; secret: string } | null>(null)
|
const nameAvatarError = ref(false)
|
||||||
|
|
||||||
/** 用户输入的 OTP 码 */
|
|
||||||
const otpInputCode = ref('')
|
|
||||||
|
|
||||||
/** OTP 加载状态 */
|
|
||||||
const otpLoading = ref(false)
|
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
const copyToClipboard = async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
ElMessage.success('已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('复制失败,请手动复制')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 是否已绑定 OTP */
|
|
||||||
const isOtpBound = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开 OTP 设置对话框
|
|
||||||
*/
|
|
||||||
async function handleOpenOtp(): Promise<void> {
|
|
||||||
otpLoading.value = true
|
|
||||||
otpDialogVisible.value = true
|
|
||||||
otpInputCode.value = ''
|
|
||||||
try {
|
|
||||||
const data = await bindOtp()
|
|
||||||
otpBindData.value = data
|
|
||||||
isOtpBound.value = !!data.secret
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取OTP绑定信息失败:', error)
|
|
||||||
ElMessage.error('获取OTP绑定信息失败')
|
|
||||||
} finally {
|
|
||||||
otpLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证并启用 OTP
|
|
||||||
*/
|
|
||||||
async function handleVerifyOtp(): Promise<void> {
|
|
||||||
if (!otpInputCode.value || otpInputCode.value.length < 6) {
|
|
||||||
ElMessage.warning('请输入6位OTP码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
otpLoading.value = true
|
|
||||||
try {
|
|
||||||
await verifyOtp(agentStore.userId, otpInputCode.value)
|
|
||||||
ElMessage.success('OTP验证成功,已启用二次验证')
|
|
||||||
otpDialogVisible.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OTP验证失败:', error)
|
|
||||||
} finally {
|
|
||||||
otpLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解绑 OTP
|
|
||||||
*/
|
|
||||||
async function handleUnbindOtp(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('确定要解绑OTP吗?解绑后将不再需要二次验证。', '提示', {
|
|
||||||
confirmButtonText: '确定解绑',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
otpLoading.value = true
|
|
||||||
await unbindOtp()
|
|
||||||
ElMessage.success('OTP已解绑')
|
|
||||||
otpDialogVisible.value = false
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as Error)?.message?.includes('cancel')) {
|
|
||||||
// 用户取消
|
|
||||||
} else {
|
|
||||||
console.error('解绑OTP失败:', error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
otpLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 计算属性
|
// 计算属性
|
||||||
@@ -265,14 +187,10 @@ const statusLabel = computed(() => {
|
|||||||
return statusMap[agentStore.agentStatus] || '离线'
|
return statusMap[agentStore.agentStatus] || '离线'
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 坐席状态标签类型 */
|
/** 🆕 预置头像配置(avatar_source='preset' 时取自 avatar_preset,否则取默认) */
|
||||||
const statusTagType = computed(() => {
|
const presetAvatar = computed(() => {
|
||||||
const typeMap: Record<string, string> = {
|
const id = agentStore.agentInfo?.avatar_preset
|
||||||
online: 'success',
|
return findPresetById(id)
|
||||||
busy: 'warning',
|
|
||||||
offline: 'info',
|
|
||||||
}
|
|
||||||
return typeMap[agentStore.agentStatus] || 'info'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -288,16 +206,30 @@ function onThemeSwitch(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换坐席状态
|
* 🆕 REQ-坐席-013:姓名账户菜单 handler
|
||||||
|
* - profile:打开个性化设置对话框
|
||||||
|
* - otp:打开 OTP 设置对话框
|
||||||
*/
|
*/
|
||||||
async function handleStatusChange(status: string): Promise<void> {
|
async function handleAccountMenu(command: string): Promise<void> {
|
||||||
if (status === 'otp') {
|
if (command === 'profile') {
|
||||||
// 打开OTP设置
|
profileDialogVisible.value = true
|
||||||
await handleOpenOtp()
|
} else if (command === 'otp') {
|
||||||
|
otpDialogVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 REQ-坐席-013:状态按钮 handler
|
||||||
|
* - online/busy/offline:切换坐席状态
|
||||||
|
* - logout:二次确认后退出登录(迁移自原 handleLogout 逻辑)
|
||||||
|
*/
|
||||||
|
async function handleStatusChange(command: string): Promise<void> {
|
||||||
|
if (command === 'logout') {
|
||||||
|
await handleLogout()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await agentStore.changeStatus(status)
|
await agentStore.changeStatus(command)
|
||||||
ElMessage.success(`已切换为${statusLabel.value}`)
|
ElMessage.success(`已切换为${statusLabel.value}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('切换状态失败:', error)
|
console.error('切换状态失败:', error)
|
||||||
@@ -305,7 +237,7 @@ async function handleStatusChange(status: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登出
|
* 登出(从原 handleLogout 复用逻辑)
|
||||||
*/
|
*/
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -316,7 +248,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
})
|
})
|
||||||
disconnectWs()
|
disconnectWs()
|
||||||
conversationStore.stopAllPolling()
|
conversationStore.stopAllPolling()
|
||||||
agentStore.logout()
|
await agentStore.logout()
|
||||||
} catch {
|
} catch {
|
||||||
// 用户取消
|
// 用户取消
|
||||||
}
|
}
|
||||||
@@ -381,17 +313,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle-btn {
|
/* ===== 主题切换滑轨(匹配原型v5.3) ===== */
|
||||||
font-size: 18px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-name {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主题切换滑轨样式(匹配原型v5.3) */
|
|
||||||
.theme-switch {
|
.theme-switch {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -399,11 +321,9 @@ async function handleLogout(): Promise<void> {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-switch .switch-icon {
|
.theme-switch .switch-icon {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-switch .switch-track {
|
.theme-switch .switch-track {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -412,11 +332,9 @@ async function handleLogout(): Promise<void> {
|
|||||||
position: relative;
|
position: relative;
|
||||||
transition: background 0.3s;
|
transition: background 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .theme-switch .switch-track {
|
[data-theme="dark"] .theme-switch .switch-track {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-switch .switch-thumb {
|
.theme-switch .switch-thumb {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -428,19 +346,91 @@ async function handleLogout(): Promise<void> {
|
|||||||
transition: transform 0.3s;
|
transition: transform 0.3s;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .theme-switch .switch-thumb {
|
[data-theme="dark"] .theme-switch .switch-thumb {
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 小屏幕下显示助手切换按钮 */
|
/* ===== REQ-坐席-013 状态按钮 + 姓名按钮 通用样式 ===== */
|
||||||
|
.prd013-status-btn,
|
||||||
|
.prd013-name-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.prd013-status-btn:hover,
|
||||||
|
.prd013-name-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.prd013-status-btn:focus-visible,
|
||||||
|
.prd013-name-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prd013-status-dot,
|
||||||
|
.prd013-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot-online { background: #22c55e; box-shadow: 0 0 0 2px rgba(34, 197, 94, .15); }
|
||||||
|
.dot-busy { background: #f59e0b; box-shadow: 0 0 0 2px rgba(245, 158, 11, .15); }
|
||||||
|
.dot-offline { background: #94a3b8; box-shadow: 0 0 0 2px rgba(148, 163, 184, .15); }
|
||||||
|
|
||||||
|
.prd013-caret {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prd013-logout-item {
|
||||||
|
color: var(--color-danger, #f56c6c) !important;
|
||||||
|
}
|
||||||
|
.prd013-logout-item:hover {
|
||||||
|
background: var(--danger-soft, rgba(245, 108, 108, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 姓名按钮:头像 + 文字 ===== */
|
||||||
|
.prd013-name-avatar-img,
|
||||||
|
.prd013-name-avatar-letter {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
.prd013-name-text {
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 小屏幕下显示助手切换按钮 ===== */
|
||||||
.assistant-toggle-btn {
|
.assistant-toggle-btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.assistant-toggle-btn {
|
.assistant-toggle-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user