Compare commits

...

1 Commits

Author SHA1 Message Date
Simon c0810c6b69 fix(REQ-013): 修复 2 个 P0 Bug — TopBar v-model 命名 + OtpBindDialog API 端点
隔离自 f51ca01(原位于 feat/task-actions-pivot-v1.8,该分支含 447 项未评审 WIP 与 temp/ 垃圾文件,
为避免污染 main 予以隔离)。本提交仅含 3 个文件的最终修复态:

- src/frontend-agent/src/components/agent/AgentProfileDialog.vue
  头像上传后保存按钮激活逻辑修正(v1.1.3a 已修复线上)
- src/frontend-agent/src/components/agent/OtpBindDialog.vue
  API 端点修正
- src/frontend-agent/src/components/layout/TopBar.vue
  v-model 命名修正

以上修复均已随 v1.1.3a 部署上线(agent 构建 hash Bdi0gxPi),此处仅将源码合入 main。
2026-08-14 13:22:00 +08:00
3 changed files with 1005 additions and 193 deletions
@@ -0,0 +1,603 @@
<!-- =============================================================================
// 企微IT智能服务台 — 个性化设置对话框(REQ-坐席-013)
// =============================================================================
// 说明:坐席端「个性化设置」对话框,从姓名账户菜单第 1 项触发。
// 功能(三档头像 + 昵称):
// - 头像 A 档(wecom):使用企微头像
// - 头像 B 档(preset):8 套预置 SVG4×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')
/** 上传预览 URLbase64 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
})
/** 昵称预览:「nicknamename」或「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 顶部栏抽离
// 包含:Logo + 标题 + 主题切换开关 + 坐席状态 + 登出
// 说明:从原 4 控件「主题 / 姓名+状态 / 登出 / 助手」重构为
// 4 控件「主题 / 状态按钮(下拉含退出) / 姓名账户菜单(下拉含个性化设置+OTP) / 助手」
// 删除原独立登出按钮与 OTP 入口,OTP 弹窗迁移到 OtpBindDialog 组件。
// ============================================================================= -->
<template>
@@ -18,7 +19,7 @@
<span class="subtitle">· 坐席工作台 AI驱动 · 多系统对接 · 一站式处理</span>
</div>
<!-- 右侧主题切换开关 + 坐席状态 + 登出 -->
<!-- 右侧主题切换 + 状态按钮 + 姓名账户菜单 + 助手 -->
<div class="top-bar-right">
<!-- 主题切换开关 滑轨 🌙匹配原型v5.3 -->
<div
@@ -33,38 +34,74 @@
<span class="switch-icon">🌙</span>
</div>
<!-- 坐席状态切换 -->
<el-dropdown trigger="click" @command="handleStatusChange">
<span style="cursor: pointer; display: flex; align-items: center; gap: 4px;">
<el-tag :type="statusTagType" size="small" effect="dark">
{{ statusLabel }}
</el-tag>
<span class="agent-name">{{ agentStore.agentName }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<!-- 🆕 REQ-坐席-013 状态按钮替换原状态下拉 -->
<el-dropdown
trigger="click"
class="prd013-status-dropdown-anchor"
@command="handleStatusChange"
>
<button
class="prd013-status-btn"
: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>
<el-dropdown-menu>
<el-dropdown-menu class="prd013-status-dropdown">
<el-dropdown-item command="online">
🟢 在线 接收新会话
<span class="prd013-dot dot-online"></span>在线 接收新会话
</el-dropdown-item>
<el-dropdown-item command="busy">
🟡 忙碌 不接新会话
<span class="prd013-dot dot-busy"></span>忙碌 不接新会话
</el-dropdown-item>
<el-dropdown-item command="offline">
离线 不接收任何会话
<span class="prd013-dot dot-offline"></span>离线 不接收任何会话
</el-dropdown-item>
<el-dropdown-item divided command="otp">
🔐 OTP二次验证
<el-dropdown-item divided command="logout" class="prd013-logout-item">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 登出按钮 -->
<el-button text type="danger" @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
登出
</el-button>
<!-- 🆕 REQ-坐席-013 姓名账户菜单 -->
<el-dropdown
trigger="click"
class="prd013-name-dropdown-anchor"
@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
@@ -76,51 +113,17 @@
</el-button>
</div>
</div>
</header>
<!-- ==================================================================== -->
<!-- OTP 设置对话框 -->
<!-- 个性化设置对话框REQ-坐席-013 新增 -->
<!-- ==================================================================== -->
<el-dialog
v-model="otpDialogVisible"
title="OTP二次验证设置"
width="400px"
:close-on-click-modal="false"
>
<div v-if="otpLoading" v-loading="otpLoading" 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>
<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>
<AgentProfileDialog v-model:visible="profileDialogVisible" />
<!-- ==================================================================== -->
<!-- OTP 设置对话框REQ-坐席-013 从原 TopBar 迁移 -->
<!-- ==================================================================== -->
<OtpBindDialog v-model:visible="otpDialogVisible" />
</header>
</template>
<script setup lang="ts">
@@ -129,11 +132,14 @@
// ============================================================================
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown, SwitchButton, Setting, Lock, Operation } from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
import { useThemeStore } from '@/stores/theme'
import { useWebSocket } from '@/composables/useWebSocket'
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 组合式函数 */
const { disconnect: disconnectWs } = useWebSocket()
// ============================================================================
// OTP 双因素认证
// ============================================================================
/** 🆕 REQ-坐席-013:个性化设置对话框可见性 */
const profileDialogVisible = ref(false)
/** OTP 对话框可见性 */
/** 🆕 REQ-坐席-013:OTP 对话框可见性(迁移自原 TopBar) */
const otpDialogVisible = ref(false)
/** OTP 绑定数据(二维码和密钥 */
const otpBindData = ref<{ qr_code: string; secret: string } | null>(null)
/** 用户输入的 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
}
}
/** 🆕 姓名头像加载失败标记(避免无限 error 循环 */
const nameAvatarError = ref(false)
// ============================================================================
// 计算属性
@@ -265,14 +187,10 @@ const statusLabel = computed(() => {
return statusMap[agentStore.agentStatus] || '离线'
})
/** 坐席状态标签类型 */
const statusTagType = computed(() => {
const typeMap: Record<string, string> = {
online: 'success',
busy: 'warning',
offline: 'info',
}
return typeMap[agentStore.agentStatus] || 'info'
/** 🆕 预置头像配置(avatar_source='preset' 时取自 avatar_preset,否则取默认) */
const presetAvatar = computed(() => {
const id = agentStore.agentInfo?.avatar_preset
return findPresetById(id)
})
// ============================================================================
@@ -288,16 +206,30 @@ function onThemeSwitch(): void {
}
/**
* 切换坐席状态
* 🆕 REQ-坐席-013:姓名账户菜单 handler
* - profile:打开个性化设置对话框
* - otp:打开 OTP 设置对话框
*/
async function handleStatusChange(status: string): Promise<void> {
if (status === 'otp') {
// 打开OTP设置
await handleOpenOtp()
async function handleAccountMenu(command: string): Promise<void> {
if (command === 'profile') {
profileDialogVisible.value = true
} 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
}
try {
await agentStore.changeStatus(status)
await agentStore.changeStatus(command)
ElMessage.success(`已切换为${statusLabel.value}`)
} catch (error) {
console.error('切换状态失败:', error)
@@ -305,7 +237,7 @@ async function handleStatusChange(status: string): Promise<void> {
}
/**
* 登出
* 登出(从原 handleLogout 复用逻辑)
*/
async function handleLogout(): Promise<void> {
try {
@@ -316,7 +248,7 @@ async function handleLogout(): Promise<void> {
})
disconnectWs()
conversationStore.stopAllPolling()
agentStore.logout()
await agentStore.logout()
} catch {
// 用户取消
}
@@ -381,17 +313,7 @@ async function handleLogout(): Promise<void> {
gap: 12px;
}
.theme-toggle-btn {
font-size: 18px;
padding: 4px 8px;
}
.agent-name {
font-size: 14px;
color: var(--text-secondary);
}
/* 主题切换滑轨样式(匹配原型v5.3) */
/* ===== 主题切换滑轨(匹配原型v5.3 ===== */
.theme-switch {
display: flex;
align-items: center;
@@ -399,11 +321,9 @@ async function handleLogout(): Promise<void> {
cursor: pointer;
user-select: none;
}
.theme-switch .switch-icon {
font-size: 15px;
}
.theme-switch .switch-track {
width: 40px;
height: 22px;
@@ -412,11 +332,9 @@ async function handleLogout(): Promise<void> {
position: relative;
transition: background 0.3s;
}
[data-theme="dark"] .theme-switch .switch-track {
background: var(--accent);
}
.theme-switch .switch-thumb {
width: 18px;
height: 18px;
@@ -428,16 +346,88 @@ async function handleLogout(): Promise<void> {
transition: transform 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
[data-theme="dark"] .theme-switch .switch-thumb {
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 {
display: none;
}
@media (max-width: 1024px) {
.assistant-toggle-btn {
display: inline-flex;