chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — AI助手面板容器组件(v5.3 重构)
|
||||
// =============================================================================
|
||||
// 说明:坐席工作台右侧的AI助手面板
|
||||
// 功能:上下两区域布局
|
||||
// 上方 ~1/3:🤖 AI 智能推荐区(1-3张推荐卡片)
|
||||
// 下方 ~2/3:快速回复区(复用 QuickReplyPanel)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="ai-assistant-panel">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 上方 ~1/3 区域 — 🤖 AI 智能推荐区 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="ai-recommend-section">
|
||||
<!-- 标题栏 -->
|
||||
<div class="ai-recommend-header">
|
||||
<span class="ai-recommend-bar"></span>
|
||||
<span class="ai-recommend-title">🤖 AI 智能推荐</span>
|
||||
</div>
|
||||
|
||||
<!-- 推荐卡片列表 -->
|
||||
<div v-if="recommendations.length > 0" class="ai-recommend-list">
|
||||
<AiSuggestReply
|
||||
v-for="(rec, index) in recommendations"
|
||||
:key="index"
|
||||
:recommendation="rec"
|
||||
:index="index"
|
||||
@select="handleSelectRecommendation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 无推荐时提示 -->
|
||||
<div v-else class="ai-recommend-empty">
|
||||
暂无推荐
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="ai-section-divider"></div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 下方 ~2/3 区域 — 快速回复区 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="quick-reply-section">
|
||||
<QuickReplyPanel @use-template="handleUseTemplate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import AiSuggestReply from './AiSuggestReply.vue'
|
||||
import QuickReplyPanel from './QuickReplyPanel.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import type { DraftResult } from '@/api/wingman'
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** AI 推荐项类型 */
|
||||
interface AiRecommendation {
|
||||
/** 方案名称 */
|
||||
title: string
|
||||
/** 推荐内容 */
|
||||
content: string
|
||||
/** 置信度(0-1) */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 会话 Store */
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** AI 推荐列表(Mock 数据,后续由 AI 引擎填充) */
|
||||
const recommendations = ref<AiRecommendation[]>([])
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 从当前会话的 AI 草稿生成推荐列表
|
||||
* 将 draft 数据映射为推荐卡片格式
|
||||
*/
|
||||
const loadRecommendationsFromDraft = computed(() => {
|
||||
const convId = conversationStore.currentConversationId
|
||||
if (!convId) return []
|
||||
|
||||
// 尝试从 AI 草稿缓存中获取推荐
|
||||
const convDrafts = conversationStore.aiDrafts.get(convId)
|
||||
if (!convDrafts || convDrafts.size === 0) return []
|
||||
|
||||
const result: AiRecommendation[] = []
|
||||
convDrafts.forEach((draft: DraftResult) => {
|
||||
result.push({
|
||||
title: 'AI 回复建议',
|
||||
content: draft.content,
|
||||
confidence: draft.confidence,
|
||||
})
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理 AI 推荐卡片的选择
|
||||
* 将推荐内容填充到对话输入框
|
||||
*
|
||||
* @param content - 推荐内容
|
||||
*/
|
||||
function handleSelectRecommendation(content: string): void {
|
||||
conversationStore.pendingReplyText = content
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理快速回复模板的"使用"事件
|
||||
* 将模板内容填充到对话输入框
|
||||
*
|
||||
* @param content - 模板内容(已替换变量)
|
||||
*/
|
||||
function handleUseTemplate(content: string): void {
|
||||
conversationStore.pendingReplyText = content
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 AI 推荐数据
|
||||
* 优先从草稿缓存获取,否则使用 Mock 数据
|
||||
*/
|
||||
function loadRecommendations(): void {
|
||||
const fromDraft = loadRecommendationsFromDraft.value
|
||||
if (fromDraft.length > 0) {
|
||||
recommendations.value = fromDraft
|
||||
return
|
||||
}
|
||||
|
||||
// Mock 数据:当无真实 AI 推荐时展示示例
|
||||
if (conversationStore.currentConversationId) {
|
||||
recommendations.value = [
|
||||
{
|
||||
title: '重置密码流程',
|
||||
content: '您好,密码重置可通过企业门户自助操作:访问 portal.company.com → 忘记密码 → 按提示完成重置。如无法自助,请提供工号,我帮您后台重置。',
|
||||
confidence: 0.92,
|
||||
},
|
||||
{
|
||||
title: 'VPN 连接故障排查',
|
||||
content: 'VPN 连接问题常见原因:1) 网络切换后未重连;2) 证书过期。请先尝试:断开 VPN → 重启客户端 → 重新连接。如仍失败,请提供错误截图。',
|
||||
confidence: 0.85,
|
||||
},
|
||||
]
|
||||
} else {
|
||||
recommendations.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
|
||||
onMounted(() => {
|
||||
loadRecommendations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-assistant-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 上方 AI 推荐区 ---- */
|
||||
.ai-recommend-section {
|
||||
flex: 0 0 auto;
|
||||
max-height: 33%;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-recommend-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-recommend-bar {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background-color: var(--accent);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-recommend-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-recommend-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
|
||||
.ai-recommend-empty {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 分隔线 ---- */
|
||||
.ai-section-divider {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 下方快速回复区 ---- */
|
||||
.quick-reply-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — AI 智能推荐卡片组件(v5.3 重构)
|
||||
// =============================================================================
|
||||
// 说明:右栏上方 AI 推荐区的单张推荐卡片
|
||||
// 功能:
|
||||
// 1. 方案名称 + 置信度药丸
|
||||
// 2. 推荐内容(2行截断)
|
||||
// 3. Ctrl+N 快捷键提示
|
||||
// 4. 点击 → conversationStore.pendingReplyText = content
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ai-suggest-card"
|
||||
@click="handleSelect"
|
||||
>
|
||||
<!-- 卡片头:方案名称 + 置信度药丸 -->
|
||||
<div class="ai-card-header">
|
||||
<span class="ai-card-title">{{ recommendation.title }}</span>
|
||||
<span class="ai-card-confidence" :style="confidenceStyle">
|
||||
{{ confidencePercent }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 卡片文本:2行截断 -->
|
||||
<div class="ai-card-text">
|
||||
{{ recommendation.content }}
|
||||
</div>
|
||||
|
||||
<!-- 快捷键提示 -->
|
||||
<div class="ai-card-shortcut">
|
||||
Ctrl+{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** AI 推荐项类型(与 AiAssistantPanel 一致) */
|
||||
interface AiRecommendation {
|
||||
/** 方案名称 */
|
||||
title: string
|
||||
/** 推荐内容 */
|
||||
content: string
|
||||
/** 置信度(0-1) */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 推荐数据 */
|
||||
recommendation: AiRecommendation
|
||||
/** 推荐索引(0-based,用于快捷键 Ctrl+1/2/3) */
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
/** 选中推荐事件 */
|
||||
(e: 'select', content: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 置信度百分比(整数) */
|
||||
const confidencePercent = computed<number>(() => {
|
||||
return Math.round(props.recommendation.confidence * 100)
|
||||
})
|
||||
|
||||
/** 置信度药丸样式(颜色根据置信度变化) */
|
||||
const confidenceStyle = computed(() => {
|
||||
const percent = confidencePercent.value
|
||||
let bgColor = 'var(--accent-soft)'
|
||||
let textColor = 'var(--accent)'
|
||||
|
||||
if (percent >= 85) {
|
||||
bgColor = 'var(--accent-soft)'
|
||||
textColor = 'var(--accent)'
|
||||
} else if (percent >= 60) {
|
||||
bgColor = 'rgba(230, 162, 60, 0.1)'
|
||||
textColor = 'var(--color-warning)'
|
||||
} else {
|
||||
bgColor = 'rgba(144, 147, 153, 0.1)'
|
||||
textColor = 'var(--text-tertiary)'
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
color: textColor,
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理推荐卡片的选择
|
||||
* 将推荐内容填充到对话输入框
|
||||
*/
|
||||
function handleSelect(): void {
|
||||
conversationStore.pendingReplyText = props.recommendation.content
|
||||
emit('select', props.recommendation.content)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-suggest-card {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-suggest-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ai-suggest-card:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
/* ---- 卡片头 ---- */
|
||||
.ai-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-card-confidence {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---- 卡片文本 ---- */
|
||||
.ai-card-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- 快捷键提示 ---- */
|
||||
.ai-card-shortcut {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.ai-suggest-card:hover .ai-card-shortcut {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,279 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 操作步骤组件
|
||||
// =============================================================================
|
||||
// 说明:展示按问题分类的解决步骤
|
||||
// 第一步使用硬编码的步骤数据
|
||||
// 后续步骤从后端配置获取
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="operation-steps">
|
||||
<!-- 问题分类选择 -->
|
||||
<el-select
|
||||
v-model="selectedCategory"
|
||||
placeholder="选择问题分类"
|
||||
class="category-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
:key="cat.name"
|
||||
:label="cat.label"
|
||||
:value="cat.name"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 操作步骤列表 -->
|
||||
<div v-if="currentSteps.length > 0" class="step-list">
|
||||
<div
|
||||
v-for="(step, index) in currentSteps"
|
||||
:key="index"
|
||||
class="step-card"
|
||||
>
|
||||
<div class="step-card-inner">
|
||||
<!-- 步骤序号 -->
|
||||
<span class="step-number">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<!-- 步骤内容 -->
|
||||
<div class="step-content">
|
||||
<div class="step-title">{{ step.title }}</div>
|
||||
<div class="step-desc">{{ step.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="steps-empty">
|
||||
请选择问题分类查看操作步骤
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 操作步骤 */
|
||||
interface Step {
|
||||
/** 步骤标题 */
|
||||
title: string
|
||||
/** 步骤详细说明 */
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 问题分类 */
|
||||
interface Category {
|
||||
/** 分类名称(英文key) */
|
||||
name: string
|
||||
/** 分类显示名称 */
|
||||
label: string
|
||||
/** 该分类下的操作步骤 */
|
||||
steps: Step[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 硬编码的操作步骤数据(第一步)
|
||||
// ============================================================================
|
||||
const categories: Category[] = [
|
||||
{
|
||||
name: 'vpn',
|
||||
label: '🌐 VPN连接问题',
|
||||
steps: [
|
||||
{
|
||||
title: '确认VPN账号状态',
|
||||
description: '在IT管理后台检查该员工的VPN账号是否已开通、是否过期、是否被锁定。',
|
||||
},
|
||||
{
|
||||
title: '检查客户端版本',
|
||||
description: '确认员工使用的是最新版VPN客户端,旧版本可能存在兼容性问题。',
|
||||
},
|
||||
{
|
||||
title: '检查网络环境',
|
||||
description: '确认员工当前网络可以访问公司VPN网关(可用 ping 测试),排除防火墙拦截。',
|
||||
},
|
||||
{
|
||||
title: '重置VPN密码',
|
||||
description: '如密码错误,在管理后台重置VPN密码,通知员工通过自助门户重新设置。',
|
||||
},
|
||||
{
|
||||
title: '查看VPN日志',
|
||||
description: '如以上步骤未能解决,要求员工发送VPN客户端日志,转交网络组排查。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'account',
|
||||
label: '🔑 账号登录问题',
|
||||
steps: [
|
||||
{
|
||||
title: '确认账号状态',
|
||||
description: '检查该员工的AD/统一认证账号是否正常(未锁定、未过期)。',
|
||||
},
|
||||
{
|
||||
title: '检查密码是否过期',
|
||||
description: '确认密码是否超过90天未修改,提醒员工通过SSO门户修改密码。',
|
||||
},
|
||||
{
|
||||
title: '检查多因素认证',
|
||||
description: '确认MFA设备是否正常绑定,如更换手机需重新绑定。',
|
||||
},
|
||||
{
|
||||
title: '检查账号权限',
|
||||
description: '确认员工是否有所需系统的访问权限,如无权限需要走审批流程。',
|
||||
},
|
||||
{
|
||||
title: '清除浏览器缓存',
|
||||
description: '部分登录问题由浏览器缓存引起,指导员工清除缓存或使用无痕模式。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'permission',
|
||||
label: '🔐 权限申请问题',
|
||||
steps: [
|
||||
{
|
||||
title: '确认权限类型',
|
||||
description: '了解员工需要申请哪类权限(系统权限/文件权限/网络权限)。',
|
||||
},
|
||||
{
|
||||
title: '检查现有权限',
|
||||
description: '在对应系统中检查员工当前已有的权限,避免重复申请。',
|
||||
},
|
||||
{
|
||||
title: '引导走审批流程',
|
||||
description: '提供对应的审批链接,指导员工提交权限申请。审批链接可在"审批流程"面板查看。',
|
||||
},
|
||||
{
|
||||
title: '紧急权限加急',
|
||||
description: '如为紧急情况,联系对应系统管理员临时开通,后续补审批。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'device',
|
||||
label: '💻 设备故障问题',
|
||||
steps: [
|
||||
{
|
||||
title: '远程诊断',
|
||||
description: '通过远程工具连接员工电脑,检查设备状态、系统日志、硬件状态。',
|
||||
},
|
||||
{
|
||||
title: '常见问题排查',
|
||||
description: '蓝屏:检查最近驱动更新;慢:检查磁盘空间和内存;无法开机:检查电源。',
|
||||
},
|
||||
{
|
||||
title: '重启相关服务',
|
||||
description: '尝试重启相关系统服务或进程,很多问题可通过重启解决。',
|
||||
},
|
||||
{
|
||||
title: '创建维修工单',
|
||||
description: '如无法远程解决,创建硬件维修工单,安排现场支持或设备更换。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'software',
|
||||
label: '📦 软件安装问题',
|
||||
steps: [
|
||||
{
|
||||
title: '确认软件在允许列表中',
|
||||
description: '检查该软件是否在公司允许安装的软件清单中,不在清单的需要走审批。',
|
||||
},
|
||||
{
|
||||
title: '检查系统要求',
|
||||
description: '确认员工电脑满足软件的最低系统要求(操作系统版本、内存、磁盘空间)。',
|
||||
},
|
||||
{
|
||||
title: '提供下载链接',
|
||||
description: '从公司软件库提供正版安装包和授权码,下载链接可在"软件下载"面板查看。',
|
||||
},
|
||||
{
|
||||
title: '远程协助安装',
|
||||
description: '如员工不会安装,通过远程工具协助安装和配置。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 当前选中的分类 */
|
||||
const selectedCategory = ref('')
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 当前分类的操作步骤 */
|
||||
const currentSteps = computed(() => {
|
||||
const cat = categories.find(c => c.name === selectedCategory.value)
|
||||
return cat?.steps || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.operation-steps {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.category-select {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-list {
|
||||
|
||||
}
|
||||
|
||||
.step-card-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.steps-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,746 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 快速回复组件(v5.3 终版 · 三层渐进导航)
|
||||
// =============================================================================
|
||||
// 导航结构:L1(7分类网格) → L2(chip子分类) → L3(条目列表) → Enter填入
|
||||
// 键盘操作:Alt+1~7(L1) → 数字(L2/L3) → Enter填入 → ←/Backspace返回 → /搜索
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="qr-panel">
|
||||
<!-- ================================================================ -->
|
||||
<!-- 搜索栏(置顶) -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="qr-search">
|
||||
<el-input
|
||||
ref="searchRef"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索快速回复 / Alt+目录数字"
|
||||
size="small"
|
||||
clearable
|
||||
:prefix-icon="SearchIcon"
|
||||
class="qr-search-input"
|
||||
@keydown.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 面包屑导航 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="qr-breadcrumb">
|
||||
<span v-if="navState.l1Index >= 0" class="bc-back" @click="goBack">
|
||||
← 返回
|
||||
</span>
|
||||
<template v-if="navState.l1Index >= 0">
|
||||
<span
|
||||
class="bc-item"
|
||||
:class="{ 'bc-active': navState.l2Index < 0 }"
|
||||
@click="resetToL1"
|
||||
>{{ catName(navState.l1Index) }}</span>
|
||||
<template v-if="navState.l2Index >= 0">
|
||||
<span class="bc-sep">›</span>
|
||||
<span class="bc-item bc-active">{{ subName(navState.l1Index, navState.l2Index) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span class="bc-placeholder" v-else>选择一个分类开始浏览</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- L1 一级分类:7列网格,按钮上下排列,强制一行 -->
|
||||
<!-- ================================================================ -->
|
||||
<div v-show="showL1" class="qr-l1-grid">
|
||||
<button
|
||||
v-for="(cat, i) in qrData"
|
||||
:key="i"
|
||||
class="qr-l1-btn"
|
||||
:class="{ active: navState.l1Index === i }"
|
||||
@click="selectL1(i)"
|
||||
>
|
||||
<span class="l1-num">{{ i + 1 }}</span>
|
||||
<span class="l1-name">{{ cat.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- L2 二级子分类:chip 横向流式 -->
|
||||
<!-- ================================================================ -->
|
||||
<div v-show="showL2" class="qr-l2-row">
|
||||
<button
|
||||
v-for="(sub, i) in currentSubs"
|
||||
:key="i"
|
||||
class="qr-l2-chip"
|
||||
:class="{ selected: navState.l2Index === i }"
|
||||
@click="selectL2(i)"
|
||||
>
|
||||
<span class="l2-num">{{ i + 1 }}</span>
|
||||
<span>{{ sub.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- L3 回复列表 -->
|
||||
<!-- ================================================================ -->
|
||||
<div v-show="showL3" class="qr-l3-scroll">
|
||||
<div v-if="filteredItems.length === 0" class="qr-empty">
|
||||
{{ searchQuery ? '无匹配结果' : '暂无回复模板' }}
|
||||
</div>
|
||||
<div v-else class="qr-l3-list">
|
||||
<div
|
||||
v-for="(item, i) in filteredItems"
|
||||
:key="i"
|
||||
class="qr-l3-item"
|
||||
:class="{ selected: navState.selectedIndex === i }"
|
||||
:ref="(el) => { if (el) itemRefs[i] = el as HTMLElement }"
|
||||
@click="selectL3(i)"
|
||||
@mouseenter="navState.selectedIndex = i"
|
||||
>
|
||||
<span class="qr-l3-num">{{ i + 1 }}</span>
|
||||
<div class="qr-l3-body">
|
||||
<div class="qr-l3-title">{{ item.title }}</div>
|
||||
<div class="qr-l3-content">{{ item.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 选中预览条 -->
|
||||
<!-- ================================================================ -->
|
||||
<div v-if="selectedPreview" class="qr-selected-bar" @click="fillSelected">
|
||||
<span class="qr-selected-label">已选:</span>
|
||||
<span class="qr-selected-text">{{ selectedPreview }}</span>
|
||||
<span class="qr-selected-enter">Enter ↵ 填入</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 底部键盘指南 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="qr-keyboard-guide">
|
||||
<span><kbd>Alt+1-7</kbd> 一级</span>
|
||||
<span class="qr-guide-sep">|</span>
|
||||
<span><kbd>数字</kbd> 选子项</span>
|
||||
<span class="qr-guide-sep">|</span>
|
||||
<span><kbd>Enter</kbd> 填入</span>
|
||||
<span class="qr-guide-sep">|</span>
|
||||
<span><kbd>←</kbd> 返回</span>
|
||||
<span class="qr-guide-sep">|</span>
|
||||
<span><kbd>/</kbd> 搜索</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================================================================== -->
|
||||
<!-- Script -->
|
||||
<!-- ==================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { Search as SearchIcon } from '@element-plus/icons-vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import { qrData, type QrCategory, type QrItem } from '@/data/qrData'
|
||||
|
||||
// ========================================================================
|
||||
// Interface
|
||||
// ========================================================================
|
||||
|
||||
interface Emits {
|
||||
(e: 'use-template', content: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ========================================================================
|
||||
// State
|
||||
// ========================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const searchRef = ref<InstanceType<typeof import('element-plus')['ElInput']> | null>(null)
|
||||
const itemRefs = ref<Record<number, HTMLElement>>({})
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchQuery = ref('')
|
||||
|
||||
/**
|
||||
* 导航状态机
|
||||
* l1Index: -1 = 初始/L1选择中; >=0 = 已选L1分类
|
||||
* l2Index: -1 = L2选择中; >=0 = 已选L2子分类,展示L3
|
||||
* selectedIndex: L3列表中的选中索引
|
||||
*/
|
||||
interface NavState {
|
||||
l1Index: number
|
||||
l2Index: number
|
||||
selectedIndex: number
|
||||
}
|
||||
const navState = ref<NavState>({
|
||||
l1Index: -1,
|
||||
l2Index: -1,
|
||||
selectedIndex: 0,
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Computed
|
||||
// ========================================================================
|
||||
|
||||
/** 是否显示 L1 网格 */
|
||||
const showL1 = computed(() => navState.value.l1Index < 0)
|
||||
|
||||
/** 是否显示 L2 chip 行 */
|
||||
const showL2 = computed(() => navState.value.l1Index >= 0 && navState.value.l2Index < 0)
|
||||
|
||||
/** 是否显示 L3 列表 */
|
||||
const showL3 = computed(() => navState.value.l1Index >= 0 && navState.value.l2Index >= 0)
|
||||
|
||||
/** 当前 L1 分类 */
|
||||
const currentCategory = computed<QrCategory | null>(() => {
|
||||
if (navState.value.l1Index < 0) return null
|
||||
return qrData[navState.value.l1Index] ?? null
|
||||
})
|
||||
|
||||
/** 当前 L2 子分类列表 */
|
||||
const currentSubs = computed(() => {
|
||||
const cat = currentCategory.value
|
||||
return cat ? cat.subs : []
|
||||
})
|
||||
|
||||
/** 当前 L3 条目列表 */
|
||||
const currentItems = computed(() => {
|
||||
const cat = currentCategory.value
|
||||
if (!cat || navState.value.l2Index < 0) return []
|
||||
const sub = cat.subs[navState.value.l2Index]
|
||||
return sub ? sub.items : []
|
||||
})
|
||||
|
||||
/** 搜索过滤后的 L3 条目 */
|
||||
const filteredItems = computed<QrItem[]>(() => {
|
||||
const items = currentItems.value
|
||||
if (!searchQuery.value.trim()) return items
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
item.content.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
/** 当前选中的条目文案预览 */
|
||||
const selectedPreview = computed(() => {
|
||||
if (!showL3.value) return null
|
||||
const items = filteredItems.value
|
||||
if (items.length === 0) return null
|
||||
const idx = navState.value.selectedIndex
|
||||
if (idx < 0 || idx >= items.length) return null
|
||||
return items[idx].title
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
|
||||
function catName(index: number): string {
|
||||
return qrData[index]?.name ?? ''
|
||||
}
|
||||
|
||||
function subName(l1: number, l2: number): string {
|
||||
return qrData[l1]?.subs[l2]?.name ?? ''
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Navigation Methods
|
||||
// ========================================================================
|
||||
|
||||
/** 选择 L1 分类 → 进入 L2 */
|
||||
function selectL1(index: number): void {
|
||||
if (index < 0 || index >= qrData.length) return
|
||||
navState.value = { l1Index: index, l2Index: -1, selectedIndex: 0 }
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
/** 选择 L2 子分类 → 进入 L3 */
|
||||
function selectL2(index: number): void {
|
||||
const cat = currentCategory.value
|
||||
if (!cat || index < 0 || index >= cat.subs.length) return
|
||||
navState.value.l2Index = index
|
||||
navState.value.selectedIndex = 0
|
||||
searchQuery.value = ''
|
||||
nextTick(() => scrollToSelected())
|
||||
}
|
||||
|
||||
/** 点击 L3 条目 → 直接填入 */
|
||||
function selectL3(index: number): void {
|
||||
const items = filteredItems.value
|
||||
if (index < 0 || index >= items.length) return
|
||||
fillContent(items[index].content)
|
||||
}
|
||||
|
||||
/** 将选中条目填入输入框 */
|
||||
function fillSelected(): void {
|
||||
const items = filteredItems.value
|
||||
const idx = navState.value.selectedIndex
|
||||
if (idx < 0 || idx >= items.length) return
|
||||
fillContent(items[idx].content)
|
||||
}
|
||||
|
||||
/** 填入内容(含变量替换) */
|
||||
function fillContent(content: string): void {
|
||||
const conv = conversationStore.currentConversation
|
||||
const variables: Record<string, string> = {}
|
||||
if (conv) {
|
||||
variables.employee_name = conv.employee_name || ''
|
||||
variables.department = conv.department || ''
|
||||
variables.position = conv.position || ''
|
||||
}
|
||||
|
||||
let result = content
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
result = result.replaceAll(`{${key}}`, value)
|
||||
}
|
||||
|
||||
emit('use-template', result)
|
||||
}
|
||||
|
||||
/** 返回上一级 */
|
||||
function goBack(): void {
|
||||
if (navState.value.l2Index >= 0) {
|
||||
// L3 → L2
|
||||
navState.value.l2Index = -1
|
||||
navState.value.selectedIndex = 0
|
||||
} else if (navState.value.l1Index >= 0) {
|
||||
// L2 → L1
|
||||
navState.value.l1Index = -1
|
||||
navState.value.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 回到 L1(点击面包屑中的 L1) */
|
||||
function resetToL1(): void {
|
||||
if (navState.value.l2Index >= 0) {
|
||||
navState.value.l2Index = -1
|
||||
navState.value.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动选中项到视图 */
|
||||
function scrollToSelected(): void {
|
||||
nextTick(() => {
|
||||
const el = itemRefs.value[navState.value.selectedIndex]
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Keyboard Navigation (数字键选择)
|
||||
// ========================================================================
|
||||
|
||||
function handleDigitKey(digit: number): void {
|
||||
if (showL1.value) {
|
||||
// L1: 数字键选择分类
|
||||
if (digit >= 1 && digit <= qrData.length) {
|
||||
selectL1(digit - 1)
|
||||
}
|
||||
} else if (showL2.value) {
|
||||
// L2: 数字键选择子分类
|
||||
const subs = currentSubs.value
|
||||
if (digit >= 1 && digit <= subs.length) {
|
||||
selectL2(digit - 1)
|
||||
}
|
||||
} else if (showL3.value) {
|
||||
// L3: 数字键选择条目
|
||||
const items = filteredItems.value
|
||||
if (digit >= 1 && digit <= items.length) {
|
||||
selectL3(digit - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function navigateUpDown(direction: 'up' | 'down'): void {
|
||||
if (showL3.value) {
|
||||
const len = filteredItems.value.length
|
||||
if (len === 0) return
|
||||
if (direction === 'up') {
|
||||
navState.value.selectedIndex =
|
||||
navState.value.selectedIndex > 0 ? navState.value.selectedIndex - 1 : len - 1
|
||||
} else {
|
||||
navState.value.selectedIndex =
|
||||
navState.value.selectedIndex < len - 1 ? navState.value.selectedIndex + 1 : 0
|
||||
}
|
||||
scrollToSelected()
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSelection(): void {
|
||||
if (showL3.value) {
|
||||
fillSelected()
|
||||
}
|
||||
}
|
||||
|
||||
function focusSearch(): void {
|
||||
searchRef.value?.focus()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Keyboard Shortcuts Registration
|
||||
// ========================================================================
|
||||
|
||||
// 注册 Alt+1~7 分类切换
|
||||
function handleCategoryShortcut(index: number): void {
|
||||
if (index >= 0 && index < qrData.length) {
|
||||
selectL1(index)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册全局键盘快捷键
|
||||
useKeyboardShortcuts({
|
||||
onQuickReplyCategory: handleCategoryShortcut,
|
||||
onQuickReplyDigit: handleDigitKey,
|
||||
onQuickReplyBack: goBack,
|
||||
onQuickReplyNavigate: navigateUpDown,
|
||||
onQuickReplyConfirm: confirmSelection,
|
||||
onFocusSearch: focusSearch,
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Watchers — 搜索词变化后重置选中
|
||||
// ========================================================================
|
||||
|
||||
watch(searchQuery, () => {
|
||||
navState.value.selectedIndex = 0
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle — 初始进入默认展示 L1
|
||||
// ========================================================================
|
||||
|
||||
onMounted(() => {
|
||||
navState.value = { l1Index: -1, l2Index: -1, selectedIndex: 0 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ==================================================================== -->
|
||||
<!-- Styles -->
|
||||
<!-- ==================================================================== -->
|
||||
<style scoped>
|
||||
.qr-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 搜索栏 ---- */
|
||||
.qr-search {
|
||||
padding: 6px 10px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.qr-search-input {
|
||||
width: 100%;
|
||||
}
|
||||
.qr-search-input :deep(.el-input__wrapper) {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: none !important;
|
||||
border: 1px solid var(--border-light);
|
||||
font-size: 12px;
|
||||
}
|
||||
.qr-search-input :deep(.el-input__wrapper:hover) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.qr-search-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent) !important;
|
||||
}
|
||||
.qr-search-input :deep(.el-input__prefix-inner) {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ---- 面包屑 ---- */
|
||||
.qr-breadcrumb {
|
||||
padding: 5px 10px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-tertiary);
|
||||
min-height: 26px;
|
||||
}
|
||||
.bc-back {
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.bc-back:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.bc-sep {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
.bc-item {
|
||||
color: var(--text-secondary);
|
||||
transition: 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
.bc-item.bc-active {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bc-item:not(.bc-active) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.bc-item:not(.bc-active):hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.bc-placeholder {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- L1 一级分类:7列网格,强制一行,按钮内上下排列 ---- */
|
||||
.qr-l1-grid {
|
||||
padding: 4px 6px 6px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.qr-l1-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 5px 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-light);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qr-l1-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.qr-l1-btn.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.l1-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.qr-l1-btn.active .l1-num {
|
||||
background: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.l1-name {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---- L2 二级子分类 chip ---- */
|
||||
.qr-l2-row {
|
||||
padding: 5px 8px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.qr-l2-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qr-l2-chip:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.qr-l2-chip.selected {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.l2-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.qr-l2-chip.selected .l2-num {
|
||||
background: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ---- L3 列表 ---- */
|
||||
.qr-l3-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.qr-empty {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.qr-l3-list {
|
||||
padding: 2px 0;
|
||||
}
|
||||
.qr-l3-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.qr-l3-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.qr-l3-item.selected {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.qr-l3-num {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qr-l3-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qr-l3-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qr-l3-content {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- 选中预览条 ---- */
|
||||
.qr-selected-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-accent-soft);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.qr-selected-bar:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.qr-selected-label {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qr-selected-text {
|
||||
flex: 1;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qr-selected-enter {
|
||||
color: var(--text-placeholder);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 键盘指南 ---- */
|
||||
.qr-keyboard-guide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qr-keyboard-guide kbd {
|
||||
display: inline-block;
|
||||
padding: 0 3px;
|
||||
font-size: 9px;
|
||||
font-family: inherit;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 2px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.qr-guide-sep {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 风险提示组件
|
||||
// =============================================================================
|
||||
// 说明:显示系统当前已知故障/风险信息
|
||||
// 第一步:显示"当前无已知故障"占位
|
||||
// 预留接口,后续从后端获取故障列表
|
||||
// 红色警告样式预留
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="risk-alert-wrapper">
|
||||
<!-- 正常状态(无故障) -->
|
||||
<div class="risk-alert-ok">
|
||||
<div class="risk-alert-ok__icon">✅</div>
|
||||
<div class="risk-alert-ok__title">当前无已知故障</div>
|
||||
<div class="risk-alert-ok__desc">
|
||||
所有系统正常运行
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预留的故障列表区域 -->
|
||||
<!--
|
||||
后续步骤会从后端获取故障数据,结构如下:
|
||||
<div v-for="alert in alerts" class="risk-alert-card">
|
||||
<div class="risk-alert-level">{{ alert.level }}</div>
|
||||
<div class="risk-alert-title">{{ alert.title }}</div>
|
||||
<div class="risk-alert-desc">{{ alert.description }}</div>
|
||||
<div class="risk-alert-time">{{ alert.time }}</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- 风险监控说明 -->
|
||||
<div class="risk-alert-scope">
|
||||
<div class="risk-alert-scope__title">
|
||||
📋 风险监控范围
|
||||
</div>
|
||||
<ul class="risk-alert-scope__list">
|
||||
<li>VPN 网关连通性</li>
|
||||
<li>邮件系统服务状态</li>
|
||||
<li>AD 域控制器状态</li>
|
||||
<li>网络核心设备告警</li>
|
||||
<li>业务系统可用性</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 红色警告样式预留(注释形式,后续启用) -->
|
||||
<!--
|
||||
<div class="risk-alert-danger">
|
||||
<div class="risk-alert-danger__header">
|
||||
<span class="risk-alert-danger__icon">🔴</span>
|
||||
<span class="risk-alert-danger__title">VPN 网关异常</span>
|
||||
</div>
|
||||
<div class="risk-alert-danger__desc">
|
||||
VPN 网关 B 节点响应超时,影响华南地区员工连接。
|
||||
网络组正在排查,预计 30 分钟内修复。
|
||||
</div>
|
||||
<div class="risk-alert-danger__time">
|
||||
🕐 发现时间:2025-01-15 10:30
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 风险提示组件 — 第一步为静态占位
|
||||
// ============================================================================
|
||||
// 后续步骤需要:
|
||||
// 1. 从后端 GET /api/system-alerts 获取故障列表
|
||||
// 2. 定期轮询(每30秒)
|
||||
// 3. 有故障时显示红色警告卡片
|
||||
// 4. 按严重程度排序(P0 > P1 > P2)
|
||||
// ============================================================================
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.risk-alert-wrapper {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 正常状态(无故障) */
|
||||
.risk-alert-ok {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
background-color: var(--success-soft);
|
||||
border: 1px solid var(--success-soft);
|
||||
}
|
||||
|
||||
.risk-alert-ok__icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.risk-alert-ok__title {
|
||||
font-weight: 500;
|
||||
color: var(--color-success);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk-alert-ok__desc {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 风险监控说明 */
|
||||
.risk-alert-scope {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.risk-alert-scope__title {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.risk-alert-scope__list {
|
||||
padding-left: 20px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* 红色警告样式预留 */
|
||||
/*
|
||||
.risk-alert-danger {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--danger-soft);
|
||||
border: 1px solid var(--danger-soft);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.risk-alert-danger__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.risk-alert-danger__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.risk-alert-danger__title {
|
||||
font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.risk-alert-danger__desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.risk-alert-danger__time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
*/
|
||||
</style>
|
||||
@@ -0,0 +1,445 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 用户信息面板组件
|
||||
// =============================================================================
|
||||
// 说明:显示当前会话员工的详细信息
|
||||
// 功能:
|
||||
// 1. 员工基本信息(部门/职位/机构)
|
||||
// 2. VIP 标记
|
||||
// 3. 历史咨询模式(如"近30天3次VPN问题")
|
||||
// 4. 坐席备注区(可添加/编辑/删除备注)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="user-info-panel">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="!conversation" class="empty-hint">
|
||||
请先选择一个会话
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- ================================================================ -->
|
||||
<!-- 员工基本信息 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="section-block">
|
||||
<div class="user-header">
|
||||
<!-- 头像 -->
|
||||
<div class="user-avatar">
|
||||
{{ conversation.employee_name?.charAt(conversation.employee_name.length - 1) || '?' }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="user-name">
|
||||
{{ conversation.employee_name || '未知' }}
|
||||
<!-- VIP 标记 -->
|
||||
<el-tag v-if="conversation.is_vip" type="danger" size="small" effect="dark" style="margin-left: 4px;">
|
||||
VIP
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="user-id">
|
||||
{{ conversation.employee_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div class="info-card">
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="部门">
|
||||
{{ conversation.department || '未知' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="岗位">
|
||||
{{ conversation.position || '未知' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="等级">
|
||||
{{ conversation.level || '未知' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="紧急度">
|
||||
<div class="urgency-stars">
|
||||
<span
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="urgency-star"
|
||||
:class="{ empty: i > conversation.urgency_score }"
|
||||
>★</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 历史咨询模式 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="section-block">
|
||||
<div class="section-label">
|
||||
📊 历史咨询模式
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<!-- 从 tags 中推断历史模式 -->
|
||||
<div v-if="conversation.tags?.repeat_count && conversation.tags.repeat_count > 0" style="margin-bottom: 4px;">
|
||||
<el-tag size="small" type="warning">🔄 追问 {{ conversation.tags.repeat_count }} 轮</el-tag>
|
||||
</div>
|
||||
<div v-if="conversation.tags?.emotion && conversation.tags.emotion !== 'neutral'" style="margin-bottom: 4px;">
|
||||
<el-tag size="small" type="danger">
|
||||
{{ emotionHistoryLabel }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="conversation.tags?.hand_raise" style="margin-bottom: 4px;">
|
||||
<el-tag size="small" type="warning">🙋 已要求转人工</el-tag>
|
||||
</div>
|
||||
<!-- 无特殊标记 -->
|
||||
<div
|
||||
v-if="(!conversation.tags?.repeat_count || conversation.tags.repeat_count === 0)
|
||||
&& (!conversation.tags?.emotion || conversation.tags.emotion === 'neutral')
|
||||
&& !conversation.tags?.hand_raise"
|
||||
class="no-tag-hint"
|
||||
>
|
||||
暂无特殊咨询模式标记
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 坐席备注区 -->
|
||||
<!-- ================================================================ -->
|
||||
<div>
|
||||
<div class="note-header">
|
||||
<span class="section-label">📝 坐席备注</span>
|
||||
<el-button type="primary" size="small" text @click="showAddNote">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 备注列表 -->
|
||||
<div v-if="notes.length > 0">
|
||||
<div
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="note-item"
|
||||
>
|
||||
<!-- 备注内容 -->
|
||||
<div class="note-content">{{ note.content }}</div>
|
||||
<!-- 备注元信息 -->
|
||||
<div class="note-meta">
|
||||
<span class="note-time">{{ formatNoteTime(note.created_at) }}</span>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<el-button type="warning" size="small" text @click="showEditNote(note)">编辑</el-button>
|
||||
<el-button type="danger" size="small" text @click="handleDeleteNote(note)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无备注 -->
|
||||
<div v-else class="empty-hint">暂无备注</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 添加/编辑备注弹窗 -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog v-model="noteDialogVisible" :title="isEditingNote ? '编辑备注' : '添加备注'" width="400px">
|
||||
<el-input
|
||||
v-model="noteContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="输入备注内容..."
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="noteDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="noteSubmitting" @click="handleNoteSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import {
|
||||
getAgentNotes,
|
||||
createAgentNote,
|
||||
updateAgentNote,
|
||||
deleteAgentNote,
|
||||
} from '@/api/quickReply'
|
||||
import type { AgentNote } from '@/api/quickReply'
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
/** 当前会话 */
|
||||
const conversation = computed(() => conversationStore.currentConversation)
|
||||
|
||||
/** 备注列表 */
|
||||
const notes = ref<AgentNote[]>([])
|
||||
|
||||
/** 备注弹窗是否可见 */
|
||||
const noteDialogVisible = ref(false)
|
||||
|
||||
/** 是否编辑模式 */
|
||||
const isEditingNote = ref(false)
|
||||
|
||||
/** 正在编辑的备注ID */
|
||||
const editingNoteId = ref('')
|
||||
|
||||
/** 备注内容 */
|
||||
const noteContent = ref('')
|
||||
|
||||
/** 是否正在提交备注 */
|
||||
const noteSubmitting = ref(false)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 情绪历史标签 */
|
||||
const emotionHistoryLabel = computed(() => {
|
||||
const emotion = conversation.value?.tags?.emotion
|
||||
const map: Record<string, string> = {
|
||||
urgent: '🔴 情绪紧急',
|
||||
angry: '😡 情绪愤怒',
|
||||
worried: '😟 情绪担忧',
|
||||
}
|
||||
return map[emotion || ''] || ''
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 加载备注列表
|
||||
*/
|
||||
async function loadNotes(): Promise<void> {
|
||||
if (!conversation.value) return
|
||||
try {
|
||||
const data = await getAgentNotes(conversation.value.employee_id)
|
||||
notes.value = data.items
|
||||
} catch (error) {
|
||||
console.error('获取备注失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示添加备注弹窗
|
||||
*/
|
||||
function showAddNote(): void {
|
||||
isEditingNote.value = false
|
||||
editingNoteId.value = ''
|
||||
noteContent.value = ''
|
||||
noteDialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示编辑备注弹窗
|
||||
*/
|
||||
function showEditNote(note: AgentNote): void {
|
||||
isEditingNote.value = true
|
||||
editingNoteId.value = note.id
|
||||
noteContent.value = note.content
|
||||
noteDialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交备注(新增或编辑)
|
||||
*/
|
||||
async function handleNoteSubmit(): Promise<void> {
|
||||
if (!noteContent.value.trim()) {
|
||||
ElMessage.warning('请输入备注内容')
|
||||
return
|
||||
}
|
||||
|
||||
noteSubmitting.value = true
|
||||
try {
|
||||
if (isEditingNote.value) {
|
||||
// 编辑
|
||||
await updateAgentNote(editingNoteId.value, noteContent.value.trim())
|
||||
ElMessage.success('备注已更新')
|
||||
} else {
|
||||
// 新增
|
||||
if (!conversation.value) return
|
||||
await createAgentNote(
|
||||
conversation.value.id,
|
||||
agentStore.userId,
|
||||
noteContent.value.trim()
|
||||
)
|
||||
ElMessage.success('备注已添加')
|
||||
}
|
||||
noteDialogVisible.value = false
|
||||
// 重新加载备注
|
||||
await loadNotes()
|
||||
} catch (error) {
|
||||
console.error('保存备注失败:', error)
|
||||
} finally {
|
||||
noteSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除备注
|
||||
*/
|
||||
async function handleDeleteNote(note: AgentNote): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除此备注吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteAgentNote(note.id)
|
||||
ElMessage.success('备注已删除')
|
||||
await loadNotes()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化备注时间
|
||||
*/
|
||||
function formatNoteTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}/${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
// 切换会话时重新加载备注
|
||||
watch(
|
||||
() => conversationStore.currentConversationId,
|
||||
() => {
|
||||
loadNotes()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 面板容器 */
|
||||
.user-info-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 空提示文字 */
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
padding: 12px 20px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 区块间距 */
|
||||
.section-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.section-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 用户头部:头像+名称 */
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 用户名 */
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 用户ID */
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 信息卡片(浅色背景区块) */
|
||||
.info-card {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 无标记提示 */
|
||||
.no-tag-hint {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 备注头部 */
|
||||
.note-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 备注条目 */
|
||||
.note-item {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
/* 备注内容 */
|
||||
.note-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 备注元信息 */
|
||||
.note-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 备注时间 */
|
||||
.note-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — AI 草稿气泡组件(内嵌在对话流中)
|
||||
// =============================================================================
|
||||
// 说明:在员工消息下方显示 AI 草稿气泡
|
||||
// 特殊样式:浅蓝底色(#F0F9FF) + 左侧蓝色竖线 + 🤖 AI建议 标记
|
||||
// 操作按钮:[采纳]/[编辑]/[忽略]
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<transition name="ai-draft-fade">
|
||||
<div v-if="visible" class="ai-draft-bubble">
|
||||
<!-- 标题行 -->
|
||||
<div class="ai-draft-header">
|
||||
<span class="ai-draft-icon">🤖</span>
|
||||
<span class="ai-draft-label">AI建议</span>
|
||||
<span v-if="confidence > 0" class="ai-draft-confidence">
|
||||
置信度 {{ Math.round(confidence * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 草稿内容 -->
|
||||
<div class="ai-draft-content">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="ai-draft-actions">
|
||||
<el-button size="small" type="primary" @click="handleAccept">
|
||||
采纳
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleEdit">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" text @click="handleIgnore">
|
||||
忽略
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 会话ID */
|
||||
conversationId: string
|
||||
/** 消息ID(草稿关联的员工消息) */
|
||||
messageId: string
|
||||
/** 草稿内容 */
|
||||
content: string
|
||||
/** 置信度 */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** 是否可见(用于淡出动画) */
|
||||
const visible = ref(true)
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 采纳草稿 — 将内容填入 ReplyBox
|
||||
*/
|
||||
function handleAccept(): void {
|
||||
conversationStore.acceptDraft(props.conversationId, props.messageId)
|
||||
ElMessage.success('草稿已填入回复框')
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑草稿 — 将内容填入 ReplyBox 并聚焦
|
||||
*/
|
||||
function handleEdit(): void {
|
||||
conversationStore.editDraft(props.conversationId, props.messageId)
|
||||
ElMessage.success('草稿已填入回复框,可直接编辑')
|
||||
}
|
||||
|
||||
/**
|
||||
* 忽略草稿 — 带淡出动画移除
|
||||
*/
|
||||
function handleIgnore(): void {
|
||||
// 触发淡出动画
|
||||
visible.value = false
|
||||
// 动画结束后从 store 移除
|
||||
setTimeout(() => {
|
||||
conversationStore.ignoreDraft(props.conversationId, props.messageId)
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-draft-bubble {
|
||||
margin-top: 4px;
|
||||
margin-left: 4px;
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--accent-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
max-width: 85%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-draft-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ai-draft-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ai-draft-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ai-draft-confidence {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.ai-draft-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai-draft-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 淡出动画 */
|
||||
.ai-draft-fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.ai-draft-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — AI 推荐回复(内联)组件
|
||||
// =============================================================================
|
||||
// 说明:在聊天消息流中,最后一条用户消息之后、坐席回复之前
|
||||
// 显示条件:仅坐席未回复时(最后一条消息是用户发的)
|
||||
// 交互:点击卡片或 Ctrl+1/2/3 → 填入回复输入框
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShow" class="ai-recommend-inline">
|
||||
<div class="ai-recommend-inline__header">
|
||||
<span class="ai-recommend-inline__title">🤖 AI 推荐回复</span>
|
||||
</div>
|
||||
<div class="ai-recommend-inline__cards">
|
||||
<div
|
||||
v-for="(rec, index) in recommendations"
|
||||
:key="index"
|
||||
class="ai-recommend-card"
|
||||
:class="{ 'is-hovered': hoveredIndex === index }"
|
||||
@mouseenter="hoveredIndex = index"
|
||||
@mouseleave="hoveredIndex = -1"
|
||||
@click="handleSelect(index)"
|
||||
>
|
||||
<div class="ai-recommend-card__header">
|
||||
<span class="ai-recommend-card__name">{{ rec.name }}</span>
|
||||
<span class="ai-recommend-card__confidence">{{ rec.confidence }}%</span>
|
||||
</div>
|
||||
<div class="ai-recommend-card__text">{{ rec.content }}</div>
|
||||
<div class="ai-recommend-card__shortcut">Ctrl+{{ index + 1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import type { Message } from '@/api/message'
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** 当前 hover 的卡片索引 */
|
||||
const hoveredIndex = ref(-1)
|
||||
|
||||
/** AI 推荐数据 */
|
||||
interface AiRecommendation {
|
||||
/** 方案名称 */
|
||||
name: string
|
||||
/** 置信度百分比 */
|
||||
confidence: number
|
||||
/** 推荐内容 */
|
||||
content: string
|
||||
}
|
||||
|
||||
/** 推荐列表(Mock 数据,后续对接 wingman API) */
|
||||
const recommendations = ref<AiRecommendation[]>([])
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 是否应该显示推荐(最后一条消息是用户发的,且坐席未回复) */
|
||||
const shouldShow = computed(() => {
|
||||
const msgs = conversationStore.messages
|
||||
if (msgs.length === 0) return false
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
// 最后一条消息是用户发的(employee),说明坐席还没回复
|
||||
return lastMsg.sender_type === 'employee'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Mock 数据生成
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 根据 Messages 生成 Mock 推荐数据
|
||||
* P0 阶段使用 Mock,后续对接 wingman API
|
||||
*/
|
||||
function generateMockRecommendations(messages: Message[]): void {
|
||||
if (messages.length === 0) {
|
||||
recommendations.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从最后一条用户消息提取关键词
|
||||
const lastUserMsg = [...messages].reverse().find(m => m.sender_type === 'employee')
|
||||
const content = lastUserMsg?.content?.toLowerCase() || ''
|
||||
|
||||
// 基于关键词匹配 Mock 推荐方案
|
||||
if (content.includes('vpn') || content.includes('连接')) {
|
||||
recommendations.value = [
|
||||
{ name: 'VPN 重连方案', confidence: 92, content: '请先断开 VPN 连接,等待10秒后重新连接。如仍然失败,请尝试切换到备用线路。' },
|
||||
{ name: 'VPN 密码重置', confidence: 78, content: 'VPN 登录失败可能是密码过期导致,建议重置 VPN 密码后重试。' },
|
||||
{ name: '网络环境检查', confidence: 65, content: '请检查当前网络环境是否为公司内网,VPN 仅在公司外网使用。' },
|
||||
]
|
||||
} else if (content.includes('邮箱') || content.includes('邮件')) {
|
||||
recommendations.value = [
|
||||
{ name: '邮箱容量清理', confidence: 88, content: '您的邮箱容量已接近上限,建议清理大附件邮件或联系IT申请扩容。' },
|
||||
{ name: '邮箱配置检查', confidence: 75, content: '请检查 Outlook 账户设置是否正确,服务器地址为 mail.company.com。' },
|
||||
{ name: '邮箱重置密码', confidence: 60, content: '如邮箱无法登录,可能是密码过期。请访问自助门户重置密码。' },
|
||||
]
|
||||
} else if (content.includes('系统') || content.includes('登录') || content.includes('账号')) {
|
||||
recommendations.value = [
|
||||
{ name: '账号解锁方案', confidence: 85, content: '您的账号可能因多次输入错误密码被锁定,请在自助门户解锁或联系IT处理。' },
|
||||
{ name: '密码重置指引', confidence: 72, content: '请访问密码自助重置页面,按照提示完成密码重置。新密码需满足复杂度要求。' },
|
||||
{ name: 'SSO 单点登录', confidence: 55, content: '推荐使用企业SSO单点登录,可统一管理各系统账号。' },
|
||||
]
|
||||
} else {
|
||||
// 通用推荐
|
||||
recommendations.value = [
|
||||
{ name: '通用排查方案', confidence: 80, content: '请先尝试清除浏览器缓存后重试,如问题仍存在请提供详细报错截图。' },
|
||||
{ name: '远程协助方案', confidence: 65, content: '如自助排查无法解决,坐席可发起远程协助,请保持电脑开机状态。' },
|
||||
{ name: '工单转交方案', confidence: 45, content: '该问题可能需要二线团队处理,我将为您创建工单并转交相关团队。' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 选中推荐方案 → 填入回复输入框
|
||||
*
|
||||
* @param index - 推荐方案索引
|
||||
*/
|
||||
function handleSelect(index: number): void {
|
||||
if (index < 0 || index >= recommendations.value.length) return
|
||||
conversationStore.pendingReplyText = recommendations.value[index].content
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 暴露方法供快捷键调用
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 通过索引选择推荐(供 useKeyboardShortcuts 调用)
|
||||
*/
|
||||
function selectByIndex(index: number): void {
|
||||
handleSelect(index)
|
||||
}
|
||||
|
||||
defineExpose({ selectByIndex })
|
||||
|
||||
// ============================================================================
|
||||
// 监听消息变化 → 重新生成 Mock 推荐
|
||||
// ============================================================================
|
||||
|
||||
watch(
|
||||
() => conversationStore.messages.length,
|
||||
() => {
|
||||
generateMockRecommendations(conversationStore.messages)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* AI 推荐容器 */
|
||||
.ai-recommend-inline {
|
||||
margin: 12px 0;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-accent-soft);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ai-recommend-inline__header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-recommend-inline__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 推荐卡片容器 */
|
||||
.ai-recommend-inline__cards {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 推荐卡片 */
|
||||
.ai-recommend-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ai-recommend-card:hover,
|
||||
.ai-recommend-card.is-hovered {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.ai-recommend-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ai-recommend-card__name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 置信度药丸 */
|
||||
.ai-recommend-card__confidence {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 推荐文本(2 行截断) */
|
||||
.ai-recommend-card__text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 快捷键提示 */
|
||||
.ai-recommend-card__shortcut {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕下卡片竖排 */
|
||||
@media (max-width: 768px) {
|
||||
.ai-recommend-inline__cards {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,583 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 对话区组件
|
||||
// =============================================================================
|
||||
// 说明:坐席工作台中间的对话区
|
||||
// 功能:
|
||||
// 1. 顶部 UserInfoBar(替代原标题栏,含6卡片展开详情)
|
||||
// 2. 排查步骤栏(UserInfoBar 下方,始终可见)
|
||||
// 3. 协作信息行
|
||||
// 4. 消息列表
|
||||
// 5. 底部回复输入框
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div style="height: 100%; display: flex; flex-direction: column;">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 顶部:用户信息栏(替代原标题栏) -->
|
||||
<!-- ================================================================== -->
|
||||
<UserInfoBar
|
||||
ref="userInfoBarRef"
|
||||
:conversation="conversationStore.currentConversation"
|
||||
:available-agents="agentStore.availableAgents"
|
||||
:can-invite-collaborator="canInviteCollaborator"
|
||||
@assign="handleAssign"
|
||||
@resolve="handleResolve"
|
||||
@toggle-pin="handleTogglePin"
|
||||
@toggle-todo="handleToggleTodo"
|
||||
@transfer="handleTransfer"
|
||||
@invite="inviteDialogVisible = true"
|
||||
/>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 排查步骤栏(紧跟用户信息栏下方,始终可见) -->
|
||||
<!-- ================================================================== -->
|
||||
<TroubleshootBar />
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 参与者面板(有参与者时显示) -->
|
||||
<!-- ================================================================== -->
|
||||
<ParticipantBar
|
||||
v-if="hasAnyParticipants"
|
||||
:participants="currentConv?.participants || []"
|
||||
:agent-name="currentConv?.assigned_agent_name || '坐席'"
|
||||
:is-primary-agent="isPrimaryAgent"
|
||||
:collaborating-agent-ids="currentConv?.collaborating_agent_ids || []"
|
||||
:collaborating-agent-names="currentConv?.collaborating_agent_names || {}"
|
||||
@invite="showInviteParticipantDialog = true"
|
||||
@remove="handleRemoveParticipant"
|
||||
/>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 协作信息行(仅展示) -->
|
||||
<!-- ================================================================== -->
|
||||
<div
|
||||
v-if="collaborationInfoText"
|
||||
style="
|
||||
padding: 4px 20px;
|
||||
background: var(--bg-accent-soft);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
"
|
||||
>
|
||||
{{ collaborationInfoText }}
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 消息列表(flex: 1 占满剩余空间) -->
|
||||
<!-- ================================================================== -->
|
||||
<div ref="messageListRef" class="message-list-scroll">
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="conversationStore.loadingMessages" style="text-align: center; padding: 20px;">
|
||||
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
|
||||
<div style="margin-top: 8px; color: var(--text-tertiary); font-size: 12px;">加载消息中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<template v-else>
|
||||
<MessageBubble
|
||||
v-for="msg in conversationStore.messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
@reply="handleReplyTo"
|
||||
@scroll-to-message="scrollToMessage"
|
||||
/>
|
||||
|
||||
<!-- 空消息 -->
|
||||
<div
|
||||
v-if="conversationStore.messages.length === 0"
|
||||
style="text-align: center; padding: 40px; color: var(--text-tertiary);"
|
||||
>
|
||||
暂无消息
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 输入指示器(某人在输入时显示) -->
|
||||
<!-- ================================================================== -->
|
||||
<div v-if="typingText" class="typing-indicator">
|
||||
<span class="typing-dots">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</span>
|
||||
<span class="typing-text">{{ typingText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 回复输入框(在消息列表下方、排查步骤上方) -->
|
||||
<!-- ================================================================== -->
|
||||
<ReplyBox
|
||||
v-if="conversationStore.currentConversation?.status !== 'resolved'"
|
||||
:reply-to-message="replyToMessage"
|
||||
@send="handleSend"
|
||||
@cancel-reply="replyToMessage = null"
|
||||
/>
|
||||
<!-- 已结单提示 -->
|
||||
<div
|
||||
v-else
|
||||
style="
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
background-color: var(--bg-tertiary);
|
||||
"
|
||||
>
|
||||
该会话已结单,无法发送消息
|
||||
</div>
|
||||
|
||||
<!-- 摇人弹窗 -->
|
||||
<InviteDialog
|
||||
v-model="inviteDialogVisible"
|
||||
:exclude-agent-ids="excludeAgentIds"
|
||||
@confirm="handleInvite"
|
||||
/>
|
||||
|
||||
<!-- 邀请员工弹窗 -->
|
||||
<InviteParticipantDialog
|
||||
v-model="showInviteParticipantDialog"
|
||||
:conversation-id="conversationStore.currentConversation?.id || ''"
|
||||
:existing-participant-ids="existingParticipantIds"
|
||||
@success="onInviteParticipantSuccess"
|
||||
/>
|
||||
|
||||
<!-- 结单摘要确认弹窗 -->
|
||||
<el-dialog
|
||||
v-model="summaryDialogVisible"
|
||||
title="会话摘要确认"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div v-if="conversationStore.loadingSummary" style="text-align: center; padding: 20px;">
|
||||
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
|
||||
<div style="margin-top: 8px; color: var(--text-tertiary);">正在生成 AI 摘要...</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
||||
以下为 AI 自动生成的会话摘要,请确认或修改后提交:
|
||||
</p>
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="问题">
|
||||
<el-input v-model="summaryForm.problem" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="原因">
|
||||
<el-input v-model="summaryForm.cause" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="解决方案">
|
||||
<el-input v-model="summaryForm.solution" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="summaryDialogVisible = false">取消结单</el-button>
|
||||
<el-button type="primary" @click="handleConfirmSummary">确认结单</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import type { Message } from '@/api/message'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import ReplyBox from './ReplyBox.vue'
|
||||
import InviteDialog from '@/components/conversation/InviteDialog.vue'
|
||||
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
||||
import ParticipantBar from '@/components/conversation/ParticipantBar.vue'
|
||||
import UserInfoBar from './UserInfoBar.vue'
|
||||
import TroubleshootBar from './TroubleshootBar.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
/** 当前会话(简化空值检查) */
|
||||
const currentConv = computed(() => conversationStore.currentConversation)
|
||||
|
||||
/** 消息列表DOM引用(用于自动滚动) */
|
||||
const messageListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** UserInfoBar 组件引用 */
|
||||
const userInfoBarRef = ref<InstanceType<typeof UserInfoBar> | null>(null)
|
||||
|
||||
/** 摇人弹窗可见性 */
|
||||
const inviteDialogVisible = ref(false)
|
||||
|
||||
/** 邀请员工弹窗可见性 */
|
||||
const showInviteParticipantDialog = ref(false)
|
||||
|
||||
/** 引用回复:当前正在回复的消息(null 表示普通发送) */
|
||||
const replyToMessage = ref<Message | null>(null)
|
||||
|
||||
/** 结单摘要确认弹窗可见性 */
|
||||
const summaryDialogVisible = ref(false)
|
||||
|
||||
/** 结单摘要表单 */
|
||||
const summaryForm = ref({
|
||||
problem: '',
|
||||
cause: '',
|
||||
solution: '',
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 是否可以摇人(邀请协作坐席) */
|
||||
const canInviteCollaborator = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv || conv.status !== 'serving') return false
|
||||
return conv.is_mine || conv.is_collaborator
|
||||
})
|
||||
|
||||
/** 当前坐席是否为主责坐席(邀请功能权限控制) */
|
||||
const isPrimaryAgent = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv) return false
|
||||
return conv.assigned_agent_id === agentStore.userId
|
||||
})
|
||||
|
||||
/** 是否有参与者或协作坐席(决定是否显示 ParticipantBar) */
|
||||
const hasAnyParticipants = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv) return false
|
||||
return (conv.participants?.length || 0) > 0 || (conv.collaborating_agent_ids?.length || 0) > 0
|
||||
})
|
||||
|
||||
/** 排除的坐席ID列表(主责坐席 + 已在协作中的坐席 + 自己) */
|
||||
const excludeAgentIds = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv) return []
|
||||
const exclude = new Set<string>()
|
||||
if (conv.assigned_agent_id) exclude.add(conv.assigned_agent_id)
|
||||
for (const aid of conv.collaborating_agent_ids || []) {
|
||||
exclude.add(aid)
|
||||
}
|
||||
exclude.add(agentStore.userId)
|
||||
return Array.from(exclude)
|
||||
})
|
||||
|
||||
/** 协作信息文本(主责 + 协作坐席) */
|
||||
const collaborationInfoText = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv || conv.status !== 'serving') return ''
|
||||
|
||||
const parts: string[] = []
|
||||
if (conv.assigned_agent_name) {
|
||||
parts.push(`主责:${conv.assigned_agent_name}`)
|
||||
}
|
||||
const collabIds = conv.collaborating_agent_ids || []
|
||||
if (collabIds.length > 0) {
|
||||
const names = collabIds.map(
|
||||
aid => conv.collaborating_agent_names?.[aid] || '未知'
|
||||
)
|
||||
parts.push(`协作:${names.join('、')}`)
|
||||
}
|
||||
return parts.join(' | ')
|
||||
})
|
||||
|
||||
/** 输入指示器文本(如 "张三正在输入...") */
|
||||
const typingText = computed(() => {
|
||||
const convId = conversationStore.currentConversationId
|
||||
if (!convId) return ''
|
||||
return conversationStore.getTypingText(convId)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 快捷键
|
||||
// ============================================================================
|
||||
|
||||
useKeyboardShortcuts({
|
||||
// AI 推荐已移至右边栏,快捷键在 AiAssistantPanel 中处理
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 接单
|
||||
*/
|
||||
async function handleAssign(): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await conversationStore.assignConv(
|
||||
conversationStore.currentConversation.id,
|
||||
agentStore.userId
|
||||
)
|
||||
ElMessage.success('接单成功')
|
||||
} catch (error) {
|
||||
console.error('接单失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结单
|
||||
* 先确认 → 生成 AI 摘要 → 弹出摘要确认对话框 → 最终结单
|
||||
*/
|
||||
async function handleResolve(): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要结单吗?结单后无法继续回复。', '确认结单', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
const convId = conversationStore.currentConversation.id
|
||||
|
||||
summaryDialogVisible.value = true
|
||||
|
||||
await conversationStore.fetchSummary(convId)
|
||||
|
||||
if (conversationStore.currentSummary) {
|
||||
summaryForm.value.problem = conversationStore.currentSummary.problem
|
||||
summaryForm.value.cause = conversationStore.currentSummary.cause
|
||||
summaryForm.value.solution = conversationStore.currentSummary.solution
|
||||
} else {
|
||||
summaryForm.value = { problem: '', cause: '', solution: '' }
|
||||
}
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认摘要并完成结单
|
||||
*/
|
||||
async function handleConfirmSummary(): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await conversationStore.resolveConv(conversationStore.currentConversation.id)
|
||||
summaryDialogVisible.value = false
|
||||
ElMessage.success('已结单')
|
||||
} catch (error) {
|
||||
console.error('结单失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶/取消置顶
|
||||
*/
|
||||
async function handleTogglePin(): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await conversationStore.togglePinConv(conversationStore.currentConversation.id)
|
||||
} catch (error) {
|
||||
console.error('切换置顶失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 代办/取消代办
|
||||
*/
|
||||
async function handleToggleTodo(): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await conversationStore.toggleTodoConv(conversationStore.currentConversation.id)
|
||||
} catch (error) {
|
||||
console.error('切换代办失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转接
|
||||
*/
|
||||
async function handleTransfer(targetAgentId: string): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要转接给其他坐席吗?', '确认转接', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await conversationStore.transferConv(
|
||||
conversationStore.currentConversation.id,
|
||||
targetAgentId
|
||||
)
|
||||
ElMessage.success('转接成功')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 摇人(邀请协作坐席)
|
||||
*/
|
||||
async function handleInvite(agentId: string): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await conversationStore.inviteToConversation(
|
||||
conversationStore.currentConversation.id,
|
||||
agentId
|
||||
)
|
||||
inviteDialogVisible.value = false
|
||||
ElMessage.success('已发送摇人邀请')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '摇人失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 已在参与者列表中的ID(排除,避免重复邀请) */
|
||||
const existingParticipantIds = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv) return []
|
||||
const ids: string[] = [conv.employee_id]
|
||||
conv.participants?.forEach(p => ids.push(p.id))
|
||||
return ids
|
||||
})
|
||||
|
||||
/** 邀请员工成功回调 */
|
||||
function onInviteParticipantSuccess(): void {
|
||||
conversationStore.fetchConversations()
|
||||
}
|
||||
|
||||
/** 移除参与者 */
|
||||
async function handleRemoveParticipant(userId: string): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定移除该参与者?', '移除确认', {
|
||||
type: 'warning',
|
||||
})
|
||||
await conversationStore.removeParticipantFromConv(
|
||||
conversationStore.currentConversation.id,
|
||||
userId
|
||||
)
|
||||
ElMessage.success('已移除参与者')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息回复(点击消息气泡上的回复按钮)
|
||||
* 做什么:设置 replyToMessage,在输入框上方显示引用摘要
|
||||
*/
|
||||
function handleReplyTo(message: Message): void {
|
||||
replyToMessage.value = message
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(含引用回复)
|
||||
* 如果 replyToMessage 不为空,在发送时附带 reply_to_id
|
||||
*/
|
||||
async function handleSend(content: string): Promise<void> {
|
||||
const replyToId = replyToMessage.value?.id || undefined
|
||||
await conversationStore.sendReply(content, replyToId)
|
||||
// 发送成功后清除引用回复
|
||||
replyToMessage.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到指定消息(点击引用回复摘要时触发)
|
||||
*/
|
||||
function scrollToMessage(messageId: string): void {
|
||||
const msgIndex = conversationStore.messages.findIndex(m => m.id === messageId)
|
||||
if (msgIndex >= 0 && messageListRef.value) {
|
||||
const messageEls = messageListRef.value.querySelectorAll('.message-row')
|
||||
const targetEl = messageEls[msgIndex] as HTMLElement
|
||||
if (targetEl) {
|
||||
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
targetEl.style.transition = 'background 0.3s'
|
||||
targetEl.style.background = 'var(--accent-soft)'
|
||||
setTimeout(() => {
|
||||
targetEl.style.background = ''
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动滚动到底部
|
||||
*/
|
||||
function scrollToBottom(): void {
|
||||
nextTick(() => {
|
||||
if (messageListRef.value) {
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
// 消息变化时自动滚动到底部
|
||||
watch(
|
||||
() => conversationStore.messages.length,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
|
||||
// 切换会话时自动滚动到底部 + 重置 UserInfoBar
|
||||
watch(
|
||||
() => conversationStore.currentConversationId,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
agentStore.loadAvailableAgents()
|
||||
// 重置 UserInfoBar 的展开状态
|
||||
userInfoBarRef.value?.resetForNewConversation()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件样式在 global.css 中定义 */
|
||||
|
||||
/* 输入指示器样式 */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.typing-dots .dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-dots .dot:nth-child(1) { animation-delay: 0s; }
|
||||
.typing-dots .dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots .dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.typing-text {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 决策树递归节点组件
|
||||
// =============================================================================
|
||||
// 说明:递归渲染决策树节点
|
||||
// 节点类型:
|
||||
// - step: 圆形编号节点 + 连接线 + 标签
|
||||
// - decision: 黄底方块 "❓ 判断" + 判断文字 + 是/否分支
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="flowchart-node" :class="[`flowchart-node--${node.type}`, `flowchart-node--${node.status || 'pending'}`]">
|
||||
<!-- 步骤节点 -->
|
||||
<div v-if="node.type === 'step'" class="flowchart-step">
|
||||
<div class="flowchart-step__indicator" :class="statusClass">
|
||||
<span class="flowchart-step__number">{{ stepIndex }}</span>
|
||||
</div>
|
||||
<div class="flowchart-step__connector" :class="statusClass"></div>
|
||||
<div class="flowchart-step__label" :class="statusClass">{{ node.label }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 判断节点 -->
|
||||
<div v-if="node.type === 'decision'" class="flowchart-decision">
|
||||
<div class="flowchart-decision__box">
|
||||
<span class="flowchart-decision__icon">❓</span>
|
||||
<span class="flowchart-decision__text">{{ node.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 分支 -->
|
||||
<div class="flowchart-decision__branches">
|
||||
<!-- "是" 分支 -->
|
||||
<div v-if="node.yes_branch" class="flowchart-branch flowchart-branch--yes">
|
||||
<div class="flowchart-branch__label">是</div>
|
||||
<div class="flowchart-branch__connector"></div>
|
||||
<div class="flowchart-branch__content">
|
||||
<FlowchartNode
|
||||
:node="node.yes_branch"
|
||||
:base-index="stepIndex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- "否" 分支 -->
|
||||
<div v-if="node.no_branch" class="flowchart-branch flowchart-branch--no">
|
||||
<div class="flowchart-branch__label">否</div>
|
||||
<div class="flowchart-branch__connector"></div>
|
||||
<div class="flowchart-branch__content">
|
||||
<FlowchartNode
|
||||
:node="node.no_branch"
|
||||
:base-index="stepIndex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子节点列表(step 类型的 children) -->
|
||||
<div v-if="node.type === 'step' && node.children && node.children.length > 0" class="flowchart-node__children">
|
||||
<FlowchartNode
|
||||
v-for="(child, idx) in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:base-index="stepIndex + idx + 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
import type { FlowchartNode as FlowchartNodeType } from '@/api/troubleshooting'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 节点数据 */
|
||||
node: FlowchartNodeType
|
||||
/** 基础序号(用于步骤编号) */
|
||||
baseIndex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
baseIndex: 1,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 步骤序号(1-based) */
|
||||
const stepIndex = computed(() => {
|
||||
return props.baseIndex
|
||||
})
|
||||
|
||||
/** 状态样式类 */
|
||||
const statusClass = computed(() => {
|
||||
return `flowchart-status--${props.node.status || 'pending'}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 节点容器 */
|
||||
.flowchart-node {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ===== 步骤节点 ===== */
|
||||
.flowchart-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 步骤指示器(圆形编号) */
|
||||
.flowchart-step__indicator {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.flowchart-step__indicator.flowchart-status--done {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.flowchart-step__indicator.flowchart-status--current {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.flowchart-step__indicator.flowchart-status--pending {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 连接线 */
|
||||
.flowchart-step__connector {
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.flowchart-step__connector.flowchart-status--done {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.flowchart-step__connector.flowchart-status--current {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.flowchart-step__connector.flowchart-status--pending {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* 步骤标签 */
|
||||
.flowchart-step__label {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.flowchart-step__label.flowchart-status--done {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.flowchart-step__label.flowchart-status--current {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flowchart-step__label.flowchart-status--pending {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 子节点容器 */
|
||||
.flowchart-node__children {
|
||||
margin-left: 11px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* ===== 判断节点 ===== */
|
||||
.flowchart-decision {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.flowchart-decision__box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: var(--warning-soft);
|
||||
border: 1px solid var(--warning-soft);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flowchart-decision__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flowchart-decision__text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 分支容器 */
|
||||
.flowchart-decision__branches {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 分支 */
|
||||
.flowchart-branch {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flowchart-branch__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.flowchart-branch--yes .flowchart-branch__label {
|
||||
background: var(--success-soft);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.flowchart-branch--no .flowchart-branch__label {
|
||||
background: var(--danger-soft);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.flowchart-branch__connector {
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
margin-left: 16px;
|
||||
border-left: 2px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.flowchart-branch__content {
|
||||
padding-left: 12px;
|
||||
border-left: 2px dashed var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,806 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端输入框组件
|
||||
// =============================================================================
|
||||
// 说明:坐席端回复消息的输入区域
|
||||
// 功能:
|
||||
// - 输入框默认3行高度,自动扩展(max-height: 150px)
|
||||
// - 底部显示字数统计(当前/最大,如:120/500)
|
||||
// - 右下角发送按钮(icon)
|
||||
// - Enter键发送,Shift+Enter换行
|
||||
// - 空内容时禁用发送按钮
|
||||
// - 支持粘贴图片/文件上传
|
||||
// - 快捷工具栏(表情/图片/截图/文件/语音/快速回复)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="input-box" ref="replyBoxRef">
|
||||
<!-- 引用回复预览(回复某条消息时显示) -->
|
||||
<div v-if="replyToMessage" class="reply-preview">
|
||||
<div class="reply-preview-bar"></div>
|
||||
<div class="reply-preview-content">
|
||||
<span class="reply-preview-sender">{{ replyToMessage.sender_name || '未知' }}</span>
|
||||
<span class="reply-preview-text">{{ replyToMessage.content?.substring(0, 60) }}{{ replyToMessage.content?.length > 60 ? '...' : '' }}</span>
|
||||
</div>
|
||||
<button class="reply-preview-close" title="取消回复" @click="$emit('cancelReply')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 快捷工具栏 -->
|
||||
<div class="chat-toolbar">
|
||||
<div class="emoji-wrapper">
|
||||
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
|
||||
😊
|
||||
<span class="tb-tip">表情</span>
|
||||
</button>
|
||||
<!-- 自定义中文表情选择面板 -->
|
||||
<div v-if="showEmojiPicker" class="emoji-picker-popup">
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in commonEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-grid__item"
|
||||
@click="onEmojiSelect(emoji)"
|
||||
>{{ emoji }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
|
||||
</div>
|
||||
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
|
||||
🖼
|
||||
<span class="tb-tip">图片</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
|
||||
✂
|
||||
<span class="tb-tip">截图</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
|
||||
📎
|
||||
<span class="tb-tip">文件</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="语音" @click="handleToolbarClick('voice')">
|
||||
🎤
|
||||
<span class="tb-tip">语音</span>
|
||||
</button>
|
||||
<div class="tb-sep"></div>
|
||||
<button class="tb-btn" title="快速回复" @click="handleToolbarClick('quickReply')">
|
||||
⚡
|
||||
<span class="tb-tip">快速回复</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 输入行 -->
|
||||
<div class="chat-input-card">
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
v-model="inputText"
|
||||
class="chat-input"
|
||||
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
|
||||
:style="{ height: textareaHeight + 'px' }"
|
||||
:disabled="!conversationStore.currentConversation"
|
||||
@keydown="handleKeydown"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
|
||||
<!-- 发送按钮 — icon样式 -->
|
||||
<button
|
||||
class="btn-send"
|
||||
:class="{ 'btn-send--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg v-if="!conversationStore.loading" class="send-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15-2-15-2z"/>
|
||||
</svg>
|
||||
<van-loading v-else size="16px" color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 字数统计 -->
|
||||
<div class="input-box__counter">
|
||||
{{ charCount }}/{{ maxChars }}
|
||||
</div>
|
||||
|
||||
<!-- 邀请弹窗 -->
|
||||
<InviteParticipantDialog
|
||||
v-model="showInviteDialog"
|
||||
:conversation-id="conversationStore.currentConversation?.id || ''"
|
||||
:existing-participant-ids="existingParticipantIds"
|
||||
@success="onInviteSuccess"
|
||||
/>
|
||||
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 截图编辑器 -->
|
||||
<ScreenshotEditor
|
||||
v-if="showScreenshotEditor"
|
||||
:screenshot-canvas="screenshotCanvas"
|
||||
@confirm="onScreenshotConfirm"
|
||||
@cancel="onScreenshotCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InputBox 坐席端输入框组件
|
||||
* 输入框默认3行高度,自动扩展
|
||||
* 底部显示字数统计,右下角发送按钮
|
||||
* Enter发送,Shift+Enter换行
|
||||
*/
|
||||
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
||||
import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import { sendMessage } from '@/api/message'
|
||||
import type { Message } from '@/api/message'
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
function formatErrorDetail(detail: any): string {
|
||||
if (!detail) return ''
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((d: any) => {
|
||||
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
|
||||
if (d.msg) return d.msg
|
||||
return JSON.stringify(d)
|
||||
}).join('; ')
|
||||
}
|
||||
if (typeof detail === 'object') return JSON.stringify(detail)
|
||||
return String(detail)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
interface Props {
|
||||
/** 引用回复:正在回复的消息 */
|
||||
replyToMessage?: Message | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', content: string): void
|
||||
(e: 'cancelReply'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
replyToMessage: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** 输入框文本 */
|
||||
const inputText = ref('')
|
||||
|
||||
/** 输入框 DOM 引用 */
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
/** 隐藏文件输入框 DOM 引用 */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** ReplyBox 容器 DOM 引用 */
|
||||
const replyBoxRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** textarea 高度 */
|
||||
const textareaHeight = ref(60)
|
||||
|
||||
/** 最大字符数 */
|
||||
const maxChars = 500
|
||||
|
||||
/** 是否显示表情面板 */
|
||||
const showEmojiPicker = ref(false)
|
||||
|
||||
/** 邀请弹窗是否可见 */
|
||||
const showInviteDialog = ref(false)
|
||||
|
||||
/** 截图编辑器是否可见 */
|
||||
const showScreenshotEditor = ref(false)
|
||||
|
||||
/** html2canvas 生成的截图 Canvas */
|
||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||
|
||||
/** 常用表情列表 */
|
||||
const commonEmojis = [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
||||
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
||||
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
||||
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
||||
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
||||
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
||||
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
||||
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
||||
]
|
||||
|
||||
/** 当前字符数 */
|
||||
const charCount = computed(() => inputText.value.length))
|
||||
|
||||
/** 是否可以发送 */
|
||||
const canSend = computed(() => {
|
||||
return inputText.value.trim().length > 0 && !conversationStore.loading && charCount.value <= maxChars
|
||||
})
|
||||
|
||||
/** 已在参与者列表中的ID */
|
||||
const existingParticipantIds = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv) return []
|
||||
const ids: string[] = [conv.employee_id]
|
||||
if (conv.participants) {
|
||||
ids.push(...conv.participants.map((p: any) => p.id))
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
watch(
|
||||
() => conversationStore.pendingReplyText,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
inputText.value = newVal
|
||||
conversationStore.pendingReplyText = ''
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 输入框高度自适应
|
||||
// ============================================================================
|
||||
function handleInput(): void {
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
const scrollHeight = inputRef.value.scrollHeight
|
||||
const newHeight = Math.min(Math.max(scrollHeight, 60), 150)
|
||||
textareaHeight.value = newHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 键盘事件
|
||||
// ============================================================================
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 发送消息
|
||||
// ============================================================================
|
||||
async function handleSend(): Promise<void> {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || conversationStore.loading) return
|
||||
|
||||
try {
|
||||
emit('send', content)
|
||||
inputText.value = ''
|
||||
textareaHeight.value = 60
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 粘贴事件
|
||||
// ============================================================================
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 文件上传
|
||||
// ============================================================================
|
||||
async function handleImageUpload(file: File | Blob): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ElMessage.info('图片上传中...')
|
||||
const result = await uploadFile(file)
|
||||
const newMsg = await sendMessage(convId, '[图片]', 'image', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
conversationStore.messages.push(newMsg)
|
||||
ElMessage.success('图片发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('图片上传失败:', error)
|
||||
ElMessage.error(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'图片上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(file: File | Blob): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = file instanceof File ? file.name : '文件'
|
||||
ElMessage.info(`正在上传: ${fileName}`)
|
||||
const result = await uploadFile(file)
|
||||
const newMsg = await sendMessage(convId, `[文件] ${result.filename}`, 'file', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
conversationStore.messages.push(newMsg)
|
||||
ElMessage.success('文件发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('文件发送失败:', error)
|
||||
ElMessage.error(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'文件发送失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
if (file === files[0]) {
|
||||
ElMessage.info(`正在上传: ${file.name}`)
|
||||
}
|
||||
const result = await uploadFile(file)
|
||||
const isImage = result.msg_type === 'image'
|
||||
const newMsg = await sendMessage(
|
||||
convId,
|
||||
isImage ? '[图片]' : `[文件] ${file.name}`,
|
||||
isImage ? 'image' : 'file',
|
||||
{
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
}
|
||||
)
|
||||
conversationStore.messages.push(newMsg)
|
||||
ElMessage.success(`${file.name} 发送成功`)
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
ElMessage.error(
|
||||
`${file.name} 上传失败: ${
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'请重试'
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具栏
|
||||
// ============================================================================
|
||||
function handleToolbarClick(action: string): void {
|
||||
switch (action) {
|
||||
case 'image':
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
break
|
||||
|
||||
case 'file':
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = ''
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
break
|
||||
|
||||
case 'quickReply':
|
||||
inputRef.value?.focus()
|
||||
break
|
||||
|
||||
case 'screenshot':
|
||||
handleScreenshot()
|
||||
break
|
||||
|
||||
default:
|
||||
const actionMap: Record<string, string> = {
|
||||
voice: '语音消息功能开发中',
|
||||
}
|
||||
ElMessage.info(actionMap[action] || '功能开发中')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScreenshot(): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ElMessage.info('正在截取页面...')
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
})
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
} catch (error) {
|
||||
console.error('截图失败:', error)
|
||||
ElMessage.error('截图失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function onScreenshotConfirm(blob: Blob): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) return
|
||||
|
||||
try {
|
||||
ElMessage.info('截图上传中...')
|
||||
const result = await uploadFile(blob)
|
||||
const newMsg = await sendMessage(convId, '[截图]', 'image', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
conversationStore.messages.push(newMsg)
|
||||
ElMessage.success('截图发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('[InputBox] 截图发送失败:', error)
|
||||
ElMessage.error(
|
||||
`截图发送失败:${
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'未知错误'
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
}
|
||||
|
||||
function onScreenshotCancel(): void {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
|
||||
function onEmojiSelect(emoji: string): void {
|
||||
inputText.value += emoji
|
||||
showEmojiPicker.value = false
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onInviteSuccess(): void {
|
||||
conversationStore.fetchConversations()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 整体容器 */
|
||||
.input-box {
|
||||
padding: 0 12px 8px 12px;
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 快捷工具栏 */
|
||||
.chat-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 8px 6px 4px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tb-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tb-tip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 6px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-secondary);
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tb-btn:hover .tb-tip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tb-sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--border);
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 输入卡片 */
|
||||
.chat-input-card {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.chat-input-card:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 0 10px 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
max-height: 150px;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 发送按钮 */
|
||||
.btn-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
cursor: not-allowed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 4px 6px 4px 0;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-send--active {
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-send--active:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.btn-send--active .send-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 字数统计 */
|
||||
.input-box__counter {
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 表情选择器 */
|
||||
.emoji-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emoji-picker-popup {
|
||||
position: absolute;
|
||||
bottom: 36px;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 32px);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emoji-grid__item {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.emoji-grid__item:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.emoji-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* 引用回复预览 */
|
||||
.reply-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-preview-bar {
|
||||
width: 3px;
|
||||
height: 28px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-preview-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.reply-preview-sender {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reply-preview-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-preview-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reply-preview-close:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — IT 等级徽标组件
|
||||
// =============================================================================
|
||||
// 说明:7 级段位徽标,用于 UserInfoBar 和其他位置
|
||||
// 等级:bronze / silver / gold / platinum / diamond / star / king
|
||||
// 样式:CSS 类名在 global.css 中定义
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="it-badge"
|
||||
:class="[level, `it-badge-${size}`]"
|
||||
:title="levelInfo.title"
|
||||
>
|
||||
{{ levelInfo.icon }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** IT 等级:bronze/silver/gold/platinum/diamond/star/king */
|
||||
level: string
|
||||
/** 尺寸变体:sm(12px) / md(14px) / lg(16px) */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 等级元数据
|
||||
// ============================================================================
|
||||
|
||||
/** 等级配置表 */
|
||||
const LEVEL_MAP: Record<string, { icon: string; name: string; lv: number; title: string }> = {
|
||||
bronze: { icon: '🛡️', name: '青铜', lv: 1, title: '青铜 Lv.1 — IT基础薄弱,需要详细指导' },
|
||||
silver: { icon: '🥈', name: '白银', lv: 2, title: '白银 Lv.2 — 能完成基本操作,需协助复杂问题' },
|
||||
gold: { icon: '🥇', name: '黄金', lv: 3, title: '黄金 Lv.3 — 熟悉常见操作,可独立解决一般问题' },
|
||||
platinum: { icon: '⭐', name: '铂金', lv: 4, title: '铂金 Lv.4 — 熟练使用办公软件,能自助排查常见故障' },
|
||||
diamond: { icon: '💎', name: '钻石', lv: 5, title: '钻石 Lv.5 — 具备一定技术能力,能理解技术解释' },
|
||||
star: { icon: '🌟', name: '星耀', lv: 6, title: '星耀 Lv.6 — IT能力较强,可自行解决大部分问题' },
|
||||
king: { icon: '👑', name: '王者', lv: 7, title: '王者 Lv.7 — IT达人级别,仅少数问题需协助' },
|
||||
}
|
||||
|
||||
/** 计算等级信息 */
|
||||
const levelInfo = computed(() => {
|
||||
return LEVEL_MAP[props.level] || LEVEL_MAP['silver']
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 尺寸变体 */
|
||||
.it-badge-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.it-badge-md {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.it-badge-lg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 消息气泡组件
|
||||
// =============================================================================
|
||||
// 说明:单条消息的气泡展示
|
||||
// 根据 sender_type 区分:
|
||||
// - employee: 靠左灰底
|
||||
// - agent: 靠右蓝底白字
|
||||
// - ai: 靠左绿底+AI标签
|
||||
// - system: 居中灰字
|
||||
// 图片消息显示缩略图,文件消息显示文件图标+名称
|
||||
// 时间戳显示
|
||||
// 对于 employee 消息,如果存在 AI 草稿,在下方显示草稿气泡
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="message-row" :class="`message-row-${message.sender_type}`">
|
||||
<!-- 系统消息(居中灰字) -->
|
||||
<template v-if="message.sender_type === 'system'">
|
||||
<div class="message-bubble message-system">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 非系统消息 -->
|
||||
<template v-else>
|
||||
<!-- 发送者名称 -->
|
||||
<div class="message-sender-name">
|
||||
{{ senderLabel }}
|
||||
<!-- AI消息带AI标签 -->
|
||||
<span v-if="message.sender_type === 'ai'" class="ai-tag">AI</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<div class="message-bubble" :class="`message-${message.sender_type}`"
|
||||
@mouseenter="showCopyBtn = true"
|
||||
@mouseleave="showCopyBtn = false"
|
||||
>
|
||||
<!-- 引用回复摘要(当此消息回复了某条消息时显示) -->
|
||||
<div v-if="message.reply_to_id && replyToContent" class="reply-quote" @click.stop="$emit('scrollToMessage', message.reply_to_id)">
|
||||
<div class="reply-quote-bar"></div>
|
||||
<div class="reply-quote-text">
|
||||
<span class="reply-quote-sender">{{ replyToSender }}</span>
|
||||
{{ replyToContent }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<template v-if="message.msg_type === 'text'">
|
||||
<div style="white-space: pre-wrap;">{{ message.content }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="message.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<img
|
||||
v-if="message.media_url || message.extra_data?.pic_url"
|
||||
:src="message.media_url || message.extra_data?.pic_url"
|
||||
:alt="message.file_name || '图片'"
|
||||
class="image-thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- 无URL时显示占位卡片 -->
|
||||
<div v-else class="media-card">
|
||||
<div class="media-icon">🖼️</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">图片消息</span>
|
||||
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件消息:显示文件卡片 -->
|
||||
<template v-else-if="message.msg_type === 'file'">
|
||||
<a
|
||||
v-if="message.media_url"
|
||||
:href="message.media_url"
|
||||
target="_blank"
|
||||
class="media-card media-card-link"
|
||||
>
|
||||
<div class="media-icon">📎</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
|
||||
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- 无URL时显示纯卡片 -->
|
||||
<div v-else class="media-card">
|
||||
<div class="media-icon">📎</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
|
||||
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他非文本消息(语音/视频/位置等):显示通用媒体卡片 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
<div class="media-icon">{{ mediaIcon }}</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">{{ mediaTypeLabel }}</span>
|
||||
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
|
||||
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮组(hover 显示):复制 + 回复 -->
|
||||
<div v-if="showCopyBtn" class="bubble-actions">
|
||||
<button
|
||||
v-if="message.msg_type === 'text'"
|
||||
class="action-btn"
|
||||
title="复制消息"
|
||||
@click.stop="copyMessage"
|
||||
>
|
||||
{{ copySuccess ? '✓' : '📋' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
title="回复"
|
||||
@click.stop="$emit('reply', message)"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div class="message-time">{{ formatMessageTime }}</div>
|
||||
|
||||
<!-- AI 草稿气泡(仅对员工消息显示) -->
|
||||
<AiDraftBubble
|
||||
v-if="message.sender_type === 'employee' && draftForMessage"
|
||||
:conversation-id="message.conversation_id"
|
||||
:message-id="message.id"
|
||||
:content="draftForMessage.content"
|
||||
:confidence="draftForMessage.confidence"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import type { Message } from '@/api/message'
|
||||
import type { DraftResult } from '@/api/wingman'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import AiDraftBubble from './AiDraftBubble.vue'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 消息对象 */
|
||||
message: Message
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 事件(模板中使用 $emit 触发)
|
||||
defineEmits<{
|
||||
/** 点击引用回复摘要,滚动到被回复的消息 */
|
||||
(e: 'scrollToMessage', messageId: string): void
|
||||
/** 点击回复按钮,回复某条消息 */
|
||||
(e: 'reply', message: Message): void
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
// ============================================================================
|
||||
// 剪贴板相关(消息复制功能)
|
||||
// ============================================================================
|
||||
|
||||
/** useClipboard:VueUse 提供的剪贴板操作组合函数 */
|
||||
const { copy } = useClipboard()
|
||||
|
||||
/** 是否显示复制按钮(鼠标悬停时显示) */
|
||||
const showCopyBtn = ref(false)
|
||||
|
||||
/** 复制成功反馈标识(1.5秒后自动消失) */
|
||||
const copySuccess = ref(false)
|
||||
|
||||
/**
|
||||
* 复制消息内容到剪贴板。
|
||||
* 使用 VueUse 的 useClipboard 封装,兼容各浏览器。
|
||||
* 复制成功后显示 ✓ 图标 1.5 秒。
|
||||
*/
|
||||
async function copyMessage(): Promise<void> {
|
||||
try {
|
||||
await copy(props.message.content)
|
||||
copySuccess.value = true
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 发送者标签文字 */
|
||||
const senderLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
employee: props.message.sender_name || '员工',
|
||||
agent: props.message.sender_name || '我',
|
||||
ai: 'AI助手',
|
||||
}
|
||||
return labelMap[props.message.sender_type] || '未知'
|
||||
})
|
||||
|
||||
/** 格式化消息时间 */
|
||||
const formatMessageTime = computed(() => {
|
||||
if (!props.message.created_at) return ''
|
||||
const date = new Date(props.message.created_at)
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
})
|
||||
|
||||
/** 获取当前消息的 AI 草稿数据 */
|
||||
const draftForMessage = computed<DraftResult | null>(() => {
|
||||
if (props.message.sender_type !== 'employee') return null
|
||||
if (!props.message.conversation_id) return null
|
||||
|
||||
return conversationStore.getDraft(
|
||||
props.message.conversation_id,
|
||||
props.message.id
|
||||
)
|
||||
})
|
||||
|
||||
// ==================================================================
|
||||
// 非文本消息相关计算属性
|
||||
// ==================================================================
|
||||
|
||||
/** 消息类型对应的 Emoji 图标 */
|
||||
const mediaIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
image: '🖼️',
|
||||
voice: '🎤',
|
||||
video: '🎬',
|
||||
file: '📎',
|
||||
location: '📍',
|
||||
}
|
||||
return icons[props.message.msg_type] || '📄'
|
||||
})
|
||||
|
||||
/** 消息类型对应的中文标签 */
|
||||
const mediaTypeLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
image: '图片消息',
|
||||
voice: '语音消息',
|
||||
video: '视频消息',
|
||||
file: '文件消息',
|
||||
location: '位置消息',
|
||||
}
|
||||
return labels[props.message.msg_type] || '媒体消息'
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化文件大小为人类可读字符串。
|
||||
*
|
||||
* @param bytes - 文件大小(字节)
|
||||
* @returns 格式化后的字符串,如 "1.5 MB"
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览:点击图片缩略图时,在新标签页打开大图。
|
||||
* 使用浏览器原生能力,无需引入图片预览组件。
|
||||
*/
|
||||
function previewImage(): void {
|
||||
const url = props.message.media_url || props.message.extra_data?.pic_url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 引用回复相关计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 被回复的消息内容摘要(截取前50字) */
|
||||
const replyToContent = computed(() => {
|
||||
if (!props.message.reply_to_id) return ''
|
||||
// 从 store 的消息列表中查找被回复的消息
|
||||
const repliedMsg = conversationStore.messages.find(
|
||||
(m: Message) => m.id === props.message.reply_to_id
|
||||
)
|
||||
if (!repliedMsg) return '...'
|
||||
// 截取前50字作为摘要
|
||||
const text = repliedMsg.content || ''
|
||||
return text.length > 50 ? text.substring(0, 50) + '...' : text
|
||||
})
|
||||
|
||||
/** 被回复的消息发送者 */
|
||||
const replyToSender = computed(() => {
|
||||
if (!props.message.reply_to_id) return ''
|
||||
const repliedMsg = conversationStore.messages.find(
|
||||
(m: Message) => m.id === props.message.reply_to_id
|
||||
)
|
||||
if (!repliedMsg) return ''
|
||||
return repliedMsg.sender_name || '未知'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 非文本消息媒体卡片样式
|
||||
// ============================================================================ */
|
||||
|
||||
.media-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.media-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-type-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.media-filename {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 操作按钮组 — 悬停在消息气泡上时显示 */
|
||||
.message-bubble {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bubble-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 图片消息样式
|
||||
// ============================================================================ */
|
||||
|
||||
.image-message {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.image-thumbnail {
|
||||
display: block;
|
||||
max-width: 280px;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 文件消息卡片链接样式 */
|
||||
.media-card-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.media-card-link:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 引用回复样式
|
||||
// ============================================================================ */
|
||||
|
||||
.reply-quote {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.reply-quote:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reply-quote-bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-quote-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.reply-quote-sender {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,635 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端消息气泡组件
|
||||
// =============================================================================
|
||||
// 说明:单条消息的气泡展示
|
||||
// 功能:
|
||||
// - 长按/右键弹出操作菜单:复制、撤回(2分钟内)、删除
|
||||
// - 消息状态显示:发送中、已发送、已送达、已读
|
||||
// - 时间戳显示规则:同日期只显示时间,不同日期显示月日时间
|
||||
// - 消息类型:文本、图片、文件、语音、系统消息
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 系统消息:居中灰色文字 -->
|
||||
<div v-if="message.sender_type === 'system'" class="message-item message-item--system">
|
||||
<span class="message-item__system-text">{{ message.content }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 非系统消息 -->
|
||||
<div
|
||||
v-else
|
||||
class="message-row"
|
||||
:class="`message-row-${message.sender_type}`"
|
||||
@contextmenu.prevent="showContextMenu"
|
||||
>
|
||||
<!-- 发送者名称 -->
|
||||
<div v-if="message.sender_type !== 'agent'" class="message-sender-name">
|
||||
{{ senderLabel }}
|
||||
<span v-if="message.sender_type === 'ai'" class="ai-tag">AI</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<div class="message-bubble" :class="`message-${message.sender_type}`">
|
||||
<!-- 引用回复摘要 -->
|
||||
<div v-if="message.reply_to_id && replyToContent" class="reply-quote" @click.stop="$emit('scrollToMessage', message.reply_to_id)">
|
||||
<div class="reply-quote-bar"></div>
|
||||
<div class="reply-quote-text">
|
||||
<span class="reply-quote-sender">{{ replyToSender }}</span>
|
||||
{{ replyToContent }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<template v-if="message.msg_type === 'text'">
|
||||
<div style="white-space: pre-wrap;">{{ message.content }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片消息 -->
|
||||
<template v-else-if="message.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<img
|
||||
v-if="message.media_url || message.extra_data?.pic_url"
|
||||
:src="message.media_url || message.extra_data?.pic_url"
|
||||
:alt="message.file_name || '图片'"
|
||||
class="image-thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="media-card">
|
||||
<div class="media-icon">🖼️</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">图片消息</span>
|
||||
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件消息 -->
|
||||
<template v-else-if="message.msg_type === 'file'">
|
||||
<a
|
||||
v-if="message.media_url"
|
||||
:href="message.media_url"
|
||||
target="_blank"
|
||||
class="media-card media-card-link"
|
||||
>
|
||||
<div class="media-icon">📎</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
|
||||
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div v-else class="media-card">
|
||||
<div class="media-icon">📎</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
|
||||
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他消息 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
<div class="media-icon">{{ mediaIcon }}</div>
|
||||
<div class="media-info">
|
||||
<span class="media-type-label">{{ mediaTypeLabel }}</span>
|
||||
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
|
||||
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮(hover 显示) -->
|
||||
<div v-if="showActions" class="bubble-actions">
|
||||
<button
|
||||
v-if="message.msg_type === 'text'"
|
||||
class="action-btn"
|
||||
title="复制消息"
|
||||
@click.stop="copyMessage"
|
||||
>
|
||||
{{ copySuccess ? '✓' : '📋' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
title="回复"
|
||||
@click.stop="$emit('reply', message)"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 消息状态 -->
|
||||
<div v-if="showStatus" class="message-status">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div class="message-time">{{ formatMessageTime }}</div>
|
||||
|
||||
<!-- 操作菜单 -->
|
||||
<div v-if="contextMenuVisible" class="context-menu" :style="contextMenuStyle">
|
||||
<button class="context-menu__item" @click.stop="copyMessage">
|
||||
📋 复制
|
||||
</button>
|
||||
<button
|
||||
v-if="canRecall"
|
||||
class="context-menu__item"
|
||||
@click.stop="recallMessage"
|
||||
>
|
||||
↩️ 撤回
|
||||
</button>
|
||||
<button
|
||||
v-if="message.sender_type === 'employee'"
|
||||
class="context-menu__item context-menu__item--danger"
|
||||
@click.stop="deleteMessage"
|
||||
>
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="contextMenuVisible" class="context-menu__overlay" @click="closeContextMenu"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MessageItem 消息气泡组件
|
||||
* 长按/右键弹出操作菜单
|
||||
* 消息状态显示
|
||||
* 时间戳显示规则
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Message } from '@/api/message'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scrollToMessage', messageId: string): void
|
||||
(e: 'reply', message: Message): void
|
||||
(e: 'recall', messageId: string): void
|
||||
(e: 'delete', messageId: string): void
|
||||
}>()
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
/** 是否显示操作按钮 */
|
||||
const showActions = ref(false)
|
||||
|
||||
/** 操作菜单位置 */
|
||||
const contextMenuVisible = ref(false)
|
||||
const contextMenuStyle = ref<Record<string, string>>({})
|
||||
|
||||
/** 复制成功反馈 */
|
||||
const copySuccess = ref(false)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 发送者标签 */
|
||||
const senderLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
employee: props.message.sender_name || '员工',
|
||||
agent: props.message.sender_name || '我',
|
||||
ai: 'AI助手',
|
||||
}
|
||||
return labelMap[props.message.sender_type] || '未知'
|
||||
})
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatMessageTime = computed(() => {
|
||||
if (!props.message.created_at) return ''
|
||||
const date = new Date(props.message.created_at)
|
||||
const now = new Date()
|
||||
const isSameDay = date.toDateString() === now.toDateString()
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
|
||||
if (isSameDay) {
|
||||
return `${hours}:${minutes}`
|
||||
} else {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
})
|
||||
|
||||
/** 是否显示状态 */
|
||||
const showStatus = computed(() => {
|
||||
return props.message.sender_type === 'agent' && props.message.status
|
||||
})
|
||||
|
||||
/** 状态文本 */
|
||||
const statusText = computed(() => {
|
||||
const statusMap: Record<string, string> = {
|
||||
sending: '发送中',
|
||||
sent: '已发送',
|
||||
delivered: '已送达',
|
||||
read: '已读',
|
||||
}
|
||||
return statusMap[props.message.status || ''] || ''
|
||||
})
|
||||
|
||||
/** 是否可以撤回 */
|
||||
const canRecall = computed(() => {
|
||||
if (props.message.sender_type !== 'agent') return false
|
||||
if (!props.message.created_at) return false
|
||||
const createdAt = new Date(props.message.created_at)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - createdAt.getTime()
|
||||
const diffMinutes = diffMs / (1000 * 60)
|
||||
return diffMinutes <= 2
|
||||
})
|
||||
|
||||
/** 媒体图标 */
|
||||
const mediaIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
image: '🖼️',
|
||||
voice: '🎤',
|
||||
video: '🎬',
|
||||
file: '📎',
|
||||
location: '📍',
|
||||
}
|
||||
return icons[props.message.msg_type] || '📄'
|
||||
})
|
||||
|
||||
/** 媒体类型标签 */
|
||||
const mediaTypeLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
image: '图片消息',
|
||||
voice: '语音消息',
|
||||
video: '视频消息',
|
||||
file: '文件消息',
|
||||
location: '位置消息',
|
||||
}
|
||||
return labels[props.message.msg_type] || '媒体消息'
|
||||
})
|
||||
|
||||
/** 引用回复内容 */
|
||||
const replyToContent = computed(() => {
|
||||
if (!props.message.reply_to_id) return ''
|
||||
const repliedMsg = conversationStore.messages.find(
|
||||
(m: Message) => m.id === props.message.reply_to_id
|
||||
)
|
||||
if (!repliedMsg) return '...'
|
||||
const text = repliedMsg.content || ''
|
||||
return text.length > 50 ? text.substring(0, 50) + '...' : text
|
||||
})
|
||||
|
||||
/** 引用回复发送者 */
|
||||
const replyToSender = computed(() => {
|
||||
if (!props.message.reply_to_id) return ''
|
||||
const repliedMsg = conversationStore.messages.find(
|
||||
(m: Message) => m.id === props.message.reply_to_id
|
||||
)
|
||||
if (!repliedMsg) return ''
|
||||
return repliedMsg.sender_name || '未知'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 操作菜单
|
||||
// ============================================================================
|
||||
function showContextMenu(event: MouseEvent): void {
|
||||
contextMenuStyle.value = {
|
||||
left: `${event.clientX}px`,
|
||||
top: `${event.clientY}px`,
|
||||
}
|
||||
contextMenuVisible.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu(): void {
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息操作
|
||||
// ============================================================================
|
||||
async function copyMessage(): Promise<void> {
|
||||
try {
|
||||
await copy(props.message.content)
|
||||
copySuccess.value = true
|
||||
ElMessage.success('已复制')
|
||||
closeContextMenu()
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function recallMessage(): void {
|
||||
emit('recall', props.message.id)
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function deleteMessage(): void {
|
||||
emit('delete', props.message.id)
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具方法
|
||||
// ============================================================================
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function previewImage(): void {
|
||||
const url = props.message.media_url || props.message.extra_data?.pic_url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 消息行
|
||||
// ============================================================================ */
|
||||
.message-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 16px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-row-employee {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-row-agent {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-row-ai {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 系统消息 */
|
||||
.message-item--system {
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.message-item__system-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-system-text);
|
||||
background-color: var(--color-system-bg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 发送者名称 */
|
||||
.message-sender-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ai-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: var(--color-ai-tag-text);
|
||||
background-color: var(--color-ai-tag-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 消息气泡 */
|
||||
.message-bubble {
|
||||
max-width: 75%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-employee {
|
||||
background-color: var(--color-agent-bg);
|
||||
border: 1px solid var(--color-agent-border);
|
||||
}
|
||||
|
||||
.message-agent {
|
||||
background-color: var(--color-employee-bg);
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-ai {
|
||||
background-color: var(--color-ai-bg);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* 消息时间 */
|
||||
.message-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* 引用回复 */
|
||||
.reply-quote {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reply-quote:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reply-quote-bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-quote-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reply-quote-sender {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 图片消息 */
|
||||
.image-message {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.image-thumbnail {
|
||||
display: block;
|
||||
max-width: 280px;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 媒体卡片 */
|
||||
.media-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.media-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-type-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.media-filename {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.media-card-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.media-card-link:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.bubble-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* 消息状态 */
|
||||
.message-status {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 操作菜单 */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.context-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.context-menu__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.context-menu__item--danger {
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
.context-menu__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,983 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 回复输入框组件(v5.4 圆角卡片+工具栏+拖拽调整)
|
||||
// =============================================================================
|
||||
// 说明:坐席回复消息的输入区域
|
||||
// 功能:
|
||||
// 1. 上方拖拽手柄调整输入区高度(textarea 同步伸缩)
|
||||
// 2. 快捷工具栏(表情/图片/截图/文件/语音/远程协助/快速回复)
|
||||
// 3. 输入框+发送按钮合为圆角卡片,渐变蓝紫发送按钮
|
||||
// 4. Enter 发送,Shift+Enter 换行
|
||||
// 5. 支持粘贴图片/拖拽文件上传(预留接口)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="reply-box" ref="replyBoxRef">
|
||||
<!-- 上方拖拽手柄(调整输入区高度) -->
|
||||
<div
|
||||
class="input-resize-handle"
|
||||
:class="{ dragging: isResizing }"
|
||||
@mousedown="startResize"
|
||||
title="拖拽调整输入框高度"
|
||||
></div>
|
||||
|
||||
<!-- 引用回复预览(回复某条消息时显示) -->
|
||||
<div v-if="replyToMessage" class="reply-preview">
|
||||
<div class="reply-preview-bar"></div>
|
||||
<div class="reply-preview-content">
|
||||
<span class="reply-preview-sender">{{ replyToMessage.sender_name || '未知' }}</span>
|
||||
<span class="reply-preview-text">{{ replyToMessage.content?.substring(0, 60) }}{{ replyToMessage.content?.length > 60 ? '...' : '' }}</span>
|
||||
</div>
|
||||
<button class="reply-preview-close" title="取消回复" @click="$emit('cancelReply')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 快捷工具栏 -->
|
||||
<div class="chat-toolbar">
|
||||
<div class="emoji-wrapper">
|
||||
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
|
||||
😊
|
||||
<span class="tb-tip">表情</span>
|
||||
</button>
|
||||
<!-- 自定义中文表情选择面板(8x8 网格,64个常用表情) -->
|
||||
<div v-if="showEmojiPicker" class="emoji-picker-popup">
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in commonEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-grid__item"
|
||||
@click="onEmojiSelect(emoji)"
|
||||
>{{ emoji }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 点击表情面板外部关闭 -->
|
||||
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
|
||||
</div>
|
||||
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
|
||||
🖼
|
||||
<span class="tb-tip">图片</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
|
||||
✂
|
||||
<span class="tb-tip">截图</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
|
||||
📎
|
||||
<span class="tb-tip">文件</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="语音" @click="handleToolbarClick('voice')">
|
||||
🎤
|
||||
<span class="tb-tip">语音</span>
|
||||
</button>
|
||||
<div class="tb-sep"></div>
|
||||
<button class="tb-btn" title="邀请员工/部门" @click="showInviteDialog = true">
|
||||
👥
|
||||
<span class="tb-tip">邀请</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="远程协助" @click="handleToolbarClick('remote')">
|
||||
🖥
|
||||
<span class="tb-tip">远程协助</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="快速回复" @click="handleToolbarClick('quickReply')">
|
||||
⚡
|
||||
<span class="tb-tip">快速回复</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 输入行(圆角卡片:textarea + 发送按钮) -->
|
||||
<div class="chat-input-card">
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
v-model="inputText"
|
||||
class="chat-input"
|
||||
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
|
||||
:style="{ height: textareaHeight + 'px' }"
|
||||
@keydown="handleKeydown"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-send"
|
||||
:disabled="!inputText.trim()"
|
||||
@click="handleSend"
|
||||
>
|
||||
发 送
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 邀请员工/部门弹窗 -->
|
||||
<InviteParticipantDialog
|
||||
v-model="showInviteDialog"
|
||||
:conversation-id="conversationStore.currentConversation?.id || ''"
|
||||
:existing-participant-ids="existingParticipantIds"
|
||||
@success="onInviteSuccess"
|
||||
/>
|
||||
|
||||
<!-- 隐藏的文件输入框(图片/文件上传用,由工具栏按钮触发) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 截图区域选择编辑器(对标微信/企微截图体验) -->
|
||||
<ScreenshotEditor
|
||||
v-if="showScreenshotEditor"
|
||||
:screenshot-canvas="screenshotCanvas"
|
||||
@confirm="onScreenshotConfirm"
|
||||
@cancel="onScreenshotCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, watch, nextTick, onUnmounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
||||
import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import { sendMessage } from '@/api/message'
|
||||
import type { Message } from '@/api/message'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数:安全提取错误详情(防止 [object Object])
|
||||
// ============================================================================
|
||||
/**
|
||||
* 将 error.response.data.detail 安全转换为字符串
|
||||
* FastAPI 422 时 detail 是数组,直接拼接会变成 "[object Object]"
|
||||
*/
|
||||
function formatErrorDetail(detail: any): string {
|
||||
if (!detail) return ''
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
// FastAPI 验证错误:取每个元素的 msg 和 loc
|
||||
return detail.map((d: any) => {
|
||||
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
|
||||
if (d.msg) return d.msg
|
||||
return JSON.stringify(d)
|
||||
}).join('; ')
|
||||
}
|
||||
if (typeof detail === 'object') return JSON.stringify(detail)
|
||||
return String(detail)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 引用回复:正在回复的消息(null 表示普通发送) */
|
||||
replyToMessage?: Message | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 发送消息事件 */
|
||||
(e: 'send', content: string): void
|
||||
/** 取消引用回复 */
|
||||
(e: 'cancelReply'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
replyToMessage: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 输入框文本 */
|
||||
const inputText = ref('')
|
||||
|
||||
/** 输入框 DOM 引用 */
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
/** 隐藏文件输入框 DOM 引用(用于触发系统文件选择器) */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** ReplyBox 容器 DOM 引用 */
|
||||
const replyBoxRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 会话 Store */
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** WebSocket 组合函数(用于发送 typing 事件) */
|
||||
const { sendTyping } = useWebSocket()
|
||||
|
||||
/** textarea 高度(px),默认3行约60px */
|
||||
const textareaHeight = ref(60)
|
||||
|
||||
/** 是否正在拖拽调整高度 */
|
||||
const isResizing = ref(false)
|
||||
|
||||
/** 邀请弹窗是否可见 */
|
||||
const showInviteDialog = ref(false)
|
||||
|
||||
/** 截图编辑器是否可见 */
|
||||
const showScreenshotEditor = ref(false)
|
||||
|
||||
/** html2canvas 生成的完整页面截图 Canvas 对象(传给 ScreenshotEditor) */
|
||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||
const showEmojiPicker = ref(false)
|
||||
|
||||
/**
|
||||
* 常用表情列表(8行x8列 = 64 个常用表情)
|
||||
* 覆盖日常沟通场景,与 H5 端保持一致
|
||||
*/
|
||||
const commonEmojis = [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
||||
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
||||
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
||||
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
||||
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
||||
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
||||
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
||||
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
||||
]
|
||||
|
||||
/** 已在参与者列表中的ID(排除已有员工和参与者) */
|
||||
const existingParticipantIds = computed(() => {
|
||||
const conv = conversationStore.currentConversation
|
||||
if (!conv) return []
|
||||
const ids: string[] = [conv.employee_id] // 排除发起咨询的员工自己
|
||||
if (conv.participants) {
|
||||
ids.push(...conv.participants.map((p: any) => p.id))
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
/** 邀请成功回调 */
|
||||
function onInviteSuccess(): void {
|
||||
// 刷新会话列表(重新拉取最新数据)
|
||||
conversationStore.fetchConversations()
|
||||
}
|
||||
|
||||
/** 表情选择回调 — 将选中的表情插入到输入框 */
|
||||
function onEmojiSelect(emoji: string): void {
|
||||
inputText.value += emoji
|
||||
showEmojiPicker.value = false
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
/** 拖拽相关临时变量 */
|
||||
let resizeStartY = 0
|
||||
let resizeStartHeight = 0
|
||||
let resizeStartTextareaHeight = 0
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 监听 pendingReplyText 变化
|
||||
* 当快速回复模板设置待填充文本时,自动填充到输入框
|
||||
*/
|
||||
watch(
|
||||
() => conversationStore.pendingReplyText,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
inputText.value = newVal
|
||||
conversationStore.pendingReplyText = ''
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 监听输入框文本变化,发送 typing 事件
|
||||
* 当用户输入文字时,通知 WebSocket 让其他人看到"正在输入..."
|
||||
* 节流:3 秒内最多发送一次(在 sendTyping 内部控制)
|
||||
*/
|
||||
watch(inputText, () => {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (convId && inputText.value.trim()) {
|
||||
sendTyping(convId)
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 拖拽调整输入区高度
|
||||
// ============================================================================
|
||||
|
||||
/** 开始拖拽 */
|
||||
function startResize(e: MouseEvent): void {
|
||||
e.preventDefault()
|
||||
isResizing.value = true
|
||||
resizeStartY = e.clientY
|
||||
resizeStartHeight = replyBoxRef.value?.offsetHeight || 80
|
||||
resizeStartTextareaHeight = textareaHeight.value
|
||||
document.addEventListener('mousemove', onResizeMove)
|
||||
document.addEventListener('mouseup', onResizeEnd)
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
/** 拖拽中 */
|
||||
function onResizeMove(e: MouseEvent): void {
|
||||
if (!isResizing.value) return
|
||||
// 向上拖 → 输入区变高(delta为负),向下拖 → 变矮
|
||||
const dy = resizeStartY - e.clientY
|
||||
const newBoxH = Math.max(80, Math.min(400, resizeStartHeight + dy))
|
||||
const delta = newBoxH - resizeStartHeight
|
||||
const newTaH = Math.max(60, Math.min(300, resizeStartTextareaHeight + delta))
|
||||
textareaHeight.value = newTaH
|
||||
}
|
||||
|
||||
/** 结束拖拽 */
|
||||
function onResizeEnd(): void {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理键盘事件
|
||||
* Enter 发送消息,Shift+Enter 换行
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// Enter 且没有按 Shift → 发送
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
// Shift+Enter → 默认换行,不处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
async function handleSend(): Promise<void> {
|
||||
const content = inputText.value.trim()
|
||||
if (!content) return
|
||||
|
||||
try {
|
||||
emit('send', content)
|
||||
inputText.value = ''
|
||||
// 恢复 textarea 高度(3行默认)
|
||||
textareaHeight.value = 60
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
* 做什么:检测剪贴板中的图片或文件,自动上传并发送消息
|
||||
* 为什么:用户需要从剪贴板直接粘贴图片/文件,不用每次点按钮
|
||||
*
|
||||
* 支持:
|
||||
* 1. 粘贴图片(截图工具 Ctrl+V / 复制图片)
|
||||
* 2. 粘贴文件(从文件管理器复制的文件)
|
||||
* 3. 纯文本粘贴(默认行为,不拦截)
|
||||
*/
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
// 情况1:剪贴板包含文件(图片/任意文件)
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault() // 阻止默认粘贴(文件不插入文本)
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
// 根据文件类型选择上传方式
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return // 每次粘贴只处理第一个文件
|
||||
}
|
||||
|
||||
// 情况2:剪贴板包含图片类型数据(如截图工具复制的PNG)
|
||||
if (item.type.startsWith('image/') && item.kind === 'string') {
|
||||
// 某些浏览器将截图放在 item.getAsFile() 中,上面 'file' 分支已覆盖
|
||||
// 这里仅作防御性检查
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
await handleImageUpload(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// 纯文本:不拦截,浏览器默认行为(插入文本到输入框)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片并发送图片消息
|
||||
*
|
||||
* @param file - 图片文件对象(File 或 Blob)
|
||||
*/
|
||||
async function handleImageUpload(file: File | Blob): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ElMessage.info('图片上传中...')
|
||||
|
||||
// 1. 上传图片到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
// 2. 发送图片消息
|
||||
const newMsg = await sendMessage(convId, '[图片]', 'image', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
// 3. 把新消息加入 store,让界面立即刷新显示
|
||||
conversationStore.messages.push(newMsg)
|
||||
|
||||
ElMessage.success('图片发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('图片上传失败:', error)
|
||||
ElMessage.error(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'图片上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件并发送文件消息
|
||||
* 做什么:处理非图片文件的上传和发送
|
||||
* 为什么:粘贴功能需要支持任意文件类型,不仅限于图片
|
||||
*
|
||||
* @param file - 文件对象(File 或 Blob)
|
||||
*/
|
||||
async function handleFileUpload(file: File | Blob): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = file instanceof File ? file.name : '文件'
|
||||
ElMessage.info(`正在上传: ${fileName}`)
|
||||
|
||||
// 1. 上传文件到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
// 2. 发送文件消息
|
||||
const newMsg = await sendMessage(convId, `[文件] ${result.filename}`, 'file', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
// 3. 把新消息加入 store,让界面立即刷新显示
|
||||
conversationStore.messages.push(newMsg)
|
||||
|
||||
ElMessage.success('文件发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('文件发送失败:', error)
|
||||
ElMessage.error(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'文件发送失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件选择上传
|
||||
* 通过隐藏的 <input type="file"> 触发
|
||||
*/
|
||||
async function handleFileSelect(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
// 显示上传中提示(仅第一个文件显示)
|
||||
if (file === files[0]) {
|
||||
ElMessage.info(`正在上传: ${file.name}`)
|
||||
}
|
||||
|
||||
// 1. 上传文件到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
// 2. 发送文件消息
|
||||
const isImage = result.msg_type === 'image'
|
||||
const newMsg = await sendMessage(
|
||||
convId,
|
||||
isImage ? '[图片]' : `[文件] ${file.name}`,
|
||||
isImage ? 'image' : 'file',
|
||||
{
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
}
|
||||
)
|
||||
|
||||
// 3. 把新消息加入 store,让界面立即刷新显示
|
||||
conversationStore.messages.push(newMsg)
|
||||
|
||||
ElMessage.success(`${file.name} 发送成功`)
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
ElMessage.error(
|
||||
`${file.name} 上传失败: ${
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'请重试'
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置 input,允许重复选择同一文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷工具栏按钮点击处理
|
||||
* 各功能对应的实际行为
|
||||
*/
|
||||
function handleToolbarClick(action: string): void {
|
||||
switch (action) {
|
||||
case 'image':
|
||||
// 图片上传:触发文件选择器(限定图片类型)
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
break
|
||||
|
||||
case 'file':
|
||||
// 文件上传:触发文件选择器(不限类型)
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = ''
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
break
|
||||
|
||||
case 'quickReply':
|
||||
// 快速回复按钮:聚焦输入框(快速回复面板已在右栏可用)
|
||||
inputRef.value?.focus()
|
||||
break
|
||||
|
||||
case 'screenshot':
|
||||
// 截图功能:截取页面后进入区域选择模式(对标微信/企微)
|
||||
handleScreenshot()
|
||||
break
|
||||
|
||||
default:
|
||||
// 其他功能暂未实现
|
||||
const actionMap: Record<string, string> = {
|
||||
voice: '语音消息功能开发中',
|
||||
remote: '远程协助功能开发中',
|
||||
}
|
||||
ElMessage.info(actionMap[action] || '功能开发中')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理截图功能
|
||||
*
|
||||
* 做什么:使用 html2canvas 截取当前页面内容,弹出预览,确认后上传发送
|
||||
*
|
||||
* 为什么用 html2canvas:
|
||||
* - Screen Capture API(getDisplayMedia)在企微桌面端被限制,不稳定
|
||||
* - html2canvas 纯前端渲染,不依赖任何浏览器 API,所有环境都可用
|
||||
* - 适合客服场景:坐席想截取当前对话内容发给用户
|
||||
*
|
||||
* 交互流程:点击截图按钮 → 截取页面 → 弹出预览对话框 → 确认发送 / 取消
|
||||
*/
|
||||
async function handleScreenshot(): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ElMessage.info('正在截取页面...')
|
||||
|
||||
// 1. 截取整个页面
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
})
|
||||
|
||||
// 2. 保存 canvas 并显示截图编辑器(对标微信/企微)
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
} catch (error) {
|
||||
console.error('截图失败:', error)
|
||||
ElMessage.error('截图失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认发送截图
|
||||
* 上传截图并发送图片消息
|
||||
*/
|
||||
async function onScreenshotConfirm(blob: Blob): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) return
|
||||
|
||||
try {
|
||||
ElMessage.info('截图上传中...')
|
||||
|
||||
const result = await uploadFile(blob)
|
||||
|
||||
const newMsg = await sendMessage(convId, '[截图]', 'image', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
// 把新消息加入 store,让界面立即刷新显示
|
||||
conversationStore.messages.push(newMsg)
|
||||
|
||||
ElMessage.success('截图发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('[ReplyBox] 截图发送失败:', error)
|
||||
const errMsg =
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'未知错误'
|
||||
ElMessage.error(`截图发送失败:${errMsg}`)
|
||||
} finally {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图编辑器取消回调
|
||||
*/
|
||||
function onScreenshotCancel(): void {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
|
||||
// 组件卸载时清理拖拽事件
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 整体容器 */
|
||||
.reply-box {
|
||||
padding: 0 12px 8px 12px;
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 上方拖拽手柄 */
|
||||
.input-resize-handle {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
background: var(--border);
|
||||
transition: background 0.2s;
|
||||
margin: 0 -12px 0 -12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.input-resize-handle:hover,
|
||||
.input-resize-handle.dragging {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* 快捷工具栏 */
|
||||
.chat-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 8px 6px 4px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.tb-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tb-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* 工具提示气泡 */
|
||||
.tb-tip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 6px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-secondary);
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.tb-tip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--text-primary);
|
||||
}
|
||||
.tb-btn:hover .tb-tip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 工具栏分隔线 */
|
||||
.tb-sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--border);
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 输入卡片(textarea + 发送按钮,统一圆角卡片) */
|
||||
.chat-input-card {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.25s, box-shadow 0.25s;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.chat-input-card:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* 输入框 — 默认3行(60px),最大300px,自适应内容高度 */
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 0 10px 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
max-height: 300px;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.chat-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.chat-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 发送按钮 — 内嵌于卡片右侧,渐变蓝紫 */
|
||||
.btn-send {
|
||||
padding: 8px 18px;
|
||||
margin: 4px 6px 4px 0;
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.btn-send:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.btn-send:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 表情选择器弹出层(自定义中文表情网格) */
|
||||
.emoji-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emoji-picker-popup {
|
||||
position: absolute;
|
||||
bottom: 36px;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 8x8 表情网格 */
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 32px);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emoji-grid__item {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.emoji-grid__item:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.emoji-grid__item:active {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* 表情面板遮罩(点击外部关闭) */
|
||||
.emoji-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 引用回复预览样式
|
||||
// ============================================================================ */
|
||||
|
||||
.reply-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-preview-bar {
|
||||
width: 3px;
|
||||
height: 28px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-preview-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.reply-preview-sender {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reply-preview-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-preview-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reply-preview-close:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,556 @@
|
||||
<template>
|
||||
<!--
|
||||
ScreenshotEditor - 区域选择截图组件
|
||||
对标微信/企微截图体验:
|
||||
1. 全屏遮罩,背景显示页面截图(变暗)
|
||||
2. 鼠标变成十字,拖拽框选区域
|
||||
3. 释放后显示选区和工具栏(确认/取消/重新选择)
|
||||
4. 确认后裁剪选中区域并 emit 回去
|
||||
-->
|
||||
<div class="screenshot-editor-overlay">
|
||||
<!-- 背景:页面截图(变暗) -->
|
||||
<canvas
|
||||
ref="canvasBgRef"
|
||||
class="screenshot-editor-bg"
|
||||
></canvas>
|
||||
|
||||
<!-- 选区绘制层(跟随鼠标拖拽) -->
|
||||
<div
|
||||
class="screenshot-selection-layer"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
>
|
||||
<!-- 暗色遮罩(挖空选区) -->
|
||||
<div class="screenshot-dark-overlay" :style="darkOverlayStyle"></div>
|
||||
|
||||
<!-- 选区边框 -->
|
||||
<div
|
||||
v-if="selecting || selectionComplete"
|
||||
class="screenshot-selection-box"
|
||||
:style="selectionBoxStyle"
|
||||
>
|
||||
<!-- 选区尺寸提示 -->
|
||||
<div class="screenshot-size-tip" v-if="selectionComplete">
|
||||
{{ selectionWidth }} × {{ selectionHeight }}
|
||||
</div>
|
||||
|
||||
<!-- 8个拖拽手柄(调整后发送) -->
|
||||
<div
|
||||
v-if="selectionComplete"
|
||||
class="screenshot-handle screenshot-handle--tl"
|
||||
@mousedown.stop="onHandleMouseDown($event, 'tl')"
|
||||
></div>
|
||||
<div
|
||||
v-if="selectionComplete"
|
||||
class="screenshot-handle screenshot-handle--tr"
|
||||
@mousedown.stop="onHandleMouseDown($event, 'tr')"
|
||||
></div>
|
||||
<div
|
||||
v-if="selectionComplete"
|
||||
class="screenshot-handle screenshot-handle--bl"
|
||||
@mousedown.stop="onHandleMouseDown($event, 'bl')"
|
||||
></div>
|
||||
<div
|
||||
v-if="selectionComplete"
|
||||
class="screenshot-handle screenshot-handle--br"
|
||||
@mousedown.stop="onHandleMouseDown($event, 'br')"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏(选区完成后显示) -->
|
||||
<div v-if="selectionComplete" class="screenshot-toolbar">
|
||||
<button class="screenshot-toolbar-btn" @click="handleCancel" title="取消截图">
|
||||
✕ 取消
|
||||
</button>
|
||||
<button class="screenshot-toolbar-btn" @click="handleReselect" title="重新选择">
|
||||
↺ 重选
|
||||
</button>
|
||||
<button class="screenshot-toolbar-btn screenshot-toolbar-btn--primary" @click="handleConfirm" title="确认截图">
|
||||
✓ 确认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字(未选区时显示) -->
|
||||
<div v-if="!selecting && !selectionComplete" class="screenshot-tip">
|
||||
按住鼠标拖拽选择截图区域,ESC 取消
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ScreenshotEditor 组件
|
||||
* 做什么:实现微信/企微风格的区域截图功能
|
||||
* 为什么:用户反馈截图不好用,需要对标微信/企微的截图体验
|
||||
*
|
||||
* 交互流程:
|
||||
* 1. 传入页面截图的 canvas/image
|
||||
* 2. 用户拖拽选择区域
|
||||
* 3. 确认后裁剪选中区域,通过 emit 返回 Blob
|
||||
* 4. 取消则关闭编辑器
|
||||
*
|
||||
* Props:
|
||||
* - visible: 是否显示编辑器
|
||||
* - screenshotCanvas: html2canvas 生成的完整页面截图(Canvas)
|
||||
*
|
||||
* Emits:
|
||||
* - confirm: 确认截图,参数是裁剪后图片的 Blob
|
||||
* - cancel: 取消截图
|
||||
*/
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ========== Props & Emits ==========
|
||||
interface Props {
|
||||
/** html2canvas 生成的完整页面截图 Canvas 对象 */
|
||||
screenshotCanvas: HTMLCanvasElement | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
screenshotCanvas: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', blob: Blob): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// ========== Refs ==========
|
||||
const canvasBgRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
// 选区状态
|
||||
const selecting = ref(false) // 是否正在拖拽选区
|
||||
const selectionComplete = ref(false) // 选区是否完成
|
||||
const startX = ref(0) // 拖拽起点 X
|
||||
const startY = ref(0) // 拖拽起点 Y
|
||||
const endX = ref(0) // 拖拽终点 X
|
||||
const endY = ref(0) // 拖拽终点 Y
|
||||
|
||||
// 调整手柄状态
|
||||
let resizingHandle = '' // 正在拖拽的手柄:tl/tr/bl/br
|
||||
let resizeStartX = 0
|
||||
let resizeStartY = 0
|
||||
let resizeStartStartX = 0
|
||||
let resizeStartStartY = 0
|
||||
let resizeStartEndX = 0
|
||||
let resizeStartEndY = 0
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/** 选区左坐标(取 start/end 最小值) */
|
||||
const selectionLeft = computed(() => Math.min(startX.value, endX.value))
|
||||
/** 选区上坐标 */
|
||||
const selectionTop = computed(() => Math.min(startY.value, endY.value))
|
||||
/** 选区宽度 */
|
||||
const selectionWidth = computed(() => Math.abs(endX.value - startX.value))
|
||||
/** 选区高度 */
|
||||
const selectionHeight = computed(() => Math.abs(endY.value - startY.value))
|
||||
|
||||
/** 选区盒模型样式 */
|
||||
const selectionBoxStyle = computed(() => ({
|
||||
left: `${selectionLeft.value}px`,
|
||||
top: `${selectionTop.value}px`,
|
||||
width: `${selectionWidth.value}px`,
|
||||
height: `${selectionHeight.value}px`,
|
||||
}))
|
||||
|
||||
/** 暗色遮罩样式(挖空选区位置) */
|
||||
const darkOverlayStyle = computed(() => {
|
||||
if (!selectionComplete.value && !selecting.value) {
|
||||
return {} // 未选区时全暗
|
||||
}
|
||||
// 使用 box-shadow 实现挖空效果(外围暗色,选区明亮)
|
||||
const left = selectionLeft.value
|
||||
const top = selectionTop.value
|
||||
const width = selectionWidth.value
|
||||
const height = selectionHeight.value
|
||||
return {
|
||||
boxShadow: `0 0 0 9999px rgba(0, 0, 0, 0.5)`,
|
||||
// 选区位置用透明
|
||||
background: selecting.value
|
||||
? 'rgba(0, 0, 0, 0.5)'
|
||||
: 'transparent',
|
||||
// 用 clip-path 挖空选区
|
||||
clipPath: selecting.value
|
||||
? 'none'
|
||||
: `polygon(0% 0%, 0% 100%, ${left}px ${top}px, ${left}px ${top + height}px, ${left + width}px ${top + height}px, ${left + width}px ${top}px, 0% 100%, 100% 100%, 100% 0%)`,
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
// 将截图绘制到背景 canvas
|
||||
nextTick(() => {
|
||||
drawBackground()
|
||||
})
|
||||
|
||||
// 监听 ESC 取消
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
// 监听 screenshotCanvas 变化,重新绘制背景
|
||||
watch(
|
||||
() => props.screenshotCanvas,
|
||||
() => {
|
||||
nextTick(() => drawBackground())
|
||||
}
|
||||
)
|
||||
|
||||
// ========== 方法 ==========
|
||||
|
||||
/** 将截图绘制到背景 canvas */
|
||||
function drawBackground(): void {
|
||||
const canvas = canvasBgRef.value
|
||||
if (!canvas || !props.screenshotCanvas) return
|
||||
|
||||
// 设置 canvas 尺寸为窗口大小
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 将截图绘制到 canvas(覆盖整个窗口)
|
||||
ctx.drawImage(props.screenshotCanvas, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
/** 键盘事件:ESC 取消 */
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
/** 鼠标按下:开始选区 */
|
||||
function onMouseDown(e: MouseEvent): void {
|
||||
// 如果选区已完成,不重新开始(除非点在了选区外)
|
||||
if (selectionComplete.value) {
|
||||
// 判断是否点击在选区外
|
||||
const rect = getSelectionRect()
|
||||
if (
|
||||
e.clientX < rect.left ||
|
||||
e.clientX > rect.right ||
|
||||
e.clientY < rect.top ||
|
||||
e.clientY > rect.bottom
|
||||
) {
|
||||
// 点击在选区外,重新开始选区
|
||||
resetSelection()
|
||||
} else {
|
||||
// 点击在选区内,不处理(可能是拖拽移动选区)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
selecting.value = true
|
||||
startX.value = e.clientX
|
||||
startY.value = e.clientY
|
||||
endX.value = e.clientX
|
||||
endY.value = e.clientY
|
||||
}
|
||||
|
||||
/** 鼠标移动:更新选区 */
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
if (selecting.value) {
|
||||
endX.value = e.clientX
|
||||
endY.value = e.clientY
|
||||
}
|
||||
}
|
||||
|
||||
/** 鼠标释放:完成选区 */
|
||||
function onMouseUp(): void {
|
||||
if (selecting.value) {
|
||||
selecting.value = false
|
||||
|
||||
// 判断选区是否有效(最小 10x10)
|
||||
if (selectionWidth.value < 10 || selectionHeight.value < 10) {
|
||||
// 选区太小,忽略
|
||||
resetSelection()
|
||||
return
|
||||
}
|
||||
|
||||
selectionComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 调整手柄:鼠标按下 */
|
||||
function onHandleMouseDown(e: MouseEvent, handle: string): void {
|
||||
resizingHandle = handle
|
||||
resizeStartX = e.clientX
|
||||
resizeStartY = e.clientY
|
||||
resizeStartStartX = startX.value
|
||||
resizeStartStartY = startY.value
|
||||
resizeStartEndX = endX.value
|
||||
resizeStartEndY = endY.value
|
||||
|
||||
document.addEventListener('mousemove', onHandleMouseMove)
|
||||
document.addEventListener('mouseup', onHandleMouseUp)
|
||||
}
|
||||
|
||||
/** 调整手柄:鼠标移动 */
|
||||
function onHandleMouseMove(e: MouseEvent): void {
|
||||
const dx = e.clientX - resizeStartX
|
||||
const dy = e.clientY - resizeStartY
|
||||
|
||||
switch (resizingHandle) {
|
||||
case 'tl':
|
||||
startX.value = resizeStartStartX + dx
|
||||
startY.value = resizeStartStartY + dy
|
||||
break
|
||||
case 'tr':
|
||||
endX.value = resizeStartEndX + dx
|
||||
startY.value = resizeStartStartY + dy
|
||||
break
|
||||
case 'bl':
|
||||
startX.value = resizeStartStartX + dx
|
||||
endY.value = resizeStartEndY + dy
|
||||
break
|
||||
case 'br':
|
||||
endX.value = resizeStartEndX + dx
|
||||
endY.value = resizeStartEndY + dy
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 调整手柄:鼠标释放 */
|
||||
function onHandleMouseUp(): void {
|
||||
resizingHandle = ''
|
||||
document.removeEventListener('mousemove', onHandleMouseMove)
|
||||
document.removeEventListener('mouseup', onHandleMouseUp)
|
||||
}
|
||||
|
||||
/** 重置选区 */
|
||||
function resetSelection(): void {
|
||||
selecting.value = false
|
||||
selectionComplete.value = false
|
||||
startX.value = 0
|
||||
startY.value = 0
|
||||
endX.value = 0
|
||||
endY.value = 0
|
||||
}
|
||||
|
||||
/** 获取选区矩形(绝对坐标) */
|
||||
function getSelectionRect() {
|
||||
return {
|
||||
left: selectionLeft.value,
|
||||
top: selectionTop.value,
|
||||
right: selectionLeft.value + selectionWidth.value,
|
||||
bottom: selectionTop.value + selectionHeight.value,
|
||||
width: selectionWidth.value,
|
||||
height: selectionHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消截图 */
|
||||
function handleCancel(): void {
|
||||
resetSelection()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
/** 重新选择 */
|
||||
function handleReselect(): void {
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
/** 确认截图:裁剪选中区域并 emit Blob */
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (!props.screenshotCanvas) {
|
||||
ElMessage.error('截图数据丢失,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算缩放因子(screenshotCanvas 可能包含 devicePixelRatio)
|
||||
const scaleX = props.screenshotCanvas.width / window.innerWidth
|
||||
const scaleY = props.screenshotCanvas.height / window.innerHeight
|
||||
|
||||
// 用 canvas 裁剪选中区域
|
||||
const cropCanvas = document.createElement('canvas')
|
||||
cropCanvas.width = selectionWidth.value
|
||||
cropCanvas.height = selectionHeight.value
|
||||
|
||||
const ctx = cropCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
ElMessage.error('截图裁剪失败(无法创建画布)')
|
||||
return
|
||||
}
|
||||
|
||||
// 从完整截图中裁剪选中区域(使用缩放后的坐标)
|
||||
ctx.drawImage(
|
||||
props.screenshotCanvas,
|
||||
selectionLeft.value * scaleX,
|
||||
selectionTop.value * scaleY,
|
||||
selectionWidth.value * scaleX,
|
||||
selectionHeight.value * scaleY,
|
||||
0,
|
||||
0,
|
||||
selectionWidth.value,
|
||||
selectionHeight.value
|
||||
)
|
||||
|
||||
// 转为 Blob
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
cropCanvas.toBlob((b) => resolve(b), 'image/png')
|
||||
})
|
||||
|
||||
if (!blob || blob.size === 0) {
|
||||
ElMessage.error('截图生成失败(裁剪结果为空),请重试')
|
||||
return
|
||||
}
|
||||
|
||||
emit('confirm', blob)
|
||||
resetSelection()
|
||||
} catch (err) {
|
||||
console.error('[ScreenshotEditor] 确认截图失败:', err)
|
||||
ElMessage.error('截图确认失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** 暴露方法给父组件 */
|
||||
defineExpose({
|
||||
resetSelection,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 全屏遮罩 */
|
||||
.screenshot-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 背景 canvas(显示页面截图) */
|
||||
.screenshot-editor-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 选区绘制层 */
|
||||
.screenshot-selection-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 暗色遮罩(未选区时全暗,选区后挖空)
|
||||
pointer-events: none — 让鼠标事件穿透遮罩,到达选区层 */
|
||||
.screenshot-dark-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 选区边框 */
|
||||
.screenshot-selection-box {
|
||||
position: absolute;
|
||||
border: 2px solid #1989fa;
|
||||
background: rgba(25, 137, 250, 0.05);
|
||||
z-index: 20;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 选区尺寸提示 */
|
||||
.screenshot-size-tip {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 调整手柄 */
|
||||
.screenshot-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #1989fa;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
z-index: 30;
|
||||
}
|
||||
.screenshot-handle--tl { top: -5px; left: -5px; cursor: nw-resize; }
|
||||
.screenshot-handle--tr { top: -5px; right: -5px; cursor: ne-resize; }
|
||||
.screenshot-handle--bl { bottom: -5px; left: -5px; cursor: sw-resize; }
|
||||
.screenshot-handle--br { bottom: -5px; right: -5px; cursor: se-resize; }
|
||||
|
||||
/* 工具栏 */
|
||||
.screenshot-toolbar {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.screenshot-toolbar-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.screenshot-toolbar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.screenshot-toolbar-btn--primary {
|
||||
background: #1989fa;
|
||||
border-color: #1989fa;
|
||||
}
|
||||
.screenshot-toolbar-btn--primary:hover {
|
||||
background: #0570db;
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.screenshot-tip {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 任务详情视图组件
|
||||
// =============================================================================
|
||||
// 说明:点击左侧待办事项时,中间栏从聊天视图切换为任务类型专属页面
|
||||
// 功能:根据 todoItem.type 渲染三种子视图:
|
||||
// 1. 📋 运维工单页 (ticket)
|
||||
// 2. 📝 审批单页 (approval)
|
||||
// 3. 🖥 设备异常页 (device)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="task-detail-view">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 顶部:返回按钮 + 任务标题 + 类型标签 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tdv-header">
|
||||
<button class="tdv-back-btn" @click="handleGoBack">
|
||||
<span class="tdv-back-arrow">←</span>
|
||||
<span>返回会话</span>
|
||||
</button>
|
||||
|
||||
<div class="tdv-header-center">
|
||||
<h2 class="tdv-title">{{ todoItem.title }}</h2>
|
||||
<span class="tdv-type-tag" :class="`tdv-type-${todoItem.type}`">
|
||||
{{ typeLabelMap[todoItem.type] || todoItem.type }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tdv-header-right">
|
||||
<span class="tdv-priority" :class="`tdv-priority-${todoItem.priority}`">
|
||||
{{ priorityLabelMap[todoItem.priority] || todoItem.priority }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 内容区:根据类型渲染不同子视图 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tdv-content">
|
||||
<!-- 📋 运维工单页 -->
|
||||
<TicketDetail
|
||||
v-if="todoItem.type === 'ticket'"
|
||||
:todo-item="todoItem"
|
||||
@action="handleAction"
|
||||
/>
|
||||
|
||||
<!-- 📝 审批单页 -->
|
||||
<ApprovalDetail
|
||||
v-else-if="todoItem.type === 'approval'"
|
||||
:todo-item="todoItem"
|
||||
@action="handleAction"
|
||||
/>
|
||||
|
||||
<!-- 🖥 设备异常页 -->
|
||||
<DeviceDetail
|
||||
v-else-if="todoItem.type === 'device'"
|
||||
:todo-item="todoItem"
|
||||
@action="handleAction"
|
||||
/>
|
||||
|
||||
<!-- 未知类型 fallback -->
|
||||
<div v-else class="tdv-unknown">
|
||||
<p>未知的任务类型:{{ todoItem.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
import TicketDetail from './task/TicketDetail.vue'
|
||||
import ApprovalDetail from './task/ApprovalDetail.vue'
|
||||
import DeviceDetail from './task/DeviceDetail.vue'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前选中的待办事项 */
|
||||
todoItem: TodoItemData
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
// ============================================================================
|
||||
// 映射
|
||||
// ============================================================================
|
||||
|
||||
/** 类型标签映射 */
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
ticket: '📋 运维工单',
|
||||
approval: '📝 审批单',
|
||||
device: '🖥 设备异常',
|
||||
}
|
||||
|
||||
/** 优先级标签映射 */
|
||||
const priorityLabelMap: Record<string, string> = {
|
||||
urgent: '🔴 紧急',
|
||||
high: '🟡 高',
|
||||
normal: '🟢 普通',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 返回会话视图
|
||||
*/
|
||||
function handleGoBack(): void {
|
||||
conversationStore.workspaceView = 'chat'
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理操作按钮点击(Mock 模式:仅 toast 提示)
|
||||
*
|
||||
* @param action - 操作标识
|
||||
*/
|
||||
function handleAction(action: string): void {
|
||||
ElMessage.success(`操作成功:${action}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-detail-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* ---- 顶部 ---- */
|
||||
.tdv-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tdv-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdv-back-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tdv-back-arrow {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tdv-header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tdv-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tdv-type-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tdv-type-ticket {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tdv-type-approval {
|
||||
background-color: rgba(230, 162, 60, 0.1);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.tdv-type-device {
|
||||
background-color: rgba(245, 108, 108, 0.1);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tdv-header-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tdv-priority {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tdv-priority-urgent {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tdv-priority-high {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.tdv-priority-normal {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* ---- 内容区 ---- */
|
||||
.tdv-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
/* ---- 未知类型 ---- */
|
||||
.tdv-unknown {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,647 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 排查步骤栏组件
|
||||
// =============================================================================
|
||||
// 说明:位于聊天输入框下方,始终可见(不可整体收起)
|
||||
// 包含:
|
||||
// 1. 默认视图:横向路径方块(①→②→③→④→⑤)
|
||||
// 2. 展开视图:完整决策树(递归渲染)
|
||||
// 数据:从 troubleshooting API 获取模板,默认选中第一个
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="troubleshoot-bar">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 栏头(标题 + 模板选择 + 内联路径 + 三角切换) -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="troubleshoot-bar__header">
|
||||
<span class="troubleshoot-bar__title">🔧 排查步骤</span>
|
||||
|
||||
<!-- 模板选择下拉 -->
|
||||
<el-select
|
||||
v-model="selectedTemplateId"
|
||||
size="small"
|
||||
style="width: 140px;"
|
||||
@change="handleTemplateChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:label="tpl.name"
|
||||
:value="tpl.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 内联路径步骤 -->
|
||||
<div class="troubleshoot-bar__path-inline">
|
||||
<template v-for="(step, index) in currentPathSteps" :key="index">
|
||||
<span
|
||||
class="path-step-inline"
|
||||
:class="`path-step-inline--${step.status}`"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
{{ index + 1 }} {{ step.label }}
|
||||
</span>
|
||||
<span v-if="index < currentPathSteps.length - 1" class="path-arrow-inline">→</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 展开全流程图三角图标 -->
|
||||
<span
|
||||
class="troubleshoot-bar__toggle"
|
||||
@click="toggleFlowchart"
|
||||
:title="isFlowchartExpanded ? '收起全流程图' : '展开全流程图'"
|
||||
>{{ isFlowchartExpanded ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 展开视图:决策树 -->
|
||||
<!-- ================================================================== -->
|
||||
<div
|
||||
class="troubleshoot-bar__flowchart"
|
||||
:class="{ 'is-expanded': isFlowchartExpanded }"
|
||||
>
|
||||
<div class="troubleshoot-bar__flowchart-inner">
|
||||
<FlowchartNode
|
||||
v-if="currentFlowchart"
|
||||
:node="currentFlowchart"
|
||||
:base-index="1"
|
||||
/>
|
||||
<div v-else class="troubleshoot-bar__flowchart-empty">
|
||||
暂无流程图数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import FlowchartNode from './FlowchartNode.vue'
|
||||
import {
|
||||
getTroubleshootingTemplates,
|
||||
} from '@/api/troubleshooting'
|
||||
import type { TroubleshootingTemplate, PathStep, FlowchartNode as FlowchartNodeType } from '@/api/troubleshooting'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** 是否展开全流程图 */
|
||||
const isFlowchartExpanded = ref(false)
|
||||
|
||||
/** 模板列表 */
|
||||
const templates = ref<TroubleshootingTemplate[]>([])
|
||||
|
||||
/** 当前选中的模板 ID */
|
||||
const selectedTemplateId = ref<string>('')
|
||||
|
||||
/** 当前模板的路径步骤(本地状态,可由坐席推进) */
|
||||
const localPathSteps = ref<PathStep[]>([])
|
||||
|
||||
/** 当前模板的流程图数据 */
|
||||
const localFlowchart = ref<FlowchartNodeType | null>(null)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 当前显示的路径步骤 */
|
||||
const currentPathSteps = computed(() => {
|
||||
return localPathSteps.value
|
||||
})
|
||||
|
||||
/** 当前显示的流程图 */
|
||||
const currentFlowchart = computed(() => {
|
||||
return localFlowchart.value
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 切换全流程图展开/收起
|
||||
*/
|
||||
function toggleFlowchart(): void {
|
||||
isFlowchartExpanded.value = !isFlowchartExpanded.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板选择变更
|
||||
*/
|
||||
function handleTemplateChange(templateId: string): void {
|
||||
const tpl = templates.value.find(t => t.id === templateId)
|
||||
if (tpl) {
|
||||
localPathSteps.value = [...tpl.path_steps]
|
||||
localFlowchart.value = tpl.flowchart || null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击路径方块 → 将该步骤设为 current
|
||||
*
|
||||
* @param index - 步骤索引
|
||||
*/
|
||||
function handleStepClick(index: number): void {
|
||||
const steps = localPathSteps.value
|
||||
if (!steps || index < 0 || index >= steps.length) return
|
||||
|
||||
// 更新步骤状态:index 之前的为 done,当前为 current,之后的为 pending
|
||||
const updated = steps.map((step, i) => ({
|
||||
...step,
|
||||
status: i < index ? 'done' as const : i === index ? 'current' as const : 'pending' as const,
|
||||
}))
|
||||
localPathSteps.value = updated
|
||||
|
||||
// 同步更新流程图节点状态
|
||||
if (localFlowchart.value) {
|
||||
updateFlowchartStatus(localFlowchart.value, index)
|
||||
}
|
||||
|
||||
ElMessage.success(`当前步骤:${steps[index].label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归更新流程图节点状态
|
||||
*/
|
||||
function updateFlowchartStatus(node: FlowchartNodeType, currentStepIndex: number): void {
|
||||
// 简单线性匹配:按遍历顺序给步骤编号
|
||||
let counter = 0
|
||||
function walk(n: FlowchartNodeType): void {
|
||||
if (n.type === 'step') {
|
||||
if (counter < currentStepIndex) {
|
||||
n.status = 'done'
|
||||
} else if (counter === currentStepIndex) {
|
||||
n.status = 'current'
|
||||
} else {
|
||||
n.status = 'pending'
|
||||
}
|
||||
counter++
|
||||
if (n.children) {
|
||||
for (const child of n.children) {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
} else if (n.type === 'decision') {
|
||||
// 判断节点保持原状态
|
||||
n.status = 'pending'
|
||||
if (n.yes_branch) walk(n.yes_branch)
|
||||
if (n.no_branch) walk(n.no_branch)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载排查模板列表
|
||||
*/
|
||||
async function loadTemplates(): Promise<void> {
|
||||
try {
|
||||
const data = await getTroubleshootingTemplates()
|
||||
templates.value = data.items
|
||||
|
||||
// 默认选中第一个模板
|
||||
if (data.items.length > 0) {
|
||||
selectedTemplateId.value = data.items[0].id
|
||||
localPathSteps.value = [...data.items[0].path_steps]
|
||||
localFlowchart.value = data.items[0].flowchart || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取排查模板失败:', error)
|
||||
// 使用内置 Mock 数据
|
||||
loadFallbackMockData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置 Mock 数据(API 不可用时的降级方案)
|
||||
*/
|
||||
function loadFallbackMockData(): void {
|
||||
const mockTemplates: TroubleshootingTemplate[] = [
|
||||
{
|
||||
id: 'tpl-vpn',
|
||||
name: 'VPN连接故障',
|
||||
category: 'vpn',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
path_steps: [
|
||||
{ label: '确认VPN版本', status: 'done' },
|
||||
{ label: '清除缓存重连', status: 'current' },
|
||||
{ label: '远程排查', status: 'pending' },
|
||||
{ label: '升级客户端', status: 'pending' },
|
||||
{ label: '回访确认', status: 'pending' },
|
||||
],
|
||||
flowchart: {
|
||||
id: 'fc-vpn-1',
|
||||
type: 'step',
|
||||
label: '确认VPN客户端版本',
|
||||
status: 'done',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-vpn-2',
|
||||
type: 'decision',
|
||||
label: '版本是否为最新?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-vpn-3',
|
||||
type: 'step',
|
||||
label: '清除DNS缓存并重连',
|
||||
status: 'current',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-vpn-4',
|
||||
type: 'decision',
|
||||
label: '重连是否成功?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-vpn-5',
|
||||
type: 'step',
|
||||
label: '回访确认',
|
||||
status: 'pending',
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-vpn-6',
|
||||
type: 'step',
|
||||
label: '发起远程协助',
|
||||
status: 'pending',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-vpn-7',
|
||||
type: 'decision',
|
||||
label: '远程能否解决?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-vpn-8',
|
||||
type: 'step',
|
||||
label: '回访确认并结单',
|
||||
status: 'pending',
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-vpn-9',
|
||||
type: 'step',
|
||||
label: '升级至二线团队',
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-vpn-10',
|
||||
type: 'step',
|
||||
label: '升级VPN客户端到最新版',
|
||||
status: 'pending',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-vpn-11',
|
||||
type: 'step',
|
||||
label: '重试连接',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tpl-email',
|
||||
name: '邮箱登录故障',
|
||||
category: 'email',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
path_steps: [
|
||||
{ label: '确认邮箱状态', status: 'done' },
|
||||
{ label: '重置密码', status: 'current' },
|
||||
{ label: '检查配置', status: 'pending' },
|
||||
{ label: '清理缓存', status: 'pending' },
|
||||
{ label: '回访确认', status: 'pending' },
|
||||
],
|
||||
flowchart: {
|
||||
id: 'fc-email-1',
|
||||
type: 'step',
|
||||
label: '确认邮箱账号状态',
|
||||
status: 'done',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-email-2',
|
||||
type: 'decision',
|
||||
label: '账号是否被锁定?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-email-3',
|
||||
type: 'step',
|
||||
label: '解锁账号并重置密码',
|
||||
status: 'current',
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-email-4',
|
||||
type: 'step',
|
||||
label: '检查Outlook配置',
|
||||
status: 'pending',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-email-5',
|
||||
type: 'decision',
|
||||
label: '配置是否正确?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-email-6',
|
||||
type: 'step',
|
||||
label: '清理Outlook缓存',
|
||||
status: 'pending',
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-email-7',
|
||||
type: 'step',
|
||||
label: '重新配置Outlook',
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tpl-system',
|
||||
name: '系统登录异常',
|
||||
category: 'system',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
path_steps: [
|
||||
{ label: '确认系统状态', status: 'current' },
|
||||
{ label: '清除浏览器缓存', status: 'pending' },
|
||||
{ label: '更换浏览器', status: 'pending' },
|
||||
{ label: '检查网络权限', status: 'pending' },
|
||||
{ label: '回访确认', status: 'pending' },
|
||||
],
|
||||
flowchart: {
|
||||
id: 'fc-sys-1',
|
||||
type: 'step',
|
||||
label: '确认系统服务是否正常',
|
||||
status: 'current',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-sys-2',
|
||||
type: 'decision',
|
||||
label: '系统服务是否正常?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-sys-3',
|
||||
type: 'step',
|
||||
label: '清除浏览器缓存',
|
||||
status: 'pending',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-sys-4',
|
||||
type: 'decision',
|
||||
label: '清除后是否恢复?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-sys-5',
|
||||
type: 'step',
|
||||
label: '回访确认并结单',
|
||||
status: 'pending',
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-sys-6',
|
||||
type: 'step',
|
||||
label: '更换浏览器重试',
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-sys-7',
|
||||
type: 'step',
|
||||
label: '联系运维检查服务端',
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tpl-account',
|
||||
name: '账号权限问题',
|
||||
category: 'account',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
path_steps: [
|
||||
{ label: '确认权限需求', status: 'current' },
|
||||
{ label: '提交审批', status: 'pending' },
|
||||
{ label: '配置权限', status: 'pending' },
|
||||
{ label: '验证权限', status: 'pending' },
|
||||
{ label: '回访确认', status: 'pending' },
|
||||
],
|
||||
flowchart: {
|
||||
id: 'fc-acc-1',
|
||||
type: 'step',
|
||||
label: '确认权限需求与合规性',
|
||||
status: 'current',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-acc-2',
|
||||
type: 'decision',
|
||||
label: '权限是否符合策略?',
|
||||
status: 'pending',
|
||||
yes_branch: {
|
||||
id: 'fc-acc-3',
|
||||
type: 'step',
|
||||
label: '提交权限审批流程',
|
||||
status: 'pending',
|
||||
children: [
|
||||
{
|
||||
id: 'fc-acc-4',
|
||||
type: 'step',
|
||||
label: '审批通过后配置权限',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
no_branch: {
|
||||
id: 'fc-acc-5',
|
||||
type: 'step',
|
||||
label: '建议替代方案或申请特批',
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
templates.value = mockTemplates
|
||||
if (mockTemplates.length > 0) {
|
||||
selectedTemplateId.value = mockTemplates[0].id
|
||||
localPathSteps.value = [...mockTemplates[0].path_steps]
|
||||
localFlowchart.value = mockTemplates[0].flowchart
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
|
||||
onMounted(() => {
|
||||
loadTemplates()
|
||||
})
|
||||
|
||||
// 切换会话时重置步骤进度
|
||||
watch(
|
||||
() => conversationStore.currentConversationId,
|
||||
() => {
|
||||
// 重新加载当前模板
|
||||
handleTemplateChange(selectedTemplateId.value)
|
||||
isFlowchartExpanded.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主容器 */
|
||||
.troubleshoot-bar {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 栏头 — 紧凑单行:标题 + 下拉 + 内联路径 + 三角图标 */
|
||||
.troubleshoot-bar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.troubleshoot-bar__header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.troubleshoot-bar__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 内联路径步骤(flex: 1 填充剩余空间) */
|
||||
.troubleshoot-bar__path-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 紧凑步骤标签 */
|
||||
.path-step-inline {
|
||||
padding: 2px 7px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.path-step-inline:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 已完成 */
|
||||
.path-step-inline--done {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 当前 */
|
||||
.path-step-inline--current {
|
||||
background: var(--bg-accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 待处理 */
|
||||
.path-step-inline--pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 内联箭头 */
|
||||
.path-arrow-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
padding: 0 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 三角切换图标 */
|
||||
.troubleshoot-bar__toggle {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.troubleshoot-bar__toggle:hover {
|
||||
background: var(--bg-accent-soft);
|
||||
}
|
||||
|
||||
/* 展开流程图区域 */
|
||||
.troubleshoot-bar__flowchart {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s ease;
|
||||
}
|
||||
|
||||
.troubleshoot-bar__flowchart.is-expanded {
|
||||
max-height: 800px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.troubleshoot-bar__flowchart-inner {
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.troubleshoot-bar__flowchart-empty {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,816 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 用户信息栏组件
|
||||
// =============================================================================
|
||||
// 说明:替代 ChatArea.vue 中原有的顶部标题栏
|
||||
// 功能:
|
||||
// 1. 常驻区(收起状态):头像+姓名+IT等级+chips+展开箭头
|
||||
// 2. 展开详情区(6 卡片 3 列 grid)
|
||||
// 3. 右侧操作按钮(转接/结单/摇人等)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="user-info-bar">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 常驻区:点击整行展开/收起 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="user-info-bar__persistent" @click="toggleExpand">
|
||||
<!-- 左侧:头像 + 姓名 + IT等级 + 箭头 -->
|
||||
<div class="user-info-bar__left">
|
||||
<!-- 头像 -->
|
||||
<div class="user-info-bar__avatar">
|
||||
{{ avatarText }}
|
||||
</div>
|
||||
|
||||
<!-- 姓名·部门岗位 + IT 等级 -->
|
||||
<div class="user-info-bar__name-group">
|
||||
<span class="user-info-bar__name">{{ conversation?.employee_name || '未知' }}</span>
|
||||
<span
|
||||
v-if="conversation?.department || conversation?.position"
|
||||
class="user-info-bar__dept"
|
||||
>
|
||||
· {{ conversation?.department || '' }}{{ conversation?.position ? ' ' + conversation.position : '' }}
|
||||
</span>
|
||||
<ItLevelBadge :level="employeeItLevel" size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- 展开/收起箭头 -->
|
||||
<span class="user-info-bar__arrow" :class="{ 'is-expanded': isExpanded }">▶</span>
|
||||
</div>
|
||||
|
||||
<!-- 中间:信息 chips -->
|
||||
<div class="user-info-bar__chips" @click.stop>
|
||||
<!-- 情绪状态 chip -->
|
||||
<span
|
||||
class="info-chip"
|
||||
:class="emotionChipClass"
|
||||
>
|
||||
😟 {{ emotionLabel }}
|
||||
</span>
|
||||
|
||||
<!-- 等待时长 chip -->
|
||||
<span class="info-chip info-chip--gray">
|
||||
⏱ {{ waitTimeLabel }}
|
||||
</span>
|
||||
|
||||
<!-- 对话轮次 chip -->
|
||||
<span class="info-chip info-chip--gray">
|
||||
💬 {{ turnCount }}轮
|
||||
</span>
|
||||
|
||||
<!-- IT等级 chip -->
|
||||
<span class="info-chip info-chip--accent">
|
||||
🖥 {{ levelName }} Lv.{{ levelNumber }}
|
||||
</span>
|
||||
|
||||
<!-- 重复标记 chip(有重复时才显示) -->
|
||||
<span
|
||||
v-if="repeatCount > 0"
|
||||
class="info-chip info-chip--red"
|
||||
>
|
||||
🔁 重复×{{ repeatCount }}
|
||||
</span>
|
||||
|
||||
<!-- 备注标记 chip(有备注时才显示) -->
|
||||
<span
|
||||
v-if="hasNotes"
|
||||
class="info-chip info-chip--purple"
|
||||
>
|
||||
📝 备注
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<div class="user-info-bar__actions" @click.stop>
|
||||
<!-- 接单按钮 -->
|
||||
<el-button
|
||||
v-if="conversation?.status === 'queued'"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="$emit('assign')"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
接单
|
||||
</el-button>
|
||||
|
||||
<!-- 置顶/取消置顶 -->
|
||||
<el-button
|
||||
size="small"
|
||||
:type="conversation?.is_pinned ? 'warning' : 'default'"
|
||||
@click="$emit('toggle-pin')"
|
||||
>
|
||||
{{ conversation?.is_pinned ? '取消置顶' : '📌 置顶' }}
|
||||
</el-button>
|
||||
|
||||
<!-- 代办/取消代办 -->
|
||||
<el-button
|
||||
size="small"
|
||||
:type="conversation?.is_todo ? 'warning' : 'default'"
|
||||
@click="$emit('toggle-todo')"
|
||||
>
|
||||
{{ conversation?.is_todo ? '取消代办' : '📋 代办' }}
|
||||
</el-button>
|
||||
|
||||
<!-- 转接 -->
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => $emit('transfer', cmd)">
|
||||
<el-button size="small" type="info">
|
||||
<el-icon><Sort /></el-icon>
|
||||
转接
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="agent in availableAgents"
|
||||
:key="agent.user_id"
|
||||
:command="agent.user_id"
|
||||
>
|
||||
{{ agent.name }} ({{ agent.current_load }}/{{ agent.max_load }})
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="availableAgents.length === 0" disabled>
|
||||
暂无可用坐席
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 摇人 -->
|
||||
<el-button
|
||||
v-if="canInviteCollaborator"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="$emit('invite')"
|
||||
>
|
||||
🤝 摇人
|
||||
</el-button>
|
||||
|
||||
<!-- 结单 -->
|
||||
<el-button
|
||||
v-if="conversation?.status !== 'resolved'"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="$emit('resolve')"
|
||||
>
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
结单
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 展开详情区(6 卡片 3 列 grid) -->
|
||||
<!-- ================================================================== -->
|
||||
<div
|
||||
class="user-info-bar__detail"
|
||||
:class="{ 'is-expanded': isExpanded }"
|
||||
>
|
||||
<div class="user-info-bar__detail-inner">
|
||||
<!-- 卡片 1: 情绪状态 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__header">情绪状态</div>
|
||||
<div class="detail-card__body">
|
||||
<div class="detail-card__emotion">
|
||||
<span class="detail-card__emotion-emoji">{{ emotionEmoji }}</span>
|
||||
<span class="detail-card__emotion-text">{{ emotionLabel }}</span>
|
||||
</div>
|
||||
<div class="detail-card__desc">{{ emotionDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 2: 会话详情 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__header">会话详情</div>
|
||||
<div class="detail-card__body">
|
||||
<div class="detail-card__stat">
|
||||
<span class="detail-card__stat-icon">⏱</span>
|
||||
<span>等待时长:<strong>{{ waitTimeLabel }}</strong></span>
|
||||
</div>
|
||||
<div class="detail-card__stat">
|
||||
<span class="detail-card__stat-icon">💬</span>
|
||||
<span>对话轮次:<strong>{{ turnCount }} 轮</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 3: 问题分析 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__header">问题分析</div>
|
||||
<div class="detail-card__body">
|
||||
<div class="detail-card__stat">
|
||||
<span class="detail-card__stat-icon">🔁</span>
|
||||
<span>{{ repeatCount > 0 ? '重复问题' : '非重复问题' }}</span>
|
||||
</div>
|
||||
<div class="detail-card__stat">
|
||||
<span>7天内反馈:<strong>{{ weekFeedbackCount }} 次</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 4: IT技能等级 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__header">IT技能等级</div>
|
||||
<div class="detail-card__body">
|
||||
<div class="detail-card__level-row">
|
||||
<ItLevelBadge :level="employeeItLevel" size="md" />
|
||||
<span class="detail-card__level-name">{{ levelName }} Lv.{{ levelNumber }}</span>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click.stop="showItLevelSelector = !showItLevelSelector"
|
||||
>
|
||||
调整
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="detail-card__level-desc">{{ levelDesc }}</div>
|
||||
|
||||
<!-- IT 等级选择器下拉 -->
|
||||
<div
|
||||
v-if="showItLevelSelector"
|
||||
class="it-level-selector"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-for="(info, key) in IT_LEVEL_MAP"
|
||||
:key="key"
|
||||
class="it-level-selector__item"
|
||||
:class="{ 'is-active': employeeItLevel === key }"
|
||||
@click="handleItLevelChange(key)"
|
||||
>
|
||||
<ItLevelBadge :level="key" size="sm" />
|
||||
<span>{{ info.name }} Lv.{{ info.lv }}</span>
|
||||
<span v-if="employeeItLevel === key" class="it-level-selector__check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 5: 历史工单 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__header">历史工单</div>
|
||||
<div class="detail-card__body">
|
||||
<div class="detail-card__stat">
|
||||
<span>30天内工单:<strong>{{ monthlyTicketCount }}</strong></span>
|
||||
</div>
|
||||
<div class="detail-card__stat">
|
||||
<span>类型分布:{{ ticketTypeDistribution }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 6: 其他备注 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__header">其他备注</div>
|
||||
<div class="detail-card__body">
|
||||
<div v-if="notesText" class="detail-card__notes">
|
||||
{{ notesText }}
|
||||
</div>
|
||||
<div v-else class="detail-card__notes detail-card__notes--empty">
|
||||
暂无备注
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Check, Sort, CircleClose } from '@element-plus/icons-vue'
|
||||
import ItLevelBadge from './ItLevelBadge.vue'
|
||||
import { updateEmployeeItLevel } from '@/api/troubleshooting'
|
||||
import type { Conversation } from '@/api/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface AgentInfo {
|
||||
user_id: string
|
||||
name: string
|
||||
current_load: number
|
||||
max_load: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 当前会话 */
|
||||
conversation: Conversation | null
|
||||
/** 可用坐席列表 */
|
||||
availableAgents: AgentInfo[]
|
||||
/** 是否可以摇人 */
|
||||
canInviteCollaborator: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
availableAgents: () => [],
|
||||
canInviteCollaborator: false,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'assign'): void
|
||||
(e: 'resolve'): void
|
||||
(e: 'toggle-pin'): void
|
||||
(e: 'toggle-todo'): void
|
||||
(e: 'transfer', agentId: string): void
|
||||
(e: 'invite'): void
|
||||
}
|
||||
|
||||
// emit 已声明但未使用(保留以备将来扩展)
|
||||
defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 是否展开详情 */
|
||||
const isExpanded = ref(false)
|
||||
|
||||
/** 是否显示 IT 等级选择器 */
|
||||
const showItLevelSelector = ref(false)
|
||||
|
||||
/** 员工 IT 等级(本地状态,可由坐席调整) */
|
||||
const employeeItLevel = ref('silver')
|
||||
|
||||
/** 员工备注(本地 Mock) */
|
||||
const employeeNotes = ref<Record<string, any>>({})
|
||||
|
||||
// ============================================================================
|
||||
// 等级元数据
|
||||
// ============================================================================
|
||||
|
||||
const IT_LEVEL_MAP: Record<string, { name: string; lv: number; desc: string }> = {
|
||||
bronze: { name: '青铜', lv: 1, desc: 'IT基础薄弱,需要详细指导' },
|
||||
silver: { name: '白银', lv: 2, desc: '能完成基本操作,需协助复杂问题' },
|
||||
gold: { name: '黄金', lv: 3, desc: '熟悉常见操作,可独立解决一般问题' },
|
||||
platinum: { name: '铂金', lv: 4, desc: '熟练使用办公软件,能自助排查常见故障' },
|
||||
diamond: { name: '钻石', lv: 5, desc: '具备一定技术能力,能理解技术解释' },
|
||||
star: { name: '星耀', lv: 6, desc: 'IT能力较强,可自行解决大部分问题' },
|
||||
king: { name: '王者', lv: 7, desc: 'IT达人级别,仅少数问题需协助' },
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 头像文字(取姓名前两个字) */
|
||||
const avatarText = computed(() => {
|
||||
const name = props.conversation?.employee_name || '?'
|
||||
return name.slice(0, 2)
|
||||
})
|
||||
|
||||
/** 情绪标签 */
|
||||
const emotionLabel = computed(() => {
|
||||
const state = props.conversation?.emotion_state || 'normal'
|
||||
const map: Record<string, string> = {
|
||||
normal: '正常',
|
||||
worried: '担忧',
|
||||
angry: '愤怒',
|
||||
urgent: '紧急',
|
||||
}
|
||||
return map[state] || '正常'
|
||||
})
|
||||
|
||||
/** 情绪 emoji */
|
||||
const emotionEmoji = computed(() => {
|
||||
const state = props.conversation?.emotion_state || 'normal'
|
||||
const map: Record<string, string> = {
|
||||
normal: '😊',
|
||||
worried: '😟',
|
||||
angry: '😡',
|
||||
urgent: '🔴',
|
||||
}
|
||||
return map[state] || '😊'
|
||||
})
|
||||
|
||||
/** 情绪描述 */
|
||||
const emotionDesc = computed(() => {
|
||||
const state = props.conversation?.emotion_state || 'normal'
|
||||
const map: Record<string, string> = {
|
||||
normal: '情绪平稳,正常沟通',
|
||||
worried: '语气急促,连续追问进度',
|
||||
angry: '措辞激烈,多次表达不满',
|
||||
urgent: '问题严重影响工作,要求立即处理',
|
||||
}
|
||||
return map[state] || '情绪平稳'
|
||||
})
|
||||
|
||||
/** 情绪 chip 样式 */
|
||||
const emotionChipClass = computed(() => {
|
||||
const state = props.conversation?.emotion_state || 'normal'
|
||||
if (state === 'normal') return 'info-chip--gray'
|
||||
if (state === 'angry' || state === 'urgent') return 'info-chip--red'
|
||||
return 'info-chip--yellow'
|
||||
})
|
||||
|
||||
/** 等待时长标签 */
|
||||
const waitTimeLabel = computed(() => {
|
||||
// 从会话创建时间计算等待时长
|
||||
const createdAt = props.conversation?.created_at
|
||||
if (!createdAt) return '0分钟'
|
||||
const diffMs = Date.now() - new Date(createdAt).getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 60) return `${diffMin}分钟`
|
||||
const hours = Math.floor(diffMin / 60)
|
||||
const mins = diffMin % 60
|
||||
return `${hours}小时${mins}分钟`
|
||||
})
|
||||
|
||||
/** 对话轮次(从消息数估算) */
|
||||
const turnCount = computed(() => {
|
||||
const tags = props.conversation?.tags
|
||||
return (tags?.repeat_count || 0) + 1
|
||||
})
|
||||
|
||||
/** 重复次数 */
|
||||
const repeatCount = computed(() => {
|
||||
return props.conversation?.tags?.repeat_count || 0
|
||||
})
|
||||
|
||||
/** 是否有备注 */
|
||||
const hasNotes = computed(() => {
|
||||
return Object.keys(employeeNotes.value).length > 0
|
||||
})
|
||||
|
||||
/** 备注文本 */
|
||||
const notesText = computed(() => {
|
||||
const notes = employeeNotes.value
|
||||
const parts: string[] = []
|
||||
if (notes.pregnant) parts.push('孕妇')
|
||||
if (notes.disabled) parts.push('残疾/行动不便')
|
||||
if (notes.preference) parts.push(notes.preference)
|
||||
if (notes.custom) parts.push(notes.custom)
|
||||
return parts.join(';') || ''
|
||||
})
|
||||
|
||||
/** 等级名称 */
|
||||
const levelName = computed(() => {
|
||||
return IT_LEVEL_MAP[employeeItLevel.value]?.name || '白银'
|
||||
})
|
||||
|
||||
/** 等级编号 */
|
||||
const levelNumber = computed(() => {
|
||||
return IT_LEVEL_MAP[employeeItLevel.value]?.lv || 2
|
||||
})
|
||||
|
||||
/** 等级描述 */
|
||||
const levelDesc = computed(() => {
|
||||
return IT_LEVEL_MAP[employeeItLevel.value]?.desc || ''
|
||||
})
|
||||
|
||||
/** 7天内反馈次数(Mock) */
|
||||
const weekFeedbackCount = computed(() => {
|
||||
return props.conversation?.tags?.repeat_count || 0
|
||||
})
|
||||
|
||||
/** 30天内工单数量(Mock) */
|
||||
const monthlyTicketCount = computed(() => {
|
||||
return Math.floor(Math.random() * 5) + 1
|
||||
})
|
||||
|
||||
/** 工单类型分布(Mock) */
|
||||
const ticketTypeDistribution = computed(() => {
|
||||
return 'VPN 2次,邮箱 1次'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 切换展开/收起
|
||||
*/
|
||||
function toggleExpand(): void {
|
||||
isExpanded.value = !isExpanded.value
|
||||
// 收起时关闭 IT 等级选择器
|
||||
if (!isExpanded.value) {
|
||||
showItLevelSelector.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整 IT 等级
|
||||
*
|
||||
* @param level - 新等级
|
||||
*/
|
||||
async function handleItLevelChange(level: string): Promise<void> {
|
||||
const employeeId = props.conversation?.employee_id
|
||||
if (!employeeId) {
|
||||
ElMessage.warning('无法获取员工信息')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateEmployeeItLevel(employeeId, level)
|
||||
employeeItLevel.value = level
|
||||
showItLevelSelector.value = false
|
||||
ElMessage.success(`IT 等级已调整为 ${IT_LEVEL_MAP[level]?.name || level}`)
|
||||
} catch (error) {
|
||||
console.error('更新 IT 等级失败:', error)
|
||||
// 降级:即使 API 失败也本地更新
|
||||
employeeItLevel.value = level
|
||||
showItLevelSelector.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听会话切换
|
||||
// ============================================================================
|
||||
|
||||
/** 当会话变化时重置状态 */
|
||||
function resetForNewConversation(): void {
|
||||
isExpanded.value = false
|
||||
showItLevelSelector.value = false
|
||||
// 从 conversation 初始化 IT 等级
|
||||
if (props.conversation) {
|
||||
employeeItLevel.value = props.conversation.level || 'silver'
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件
|
||||
defineExpose({ resetForNewConversation })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主容器 */
|
||||
.user-info-bar {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 常驻区 */
|
||||
.user-info-bar__persistent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-info-bar__persistent:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 左侧区域 */
|
||||
.user-info-bar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.user-info-bar__avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 姓名组 */
|
||||
.user-info-bar__name-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-info-bar__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-info-bar__dept {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 展开箭头 */
|
||||
.user-info-bar__arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform 0.35s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-info-bar__arrow.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* 信息 chips */
|
||||
.user-info-bar__chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* chip 通用样式 */
|
||||
.info-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-chip--gray {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.info-chip--yellow {
|
||||
background: var(--warning-soft);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid var(--warning-soft);
|
||||
}
|
||||
|
||||
.info-chip--red {
|
||||
background: var(--danger-soft);
|
||||
color: var(--color-danger);
|
||||
border: 1px solid var(--danger-soft);
|
||||
}
|
||||
|
||||
.info-chip--purple {
|
||||
background: var(--purple-soft);
|
||||
color: var(--purple);
|
||||
border: 1px solid var(--purple-soft);
|
||||
}
|
||||
|
||||
.info-chip--accent {
|
||||
background: var(--bg-accent-soft);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
/* 右侧操作按钮 */
|
||||
.user-info-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 展开详情区 */
|
||||
.user-info-bar__detail {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s ease;
|
||||
}
|
||||
|
||||
.user-info-bar__detail.is-expanded {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.user-info-bar__detail-inner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px 20px 16px;
|
||||
}
|
||||
|
||||
/* 详情卡片 */
|
||||
.detail-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.detail-card__header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-card__body {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 情绪展示 */
|
||||
.detail-card__emotion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-card__emotion-emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.detail-card__emotion-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-card__desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 统计行 */
|
||||
.detail-card__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-card__stat-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 等级行 */
|
||||
.detail-card__level-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-card__level-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-card__level-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* IT 等级选择器 */
|
||||
.it-level-selector {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: var(--shadow-md);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.it-level-selector__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.it-level-selector__item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.it-level-selector__item.is-active {
|
||||
background: var(--bg-accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.it-level-selector__check {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 备注文本 */
|
||||
.detail-card__notes {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.detail-card__notes--empty {
|
||||
color: var(--text-placeholder);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 响应式:小屏2列 */
|
||||
@media (max-width: 1200px) {
|
||||
.user-info-bar__detail-inner {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 审批单详情子视图
|
||||
// =============================================================================
|
||||
// 说明:TaskDetailView 中 type=approval 的子视图
|
||||
// 功能:
|
||||
// 1. 审批内容卡片(申请人/类型/预算/附件)
|
||||
// 2. 审批意见输入区(textarea)
|
||||
// 3. 底部操作按钮(审批通过/拒绝审批/转交审批)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="approval-detail">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 审批内容卡片 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">📝 审批内容</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">申请人</span>
|
||||
<span class="tic-value">{{ todoItem.description?.applicant || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">类型</span>
|
||||
<span class="tic-value">{{ todoItem.description?.approval_type || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">预算</span>
|
||||
<span class="tic-value">{{ todoItem.description?.budget ? `¥${todoItem.description.budget}` : '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">附件</span>
|
||||
<span class="tic-value">
|
||||
<span v-if="todoItem.description?.attachments?.length">
|
||||
<span v-for="(att, idx) in todoItem.description.attachments" :key="idx" class="apv-attachment">
|
||||
📎 {{ att }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>无</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">说明</span>
|
||||
<span class="tic-value tic-desc">{{ todoItem.description?.detail || todoItem.description?.description || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 审批意见输入区 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">✏️ 审批意见</div>
|
||||
<el-input
|
||||
v-model="approvalComment"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入审批意见..."
|
||||
resize="none"
|
||||
class="apv-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 底部操作按钮 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-actions">
|
||||
<button class="tic-action-btn tic-action-success" @click="$emit('action', '审批通过')">
|
||||
✅ 审批通过
|
||||
</button>
|
||||
<button class="tic-action-btn tic-action-danger" @click="$emit('action', '拒绝审批')">
|
||||
❌ 拒绝审批
|
||||
</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '转交审批')">
|
||||
🔄 转交审批
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref } from 'vue'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前选中的待办事项 */
|
||||
todoItem: TodoItemData
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
/** 操作按钮事件 */
|
||||
(e: 'action', action: string): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 审批意见文本 */
|
||||
const approvalComment = ref<string>('')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approval-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 通用卡片 ---- */
|
||||
.tic-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.tic-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.tic-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.tic-label {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tic-value {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tic-desc {
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---- 附件标签 ---- */
|
||||
.apv-attachment {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.apv-attachment:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- 审批意见输入区 ---- */
|
||||
.apv-textarea :deep(.el-textarea__inner) {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.apv-textarea :deep(.el-textarea__inner:focus) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.tic-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tic-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tic-action-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tic-action-success {
|
||||
background-color: var(--color-success);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.tic-action-success:hover {
|
||||
opacity: 0.9;
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tic-action-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tic-action-danger:hover {
|
||||
opacity: 0.9;
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 设备异常详情子视图
|
||||
// =============================================================================
|
||||
// 说明:TaskDetailView 中 type=device 的子视图
|
||||
// 功能:
|
||||
// 1. 设备状态网格(2×3 grid: 设备名称/型号/在线状态/最后在线/IP/告警次数)
|
||||
// 2. 处理记录卡片
|
||||
// 3. 底部操作按钮(一键开单/派工/标记恢复/加入巡检)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="device-detail">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 设备状态网格 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">🖥 设备状态</div>
|
||||
<div class="dev-grid">
|
||||
<div class="dev-grid-row">
|
||||
<div class="dev-grid-cell">
|
||||
<span class="dev-grid-label">设备名称</span>
|
||||
<span class="dev-grid-value">{{ todoItem.description?.device_name || todoItem.title }}</span>
|
||||
</div>
|
||||
<div class="dev-grid-cell">
|
||||
<span class="dev-grid-label">型号</span>
|
||||
<span class="dev-grid-value">{{ todoItem.description?.device_model || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dev-grid-row">
|
||||
<div class="dev-grid-cell">
|
||||
<span class="dev-grid-label">在线状态</span>
|
||||
<span class="dev-grid-value">
|
||||
<span class="dev-status-dot" :class="onlineStatusClass"></span>
|
||||
{{ onlineStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dev-grid-cell">
|
||||
<span class="dev-grid-label">最后在线</span>
|
||||
<span class="dev-grid-value">{{ todoItem.description?.last_online || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dev-grid-row">
|
||||
<div class="dev-grid-cell">
|
||||
<span class="dev-grid-label">IP 地址</span>
|
||||
<span class="dev-grid-value">{{ todoItem.description?.ip_address || '—' }}</span>
|
||||
</div>
|
||||
<div class="dev-grid-cell">
|
||||
<span class="dev-grid-label">告警次数</span>
|
||||
<span class="dev-grid-value" :class="alarmCountClass">
|
||||
{{ todoItem.description?.alarm_count ?? 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 处理记录卡片 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">📋 处理记录</div>
|
||||
<div v-if="records.length > 0" class="dev-records">
|
||||
<div v-for="(rec, idx) in records" :key="idx" class="dev-record-item">
|
||||
<div class="dev-record-time">{{ rec.time }}</div>
|
||||
<div class="dev-record-content">{{ rec.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="dev-records-empty">
|
||||
暂无处理记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 底部操作按钮 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-actions">
|
||||
<button class="tic-action-btn" @click="$emit('action', '一键开单')">📝 一键开单</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '派工')">🚚 派工</button>
|
||||
<button class="tic-action-btn tic-action-success" @click="$emit('action', '标记恢复')">✅ 标记恢复</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '加入巡检')">📅 加入巡检</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前选中的待办事项 */
|
||||
todoItem: TodoItemData
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
/** 操作按钮事件 */
|
||||
(e: 'action', action: string): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 在线状态 CSS 类 */
|
||||
const onlineStatusClass = computed<string>(() => {
|
||||
const status = props.todoItem.description?.online_status
|
||||
if (status === 'normal' || status === 'online') return 'status-normal'
|
||||
if (status === 'warning') return 'status-warning'
|
||||
if (status === 'offline' || status === 'error') return 'status-error'
|
||||
// 根据 priority 推断
|
||||
if (props.todoItem.priority === 'urgent') return 'status-error'
|
||||
if (props.todoItem.priority === 'high') return 'status-warning'
|
||||
return 'status-normal'
|
||||
})
|
||||
|
||||
/** 在线状态文本 */
|
||||
const onlineStatusText = computed<string>(() => {
|
||||
const status = props.todoItem.description?.online_status
|
||||
if (status === 'normal' || status === 'online') return '正常'
|
||||
if (status === 'warning') return '告警'
|
||||
if (status === 'offline' || status === 'error') return '异常'
|
||||
// 根据 priority 推断
|
||||
if (props.todoItem.priority === 'urgent') return '异常'
|
||||
if (props.todoItem.priority === 'high') return '告警'
|
||||
return '正常'
|
||||
})
|
||||
|
||||
/** 告警次数 CSS 类 */
|
||||
const alarmCountClass = computed<string>(() => {
|
||||
const count = props.todoItem.description?.alarm_count ?? 0
|
||||
if (count > 5) return 'alarm-critical'
|
||||
if (count > 0) return 'alarm-warning'
|
||||
return 'alarm-normal'
|
||||
})
|
||||
|
||||
/** 处理记录列表 */
|
||||
const records = computed<Array<{ time: string; content: string }>>(() => {
|
||||
const descRecords = props.todoItem.description?.records
|
||||
if (Array.isArray(descRecords)) {
|
||||
return descRecords.map((r: any) => ({
|
||||
time: r.time || r.created_at || '—',
|
||||
content: r.content || r.action || '—',
|
||||
}))
|
||||
}
|
||||
// Mock 数据
|
||||
if (props.todoItem.status === 'processing') {
|
||||
return [
|
||||
{ time: '10:32', content: '坐席已接单,正在排查中' },
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 通用卡片 ---- */
|
||||
.tic-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.tic-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* ---- 设备状态网格 ---- */
|
||||
.dev-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dev-grid-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dev-grid-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* 去掉最后一行底部边框 */
|
||||
.dev-grid-row:last-child .dev-grid-cell {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 每行第二个 cell 加左边框 */
|
||||
.dev-grid-cell:nth-child(2) {
|
||||
border-left: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.dev-grid-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dev-grid-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ---- 在线状态指示点 ---- */
|
||||
.dev-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dev-status-dot.status-normal { background-color: var(--color-success); }
|
||||
.dev-status-dot.status-warning { background-color: var(--color-warning); }
|
||||
.dev-status-dot.status-error { background-color: var(--color-danger); }
|
||||
|
||||
/* ---- 告警次数颜色 ---- */
|
||||
.alarm-normal { color: var(--color-success); }
|
||||
.alarm-warning { color: var(--color-warning); }
|
||||
.alarm-critical { color: var(--color-danger); font-weight: 600; }
|
||||
|
||||
/* ---- 处理记录 ---- */
|
||||
.dev-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dev-record-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.dev-record-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dev-record-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.dev-record-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dev-records-empty {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.tic-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tic-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tic-action-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tic-action-success {
|
||||
background-color: var(--color-success);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.tic-action-success:hover {
|
||||
opacity: 0.9;
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 运维工单详情子视图
|
||||
// =============================================================================
|
||||
// 说明:TaskDetailView 中 type=ticket 的子视图
|
||||
// 功能:
|
||||
// 1. 工单描述卡片(标题/类型/优先级/上报人/上报时间/描述)
|
||||
// 2. 处理进度卡片(状态/接单人/SLA倒计时)
|
||||
// 3. 底部操作按钮(接单/开始处理/结单/转派)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="ticket-detail">
|
||||
<!-- ================================================================== -->
|
||||
<!-- 工单描述卡片 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">📋 工单描述</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">标题</span>
|
||||
<span class="tic-value">{{ todoItem.title }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">类型</span>
|
||||
<span class="tic-value">{{ todoItem.description?.ticket_type || '运维' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">优先级</span>
|
||||
<span class="tic-value">
|
||||
<span v-if="todoItem.priority === 'urgent'" class="tic-priority urgent">🔴 紧急</span>
|
||||
<span v-else-if="todoItem.priority === 'high'" class="tic-priority high">🟡 高</span>
|
||||
<span v-else class="tic-priority normal">🟢 普通</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">上报人</span>
|
||||
<span class="tic-value">{{ todoItem.description?.reporter || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">上报时间</span>
|
||||
<span class="tic-value">{{ formatTime(todoItem.created_at) }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">描述</span>
|
||||
<span class="tic-value tic-desc">{{ todoItem.description?.detail || todoItem.description?.description || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 处理进度卡片 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">📊 处理进度</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">状态</span>
|
||||
<span class="tic-value">
|
||||
<span class="tic-status-badge" :class="`tic-status-${todoItem.status}`">
|
||||
{{ statusLabelMap[todoItem.status] || todoItem.status }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">接单人</span>
|
||||
<span class="tic-value">{{ todoItem.assigned_agent_id ? `坐席 ${todoItem.assigned_agent_id.slice(0, 8)}` : '未接单' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">SLA</span>
|
||||
<span class="tic-value">
|
||||
<span class="tic-sla" :class="slaLevel">{{ slaText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 底部操作按钮 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-actions">
|
||||
<button class="tic-action-btn" @click="$emit('action', '接单')">📥 接单</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '开始处理')">🔧 开始处理</button>
|
||||
<button class="tic-action-btn tic-action-primary" @click="$emit('action', '结单')">✅ 结单</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '转派')">🔄 转派</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前选中的待办事项 */
|
||||
todoItem: TodoItemData
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
/** 操作按钮事件 */
|
||||
(e: 'action', action: string): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 映射
|
||||
// ============================================================================
|
||||
|
||||
/** 状态标签映射 */
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已解决',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** SLA 倒计时(Mock) */
|
||||
const slaText = computed<string>(() => {
|
||||
const desc = props.todoItem.description
|
||||
if (desc?.sla_remaining) return desc.sla_remaining
|
||||
// Mock: 根据 priority 生成 SLA
|
||||
if (props.todoItem.priority === 'urgent') return '0h 32m'
|
||||
if (props.todoItem.priority === 'high') return '2h 15m'
|
||||
return '23h 45m'
|
||||
})
|
||||
|
||||
/** SLA 等级(颜色指示) */
|
||||
const slaLevel = computed<string>(() => {
|
||||
const text = slaText.value
|
||||
if (text.startsWith('0h') || text.includes('超时')) return 'sla-overdue'
|
||||
// 提取小时数判断
|
||||
const hourMatch = text.match(/(\d+)h/)
|
||||
if (hourMatch) {
|
||||
const hours = parseInt(hourMatch[1], 10)
|
||||
if (hours <= 1) return 'sla-warning'
|
||||
}
|
||||
return 'sla-normal'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*
|
||||
* @param isoString - ISO 时间字符串
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
function formatTime(isoString: string): string {
|
||||
if (!isoString) return '—'
|
||||
try {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 通用卡片 ---- */
|
||||
.tic-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.tic-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.tic-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.tic-label {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tic-value {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tic-desc {
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---- 优先级 ---- */
|
||||
.tic-priority.urgent { color: var(--color-danger); font-weight: 600; }
|
||||
.tic-priority.high { color: var(--color-warning); font-weight: 600; }
|
||||
.tic-priority.normal { color: var(--color-success); }
|
||||
|
||||
/* ---- 状态徽标 ---- */
|
||||
.tic-status-badge {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tic-status-pending {
|
||||
background-color: rgba(230, 162, 60, 0.1);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.tic-status-processing {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tic-status-resolved {
|
||||
background-color: rgba(103, 194, 58, 0.1);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* ---- SLA ---- */
|
||||
.tic-sla {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tic-sla.sla-normal { color: var(--color-success); }
|
||||
.tic-sla.sla-warning { color: var(--color-warning); }
|
||||
.tic-sla.sla-overdue { color: var(--color-danger); }
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.tic-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tic-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tic-action-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tic-action-primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tic-action-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,501 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 会话列表项组件(v5.4 头像+圆点+缩略头像)
|
||||
// =============================================================================
|
||||
// 说明:单个会话项的展示
|
||||
// 包含:头像(渐变色) + 新消息圆点(3色) + 姓名 + 标签 + 优先级图标
|
||||
// + 最后消息摘要 + 紧急度星级 + 处理对象缩略头像
|
||||
// 已结单会话名字变灰、半透明
|
||||
// 置顶显示📌图标,代办显示📋图标
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation-item"
|
||||
:class="{
|
||||
active: active,
|
||||
resolved: conversation.status === 'resolved',
|
||||
'other-agent': !conversation.is_mine && conversation.status === 'serving',
|
||||
}"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- 头像(含新消息圆点) -->
|
||||
<div class="conv-avatar-wrap">
|
||||
<div class="conversation-avatar" :class="avatarColorClass">
|
||||
{{ avatarText }}
|
||||
</div>
|
||||
<!-- 新消息圆点:有新消息时显示,3色区分优先级 -->
|
||||
<span
|
||||
v-if="hasNewMessage"
|
||||
class="new-msg-dot"
|
||||
:class="newMsgDotClass"
|
||||
:title="newMsgDotTitle"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<div class="conversation-info">
|
||||
<!-- 第一行:姓名 + 标签 + 优先级图标 -->
|
||||
<div class="conversation-name">
|
||||
<!-- 置顶图标 -->
|
||||
<span v-if="conversation.is_pinned" title="已置顶">📌</span>
|
||||
<!-- 代办图标 -->
|
||||
<span v-if="conversation.is_todo" title="代办">📋</span>
|
||||
<!-- 姓名 -->
|
||||
<span class="text-ellipsis">{{ conversation.employee_name || '未知' }}</span>
|
||||
<!-- VIP标签 -->
|
||||
<span v-if="conversation.is_vip" class="tag-badge tag-badge-vip">VIP</span>
|
||||
<!-- 招手标签 -->
|
||||
<span v-if="conversation.tags?.hand_raise" class="tag-badge tag-badge-hand-raise">招手</span>
|
||||
<!-- 需介入标签 -->
|
||||
<span v-if="conversation.tags?.need_intervene" class="tag-badge tag-badge-need-intervene">🔔需介入</span>
|
||||
<!-- 情绪标签 -->
|
||||
<span
|
||||
v-if="conversation.tags?.emotion && conversation.tags.emotion !== 'neutral'"
|
||||
class="tag-badge"
|
||||
:class="emotionBadgeClass"
|
||||
>
|
||||
{{ emotionLabel }}
|
||||
</span>
|
||||
<!-- 其他坐席姓名标签 -->
|
||||
<span
|
||||
v-if="conversation.assigned_agent_name && !conversation.is_mine && conversation.status === 'serving'"
|
||||
class="tag-badge tag-badge-agent"
|
||||
>
|
||||
{{ conversation.assigned_agent_name }}
|
||||
</span>
|
||||
<!-- 优先级图标组(右侧排列) -->
|
||||
<span class="priority-icons">
|
||||
<span
|
||||
v-for="pi in visiblePriorityIcons"
|
||||
:key="pi.key"
|
||||
class="priority-icon"
|
||||
:class="pi.cssClass"
|
||||
:title="pi.title"
|
||||
:style="{ backgroundColor: pi.bg }"
|
||||
>
|
||||
{{ pi.icon }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:最后消息摘要 + 时间 -->
|
||||
<div class="conversation-summary-row">
|
||||
<span class="conversation-summary">{{ conversation.last_message_summary || '暂无消息' }}</span>
|
||||
<span class="conversation-time">{{ formatTime }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:紧急度 + 接手按钮 -->
|
||||
<div class="conversation-meta">
|
||||
<!-- 紧急度星级 -->
|
||||
<div class="urgency-stars">
|
||||
<span
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="urgency-star"
|
||||
:class="{ empty: i > conversation.urgency_score }"
|
||||
>★</span>
|
||||
</div>
|
||||
<!-- 接手按钮(仅其他坐席的会话显示) -->
|
||||
<el-button
|
||||
v-if="showGrab && conversation.can_grab"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="grab-btn"
|
||||
@click.stop="$emit('grab')"
|
||||
>
|
||||
接手
|
||||
</el-button>
|
||||
<!-- 退出按钮(仅协作会话显示) -->
|
||||
<el-button
|
||||
v-if="showLeave"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
class="leave-btn"
|
||||
@click.stop="$emit('leave')"
|
||||
>
|
||||
退出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处理对象缩略头像(右侧) -->
|
||||
<div
|
||||
v-if="showTargetAvatar"
|
||||
class="conv-target-avatar"
|
||||
:class="targetAvatarColorClass"
|
||||
:title="targetAvatarTitle"
|
||||
>
|
||||
{{ targetAvatarText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
import type { Conversation } from '@/api/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// 优先级图标配置
|
||||
// ============================================================================
|
||||
interface PriorityIconDef {
|
||||
key: string
|
||||
icon: string
|
||||
cssClass: string
|
||||
bg: string
|
||||
highThreshold?: number
|
||||
title: string
|
||||
}
|
||||
|
||||
const PRIORITY_ICONS: PriorityIconDef[] = [
|
||||
{
|
||||
key: 'is_blocking',
|
||||
icon: '⛔',
|
||||
cssClass: 'pi-blocked',
|
||||
bg: 'var(--color-danger)',
|
||||
title: '阻断性问题',
|
||||
},
|
||||
{
|
||||
key: 'impact_scope',
|
||||
icon: '👥',
|
||||
cssClass: 'pi-impact',
|
||||
bg: 'var(--color-warning)',
|
||||
highThreshold: 5,
|
||||
title: '影响范围广',
|
||||
},
|
||||
{
|
||||
key: 'role_level',
|
||||
icon: '⭐',
|
||||
cssClass: 'pi-role',
|
||||
bg: 'var(--purple)',
|
||||
title: '高角色等级',
|
||||
},
|
||||
{
|
||||
key: 'is_repeat',
|
||||
icon: '🔁',
|
||||
cssClass: 'pi-repeat',
|
||||
bg: 'var(--color-warning)',
|
||||
title: '重复问题',
|
||||
},
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 头像渐变色映射(根据姓名首字 hash 分配颜色)
|
||||
// ============================================================================
|
||||
const AVATAR_COLORS = ['av-blue', 'av-green', 'av-orange', 'av-purple', 'av-red', 'av-teal', 'av-pink'] as const
|
||||
const TARGET_COLORS = ['ta-blue', 'ta-green', 'ta-orange', 'ta-purple', 'ta-red', 'ta-teal', 'ta-pink'] as const
|
||||
|
||||
/** 根据字符串计算颜色索引(稳定的 hash) */
|
||||
function colorIndex(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
|
||||
}
|
||||
return Math.abs(hash) % AVATAR_COLORS.length
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 会话对象 */
|
||||
conversation: Conversation
|
||||
/** 是否为当前选中的会话 */
|
||||
active: boolean
|
||||
/** 是否显示接手按钮(其他坐席会话区传入 true) */
|
||||
showGrab?: boolean
|
||||
/** 是否显示退出按钮(协作会话区传入 true) */
|
||||
showLeave?: boolean
|
||||
/** 会话所属分区: my/colleague/history(影响缩略头像显示逻辑) */
|
||||
section?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showGrab: false,
|
||||
showLeave: false,
|
||||
section: 'my',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
grab: []
|
||||
leave: []
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 头像文字(取姓名最后一个字) */
|
||||
const avatarText = computed(() => {
|
||||
const name = props.conversation.employee_name
|
||||
if (!name) return '?'
|
||||
return name.charAt(name.length - 1)
|
||||
})
|
||||
|
||||
/** 头像渐变色 CSS 类 */
|
||||
const avatarColorClass = computed(() => {
|
||||
const name = props.conversation.employee_name || 'unknown'
|
||||
return AVATAR_COLORS[colorIndex(name)]
|
||||
})
|
||||
|
||||
/** 是否有新消息(基于未读数或状态判断) */
|
||||
const hasNewMessage = computed(() => {
|
||||
// 排队中/招手/需介入 = 有新消息
|
||||
if (props.conversation.status === 'queued') return true
|
||||
if (props.conversation.tags?.hand_raise) return true
|
||||
if (props.conversation.tags?.need_intervene) return true
|
||||
// 已结单 = 无新消息
|
||||
if (props.conversation.status === 'resolved') return false
|
||||
// 其他活跃会话默认显示普通蓝色圆点
|
||||
return props.conversation.status === 'serving'
|
||||
})
|
||||
|
||||
/** 新消息圆点 CSS 类(紧急红/普通蓝/低优灰) */
|
||||
const newMsgDotClass = computed(() => {
|
||||
// 紧急情况(招手/阻断性/需介入/愤怒)= 红色
|
||||
if (
|
||||
props.conversation.tags?.hand_raise ||
|
||||
props.conversation.is_blocking ||
|
||||
props.conversation.tags?.need_intervene ||
|
||||
props.conversation.tags?.emotion === 'angry' ||
|
||||
props.conversation.tags?.emotion === 'urgent'
|
||||
) {
|
||||
return 'dot-urgent'
|
||||
}
|
||||
// 已结单 = 无圆点
|
||||
if (props.conversation.status === 'resolved') return ''
|
||||
// 排队中 = 蓝色
|
||||
if (props.conversation.status === 'queued') return 'dot-normal'
|
||||
// 普通服务中 = 蓝色
|
||||
return 'dot-normal'
|
||||
})
|
||||
|
||||
/** 圆点 hover 提示文字 */
|
||||
const newMsgDotTitle = computed(() => {
|
||||
if (newMsgDotClass.value === 'dot-urgent') return '紧急新消息'
|
||||
if (newMsgDotClass.value === 'dot-normal') return '有新消息'
|
||||
return '新消息'
|
||||
})
|
||||
|
||||
/** 是否显示右侧处理对象缩略头像 */
|
||||
const showTargetAvatar = computed(() => {
|
||||
// 历史会话不显示缩略头像
|
||||
if (props.section === 'history') return false
|
||||
// 我的会话和同事会话都显示
|
||||
return true
|
||||
})
|
||||
|
||||
/** 缩略头像文字 */
|
||||
const targetAvatarText = computed(() => {
|
||||
if (props.section === 'colleague') {
|
||||
// 同事会话:显示坐席姓名最后字
|
||||
return props.conversation.assigned_agent_name
|
||||
? props.conversation.assigned_agent_name.charAt(props.conversation.assigned_agent_name.length - 1)
|
||||
: '?'
|
||||
}
|
||||
// 我的会话:显示员工姓名最后字
|
||||
const name = props.conversation.employee_name
|
||||
return name ? name.charAt(name.length - 1) : '?'
|
||||
})
|
||||
|
||||
/** 缩略头像颜色 */
|
||||
const targetAvatarColorClass = computed(() => {
|
||||
if (props.section === 'colleague') {
|
||||
const name = props.conversation.assigned_agent_name || 'unknown'
|
||||
return TARGET_COLORS[colorIndex(name)]
|
||||
}
|
||||
const name = props.conversation.employee_name || 'unknown'
|
||||
return TARGET_COLORS[colorIndex(name)]
|
||||
})
|
||||
|
||||
/** 缩略头像 hover 提示 */
|
||||
const targetAvatarTitle = computed(() => {
|
||||
if (props.section === 'colleague') {
|
||||
return `坐席:${props.conversation.assigned_agent_name || '未知'}`
|
||||
}
|
||||
return props.conversation.employee_name || '未知'
|
||||
})
|
||||
|
||||
/** 情绪标签的 CSS 类 */
|
||||
const emotionBadgeClass = computed(() => {
|
||||
const emotion = props.conversation.tags?.emotion
|
||||
if (emotion === 'urgent') return 'tag-badge-emotion-urgent'
|
||||
if (emotion === 'angry') return 'tag-badge-emotion-angry'
|
||||
if (emotion === 'worried') return 'tag-badge-emotion-worried'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 情绪标签文字 */
|
||||
const emotionLabel = computed(() => {
|
||||
const emotionMap: Record<string, string> = {
|
||||
urgent: '🔴紧急',
|
||||
angry: '😡愤怒',
|
||||
worried: '😟担忧',
|
||||
}
|
||||
return emotionMap[props.conversation.tags?.emotion || ''] || ''
|
||||
})
|
||||
|
||||
/** 格式化时间显示 */
|
||||
const formatTime = computed(() => {
|
||||
const timeStr = props.conversation.last_message_at
|
||||
if (!timeStr) return ''
|
||||
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
|
||||
// 1分钟内:刚刚
|
||||
if (diffMin < 1) return '刚刚'
|
||||
// 1小时内:X分钟前
|
||||
if (diffMin < 60) return `${diffMin}分钟前`
|
||||
// 今天:显示时间 HH:mm
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
// 昨天:昨天
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
// 更早:MM/DD
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
})
|
||||
|
||||
/**
|
||||
* 可见的优先级图标列表
|
||||
* 根据会话属性动态计算哪些图标应该显示
|
||||
*/
|
||||
const visiblePriorityIcons = computed(() => {
|
||||
const result: Array<PriorityIconDef & { title: string }> = []
|
||||
const conv = props.conversation
|
||||
|
||||
for (const def of PRIORITY_ICONS) {
|
||||
let visible = false
|
||||
let title = def.title
|
||||
|
||||
switch (def.key) {
|
||||
case 'is_blocking':
|
||||
visible = !!conv.is_blocking
|
||||
break
|
||||
case 'impact_scope':
|
||||
// impact_scope >= highThreshold(5) 时显示
|
||||
visible = (conv.impact_scope || 0) >= (def.highThreshold || 5)
|
||||
if (visible) {
|
||||
title = `影响范围: ${conv.impact_scope}人`
|
||||
}
|
||||
break
|
||||
case 'role_level':
|
||||
// 高等级用户 (level 包含总监/VP/C* 等关键词) 时显示
|
||||
visible = isHighRoleLevel(conv)
|
||||
break
|
||||
case 'is_repeat':
|
||||
// 重复追问 (repeat_count >= 3) 时显示
|
||||
visible = (conv.tags?.repeat_count || 0) >= 3
|
||||
if (visible) {
|
||||
title = `重复追问: ${conv.tags.repeat_count}次`
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
result.push({ ...def, title })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断是否为高角色等级
|
||||
* 基于员工 level 字段判断是否包含总监/VP/CXO 等高级别关键词
|
||||
*/
|
||||
function isHighRoleLevel(conv: Conversation): boolean {
|
||||
const highLevelKeywords = ['总监', 'VP', 'CIO', 'CTO', 'CFO', 'CEO', '总裁', '副总', '高级总监']
|
||||
const level = conv.level || ''
|
||||
return highLevelKeywords.some(kw => level.includes(kw))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 其他坐席会话样式:稍微灰色 */
|
||||
.conversation-item.other-agent {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.conversation-item.other-agent .conversation-avatar {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 摘要行(含时间和文本) */
|
||||
.conversation-summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.conversation-summary-row .conversation-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-summary-row .conversation-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 接手按钮样式 */
|
||||
.grab-btn {
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 退出按钮样式 */
|
||||
.leave-btn {
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 优先级图标组 */
|
||||
.priority-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 优先级图标 — 16×16px 圆角方块 */
|
||||
.priority-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 阻断性图标闪烁提示 */
|
||||
.priority-icon.pi-blocked {
|
||||
animation: pi-blink 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pi-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 会话列表组件(v5.4 无折叠版)
|
||||
// =============================================================================
|
||||
// 说明:坐席工作台左侧的会话列表
|
||||
// 功能:
|
||||
// 1. 顶部搜索栏 + 快捷筛选标签(全部/待处理/进行中/已完成)
|
||||
// 2. 扁平会话列表:我的会话 → 同事会话 → 历史会话(无折叠,始终全部展开)
|
||||
// 3. 底部挂载 TodoPanel
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="conversation-list-root">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="sidebar-search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户、关键词..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
size="default"
|
||||
/>
|
||||
<!-- 快捷筛选标签 -->
|
||||
<div class="filter-tags">
|
||||
<span
|
||||
v-for="tag in filterTags"
|
||||
:key="tag.key"
|
||||
class="filter-tag"
|
||||
:class="{ active: activeFilter === tag.key }"
|
||||
@click="activeFilter = tag.key"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话列表滚动区(v5.4: 扁平列表,无分类折叠) -->
|
||||
<div class="conversation-list-scroll">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="conversationStore.loadingConversations" class="loading-state">
|
||||
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
|
||||
<div class="loading-text">加载中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的会话(始终展开) -->
|
||||
<ConversationItem
|
||||
v-for="conv in filteredMy"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:active="conv.id === conversationStore.currentConversationId"
|
||||
:show-grab="conv.status === 'serving' && !conv.is_mine && !conv.is_collaborator"
|
||||
:show-leave="conv.is_collaborator && conv.status === 'serving'"
|
||||
section="my"
|
||||
@click="conversationStore.selectConversation(conv.id)"
|
||||
@grab="handleGrab(conv)"
|
||||
@leave="handleLeave(conv)"
|
||||
/>
|
||||
|
||||
<!-- 同事会话(始终展开) -->
|
||||
<ConversationItem
|
||||
v-for="conv in filteredColleague"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:active="conv.id === conversationStore.currentConversationId"
|
||||
:show-grab="conv.status === 'serving' && !conv.is_mine"
|
||||
section="colleague"
|
||||
@click="conversationStore.selectConversation(conv.id)"
|
||||
@grab="handleGrab(conv)"
|
||||
/>
|
||||
|
||||
<!-- 历史会话(始终展开) -->
|
||||
<ConversationItem
|
||||
v-for="conv in filteredHistory"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:active="conv.id === conversationStore.currentConversationId"
|
||||
section="history"
|
||||
@click="conversationStore.selectConversation(conv.id)"
|
||||
/>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="!conversationStore.loadingConversations && conversationStore.conversations.length === 0"
|
||||
class="empty-state"
|
||||
>
|
||||
<el-empty description="暂无会话" :image-size="80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部待办面板 -->
|
||||
<TodoPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useTodoStore } from '@/stores/todo'
|
||||
import ConversationItem from './ConversationItem.vue'
|
||||
import TodoPanel from './TodoPanel.vue'
|
||||
import type { Conversation } from '@/api/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// 筛选标签定义
|
||||
// ============================================================================
|
||||
interface FilterTag {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const filterTags: FilterTag[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'active', label: '进行中' },
|
||||
{ key: 'done', label: '已完成' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 会话 Store */
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** 待办 Store */
|
||||
const todoStore = useTodoStore()
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchKeyword = ref('')
|
||||
|
||||
/** 当前筛选标签 */
|
||||
const activeFilter = ref<string>('all')
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 搜索 + 标签综合过滤函数
|
||||
* 同时匹配关键词和筛选标签条件
|
||||
*/
|
||||
function applyFilters(conversations: Conversation[]): Conversation[] {
|
||||
let result = conversations
|
||||
|
||||
// 关键词过滤
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
result = result.filter(conv =>
|
||||
conv.employee_name.toLowerCase().includes(keyword) ||
|
||||
conv.department.toLowerCase().includes(keyword) ||
|
||||
conv.last_message_summary.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
// 标签过滤
|
||||
if (activeFilter.value !== 'all') {
|
||||
result = result.filter(conv => {
|
||||
switch (activeFilter.value) {
|
||||
case 'pending':
|
||||
return conv.status === 'queued'
|
||||
case 'active':
|
||||
return conv.status === 'serving' || conv.status === 'ai_handling'
|
||||
case 'done':
|
||||
return conv.status === 'resolved'
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** 我的会话(过滤后) */
|
||||
const filteredMy = computed(() =>
|
||||
applyFilters(conversationStore.myConversations)
|
||||
)
|
||||
|
||||
/** 同事会话(过滤后) */
|
||||
const filteredColleague = computed(() =>
|
||||
applyFilters(conversationStore.colleagueConversations)
|
||||
)
|
||||
|
||||
/** 历史会话(过滤后) */
|
||||
const filteredHistory = computed(() =>
|
||||
applyFilters(conversationStore.historyConversations)
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 接手其他坐席的会话
|
||||
*/
|
||||
async function handleGrab(conv: Conversation): Promise<void> {
|
||||
const agentName = conv.assigned_agent_name || '其他坐席'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要接手 ${agentName} 的会话吗?接手后该会话将归您处理。`,
|
||||
'接手确认',
|
||||
{
|
||||
confirmButtonText: '确认接手',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
await conversationStore.grabConv(conv.id)
|
||||
ElMessage.success('接手成功')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error?.message) {
|
||||
ElMessage.error(error.message || '接手失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出协作
|
||||
*/
|
||||
async function handleLeave(conv: Conversation): Promise<void> {
|
||||
const ownerName = conv.assigned_agent_name || '主责坐席'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要退出 ${ownerName} 的协作会话吗?`,
|
||||
'退出确认',
|
||||
{
|
||||
confirmButtonText: '确认退出',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
await conversationStore.leaveConvCollaboration(conv.id)
|
||||
ElMessage.success('已退出协作')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error?.message) {
|
||||
ElMessage.error(error.message || '退出失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
todoStore.fetchTodoList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 根容器 */
|
||||
.conversation-list-root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-text {
|
||||
margin-top: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 快捷筛选标签 */
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.filter-tag.active {
|
||||
background-color: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 摇人选人弹窗组件
|
||||
// =============================================================================
|
||||
// 说明:坐席点击「摇人」后弹出,供其搜索并选择要邀请的在线坐席
|
||||
// 功能:
|
||||
// 1. 搜索框:按坐席姓名模糊搜索
|
||||
// 2. 坐席列表:显示在线/忙碌状态、当前负载
|
||||
// 3. 选中确认:选中目标坐席后点击「确认」触发邀请
|
||||
// 4. 排除自己、主责坐席和已在协作中的坐席
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="🤝 摇人 — 邀请坐席协作"
|
||||
width="450px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索坐席姓名..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
style="margin-bottom: 12px;"
|
||||
/>
|
||||
|
||||
<!-- 坐席列表 -->
|
||||
<div class="agent-list">
|
||||
<div v-if="filteredAgents.length === 0" class="empty-hint">
|
||||
暂无可邀请的在线坐席
|
||||
</div>
|
||||
<div
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.user_id"
|
||||
class="agent-item"
|
||||
:class="{ selected: selectedAgentId === agent.user_id, busy: agent.current_load >= agent.max_load }"
|
||||
@click="selectAgent(agent)"
|
||||
>
|
||||
<div class="agent-avatar">{{ getAvatar(agent.name) }}</div>
|
||||
<div class="agent-info">
|
||||
<div class="agent-name">
|
||||
{{ agent.name }}
|
||||
<el-tag
|
||||
v-if="agent.current_load >= agent.max_load"
|
||||
type="danger"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
忙碌
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="agent.current_load === 0"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
空闲
|
||||
</el-tag>
|
||||
<span v-else class="load-text">
|
||||
负载 {{ agent.current_load }}/{{ agent.max_load }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="agent-sub" v-if="agent.current_load <= agent.max_load * 0.6">
|
||||
<el-icon><Star /></el-icon> 推荐
|
||||
</div>
|
||||
</div>
|
||||
<el-icon v-if="selectedAgentId === agent.user_id" class="check-icon" color="var(--accent)">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="selected-hint" v-if="selectedAgent">
|
||||
已选:{{ selectedAgent.name }}
|
||||
</div>
|
||||
<div>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedAgentId" :loading="submitting" @click="handleConfirm">
|
||||
确认邀请
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import type { Agent } from '@/api/agent'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 弹窗是否可见(由父组件通过 v-model 控制) */
|
||||
modelValue: boolean
|
||||
/** 排除的坐席ID列表(主责坐席 + 已在协作中的坐席) */
|
||||
excludeAgentIds?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
excludeAgentIds: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'confirm': [agentId: string]
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
/** 弹窗可见性(双向绑定) */
|
||||
const visible = ref(props.modelValue)
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchText = ref('')
|
||||
|
||||
/** 选中的坐席ID */
|
||||
const selectedAgentId = ref<string | null>(null)
|
||||
|
||||
/** 是否正在提交 */
|
||||
const submitting = ref(false)
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/** 监听弹窗打开:刷新坐席列表 */
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
// 打开弹窗时重置搜索和选中状态
|
||||
searchText.value = ''
|
||||
selectedAgentId.value = null
|
||||
// 刷新在线坐席列表
|
||||
agentStore.loadAvailableAgents()
|
||||
}
|
||||
})
|
||||
|
||||
/** 同步内部 visible 变化到外部 */
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 当前选中的坐席对象 */
|
||||
const selectedAgent = computed(() => {
|
||||
if (!selectedAgentId.value) return null
|
||||
return agentStore.availableAgents.find(a => a.user_id === selectedAgentId.value) || null
|
||||
})
|
||||
|
||||
/** 过滤后的坐席列表(排除已过滤的 + 按搜索词筛选) */
|
||||
const filteredAgents = computed(() => {
|
||||
const excludeSet = new Set(props.excludeAgentIds)
|
||||
let agents = agentStore.availableAgents.filter(a =>
|
||||
// 排除已过滤的坐席
|
||||
!excludeSet.has(a.user_id) &&
|
||||
// 仅显示在线坐席
|
||||
a.status === 'online'
|
||||
)
|
||||
|
||||
// 按搜索词过滤
|
||||
const keyword = searchText.value.trim().toLowerCase()
|
||||
if (keyword) {
|
||||
agents = agents.filter(a => a.name.toLowerCase().includes(keyword))
|
||||
}
|
||||
|
||||
// 排序:空闲优先 → 负载低优先
|
||||
return agents.sort((a, b) => {
|
||||
if (a.current_load === 0 && b.current_load > 0) return -1
|
||||
if (b.current_load === 0 && a.current_load > 0) return 1
|
||||
return a.current_load - b.current_load
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 头像文字(取姓名最后一个字) */
|
||||
function getAvatar(name: string): string {
|
||||
if (!name) return '?'
|
||||
return name.charAt(name.length - 1)
|
||||
}
|
||||
|
||||
/** 选中坐席 */
|
||||
function selectAgent(agent: Agent): void {
|
||||
selectedAgentId.value = agent.user_id
|
||||
}
|
||||
|
||||
/** 确认邀请 */
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (!selectedAgentId.value) return
|
||||
submitting.value = true
|
||||
emit('confirm', selectedAgentId.value)
|
||||
// 父组件负责调用 store.inviteToConversation,成功后关闭弹窗
|
||||
// 这里先不关闭,等父组件确认成功后再关
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.agent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.agent-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.agent-item.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.agent-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.agent-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.load-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.agent-sub {
|
||||
font-size: 12px;
|
||||
color: var(--color-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selected-hint {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 邀请员工/部门加入会话弹窗组件
|
||||
// =============================================================================
|
||||
// 说明:主责坐席点击「邀请」后弹出,选择要邀请的员工或部门
|
||||
// 功能:
|
||||
// 1. 搜索框:按姓名/工号模糊搜索员工
|
||||
// 2. 已选列表:显示已选中的被邀请人
|
||||
// 3. 历史共享模式选择:最近10条/全部/不共享
|
||||
// 4. 确认邀请:发送企微卡片通知给被邀请人
|
||||
// 区别:和「摇人」不同,摇人选的是坐席,这里选的是员工/部门
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="📋 邀请 — 邀请员工/部门加入会话"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索员工姓名或工号..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
style="margin-bottom: 12px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
|
||||
<!-- 搜索结果 / 手动输入 -->
|
||||
<div class="search-area">
|
||||
<div v-if="searchResults.length === 0 && searchText" class="empty-hint">
|
||||
未找到匹配的员工,可直接输入添加
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0 && !searchText" class="empty-hint">
|
||||
请输入姓名或工号搜索,或直接手动添加
|
||||
</div>
|
||||
<div
|
||||
v-for="person in searchResults"
|
||||
:key="person.id"
|
||||
class="person-item"
|
||||
:class="{ selected: isSelected(person.id) }"
|
||||
@click="toggleSelect(person)"
|
||||
>
|
||||
<div class="person-avatar">
|
||||
<img
|
||||
v-if="person.avatar"
|
||||
:src="person.avatar"
|
||||
:alt="person.name"
|
||||
class="avatar-img"
|
||||
@error="onAvatarError($event)"
|
||||
/>
|
||||
<span v-else class="avatar-letter">{{ getAvatar(person.name) }}</span>
|
||||
</div>
|
||||
<div class="person-info">
|
||||
<div class="person-name">{{ person.name }}</div>
|
||||
<div class="person-dept">{{ person.department || '未知部门' }}</div>
|
||||
</div>
|
||||
<el-icon v-if="isSelected(person.id)" class="check-icon" color="var(--accent)">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 手动添加 -->
|
||||
<div class="manual-add" v-if="searchText && searchResults.length === 0">
|
||||
<el-button type="primary" link @click="addManualPerson">
|
||||
<el-icon><Plus /></el-icon>
|
||||
手动添加「{{ searchText }}」
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选列表 -->
|
||||
<div class="selected-area" v-if="selectedPeople.length > 0">
|
||||
<div class="selected-header">
|
||||
<span>已选 {{ selectedPeople.length }} 人</span>
|
||||
<el-button type="primary" link size="small" @click="selectedPeople = []">清空</el-button>
|
||||
</div>
|
||||
<div class="selected-tags">
|
||||
<el-tag
|
||||
v-for="p in selectedPeople"
|
||||
:key="p.id"
|
||||
closable
|
||||
effect="plain"
|
||||
@close="removeSelected(p.id)"
|
||||
>
|
||||
{{ p.name }}{{ p.department ? `(${p.department})` : '' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史共享模式 -->
|
||||
<div class="history-mode">
|
||||
<span class="history-label">历史消息共享:</span>
|
||||
<el-radio-group v-model="historyMode" size="small">
|
||||
<el-radio-button value="recent10">最近10条</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
<el-radio-button value="none">不共享</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="selectedPeople.length === 0"
|
||||
:loading="submitting"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
确认邀请({{ selectedPeople.length }}人)
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, watch } from 'vue'
|
||||
import { Check, Plus } from '@element-plus/icons-vue'
|
||||
import { inviteParticipant } from '@/api/conversation'
|
||||
import type { ParticipantInfo } from '@/api/conversation'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 弹窗是否可见(由父组件通过 v-model 控制) */
|
||||
modelValue: boolean
|
||||
/** 当前会话ID */
|
||||
conversationId: string
|
||||
/** 已在参与者列表中的ID(排除,避免重复邀请) */
|
||||
existingParticipantIds?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
existingParticipantIds: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'success': [conversation: any]
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 弹窗可见性 */
|
||||
const visible = ref(props.modelValue)
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchText = ref('')
|
||||
|
||||
/** 搜索结果(Mock 数据,阶段二对接企微通讯录API后替换) */
|
||||
const searchResults = ref<ParticipantInfo[]>([])
|
||||
|
||||
/** 已选人员列表 */
|
||||
const selectedPeople = ref<ParticipantInfo[]>([])
|
||||
|
||||
/** 历史共享模式 */
|
||||
const historyMode = ref<'recent10' | 'all' | 'none'>('recent10')
|
||||
|
||||
/** 是否正在提交 */
|
||||
const submitting = ref(false)
|
||||
|
||||
// ============================================================================
|
||||
// Mock 员工数据(阶段二对接企微通讯录API后替换)
|
||||
// ============================================================================
|
||||
|
||||
const mockEmployees: ParticipantInfo[] = [
|
||||
{ id: 'zhangsan', name: '张三', department: '研发一部', type: 'employee', avatar: '' },
|
||||
{ id: 'lisi', name: '李四', department: '市场部', type: 'employee', avatar: '' },
|
||||
{ id: 'wangwu', name: '王五', department: '运维部', type: 'employee', avatar: '' },
|
||||
{ id: 'zhaoliu', name: '赵六', department: '人力资源部', type: 'employee', avatar: '' },
|
||||
{ id: 'qianqi', name: '钱七', department: '财务部', type: 'employee', avatar: '' },
|
||||
{ id: 'sunba', name: '孙八', department: '产品部', type: 'employee', avatar: '' },
|
||||
{ id: 'zhoujiu', name: '周九', department: '行政部', type: 'employee', avatar: '' },
|
||||
{ id: 'wushi', name: '吴十', department: '法务部', type: 'employee', avatar: '' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
// 打开弹窗时重置
|
||||
searchText.value = ''
|
||||
searchResults.value = []
|
||||
selectedPeople.value = []
|
||||
historyMode.value = 'recent10'
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 头像文字(取姓名最后一个字) */
|
||||
function getAvatar(name: string): string {
|
||||
if (!name) return '?'
|
||||
return name.charAt(name.length - 1)
|
||||
}
|
||||
|
||||
/** 头像加载失败时隐藏 img,降级显示首字母 */
|
||||
function onAvatarError(event: Event): void {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
/** 搜索员工(阶段一用 Mock 数据,阶段二替换为企微通讯录API) */
|
||||
function handleSearch(): void {
|
||||
const keyword = searchText.value.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
// Mock: 按姓名/工号模糊匹配
|
||||
const existingSet = new Set(props.existingParticipantIds)
|
||||
searchResults.value = mockEmployees.filter(
|
||||
e => !existingSet.has(e.id) &&
|
||||
(e.name.toLowerCase().includes(keyword) || e.id.toLowerCase().includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
/** 判断是否已选中 */
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedPeople.value.some(p => p.id === id)
|
||||
}
|
||||
|
||||
/** 切换选中状态 */
|
||||
function toggleSelect(person: ParticipantInfo): void {
|
||||
const idx = selectedPeople.value.findIndex(p => p.id === person.id)
|
||||
if (idx >= 0) {
|
||||
selectedPeople.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedPeople.value.push(person)
|
||||
}
|
||||
}
|
||||
|
||||
/** 从已选列表移除 */
|
||||
function removeSelected(id: string): void {
|
||||
const idx = selectedPeople.value.findIndex(p => p.id === id)
|
||||
if (idx >= 0) {
|
||||
selectedPeople.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动添加搜索不到的员工 */
|
||||
function addManualPerson(): void {
|
||||
const name = searchText.value.trim()
|
||||
if (!name) return
|
||||
// 用搜索词作为姓名和ID
|
||||
const person: ParticipantInfo = {
|
||||
id: `manual_${Date.now()}`,
|
||||
name,
|
||||
department: '',
|
||||
type: 'employee',
|
||||
}
|
||||
if (!isSelected(person.id)) {
|
||||
selectedPeople.value.push(person)
|
||||
}
|
||||
searchText.value = ''
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
/** 确认邀请 */
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (selectedPeople.value.length === 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await inviteParticipant(props.conversationId, {
|
||||
participants: selectedPeople.value.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
department: p.department,
|
||||
type: p.type,
|
||||
})),
|
||||
history_mode: historyMode.value,
|
||||
})
|
||||
|
||||
ElMessage.success(`已邀请 ${selectedPeople.value.length} 人加入会话`)
|
||||
emit('success', result)
|
||||
visible.value = false
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || '邀请失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-area {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.person-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.person-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.person-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.person-item.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.person-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.person-avatar .avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.person-avatar .avatar-letter {
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.person-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.person-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.person-dept {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manual-add {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px dashed var(--border-light);
|
||||
}
|
||||
|
||||
.selected-area {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.selected-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 参与者面板组件
|
||||
// =============================================================================
|
||||
// 说明:在聊天区顶部展示当前会话的参与者列表,提供邀请和移除入口
|
||||
// 功能:
|
||||
// 1. 显示参与者列表(坐席 + 协作坐席 + 被邀请员工)含头像
|
||||
// 2. 主责坐席可点击「邀请」按钮打开邀请弹窗
|
||||
// 3. 主责坐席可移除参与者
|
||||
// 4. 参与者加入/退出状态实时更新
|
||||
// 位置:放在 UserInfoBar 下方,聊天消息区上方
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="participant-bar" v-if="hasParticipants">
|
||||
<!-- 参与者列表 -->
|
||||
<div class="participant-bar__list">
|
||||
<span class="participant-bar__label">
|
||||
{{ totalParticipantCount }}人参与:
|
||||
</span>
|
||||
|
||||
<!-- 主责坐席(始终第一个显示) -->
|
||||
<div class="participant-item participant-item--primary">
|
||||
<div class="participant-avatar participant-avatar--primary">
|
||||
<span class="avatar-letter">{{ agentName ? agentName.charAt(agentName.length - 1) : '席' }}</span>
|
||||
</div>
|
||||
<span class="participant-name">{{ agentName }}(主责)</span>
|
||||
</div>
|
||||
|
||||
<!-- 协作坐席 -->
|
||||
<div
|
||||
v-for="aid in collaboratingAgentIds"
|
||||
:key="'collab-' + aid"
|
||||
class="participant-item participant-item--collab"
|
||||
>
|
||||
<div class="participant-avatar participant-avatar--collab">
|
||||
<span class="avatar-letter">{{ getAgentName(aid).charAt(getAgentName(aid).length - 1) }}</span>
|
||||
</div>
|
||||
<span class="participant-name">{{ getAgentName(aid) }}(协作)</span>
|
||||
</div>
|
||||
|
||||
<!-- 被邀请参与者 -->
|
||||
<div
|
||||
v-for="p in participants"
|
||||
:key="'p-' + p.id"
|
||||
class="participant-item"
|
||||
:class="{ 'participant-item--pending': !p.joined }"
|
||||
>
|
||||
<!-- 头像:有 avatar 用 img,无则首字母降级 -->
|
||||
<div class="participant-avatar" :class="p.joined ? '' : 'participant-avatar--pending'">
|
||||
<img
|
||||
v-if="p.avatar"
|
||||
:src="p.avatar"
|
||||
:alt="p.name"
|
||||
class="avatar-img"
|
||||
@error="onAvatarError($event)"
|
||||
/>
|
||||
<span v-else class="avatar-letter">{{ p.name.charAt(p.name.length - 1) }}</span>
|
||||
</div>
|
||||
<span class="participant-name">
|
||||
{{ p.name }}{{ p.type === 'employee' ? '' : `(${p.type})` }}
|
||||
<span v-if="!p.joined" class="pending-hint">待加入</span>
|
||||
</span>
|
||||
<!-- 移除按钮(仅主责坐席可见) -->
|
||||
<el-icon
|
||||
v-if="isPrimaryAgent"
|
||||
class="remove-icon"
|
||||
@click.stop="handleRemove(p.id)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="participant-bar__actions">
|
||||
<!-- 邀请按钮(仅主责坐席可见) -->
|
||||
<el-button
|
||||
v-if="isPrimaryAgent"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="$emit('invite')"
|
||||
>
|
||||
+ 邀请
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import type { ParticipantInfo } from '@/api/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 参与者列表(不含主责坐席,从 currentConversation.participants 获取) */
|
||||
participants: ParticipantInfo[]
|
||||
/** 主责坐席姓名 */
|
||||
agentName: string
|
||||
/** 当前登录坐席是否为主责坐席 */
|
||||
isPrimaryAgent: boolean
|
||||
/** 协作坐席ID列表 */
|
||||
collaboratingAgentIds?: string[]
|
||||
/** 协作坐席姓名映射(agent_id → name) */
|
||||
collaboratingAgentNames?: Record<string, string>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collaboratingAgentIds: () => [],
|
||||
collaboratingAgentNames: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击邀请按钮 */
|
||||
'invite': []
|
||||
/** 移除参与者(主责坐席操作) */
|
||||
'remove': [userId: string]
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 是否有参与者(排除只有坐席自己一个人的情况) */
|
||||
const hasParticipants = computed(() => {
|
||||
return props.participants.length > 0 || props.collaboratingAgentIds.length > 0
|
||||
})
|
||||
|
||||
/** 总参与者数量(主责 + 协作 + 被邀请) */
|
||||
const totalParticipantCount = computed(() => {
|
||||
// 主责坐席 1人 + 协作坐席 + 被邀请参与者
|
||||
return 1 + props.collaboratingAgentIds.length + props.participants.length
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 获取协作坐席姓名(从映射表查找,找不到则显示ID) */
|
||||
function getAgentName(agentId: string): string {
|
||||
return props.collaboratingAgentNames?.[agentId] || agentId
|
||||
}
|
||||
|
||||
/** 移除参与者(仅主责坐席可操作) */
|
||||
function handleRemove(userId: string): void {
|
||||
if (!props.isPrimaryAgent) return
|
||||
emit('remove', userId)
|
||||
}
|
||||
|
||||
/** 头像加载失败时隐藏 img,降级显示首字母 */
|
||||
function onAvatarError(event: Event): void {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.participant-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-tertiary, #f5f7fa);
|
||||
border-bottom: 1px solid var(--border-light, #e4e7ed);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.participant-bar__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留滚动功能 */
|
||||
.participant-bar__list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.participant-bar__label {
|
||||
color: var(--text-secondary, #909399);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 参与者条目 */
|
||||
.participant-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary, #fff);
|
||||
border: 1px solid var(--border-light, #e4e7ed);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.participant-item:hover {
|
||||
background: var(--bg-tertiary, #f5f7fa);
|
||||
}
|
||||
|
||||
.participant-item--primary {
|
||||
border-color: var(--accent, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.participant-item--collab {
|
||||
border-color: var(--success, #67c23a);
|
||||
background: rgba(103, 194, 58, 0.06);
|
||||
}
|
||||
|
||||
.participant-item--pending {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 头像容器 */
|
||||
.participant-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.participant-avatar--primary {
|
||||
background: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.participant-avatar--collab {
|
||||
background: var(--success, #67c23a);
|
||||
}
|
||||
|
||||
.participant-avatar--pending {
|
||||
background: var(--text-tertiary, #c0c4cc);
|
||||
}
|
||||
|
||||
/* 头像图片 */
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* 头像首字降级 */
|
||||
.avatar-letter {
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 参与者姓名 */
|
||||
.participant-name {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary, #303133);
|
||||
}
|
||||
|
||||
.pending-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #c0c4cc);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* 移除图标 */
|
||||
.remove-icon {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #c0c4cc);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.remove-icon:hover {
|
||||
color: var(--danger, #f56c6c);
|
||||
}
|
||||
|
||||
.participant-bar__actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,359 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 待办事项面板组件
|
||||
// =============================================================================
|
||||
// 说明:左栏底部挂载的待办面板
|
||||
// 功能:
|
||||
// 1. 显示待办列表(优先级圆点 + 文本 + 类型标签 + 时间)
|
||||
// 2. 点击条目 → todoStore.selectTodoItem + workspaceView = 'task'
|
||||
// 3. 底部显示坐席在线统计
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="todo-panel">
|
||||
<!-- 标题行 -->
|
||||
<div class="todo-header">
|
||||
<span class="todo-title">📋 待办事项</span>
|
||||
<span v-if="todoStore.urgentCount > 0" class="todo-urgent-badge">
|
||||
{{ todoStore.urgentCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 待办列表 -->
|
||||
<div class="todo-list">
|
||||
<div
|
||||
v-for="item in todoStore.pendingTodos"
|
||||
:key="item.id"
|
||||
class="todo-item"
|
||||
:class="{ 'todo-item-active': todoStore.currentTodoItem?.id === item.id }"
|
||||
@click="handleTodoClick(item)"
|
||||
>
|
||||
<!-- 优先级圆点 -->
|
||||
<span
|
||||
class="todo-priority-dot"
|
||||
:class="`priority-${item.priority}`"
|
||||
></span>
|
||||
<!-- 文本 -->
|
||||
<span class="todo-text text-ellipsis">{{ item.title }}</span>
|
||||
<!-- 类型标签 -->
|
||||
<span class="todo-type-tag" :class="`type-${item.type}`">
|
||||
{{ typeLabel(item.type) }}
|
||||
</span>
|
||||
<!-- 时间 -->
|
||||
<span class="todo-time">{{ formatTodoTime(item.created_at) }}</span>
|
||||
<!-- v5.4: 上报人缩略头像 -->
|
||||
<div
|
||||
class="ki-avatar"
|
||||
:class="todoAvatarColor(item)"
|
||||
:title="todoAvatarTitle(item)"
|
||||
>
|
||||
{{ todoAvatarText(item) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="todoStore.pendingTodos.length === 0 && !todoStore.loading" class="todo-empty">
|
||||
暂无待办
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部坐席在线统计 -->
|
||||
<div class="todo-footer">
|
||||
<span class="agent-stat">
|
||||
<span class="agent-dot dot-online"></span>
|
||||
<span>{{ onlineAgents }} 在线</span>
|
||||
</span>
|
||||
<span class="agent-stat">
|
||||
<span class="agent-dot dot-busy"></span>
|
||||
<span>{{ busyAgents }} 忙碌</span>
|
||||
</span>
|
||||
<span class="agent-stat">
|
||||
<span class="agent-dot dot-offline"></span>
|
||||
<span>{{ offlineAgents }} 离线</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref } from 'vue'
|
||||
import { useTodoStore } from '@/stores/todo'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { getAgentStats } from '@/mock/data'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
|
||||
// ============================================================================
|
||||
// Store
|
||||
// ============================================================================
|
||||
const todoStore = useTodoStore()
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
// ============================================================================
|
||||
// 坐席在线统计(从 mock 数据计算,后续接入 agentStore 实时数据)
|
||||
// ============================================================================
|
||||
const stats = getAgentStats()
|
||||
const onlineAgents = ref(stats.onlineAgents)
|
||||
const busyAgents = ref(stats.busyAgents)
|
||||
const offlineAgents = ref(stats.offlineAgents)
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 类型标签文字映射 */
|
||||
function typeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ticket: '工单',
|
||||
approval: '审批',
|
||||
device: '设备',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
/** 格式化待办时间 */
|
||||
function formatTodoTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
|
||||
if (diffMin < 1) return '刚刚'
|
||||
if (diffMin < 60) return `${diffMin}分钟前`
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
/** 点击待办条目 */
|
||||
function handleTodoClick(item: TodoItemData): void {
|
||||
todoStore.selectTodoItem(item)
|
||||
conversationStore.workspaceView = 'task'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v5.4: 待办缩略头像辅助函数
|
||||
// ============================================================================
|
||||
|
||||
const KI_COLORS = ['ka-blue', 'ka-green', 'ka-orange', 'ka-purple', 'ka-red'] as const
|
||||
|
||||
/** 根据标题 hash 分配头像颜色 */
|
||||
function kiColorIndex(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
|
||||
}
|
||||
return Math.abs(hash) % KI_COLORS.length
|
||||
}
|
||||
|
||||
/** 缩略头像文字(取标题中第一个中文或部门首字) */
|
||||
function todoAvatarText(item: TodoItemData): string {
|
||||
const title = item.title || ''
|
||||
// 提取标题中" - "后面的部门名首字
|
||||
const dashIdx = title.indexOf(' - ')
|
||||
if (dashIdx >= 0) {
|
||||
const dept = title.substring(0, dashIdx).trim()
|
||||
return dept.charAt(dept.length - 1)
|
||||
}
|
||||
return title.charAt(0)
|
||||
}
|
||||
|
||||
/** 缩略头像颜色 */
|
||||
function todoAvatarColor(item: TodoItemData): string {
|
||||
return KI_COLORS[kiColorIndex(item.title || 'default')]
|
||||
}
|
||||
|
||||
/** 缩略头像 hover 提示 */
|
||||
function todoAvatarTitle(item: TodoItemData): string {
|
||||
const title = item.title || ''
|
||||
const dashIdx = title.indexOf(' - ')
|
||||
if (dashIdx >= 0) {
|
||||
return title.substring(0, dashIdx).trim()
|
||||
}
|
||||
return title
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 面板容器 */
|
||||
.todo-panel {
|
||||
border-top: 1px solid var(--border-color);
|
||||
max-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 标题行 */
|
||||
.todo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.todo-urgent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background-color: var(--color-danger);
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 待办列表 */
|
||||
.todo-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 待办条目 */
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.todo-item:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.todo-item-active {
|
||||
background-color: var(--bg-accent-soft);
|
||||
}
|
||||
|
||||
/* 优先级圆点 */
|
||||
.todo-priority-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-priority-dot.priority-urgent {
|
||||
background-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.todo-priority-dot.priority-high {
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.todo-priority-dot.priority-normal {
|
||||
background-color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 文本 */
|
||||
.todo-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 类型标签 */
|
||||
.todo-type-tag {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
/* 工单 — 蓝色 */
|
||||
.todo-type-tag.type-ticket {
|
||||
color: var(--accent);
|
||||
background-color: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* 审批 — 紫色 */
|
||||
.todo-type-tag.type-approval {
|
||||
color: var(--purple);
|
||||
background-color: var(--purple-soft);
|
||||
border-color: color-mix(in srgb, var(--purple) 30%, transparent);
|
||||
}
|
||||
|
||||
/* 设备 — 橙色 */
|
||||
.todo-type-tag.type-device {
|
||||
color: var(--color-warning);
|
||||
background-color: var(--warning-soft);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
|
||||
}
|
||||
|
||||
/* 时间 */
|
||||
.todo-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.todo-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 底部坐席统计 */
|
||||
.todo-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 状态圆点 */
|
||||
.agent-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-online {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.dot-busy {
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.dot-offline {
|
||||
background-color: var(--text-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,446 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 顶栏组件
|
||||
// =============================================================================
|
||||
// 说明:独立顶栏组件,从 Workspace.vue 顶部栏抽离
|
||||
// 包含:Logo + 标题 + 主题切换开关 + 坐席状态 + 登出
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<header class="top-bar">
|
||||
<!-- ==================================================================== -->
|
||||
<!-- 主顶栏 -->
|
||||
<!-- ==================================================================== -->
|
||||
<div class="top-bar-main">
|
||||
<!-- 左侧:Logo + 标题 -->
|
||||
<div class="top-bar-left">
|
||||
<span class="logo-block">IT</span>
|
||||
<span class="title-gradient">IT智能服务台</span>
|
||||
<span class="subtitle">· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:主题切换开关 + 坐席状态 + 登出 -->
|
||||
<div class="top-bar-right">
|
||||
<!-- 主题切换开关(☀️ 滑轨 🌙,匹配原型v5.3) -->
|
||||
<div
|
||||
class="theme-switch"
|
||||
:title="themeStore.currentTheme === 'light' ? '切换到深色模式' : '切换到浅色模式'"
|
||||
@click="onThemeSwitch"
|
||||
>
|
||||
<span class="switch-icon">☀️</span>
|
||||
<div class="switch-track">
|
||||
<div class="switch-thumb"></div>
|
||||
</div>
|
||||
<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>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="online">
|
||||
🟢 在线 — 接收新会话
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="busy">
|
||||
🟡 忙碌 — 不接新会话
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="offline">
|
||||
⚪ 离线 — 不接收任何会话
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="otp">
|
||||
🔐 OTP二次验证
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 登出按钮 -->
|
||||
<el-button text type="danger" @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
登出
|
||||
</el-button>
|
||||
|
||||
<!-- 小屏幕下显示/隐藏助手面板 -->
|
||||
<el-button
|
||||
class="assistant-toggle-btn"
|
||||
text
|
||||
@click="$emit('toggleAssistant')"
|
||||
>
|
||||
<el-icon><Operation /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ==================================================================== -->
|
||||
<!-- OTP 设置对话框 -->
|
||||
<!-- ==================================================================== -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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'
|
||||
|
||||
// ============================================================================
|
||||
// 事件
|
||||
// ============================================================================
|
||||
defineEmits<{
|
||||
(e: 'toggleAssistant'): void
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 坐席 Store */
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
/** 主题 Store */
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
/** 会话 Store */
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** WebSocket 组合式函数 */
|
||||
const { disconnect: disconnectWs } = useWebSocket()
|
||||
|
||||
// ============================================================================
|
||||
// OTP 双因素认证
|
||||
// ============================================================================
|
||||
|
||||
/** OTP 对话框可见性 */
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 坐席状态标签文字 */
|
||||
const statusLabel = computed(() => {
|
||||
const statusMap: Record<string, string> = {
|
||||
online: '在线',
|
||||
busy: '忙碌',
|
||||
offline: '离线',
|
||||
}
|
||||
return statusMap[agentStore.agentStatus] || '离线'
|
||||
})
|
||||
|
||||
/** 坐席状态标签类型 */
|
||||
const statusTagType = computed(() => {
|
||||
const typeMap: Record<string, string> = {
|
||||
online: 'success',
|
||||
busy: 'warning',
|
||||
offline: 'info',
|
||||
}
|
||||
return typeMap[agentStore.agentStatus] || 'info'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 主题开关切换回调(el-switch 的 @change)
|
||||
* 直接调用 themeStore.toggleTheme(),watch 会自动同步开关状态
|
||||
*/
|
||||
function onThemeSwitch(): void {
|
||||
themeStore.toggleTheme()
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换坐席状态
|
||||
*/
|
||||
async function handleStatusChange(status: string): Promise<void> {
|
||||
if (status === 'otp') {
|
||||
// 打开OTP设置
|
||||
await handleOpenOtp()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await agentStore.changeStatus(status)
|
||||
ElMessage.success(`已切换为${statusLabel.value}`)
|
||||
} catch (error) {
|
||||
console.error('切换状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
async function handleLogout(): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
disconnectWs()
|
||||
conversationStore.stopAllPolling()
|
||||
agentStore.logout()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.top-bar-main {
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.top-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent));
|
||||
color: var(--bg-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-gradient {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-toggle-btn {
|
||||
font-size: 18px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 主题切换滑轨样式(匹配原型v5.3) */
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.theme-switch .switch-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.theme-switch .switch-track {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
background: var(--border-light);
|
||||
border-radius: 11px;
|
||||
position: relative;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-switch .switch-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-switch .switch-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* 小屏幕下显示助手切换按钮 */
|
||||
.assistant-toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.assistant-toggle-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user