chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
@@ -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()
// ============================================================================
// 剪贴板相关(消息复制功能)
// ============================================================================
/** useClipboardVueUse 提供的剪贴板操作组合函数 */
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 APIgetDisplayMedia)在企微桌面端被限制,不稳定
* - 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>