Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0810c6b69 |
@@ -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 顶部栏抽离
|
||||
// 包含: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;
|
||||
|
||||
Reference in New Issue
Block a user