feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS
## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS
## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)
## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code
## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)
## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过
## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务
## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记✅已实施
- 新增架构图/时序图/类图(mermaid)
## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
This commit is contained in:
@@ -43,7 +43,7 @@ export interface Conversation {
|
||||
position: string
|
||||
/** 等级 */
|
||||
level: string
|
||||
/** 会话状态: ai_handling/queued/serving/resolved */
|
||||
/** 会话状态: ai_handling/queued/serving/pending_close/resolved */
|
||||
status: string
|
||||
/** VIP标记 */
|
||||
is_vip: boolean
|
||||
@@ -165,14 +165,27 @@ export async function assignConversation(conversationId: string, agentId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 结单
|
||||
* 将会话状态改为 resolved
|
||||
* 坐席发起结单(触发员工确认流程)
|
||||
*
|
||||
* 改造说明(决策 G1 — 坐席发起→员工确认):
|
||||
* - 原逻辑:直接将会话状态改为 resolved
|
||||
* - 新逻辑:状态改为 pending_close → 推送确认卡片给员工
|
||||
* - 员工确认后 → resolved
|
||||
* - 员工拒绝 → 恢复 serving
|
||||
* - 5 分钟超时 → 自动 resolved
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 更新后的会话信息
|
||||
* @param resolveSummary - 结单摘要(问题类型+根因+解决方式)
|
||||
* @returns 更新后的会话信息(状态为 pending_close)
|
||||
*/
|
||||
export async function resolveConversation(conversationId: string): Promise<Conversation> {
|
||||
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/resolve`)
|
||||
export async function resolveConversation(
|
||||
conversationId: string,
|
||||
resolveSummary: string
|
||||
): Promise<Conversation> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${conversationId}/resolve`,
|
||||
{ resolve_summary: resolveSummary }
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端分诊 API 封装
|
||||
// =============================================================================
|
||||
// 说明:封装坐席端 7 个分诊看板 API 调用
|
||||
// =============================================================================
|
||||
|
||||
import request from './index'
|
||||
|
||||
/** 分诊会话列表项 */
|
||||
export interface TriageSessionItem {
|
||||
id: string
|
||||
conversation_id: string
|
||||
user_id: string
|
||||
user_name: string | null
|
||||
user_dept: string | null
|
||||
request_title: string
|
||||
problem_type: string | null
|
||||
problem_category: string | null
|
||||
confidence: number | null
|
||||
urgency: string
|
||||
suggested_route: string | null
|
||||
status: string
|
||||
route_action: string | null
|
||||
route_note: string | null
|
||||
operator_id: string | null
|
||||
created_at: string | null
|
||||
operated_at: string | null
|
||||
}
|
||||
|
||||
/** 分诊详情 */
|
||||
export interface TriageDetail extends TriageSessionItem {
|
||||
user_level: string | null
|
||||
device_info: string | null
|
||||
request_content: string
|
||||
source: string
|
||||
matched_knowledge: string | null
|
||||
match_score: number | null
|
||||
context_tags: string[]
|
||||
triage_steps: Record<string, any>[]
|
||||
collected_context: string[]
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/** 分诊统计 */
|
||||
export interface TriageStats {
|
||||
pending_total: number
|
||||
today_triaged: number
|
||||
ai_self_count: number
|
||||
human_count: number
|
||||
auto_approval_count: number
|
||||
avg_duration_sec: number
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface TriageListQuery {
|
||||
urgency?: string
|
||||
problem_type?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/** 历史查询参数 */
|
||||
export interface TriageHistoryQuery {
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
route_action?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/** 路由操作请求 */
|
||||
export interface TriageRouteParams {
|
||||
route_action: string
|
||||
route_note?: string
|
||||
}
|
||||
|
||||
/** 排除选项请求 */
|
||||
export interface TriageExcludeOptionsParams {
|
||||
excluded_labels: string[]
|
||||
recommended_label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待分诊列表
|
||||
* GET /api/agent/triage/pending
|
||||
*/
|
||||
export function getPendingTriage(params: TriageListQuery): Promise<{ total: number; items: TriageSessionItem[] }> {
|
||||
return request.get('/agent/triage/pending', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分诊统计
|
||||
* GET /api/agent/triage/stats
|
||||
*/
|
||||
export function getTriageStats(): Promise<TriageStats> {
|
||||
return request.get('/agent/triage/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分诊详情
|
||||
* GET /api/agent/triage/{triage_id}
|
||||
*/
|
||||
export function getTriageDetail(triageId: string): Promise<TriageDetail> {
|
||||
return request.get(`/agent/triage/${triageId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由操作
|
||||
* POST /api/agent/triage/{triage_id}/route
|
||||
*/
|
||||
export function routeTriage(triageId: string, params: TriageRouteParams): Promise<TriageSessionItem> {
|
||||
return request.post(`/agent/triage/${triageId}/route`, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分诊历史
|
||||
* GET /api/agent/triage/history
|
||||
*/
|
||||
export function getTriageHistory(params: TriageHistoryQuery): Promise<{ total: number; items: TriageSessionItem[] }> {
|
||||
return request.get('/agent/triage/history', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出分诊记录
|
||||
* GET /api/agent/triage/export
|
||||
*/
|
||||
export function exportTriage(params: { date_from?: string; date_to?: string }): Promise<Blob> {
|
||||
return request.get('/agent/triage/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 排除/推荐分诊选项
|
||||
* POST /api/agent/triage/{triage_id}/exclude-options
|
||||
*/
|
||||
export function excludeTriageOptions(
|
||||
triageId: string,
|
||||
params: TriageExcludeOptionsParams,
|
||||
): Promise<{ excluded: boolean }> {
|
||||
return request.post(`/agent/triage/${triageId}/exclude-options`, params)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
||||
<!-- =============================================================================
|
||||
// 企微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,112 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — AI 训练区主体组件(布局优化v2 新增)
|
||||
// =============================================================================
|
||||
// 说明:右栏 AiAssistantPanel 的主体,Tab 切换三个训练功能
|
||||
// 功能:
|
||||
// 1. 智能标注 Tab — SmartTagEditor(从 AiAssistantPanel 迁出标签逻辑)
|
||||
// 2. 质量反馈 Tab — QualityFeedback(标注 AI 回复有用/无用)
|
||||
// 3. 知识贡献 Tab — KnowledgeContribute(提交知识建议)
|
||||
// 4. 切换会话时自动重置到第一个 Tab
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="ai-training-panel">
|
||||
<el-tabs v-model="activeTab" class="ai-training-panel__tabs">
|
||||
<!-- Tab 1: 智能标注 -->
|
||||
<el-tab-pane label="智能标注" name="smart-tag">
|
||||
<SmartTagEditor :conversation-id="conversationId" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 质量反馈 -->
|
||||
<el-tab-pane label="质量反馈" name="quality-feedback">
|
||||
<QualityFeedback :conversation-id="conversationId" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 知识贡献 -->
|
||||
<el-tab-pane label="知识贡献" name="knowledge-contribute">
|
||||
<KnowledgeContribute :conversation-id="conversationId" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, watch } from 'vue'
|
||||
import SmartTagEditor from './SmartTagEditor.vue'
|
||||
import QualityFeedback from './QualityFeedback.vue'
|
||||
import KnowledgeContribute from './KnowledgeContribute.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** 训练区 Tab 类型 */
|
||||
export type TrainingTab = 'smart-tag' | 'quality-feedback' | 'knowledge-contribute'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前会话 ID */
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 当前激活的 Tab */
|
||||
const activeTab = ref<TrainingTab>('smart-tag')
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/** 切换会话时自动重置到第一个 Tab */
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
activeTab.value = 'smart-tag'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-training-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* el-tabs 占满剩余空间 */
|
||||
.ai-training-panel__tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-training-panel__tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-training-panel__tabs :deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-training-panel__tabs :deep(.el-tab-pane) {
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 知识贡献面板(布局优化v2 新增)
|
||||
// =============================================================================
|
||||
// 说明:右栏训练区 Tab 3,坐席提交知识建议到知识迭代队列
|
||||
// 设计决策 Q2:复用 POST /api/knowledge-iteration/suggestions 接口
|
||||
// 功能:
|
||||
// 1. 表单包含:标题 / 问题描述 / 解决方案 / 分类
|
||||
// 2. 提交后自动关联当前会话 ID
|
||||
// 3. 提交成功后 ElMessage 提示 + 表单重置
|
||||
// 4. 切换会话时清空表单
|
||||
// API:
|
||||
// POST /api/knowledge-iteration/suggestions
|
||||
// Payload: { title, issue, action, category, suggestion_type, source_type, conversation_id }
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="knowledge-contribute">
|
||||
<!-- 标题栏 -->
|
||||
<div class="knowledge-contribute__header">
|
||||
<span class="knowledge-contribute__bar"></span>
|
||||
<span class="knowledge-contribute__title">📚 知识贡献</span>
|
||||
</div>
|
||||
|
||||
<!-- 表单 -->
|
||||
<form class="knowledge-contribute__form" @submit.prevent="handleSubmit">
|
||||
<!-- 标题 -->
|
||||
<div class="knowledge-contribute__field">
|
||||
<label class="knowledge-contribute__label">
|
||||
标题 <span class="knowledge-contribute__required">*</span>
|
||||
</label>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入知识标题"
|
||||
size="small"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分类 -->
|
||||
<div class="knowledge-contribute__field">
|
||||
<label class="knowledge-contribute__label">
|
||||
分类 <span class="knowledge-contribute__required">*</span>
|
||||
</label>
|
||||
<el-select
|
||||
v-model="form.category"
|
||||
placeholder="请选择分类"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="cat in categoryOptions"
|
||||
:key="cat.value"
|
||||
:label="cat.label"
|
||||
:value="cat.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 问题描述 -->
|
||||
<div class="knowledge-contribute__field">
|
||||
<label class="knowledge-contribute__label">
|
||||
问题描述 <span class="knowledge-contribute__required">*</span>
|
||||
</label>
|
||||
<el-input
|
||||
v-model="form.issue"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请描述遇到的问题"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 解决方案 -->
|
||||
<div class="knowledge-contribute__field">
|
||||
<label class="knowledge-contribute__label">
|
||||
解决方案 <span class="knowledge-contribute__required">*</span>
|
||||
</label>
|
||||
<el-input
|
||||
v-model="form.action"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入解决方案或操作步骤"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="knowledge-contribute__actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="submitting"
|
||||
:disabled="!isFormValid"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交知识建议
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleReset">
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 提交成功提示 -->
|
||||
<div v-if="lastSubmittedTitle" class="knowledge-contribute__success-tip">
|
||||
<span class="knowledge-contribute__success-icon">✅</span>
|
||||
<span class="knowledge-contribute__success-text">
|
||||
已提交:"{{ lastSubmittedTitle }}",等待审核
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, watch, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import apiClient from '@/api/index'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前会话 ID */
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** 知识贡献表单数据 */
|
||||
interface KnowledgeForm {
|
||||
title: string
|
||||
category: string
|
||||
issue: string
|
||||
action: string
|
||||
}
|
||||
|
||||
/** 分类选项 */
|
||||
interface CategoryOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 表单数据 */
|
||||
const form = reactive<KnowledgeForm>({
|
||||
title: '',
|
||||
category: '',
|
||||
issue: '',
|
||||
action: '',
|
||||
})
|
||||
|
||||
/** 提交中 */
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 最近一次提交的标题(用于成功提示) */
|
||||
const lastSubmittedTitle = ref('')
|
||||
|
||||
/** 分类选项列表 */
|
||||
const categoryOptions: CategoryOption[] = [
|
||||
{ label: '账号/权限', value: 'account' },
|
||||
{ label: '网络/连接', value: 'network' },
|
||||
{ label: '软件/应用', value: 'software' },
|
||||
{ label: '硬件/设备', value: 'hardware' },
|
||||
{ label: '邮件/协作', value: 'collaboration' },
|
||||
{ label: '安全/合规', value: 'security' },
|
||||
{ label: '流程/制度', value: 'process' },
|
||||
{ label: '其他', value: 'other' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 表单是否填写完整 */
|
||||
const isFormValid = computed<boolean>(() => {
|
||||
return (
|
||||
form.title.trim().length > 0 &&
|
||||
form.category.length > 0 &&
|
||||
form.issue.trim().length > 0 &&
|
||||
form.action.trim().length > 0
|
||||
)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 提交知识建议
|
||||
* 调用 POST /api/knowledge-iteration/suggestions
|
||||
*/
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!isFormValid.value) {
|
||||
ElMessage.warning('请填写完整后再提交')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await apiClient.post('/knowledge-iteration/suggestions', {
|
||||
title: form.title.trim(),
|
||||
issue: form.issue.trim(),
|
||||
action: form.action.trim(),
|
||||
category: form.category,
|
||||
suggestion_type: 'knowledge',
|
||||
source_type: 'agent',
|
||||
conversation_id: props.conversationId || undefined,
|
||||
})
|
||||
|
||||
ElMessage.success('知识建议已提交,等待审核')
|
||||
lastSubmittedTitle.value = form.title.trim()
|
||||
handleReset()
|
||||
} catch (error) {
|
||||
console.error('提交知识建议失败:', error)
|
||||
ElMessage.error('提交失败,请稍后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
function handleReset(): void {
|
||||
form.title = ''
|
||||
form.category = ''
|
||||
form.issue = ''
|
||||
form.action = ''
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/** 切换会话时清空表单和成功提示 */
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
handleReset()
|
||||
lastSubmittedTitle.value = ''
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-contribute {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.knowledge-contribute__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.knowledge-contribute__bar {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background-color: var(--accent);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knowledge-contribute__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.knowledge-contribute__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 表单字段 */
|
||||
.knowledge-contribute__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.knowledge-contribute__label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.knowledge-contribute__required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.knowledge-contribute__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 成功提示 */
|
||||
.knowledge-contribute__success-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--success-soft);
|
||||
border: 1px solid var(--color-success);
|
||||
}
|
||||
|
||||
.knowledge-contribute__success-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knowledge-contribute__success-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-success);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 右栏面板模式切换开关(布局优化v2 新增)
|
||||
// =============================================================================
|
||||
// 说明:放置在 AiAssistantPanel 右上角,控制右栏放大/缩小
|
||||
// 功能:
|
||||
// 1. normal 模式(260px) → 显示 ⤢(放大图标)
|
||||
// 2. expanded 模式(560px) → 显示 ⤡(缩小图标)
|
||||
// 3. 点击触发 emit('toggle')
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="panel-mode-toggle">
|
||||
<button
|
||||
class="panel-mode-toggle__btn"
|
||||
:class="{ 'is-expanded': mode === 'expanded' }"
|
||||
@click="$emit('toggle')"
|
||||
:title="mode === 'normal' ? '放大面板' : '缩小面板'"
|
||||
>
|
||||
<span class="panel-mode-toggle__icon">{{ mode === 'normal' ? '⤢' : '⤡' }}</span>
|
||||
<span class="panel-mode-toggle__label">{{ mode === 'normal' ? '放大' : '缩小' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** 右栏面板模式 */
|
||||
export type PanelMode = 'normal' | 'expanded'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前模式 */
|
||||
mode: PanelMode
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 切换模式 */
|
||||
(e: 'toggle'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-mode-toggle__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-mode-toggle__btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--bg-accent-soft);
|
||||
}
|
||||
|
||||
.panel-mode-toggle__btn.is-expanded {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel-mode-toggle__btn.is-expanded:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.panel-mode-toggle__icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panel-mode-toggle__label {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 质量反馈面板(布局优化v2 新增)
|
||||
// =============================================================================
|
||||
// 说明:右栏训练区 Tab 2,坐席对 AI 回复进行质量标注
|
||||
// 设计决策 Q7:仅标注"采纳了 AI 草稿的回复"(非所有坐席回复)
|
||||
// 功能:
|
||||
// 1. 从消息列表中筛选出 sender_type='agent' && ai_suggestion=true 的消息
|
||||
// 2. 每条消息可标注 "有用" / "无用"
|
||||
// 3. 已标注的消息显示标注结果,不可重复标注
|
||||
// 4. 切换会话时重新加载标注数据
|
||||
// API:
|
||||
// - GET /api/annotations/{conversationId} → 获取已有标注
|
||||
// - POST /api/annotations → 创建标注
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="quality-feedback">
|
||||
<!-- 标题栏 -->
|
||||
<div class="quality-feedback__header">
|
||||
<span class="quality-feedback__bar"></span>
|
||||
<span class="quality-feedback__title">📊 质量反馈</span>
|
||||
<span class="quality-feedback__hint">仅标注采纳AI草稿的回复</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="quality-feedback__loading">
|
||||
<el-skeleton :rows="3" animated />
|
||||
</div>
|
||||
|
||||
<!-- 无可标注的消息 -->
|
||||
<div v-else-if="aiAdoptedMessages.length === 0" class="quality-feedback__empty">
|
||||
<el-empty
|
||||
description="暂无可标注的AI回复"
|
||||
:image-size="50"
|
||||
/>
|
||||
<p class="quality-feedback__empty-tip">
|
||||
当坐席采纳AI草稿并发送回复后,可在此处对AI质量进行标注
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-else class="quality-feedback__list">
|
||||
<div
|
||||
v-for="msg in aiAdoptedMessages"
|
||||
:key="msg.id"
|
||||
class="quality-feedback__item"
|
||||
>
|
||||
<!-- 消息内容 -->
|
||||
<div class="quality-feedback__item-content">
|
||||
<div class="quality-feedback__item-meta">
|
||||
<span class="quality-feedback__item-time">{{ formatTime(msg.created_at) }}</span>
|
||||
<span class="quality-feedback__item-badge">AI采纳</span>
|
||||
</div>
|
||||
<p class="quality-feedback__item-text">{{ truncate(msg.content, 120) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 标注操作区 -->
|
||||
<div class="quality-feedback__item-actions">
|
||||
<!-- 已标注 -->
|
||||
<div
|
||||
v-if="getAnnotationForMessage(msg.id)"
|
||||
class="quality-feedback__annotated"
|
||||
:class="`quality-feedback__annotated--${getAnnotationForMessage(msg.id)!.feedback}`"
|
||||
>
|
||||
<span class="quality-feedback__annotated-icon">
|
||||
{{ getAnnotationForMessage(msg.id)!.feedback === 'useful' ? '👍' : '👎' }}
|
||||
</span>
|
||||
<span class="quality-feedback__annotated-text">
|
||||
{{ getAnnotationForMessage(msg.id)!.feedback === 'useful' ? '有用' : '无用' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getAnnotationForMessage(msg.id)!.comment"
|
||||
class="quality-feedback__annotated-comment"
|
||||
>
|
||||
{{ getAnnotationForMessage(msg.id)!.comment }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 未标注:操作按钮 -->
|
||||
<div v-else class="quality-feedback__buttons">
|
||||
<button
|
||||
class="quality-feedback__btn quality-feedback__btn--useful"
|
||||
:disabled="annotatingId === msg.id"
|
||||
@click="handleAnnotate(msg.id, 'useful')"
|
||||
>
|
||||
👍 有用
|
||||
</button>
|
||||
<button
|
||||
class="quality-feedback__btn quality-feedback__btn--useless"
|
||||
:disabled="annotatingId === msg.id"
|
||||
@click="handleAnnotate(msg.id, 'useless')"
|
||||
>
|
||||
👎 无用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import {
|
||||
createAnnotation,
|
||||
getAnnotations,
|
||||
type Annotation,
|
||||
type AnnotationFeedback,
|
||||
} from '@/api/annotation'
|
||||
import type { Message } from '@/api/message'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前会话 ID */
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 当前会话的标注列表 */
|
||||
const annotations = ref<Annotation[]>([])
|
||||
|
||||
/** 正在标注中的消息 ID(防止重复点击) */
|
||||
const annotatingId = ref<string | null>(null)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 筛选出"采纳了 AI 草稿的回复"
|
||||
* Q7 设计决策:仅标注 sender_type='agent' && ai_suggestion=true 的消息
|
||||
*/
|
||||
const aiAdoptedMessages = computed<Message[]>(() => {
|
||||
return conversationStore.messages.filter(
|
||||
(msg) => msg.sender_type === 'agent' && msg.ai_suggestion === true
|
||||
)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取指定消息的标注(如有)
|
||||
*
|
||||
* @param messageId - 消息 ID
|
||||
* @returns 标注对象,未标注返回 null
|
||||
*/
|
||||
function getAnnotationForMessage(messageId: string): Annotation | null {
|
||||
return annotations.value.find((a) => a.message_id === messageId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
*
|
||||
* @param text - 原始文本
|
||||
* @param maxLen - 最大长度
|
||||
* @returns 截断后的文本
|
||||
*/
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (!text) return ''
|
||||
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*
|
||||
* @param isoTime - ISO 时间字符串
|
||||
* @returns 格式化后的时间 (MM-DD HH:mm)
|
||||
*/
|
||||
function formatTime(isoTime: string): string {
|
||||
if (!isoTime) return ''
|
||||
const date = new Date(isoTime)
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前会话的标注数据
|
||||
*/
|
||||
async function loadAnnotations(): Promise<void> {
|
||||
if (!props.conversationId) {
|
||||
annotations.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getAnnotations(props.conversationId)
|
||||
annotations.value = result.items || []
|
||||
} catch (error) {
|
||||
console.error('获取标注数据失败:', error)
|
||||
annotations.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标注消息
|
||||
*
|
||||
* @param messageId - 消息 ID
|
||||
* @param feedback - 标注类型 ('useful' | 'useless')
|
||||
*/
|
||||
async function handleAnnotate(
|
||||
messageId: string,
|
||||
feedback: AnnotationFeedback
|
||||
): Promise<void> {
|
||||
if (!props.conversationId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
annotatingId.value = messageId
|
||||
try {
|
||||
const annotation = await createAnnotation({
|
||||
conversation_id: props.conversationId,
|
||||
message_id: messageId,
|
||||
feedback,
|
||||
})
|
||||
|
||||
// 添加到本地标注列表
|
||||
annotations.value.push(annotation)
|
||||
ElMessage.success(feedback === 'useful' ? '已标注为"有用"' : '已标注为"无用"')
|
||||
} catch (error) {
|
||||
console.error('标注失败:', error)
|
||||
ElMessage.error('标注失败,请稍后重试')
|
||||
} finally {
|
||||
annotatingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/** 会话切换时重新加载标注数据 */
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadAnnotations()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.quality-feedback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.quality-feedback__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quality-feedback__bar {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background-color: var(--accent);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quality-feedback__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quality-feedback__hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.quality-feedback__loading {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.quality-feedback__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.quality-feedback__empty-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 消息列表 */
|
||||
.quality-feedback__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 单条消息卡片 */
|
||||
.quality-feedback__item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.quality-feedback__item-content {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.quality-feedback__item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.quality-feedback__item-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.quality-feedback__item-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quality-feedback__item-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 标注操作区 */
|
||||
.quality-feedback__item-actions {
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 未标注:按钮组 */
|
||||
.quality-feedback__buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quality-feedback__btn {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quality-feedback__btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.quality-feedback__btn--useful:hover:not(:disabled) {
|
||||
background: var(--success-soft);
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.quality-feedback__btn--useless:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.quality-feedback__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 已标注状态 */
|
||||
.quality-feedback__annotated {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.quality-feedback__annotated--useful {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.quality-feedback__annotated--useless {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.quality-feedback__annotated-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quality-feedback__annotated-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quality-feedback__annotated-comment {
|
||||
margin-left: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -135,7 +135,6 @@
|
||||
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'
|
||||
|
||||
// ========================================================================
|
||||
@@ -328,76 +327,8 @@ function scrollToSelected(): void {
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 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,
|
||||
})
|
||||
// 注:键盘快捷键已移至 ReplySuggestArea(底部回复建议区)统一管理
|
||||
// 右栏 QuickReplyPanel 保留鼠标点击交互
|
||||
|
||||
// ========================================================================
|
||||
// Watchers — 搜索词变化后重置选中
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 智能标注编辑面板(布局优化v2 新增)
|
||||
// =============================================================================
|
||||
// 说明:从 AiAssistantPanel 迁出标签逻辑,作为训练区 Tab 1
|
||||
// 功能:
|
||||
// 1. 展示当前会话已保存的标签
|
||||
// 2. AI 建议标签(点击 + 添加)
|
||||
// 3. 手动添加标签(键值对)
|
||||
// 4. 删除标签
|
||||
// R3 重命名:排查流程按钮文案改为"智能标注"(消除命名混淆)
|
||||
// R4 合并标签建议入口为单一面板
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="smart-tag-editor">
|
||||
<!-- 标题栏 -->
|
||||
<div class="smart-tag-editor__header">
|
||||
<span class="smart-tag-editor__bar"></span>
|
||||
<span class="smart-tag-editor__title">🏷️ 智能标注</span>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="loadingTagSuggestions"
|
||||
@click="handleSuggestTags"
|
||||
title="获取AI标签建议"
|
||||
>
|
||||
AI建议
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 标签列表 -->
|
||||
<div class="smart-tag-editor__tags">
|
||||
<!-- 已保存的标签 -->
|
||||
<div v-if="Object.keys(currentTags).length > 0" class="smart-tag-editor__tags-saved">
|
||||
<el-tag
|
||||
v-for="(value, key) in currentTags"
|
||||
:key="key"
|
||||
class="smart-tag-editor__tag-item"
|
||||
:type="getTagType(key as string)"
|
||||
closable
|
||||
@close="handleRemoveTag(key as string)"
|
||||
>
|
||||
{{ formatTagLabel(key as string, value) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- AI 建议的标签 -->
|
||||
<div v-if="suggestedTagList.length > 0" class="smart-tag-editor__tags-suggested">
|
||||
<div class="smart-tag-editor__tags-suggested-title">AI 建议:</div>
|
||||
<el-tag
|
||||
v-for="tag in suggestedTagList"
|
||||
:key="tag"
|
||||
class="smart-tag-editor__tag-item smart-tag-editor__tag-suggested"
|
||||
type="info"
|
||||
@click="handleAddSuggestedTag(tag)"
|
||||
>
|
||||
+ {{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 无标签时提示 -->
|
||||
<div
|
||||
v-if="Object.keys(currentTags).length === 0 && suggestedTagList.length === 0"
|
||||
class="smart-tag-editor__empty"
|
||||
>
|
||||
暂无标签,点击"AI建议"获取智能标注
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动添加标签 -->
|
||||
<div class="smart-tag-editor__add">
|
||||
<el-input
|
||||
v-model="newTagKey"
|
||||
placeholder="标签名"
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
@keyup.enter="handleAddManualTag"
|
||||
/>
|
||||
<el-input
|
||||
v-model="newTagValue"
|
||||
placeholder="值"
|
||||
size="small"
|
||||
style="width: 80px"
|
||||
@keyup.enter="handleAddManualTag"
|
||||
/>
|
||||
<el-button size="small" @click="handleAddManualTag">添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { suggestTags, saveConversationTags } from '@/api/wingman'
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** 当前会话的标签类型 */
|
||||
type ConversationTags = Record<string, any>
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前会话 ID */
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
/** 当前会话的标签 */
|
||||
const currentTags = ref<ConversationTags>({})
|
||||
|
||||
/** AI 建议的标签列表 */
|
||||
const suggestedTagList = ref<string[]>([])
|
||||
|
||||
/** 加载标签建议中 */
|
||||
const loadingTagSuggestions = ref(false)
|
||||
|
||||
/** 新增标签 - 键 */
|
||||
const newTagKey = ref('')
|
||||
|
||||
/** 新增标签 - 值 */
|
||||
const newTagValue = ref('')
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取标签类型(用于 el-tag 的 type 属性)
|
||||
*
|
||||
* @param key - 标签键名
|
||||
* @returns el-tag 类型
|
||||
*/
|
||||
function getTagType(key: string): '' | 'success' | 'info' | 'warning' | 'danger' {
|
||||
const tagTypeMap: Record<string, '' | 'success' | 'info' | 'warning' | 'danger'> = {
|
||||
priority: 'danger',
|
||||
category: 'warning',
|
||||
emotion: 'info',
|
||||
blocking: 'danger',
|
||||
impact_scope: 'warning',
|
||||
}
|
||||
return tagTypeMap[key] || 'info'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化标签显示文本
|
||||
*
|
||||
* @param key - 标签键名
|
||||
* @param value - 标签值
|
||||
* @returns 格式化后的显示文本
|
||||
*/
|
||||
function formatTagLabel(key: string, value: any): string {
|
||||
const labelMap: Record<string, string> = {
|
||||
priority: '优先级',
|
||||
category: '分类',
|
||||
emotion: '情绪',
|
||||
blocking: '阻断',
|
||||
impact_scope: '影响范围',
|
||||
}
|
||||
const label = labelMap[key] || key
|
||||
return `${label}: ${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前会话的标签
|
||||
*/
|
||||
function loadCurrentTags(): void {
|
||||
const conversation = conversationStore.currentConversation
|
||||
if (conversation && conversation.tags) {
|
||||
currentTags.value = conversation.tags as ConversationTags
|
||||
} else {
|
||||
currentTags.value = {}
|
||||
}
|
||||
suggestedTagList.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 标签建议
|
||||
*/
|
||||
async function handleSuggestTags(): Promise<void> {
|
||||
if (!props.conversationId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
loadingTagSuggestions.value = true
|
||||
try {
|
||||
const result = await suggestTags(props.conversationId)
|
||||
if (result && result.suggested_tags) {
|
||||
suggestedTagList.value = result.suggested_tags.filter(
|
||||
(tag: string) => !Object.keys(currentTags.value).includes(tag)
|
||||
)
|
||||
if (suggestedTagList.value.length === 0) {
|
||||
ElMessage.info('暂无新的标签建议')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取标签建议失败:', error)
|
||||
ElMessage.error('获取标签建议失败')
|
||||
} finally {
|
||||
loadingTagSuggestions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 AI 建议的标签
|
||||
*
|
||||
* @param tag - 标签名
|
||||
*/
|
||||
async function handleAddSuggestedTag(tag: string): Promise<void> {
|
||||
if (!props.conversationId) return
|
||||
|
||||
currentTags.value[tag] = true
|
||||
suggestedTagList.value = suggestedTagList.value.filter((t) => t !== tag)
|
||||
|
||||
try {
|
||||
await saveConversationTags(props.conversationId, currentTags.value)
|
||||
ElMessage.success(`已添加标签: ${tag}`)
|
||||
} catch (error) {
|
||||
console.error('保存标签失败:', error)
|
||||
ElMessage.error('保存标签失败')
|
||||
delete currentTags.value[tag]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除已保存的标签
|
||||
*
|
||||
* @param key - 标签键名
|
||||
*/
|
||||
async function handleRemoveTag(key: string): Promise<void> {
|
||||
if (!props.conversationId) return
|
||||
|
||||
const oldValue = currentTags.value[key]
|
||||
delete currentTags.value[key]
|
||||
|
||||
try {
|
||||
await saveConversationTags(props.conversationId, currentTags.value)
|
||||
ElMessage.success(`已移除标签: ${key}`)
|
||||
} catch (error) {
|
||||
console.error('保存标签失败:', error)
|
||||
ElMessage.error('保存标签失败')
|
||||
currentTags.value[key] = oldValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动添加标签
|
||||
*/
|
||||
async function handleAddManualTag(): Promise<void> {
|
||||
if (!props.conversationId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
const key = newTagKey.value.trim()
|
||||
const value = newTagValue.value.trim()
|
||||
|
||||
if (!key) {
|
||||
ElMessage.warning('请输入标签名')
|
||||
return
|
||||
}
|
||||
|
||||
currentTags.value[key] = value || true
|
||||
newTagKey.value = ''
|
||||
newTagValue.value = ''
|
||||
|
||||
try {
|
||||
await saveConversationTags(props.conversationId, currentTags.value)
|
||||
ElMessage.success(`已添加标签: ${key}`)
|
||||
} catch (error) {
|
||||
console.error('保存标签失败:', error)
|
||||
ElMessage.error('保存标签失败')
|
||||
delete currentTags.value[key]
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/** 会话切换时重新加载标签 */
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadCurrentTags()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-tag-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.smart-tag-editor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.smart-tag-editor__bar {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background-color: var(--accent);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smart-tag-editor__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 标签列表 */
|
||||
.smart-tag-editor__tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.smart-tag-editor__tags-saved {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.smart-tag-editor__tags-suggested {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.smart-tag-editor__tags-suggested-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.smart-tag-editor__tag-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.smart-tag-editor__tag-suggested {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.smart-tag-editor__empty {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 手动添加标签 */
|
||||
.smart-tag-editor__add {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<!-- =============================================================================
|
||||
// 企微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,334 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — AI 推荐条组件(布局优化v2 新增)
|
||||
// =============================================================================
|
||||
// 说明:合并原 AiSuggestReply + AiRecommendInline 功能
|
||||
// 功能:
|
||||
// 1. 横向展示 1-3 条 AI 推荐卡片
|
||||
// 2. 每张卡片:数字标签 + 2行截断文本 + 置信度药丸
|
||||
// 3. 点击卡片 → ConversationStore.pendingReplyText = content
|
||||
// 4. 支持刷新按钮重新获取推荐
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="ai-recommend-bar">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="ai-recommend-bar__loading">
|
||||
<el-icon class="is-loading" :size="14"><Loading /></el-icon>
|
||||
<span class="ai-recommend-bar__loading-text">AI 正在生成推荐...</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="recommendations.length === 0" class="ai-recommend-bar__empty">
|
||||
暂无 AI 推荐
|
||||
<button class="ai-recommend-bar__refresh" @click="$emit('refresh')" title="刷新推荐">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 推荐卡片列表(横向) -->
|
||||
<div v-else class="ai-recommend-bar__list">
|
||||
<div
|
||||
v-for="(rec, index) in displayRecommendations"
|
||||
:key="index"
|
||||
class="ai-recommend-bar__card"
|
||||
@click="handleSelect(rec.content)"
|
||||
:title="`按 ${index + 1} 快速选用`"
|
||||
>
|
||||
<!-- 卡片头部:数字标签 + 标题 + 置信度药丸 -->
|
||||
<div class="ai-recommend-bar__card-header">
|
||||
<span v-if="index < 6" class="ai-recommend-bar__shortcut">{{ index + 1 }}</span>
|
||||
<span class="ai-recommend-bar__card-title">{{ rec.title }}</span>
|
||||
<span
|
||||
class="ai-recommend-bar__confidence"
|
||||
:class="confidenceClass(rec.confidence)"
|
||||
>
|
||||
{{ (rec.confidence * 100).toFixed(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 卡片内容(2行截断) -->
|
||||
<p class="ai-recommend-bar__card-content">{{ rec.content }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<button
|
||||
class="ai-recommend-bar__refresh-btn"
|
||||
@click="$emit('refresh')"
|
||||
title="刷新推荐列表"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** AI 推荐项类型 */
|
||||
export interface AiRecommendation {
|
||||
/** 方案名称 */
|
||||
title: string
|
||||
/** 推荐内容 */
|
||||
content: string
|
||||
/** 置信度(0-1) */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 推荐列表 */
|
||||
recommendations: AiRecommendation[]
|
||||
/** 是否加载中 */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 选中推荐 → 填入输入框 */
|
||||
(e: 'select', content: string): void
|
||||
/** 刷新推荐列表 */
|
||||
(e: 'refresh'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 最多展示 6 条推荐(支持数字键 1~6 选择) */
|
||||
const displayRecommendations = computed(() => {
|
||||
return props.recommendations.slice(0, 6)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 选中推荐内容 → 写入 ConversationStore.pendingReplyText
|
||||
*
|
||||
* @param content - 推荐内容文本
|
||||
*/
|
||||
function handleSelect(content: string): void {
|
||||
conversationStore.pendingReplyText = content
|
||||
emit('select', content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据置信度返回样式类名
|
||||
*
|
||||
* @param confidence - 置信度(0-1)
|
||||
* @returns 样式类名
|
||||
*/
|
||||
function confidenceClass(confidence: number): string {
|
||||
if (confidence >= 0.85) return 'ai-recommend-bar__confidence--high'
|
||||
if (confidence >= 0.6) return 'ai-recommend-bar__confidence--medium'
|
||||
return 'ai-recommend-bar__confidence--low'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 暴露方法(供父组件 ReplySuggestArea 通过 ref 调用)
|
||||
// ============================================================================
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* 通过索引选择推荐(数字键 1~6 触发)
|
||||
*
|
||||
* @param index - 推荐索引(0-5)
|
||||
*/
|
||||
selectByIndex(index: number): void {
|
||||
if (index < displayRecommendations.value.length) {
|
||||
handleSelect(displayRecommendations.value[index].content)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-recommend-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.ai-recommend-bar__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.ai-recommend-bar__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 推荐卡片列表(横向滚动) */
|
||||
.ai-recommend-bar__list {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* 推荐卡片 */
|
||||
.ai-recommend-bar__card {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ai-recommend-bar__card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 6px rgba(7, 193, 96, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ai-recommend-bar__card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.ai-recommend-bar__card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai-recommend-bar__card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 置信度药丸 */
|
||||
.ai-recommend-bar__confidence {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-recommend-bar__confidence--high {
|
||||
background: var(--success-soft);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.ai-recommend-bar__confidence--medium {
|
||||
background: var(--warning-soft);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ai-recommend-bar__confidence--low {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 卡片内容(2行截断) */
|
||||
.ai-recommend-bar__card-content {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 数字键标签 — 统一风格(accent绿色描边,与QuickReplyBar一致),内联在卡片头部 */
|
||||
.ai-recommend-bar__shortcut {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: var(--bg-accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ai-recommend-bar__card:hover .ai-recommend-bar__shortcut {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 刷新按钮 */
|
||||
.ai-recommend-bar__refresh,
|
||||
.ai-recommend-bar__refresh-btn {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ai-recommend-bar__refresh:hover,
|
||||
.ai-recommend-bar__refresh-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-accent-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -1,258 +0,0 @@
|
||||
<!-- =============================================================================
|
||||
// 企微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>
|
||||
@@ -112,11 +112,46 @@
|
||||
<span class="typing-text">{{ typingText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- AI 思考状态指示器(AI 正在生成回复时显示) -->
|
||||
<!-- ================================================================== -->
|
||||
<div v-if="aiThinkingText" class="ai-thinking-indicator">
|
||||
<span class="ai-thinking-dots">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</span>
|
||||
<span class="ai-thinking-text">{{ aiThinkingText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 回复建议区(布局优化v2 新增:消息列表与 ReplyBox 之间) -->
|
||||
<!-- ================================================================== -->
|
||||
<ReplySuggestArea
|
||||
v-if="conversationStore.currentConversation?.status !== 'resolved' && conversationStore.currentConversation?.status !== 'pending_close' && conversationStore.currentConversationId"
|
||||
:conversation-id="conversationStore.currentConversationId"
|
||||
@select="handleSuggestSelect"
|
||||
@send="handleShortcutSend"
|
||||
@undo="handleShortcutUndo"
|
||||
/>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 待确认结单提示条(pending_close 状态时显示在 ReplyBox 上方) -->
|
||||
<!-- ================================================================== -->
|
||||
<div
|
||||
v-if="conversationStore.currentConversation?.status === 'pending_close'"
|
||||
class="pending-close-banner"
|
||||
>
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>结单请求已发送,等待员工确认中(5分钟内未响应将自动关闭)</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 回复输入框(在消息列表下方、排查步骤上方) -->
|
||||
<!-- ================================================================== -->
|
||||
<ReplyBox
|
||||
v-if="conversationStore.currentConversation?.status !== 'resolved'"
|
||||
ref="replyBoxRef"
|
||||
:reply-to-message="replyToMessage"
|
||||
@send="handleSend"
|
||||
@cancel-reply="replyToMessage = null"
|
||||
@@ -192,14 +227,13 @@
|
||||
// ============================================================================
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { Loading, Clock } from '@element-plus/icons-vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import { sendEvaluationInvite } from '@/api/conversation'
|
||||
import type { Message } from '@/api/message'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import ReplyBox from './ReplyBox.vue'
|
||||
import ReplySuggestArea from './ReplySuggestArea.vue'
|
||||
import InviteDialog from '@/components/conversation/InviteDialog.vue'
|
||||
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
||||
import ParticipantBar from '@/components/conversation/ParticipantBar.vue'
|
||||
@@ -222,6 +256,9 @@ const messageListRef = ref<HTMLElement | null>(null)
|
||||
/** UserInfoBar 组件引用 */
|
||||
const userInfoBarRef = ref<InstanceType<typeof UserInfoBar> | null>(null)
|
||||
|
||||
/** ReplyBox 组件引用(供快捷键发送调用) */
|
||||
const replyBoxRef = ref<InstanceType<typeof ReplyBox> | null>(null)
|
||||
|
||||
/** 摇人弹窗可见性 */
|
||||
const inviteDialogVisible = ref(false)
|
||||
|
||||
@@ -305,12 +342,11 @@ const typingText = computed(() => {
|
||||
return conversationStore.getTypingText(convId)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 快捷键
|
||||
// ============================================================================
|
||||
|
||||
useKeyboardShortcuts({
|
||||
// AI 推荐已移至右边栏,快捷键在 AiAssistantPanel 中处理
|
||||
/** AI 思考状态文本(如 "AI 正在思考...") */
|
||||
const aiThinkingText = computed(() => {
|
||||
const convId = conversationStore.currentConversationId
|
||||
if (!convId) return ''
|
||||
return conversationStore.getAiThinkingText(convId)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
@@ -365,26 +401,31 @@ async function handleResolve(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认摘要并完成结单
|
||||
* 确认摘要并发起结单(触发员工确认流程)
|
||||
*
|
||||
* 改造说明(决策 G1 — 坐席发起→员工确认):
|
||||
* - 原逻辑:直接将会话改为 resolved,清空当前选中
|
||||
* - 新逻辑:状态改为 pending_close,保持当前选中(坐席可查看等待状态)
|
||||
* - 组合 problem/cause/solution 为 resolve_summary 字符串发送给后端
|
||||
*/
|
||||
async function handleConfirmSummary(): Promise<void> {
|
||||
if (!conversationStore.currentConversation) return
|
||||
try {
|
||||
await conversationStore.resolveConv(conversationStore.currentConversation.id)
|
||||
summaryDialogVisible.value = false
|
||||
ElMessage.success('已结单')
|
||||
const convId = conversationStore.currentConversation.id
|
||||
|
||||
// 结单成功后自动发送评价邀请(P1-25)
|
||||
const convId = conversationStore.currentConversation.id
|
||||
try {
|
||||
await sendEvaluationInvite(convId)
|
||||
console.log('[ChatArea] 已发送评价邀请')
|
||||
} catch (inviteError) {
|
||||
// 评价邀请发送失败不影响结单流程,只记录日志
|
||||
console.warn('[ChatArea] 发送评价邀请失败:', inviteError)
|
||||
}
|
||||
// 组合结单摘要:问题类型 + 根因 + 解决方式
|
||||
const summaryParts: string[] = []
|
||||
if (summaryForm.value.problem) summaryParts.push(`【问题】${summaryForm.value.problem}`)
|
||||
if (summaryForm.value.cause) summaryParts.push(`【根因】${summaryForm.value.cause}`)
|
||||
if (summaryForm.value.solution) summaryParts.push(`【解决】${summaryForm.value.solution}`)
|
||||
const resolveSummary = summaryParts.join(';') || '已处理完成'
|
||||
|
||||
try {
|
||||
await conversationStore.resolveConv(convId, resolveSummary)
|
||||
summaryDialogVisible.value = false
|
||||
ElMessage.success('结单请求已发送,等待员工确认(5分钟内未响应将自动关闭)')
|
||||
} catch (error) {
|
||||
console.error('结单失败:', error)
|
||||
ElMessage.error('结单失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +530,32 @@ function handleReplyTo(message: Message): void {
|
||||
replyToMessage.value = message
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理回复建议区选中内容(布局优化v2 新增)
|
||||
* 主要通过 ConversationStore.pendingReplyText 通信,这里接收事件用于可选的扩展处理
|
||||
*
|
||||
* @param content - 选中的建议内容
|
||||
*/
|
||||
function handleSuggestSelect(_content: string): void {
|
||||
// 内容已通过 Store 传递到 ReplyBox,此处无需额外处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷键发送(Shift+Space 触发,由 ReplySuggestArea emit('send') 转发)
|
||||
* 调用 ReplyBox 的 handleSend 方法发送 textarea 中的内容
|
||||
*/
|
||||
function handleShortcutSend(): void {
|
||||
replyBoxRef.value?.handleSend()
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷键撤销(ESC 触发,由 ReplySuggestArea emit('undo') 转发)
|
||||
* 调用 ReplyBox 的 handleUndo 方法清空输入框并失焦
|
||||
*/
|
||||
function handleShortcutUndo(): void {
|
||||
replyBoxRef.value?.handleUndo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(含引用回复)
|
||||
* 如果 replyToMessage 不为空,在发送时附带 reply_to_id
|
||||
@@ -600,4 +667,61 @@ watch(
|
||||
.typing-text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* AI 思考状态指示器样式 */
|
||||
.ai-thinking-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 16px;
|
||||
font-size: 12px;
|
||||
color: #07C160;
|
||||
background: rgba(7, 193, 96, 0.04);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.ai-thinking-dots {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-thinking-dots .dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #07C160;
|
||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.ai-thinking-dots .dot:nth-child(1) { animation-delay: 0s; }
|
||||
.ai-thinking-dots .dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.ai-thinking-dots .dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
.ai-thinking-text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 待确认结单提示条样式 */
|
||||
.pending-close-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--warning-soft, #fdf6ec);
|
||||
border-top: 1px solid var(--color-warning, #e6a23c);
|
||||
font-size: 13px;
|
||||
color: var(--color-warning, #e6a23c);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pending-close-banner .el-icon {
|
||||
animation: pending-close-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pending-close-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,6 +102,46 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- AI 结构化消息:文字 + 选项标签 + 推荐摘要(msg_type === 'ai_structured') -->
|
||||
<!-- 坐席端为只读视图:选项不可点击,仅展示 AI 向用户推荐了什么 -->
|
||||
<template v-else-if="message.msg_type === 'ai_structured'">
|
||||
<!-- 文字内容 -->
|
||||
<div style="white-space: pre-wrap;">{{ message.content }}</div>
|
||||
|
||||
<!-- 选项标签(只读,不可交互) -->
|
||||
<div v-if="message.extra_data?.options?.length" class="ai-structured-options">
|
||||
<span class="ai-structured-options__label">推荐选项:</span>
|
||||
<span
|
||||
v-for="(option, idx) in message.extra_data.options"
|
||||
:key="idx"
|
||||
class="ai-structured-options__tag"
|
||||
>
|
||||
{{ option.label || option.value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 推荐卡片摘要(如果 AI 同时推送了动态推荐到侧边栏) -->
|
||||
<div v-if="message.extra_data?.action" class="ai-structured-action">
|
||||
<span class="ai-structured-action__icon">📌</span>
|
||||
<span class="ai-structured-action__text">
|
||||
已推送{{ actionTypeLabel }}:{{ message.extra_data.action.title || '智能推荐' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- BYOD 补贴卡片:坐席端简化展示(msg_type === 'byod_card') -->
|
||||
<template v-else-if="message.msg_type === 'byod_card'">
|
||||
<div style="white-space: pre-wrap;">{{ message.content }}</div>
|
||||
<div v-if="message.extra_data?.byod_result" class="byod-summary">
|
||||
<span class="byod-summary__badge" :class="{ 'byod-summary__badge--eligible': message.extra_data.byod_result.eligible }">
|
||||
{{ message.extra_data.byod_result.eligible ? '符合补贴' : '可登记' }}
|
||||
</span>
|
||||
<span v-if="message.extra_data.byod_result.matched_category" class="byod-summary__category">
|
||||
{{ message.extra_data.byod_result.matched_category }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他非文本消息(语音/视频/位置等):显示通用媒体卡片 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
@@ -136,15 +176,6 @@
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<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>
|
||||
@@ -156,9 +187,7 @@
|
||||
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'
|
||||
import ContactCardInline from './ContactCardInline.vue'
|
||||
|
||||
// ============================================================================
|
||||
@@ -239,17 +268,6 @@ const formatMessageTime = computed(() => {
|
||||
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
|
||||
)
|
||||
})
|
||||
|
||||
// ==================================================================
|
||||
// 非文本消息相关计算属性
|
||||
// ==================================================================
|
||||
@@ -278,6 +296,17 @@ const mediaTypeLabel = computed(() => {
|
||||
return labels[props.message.msg_type] || '媒体消息'
|
||||
})
|
||||
|
||||
/** AI 结构化消息中 action 的类型中文标签(坐席端只读展示) */
|
||||
const actionTypeLabel = computed(() => {
|
||||
const actionType = props.message.extra_data?.action?.type
|
||||
const labels: Record<string, string> = {
|
||||
approval_card: '审批卡片',
|
||||
action_card: '操作卡片',
|
||||
info_card: '信息卡片',
|
||||
}
|
||||
return labels[actionType] || '推荐卡片'
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化文件大小为人类可读字符串。
|
||||
*
|
||||
@@ -330,6 +359,86 @@ const replyToSender = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// AI 结构化消息样式(坐席端只读视图)
|
||||
// ============================================================================ */
|
||||
|
||||
.ai-structured-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.ai-structured-options__label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-structured-options__tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
color: #07C160;
|
||||
border: 1px solid rgba(7, 193, 96, 0.2);
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-structured-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(64, 158, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.ai-structured-action__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-structured-action__text {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* BYOD 补贴卡片摘要样式 */
|
||||
.byod-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.byod-summary__badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(250, 173, 20, 0.12);
|
||||
color: #faad14;
|
||||
border: 1px solid rgba(250, 173, 20, 0.3);
|
||||
}
|
||||
|
||||
.byod-summary__badge--eligible {
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
color: #07C160;
|
||||
border-color: rgba(7, 193, 96, 0.2);
|
||||
}
|
||||
|
||||
.byod-summary__category {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 非文本消息媒体卡片样式
|
||||
// ============================================================================ */
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 快速回复条组件(布局优化v2 新增)
|
||||
// =============================================================================
|
||||
// 说明:替代右栏 QuickReplyPanel,横向 2 层布局
|
||||
// 功能(Q3 确认设计):
|
||||
// 1. L1 分类 chip 始终显示(横向滚动条)
|
||||
// 2. L2 条目用悬浮框展示(覆盖消息输入框区域,因为浏览快速回复时不需要看输入框内容)
|
||||
// 3. 点击 L1 chip 弹出悬浮框显示该分类下的所有条目
|
||||
// 4. 选择条目后悬浮框自动消失,选中文本填充到输入框
|
||||
// 5. 鼠标离开悬浮框有 200ms 延迟关闭,避免误关
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="quick-reply-bar">
|
||||
<!-- L1 分类 chip 横向滚动条(始终显示) -->
|
||||
<div class="quick-reply-bar__chips">
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
:key="category"
|
||||
class="quick-reply-bar__chip"
|
||||
:class="{ 'is-active': navState.l1Index === index, 'is-kb-selected': keyboardSelectedIndex === index }"
|
||||
@click="handleL1Click(index)"
|
||||
@mouseenter="handleL1Hover(index)"
|
||||
>
|
||||
{{ category }}
|
||||
<kbd v-if="index < 6" class="quick-reply-bar__kbd">{{ index + 1 }}</kbd>
|
||||
</button>
|
||||
|
||||
<!-- 无分类时提示 -->
|
||||
<span v-if="categories.length === 0" class="quick-reply-bar__empty">
|
||||
暂无快速回复模板
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- L2 悬浮框(覆盖消息输入框区域) -->
|
||||
<transition name="quick-reply-fade">
|
||||
<div
|
||||
v-if="showL2Panel"
|
||||
class="quick-reply-bar__l2-panel"
|
||||
@mouseenter="cancelCloseL2"
|
||||
@mouseleave="scheduleCloseL2"
|
||||
>
|
||||
<!-- L2 面板头部 -->
|
||||
<div class="quick-reply-bar__l2-header">
|
||||
<span class="quick-reply-bar__l2-title">{{ activeCategory }}</span>
|
||||
<button
|
||||
class="quick-reply-bar__l2-close"
|
||||
@click="closeL2"
|
||||
title="关闭"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
<!-- L2 条目列表 -->
|
||||
<div class="quick-reply-bar__l2-list">
|
||||
<div
|
||||
v-for="(item, index) in activeCategoryItems"
|
||||
:key="item.id"
|
||||
class="quick-reply-bar__l2-item"
|
||||
@click="handleSelectItem(item)"
|
||||
>
|
||||
<kbd v-if="index < 9" class="quick-reply-bar__l2-kbd">{{ index + 1 }}</kbd>
|
||||
<span class="quick-reply-bar__l2-item-title">{{ item.title }}</span>
|
||||
<span class="quick-reply-bar__l2-item-content">{{ item.content }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 该分类下无条目 -->
|
||||
<div
|
||||
v-if="activeCategoryItems.length === 0"
|
||||
class="quick-reply-bar__l2-empty"
|
||||
>
|
||||
该分类下暂无模板
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useQuickReplyStore } from '@/stores/quickReply'
|
||||
import type { QuickReply } from '@/api/quickReply'
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** 快速回复导航状态 */
|
||||
interface QuickReplyNav {
|
||||
/** 一级分类索引,-1 表示未选择 */
|
||||
l1Index: number
|
||||
/** 二级子分类索引,-1 表示未选择 */
|
||||
l2Index: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 快速回复模板列表 */
|
||||
templates: QuickReply[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 选中模板 → 填入输入框 */
|
||||
(e: 'select', content: string): void
|
||||
}
|
||||
|
||||
// templates prop 由父组件 ReplySuggestArea 传入,保持接口兼容性。
|
||||
// 组件内部实际使用 quickReplyStore 的 categories/templatesByCategory 获取数据。
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const quickReplyStore = useQuickReplyStore()
|
||||
|
||||
/** 导航状态 */
|
||||
const navState = ref<QuickReplyNav>({
|
||||
l1Index: -1,
|
||||
l2Index: -1,
|
||||
})
|
||||
|
||||
/** 是否显示 L2 悬浮框 */
|
||||
const showL2Panel = ref(false)
|
||||
|
||||
/** 上次选中L2条目时的L1索引(用于ESC撤销时重开L2面板) */
|
||||
const lastSelectedL1Index = ref(-1)
|
||||
|
||||
/** L2 关闭延迟定时器 */
|
||||
let closeL2Timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 所有分类名列表(去重,从 QuickReplyStore.templatesByCategory 获取) */
|
||||
const categories = computed<string[]>(() => {
|
||||
return quickReplyStore.categories
|
||||
})
|
||||
|
||||
/** 按分类分组的模板 */
|
||||
const templatesByCategory = computed<Record<string, QuickReply[]>>(() => {
|
||||
return quickReplyStore.templatesByCategory
|
||||
})
|
||||
|
||||
/** 当前激活的分类名 */
|
||||
const activeCategory = computed<string>(() => {
|
||||
if (navState.value.l1Index < 0 || navState.value.l1Index >= categories.value.length) {
|
||||
return ''
|
||||
}
|
||||
return categories.value[navState.value.l1Index]
|
||||
})
|
||||
|
||||
/** 当前激活分类下的所有条目 */
|
||||
const activeCategoryItems = computed<QuickReply[]>(() => {
|
||||
const cat = activeCategory.value
|
||||
if (!cat) return []
|
||||
return templatesByCategory.value[cat] || []
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取指定分类下的模板数量
|
||||
*
|
||||
* @param category - 分类名
|
||||
* @returns 模板数量
|
||||
*/
|
||||
function getCategoryCount(category: string): number {
|
||||
return (templatesByCategory.value[category] || []).length
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击 L1 分类 chip
|
||||
* 切换 L2 悬浮框的显示/隐藏
|
||||
*
|
||||
* @param index - 分类索引
|
||||
*/
|
||||
function handleL1Click(index: number): void {
|
||||
if (navState.value.l1Index === index && showL2Panel.value) {
|
||||
// 再次点击同一分类 → 关闭
|
||||
closeL2()
|
||||
return
|
||||
}
|
||||
|
||||
navState.value.l1Index = index
|
||||
navState.value.l2Index = -1
|
||||
showL2Panel.value = true
|
||||
cancelCloseL2()
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标悬停在 L1 chip 上时切换分类(仅当 L2 已展开时)
|
||||
*
|
||||
* @param index - 分类索引
|
||||
*/
|
||||
function handleL1Hover(index: number): void {
|
||||
if (showL2Panel.value) {
|
||||
navState.value.l1Index = index
|
||||
navState.value.l2Index = -1
|
||||
cancelCloseL2()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中某个 L2 条目
|
||||
* 变量替换后填入输入框,悬浮框自动消失
|
||||
*
|
||||
* @param item - 选中的快速回复模板
|
||||
*/
|
||||
function handleSelectItem(item: QuickReply): void {
|
||||
// 变量替换:用当前会话的员工信息填充模板变量
|
||||
const conv = conversationStore.currentConversation
|
||||
const variables: Record<string, string> = {}
|
||||
if (conv?.employee_name) {
|
||||
variables['employee_name'] = conv.employee_name
|
||||
}
|
||||
if (conv?.department) {
|
||||
variables['department'] = conv.department
|
||||
}
|
||||
|
||||
const content = quickReplyStore.replaceVariables(item.content, variables)
|
||||
|
||||
// 写入 ConversationStore.pendingReplyText
|
||||
conversationStore.pendingReplyText = content
|
||||
emit('select', content)
|
||||
|
||||
// 保存L1索引用于ESC撤销时重开L2
|
||||
lastSelectedL1Index.value = navState.value.l1Index
|
||||
|
||||
// 关闭悬浮框
|
||||
closeL2()
|
||||
|
||||
ElMessage.success(`已选用: ${item.title}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟关闭 L2 悬浮框(200ms 延迟避免误关)
|
||||
*/
|
||||
function scheduleCloseL2(): void {
|
||||
cancelCloseL2()
|
||||
closeL2Timer = setTimeout(() => {
|
||||
showL2Panel.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消延迟关闭
|
||||
*/
|
||||
function cancelCloseL2(): void {
|
||||
if (closeL2Timer) {
|
||||
clearTimeout(closeL2Timer)
|
||||
closeL2Timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即关闭 L2 悬浮框
|
||||
*/
|
||||
function closeL2(): void {
|
||||
showL2Panel.value = false
|
||||
cancelCloseL2()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 键盘快捷键支持方法(供父组件 ReplySuggestArea 通过 ref 调用)
|
||||
// ============================================================================
|
||||
|
||||
/** 键盘选中的 L1 索引(用于高亮显示) */
|
||||
const keyboardSelectedIndex = ref(-1)
|
||||
|
||||
/**
|
||||
* 通过索引选择 L1 分类(数字键 1~6 触发)
|
||||
* 等同于鼠标点击对应 chip
|
||||
*
|
||||
* @param index - 分类索引(0-5)
|
||||
*/
|
||||
function selectL1ByIndex(index: number): void {
|
||||
if (index < 0 || index >= categories.value.length) return
|
||||
keyboardSelectedIndex.value = index
|
||||
handleL1Click(index)
|
||||
// 3秒后清除高亮
|
||||
setTimeout(() => { keyboardSelectedIndex.value = -1 }, 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过数字选择 L2 条目(数字键 1~9 触发)
|
||||
*
|
||||
* @param digit - 数字(1-9)
|
||||
*/
|
||||
function selectL2ByDigit(digit: number): void {
|
||||
if (!showL2Panel.value) return
|
||||
const items = activeCategoryItems.value
|
||||
if (digit < 1 || digit > items.length) return
|
||||
handleSelectItem(items[digit - 1])
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 L2 选中(Enter 触发,选择第一个条目)
|
||||
*/
|
||||
function confirmL2Selection(): void {
|
||||
if (!showL2Panel.value) return
|
||||
const items = activeCategoryItems.value
|
||||
if (items.length > 0) {
|
||||
handleSelectItem(items[0])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 L2 面板(供父组件调用)
|
||||
*/
|
||||
function closeL2Panel(): void {
|
||||
closeL2()
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 L2 面板是否打开
|
||||
*/
|
||||
function isL2Open(): boolean {
|
||||
return showL2Panel.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销上次选中:重新打开L2面板到上次选择的分类
|
||||
* 供父组件 ReplySuggestArea 在ESC撤销时调用
|
||||
*/
|
||||
function undoSelection(): void {
|
||||
if (lastSelectedL1Index.value >= 0) {
|
||||
handleL1Click(lastSelectedL1Index.value)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 暴露方法
|
||||
// ============================================================================
|
||||
|
||||
defineExpose({
|
||||
selectL1ByIndex,
|
||||
selectL2ByDigit,
|
||||
confirmL2Selection,
|
||||
closeL2Panel,
|
||||
isL2Open,
|
||||
undoSelection,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelCloseL2()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 容器 */
|
||||
.quick-reply-bar {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* L1 分类 chip 横向滚动条 */
|
||||
.quick-reply-bar__chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 2px 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.quick-reply-bar__chips::-webkit-scrollbar {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
/* 分类 chip */
|
||||
.quick-reply-bar__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-reply-bar__chip:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--bg-accent-soft);
|
||||
}
|
||||
|
||||
.quick-reply-bar__chip.is-active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 分类下模板数量 */
|
||||
.quick-reply-bar__chip-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
border-radius: 7px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.quick-reply-bar__chip.is-active .quick-reply-bar__chip-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 键盘选中高亮 */
|
||||
.quick-reply-bar__chip.is-kb-selected {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
/* 快捷键数字标签 — 统一风格(与L2 kbd一致,accent绿色描边) */
|
||||
.quick-reply-bar__kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: var(--bg-accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.quick-reply-bar__chip.is-active .quick-reply-bar__kbd {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 空提示 */
|
||||
.quick-reply-bar__empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ===== L2 悬浮框(覆盖消息输入框区域) ===== */
|
||||
.quick-reply-bar__l2-panel {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1);
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* L2 面板头部 */
|
||||
.quick-reply-bar__l2-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-reply-bar__l2-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick-reply-bar__l2-close {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-reply-bar__l2-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* L2 条目列表 */
|
||||
.quick-reply-bar__l2-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* L2 条目 */
|
||||
.quick-reply-bar__l2-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.quick-reply-bar__l2-item:hover {
|
||||
background: var(--bg-accent-soft);
|
||||
}
|
||||
|
||||
/* L2条目数字键提示 */
|
||||
.quick-reply-bar__l2-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: var(--bg-accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.quick-reply-bar__l2-item-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick-reply-bar__l2-item-content {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* L2 空状态 */
|
||||
.quick-reply-bar__l2-empty {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== 过渡动画 ===== */
|
||||
.quick-reply-fade-enter-active,
|
||||
.quick-reply-fade-leave-active {
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.quick-reply-fade-enter-from,
|
||||
.quick-reply-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -36,55 +36,75 @@
|
||||
@remove="pendingImages.removeImage"
|
||||
/>
|
||||
|
||||
<!-- 快捷工具栏 -->
|
||||
<!-- 快捷工具栏(布局优化v2:左右分区 — 常规工具 | AI 工具) -->
|
||||
<div class="chat-toolbar">
|
||||
<!-- 截图按钮 -->
|
||||
<button class="tb-btn" title="截图" @click="handleScreenshot">
|
||||
✂️
|
||||
<span class="tb-tip">截图</span>
|
||||
</button>
|
||||
<!-- 拍照按钮 -->
|
||||
<button class="tb-btn" title="拍照" @click="handleCamera">
|
||||
📷
|
||||
<span class="tb-tip">拍照</span>
|
||||
</button>
|
||||
|
||||
<div class="tb-sep"></div>
|
||||
|
||||
<div class="emoji-wrapper">
|
||||
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
|
||||
😊
|
||||
<span class="tb-tip">表情</span>
|
||||
<!-- 左侧:常规工具(5个:截图/拍照/表情/文件/邀请) -->
|
||||
<div class="toolbar-left">
|
||||
<!-- 截图按钮 -->
|
||||
<button class="tb-btn" title="截图" @click="handleScreenshot">
|
||||
✂️
|
||||
<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>
|
||||
<!-- 拍照按钮 -->
|
||||
<button class="tb-btn" title="拍照" @click="handleCamera">
|
||||
📷
|
||||
<span class="tb-tip">拍照</span>
|
||||
</button>
|
||||
|
||||
<div class="tb-sep"></div>
|
||||
|
||||
<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>
|
||||
<!-- 点击表情面板外部关闭 -->
|
||||
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
|
||||
|
||||
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
|
||||
📎
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:AI 工具(4个:自动补齐/语气/润色/改写) -->
|
||||
<div class="toolbar-right">
|
||||
<button class="tb-btn tb-btn--ai" title="AI自动补齐" @click="handleAiTool('autocomplete')">
|
||||
✨
|
||||
<span class="tb-tip">自动补齐</span>
|
||||
</button>
|
||||
<button class="tb-btn tb-btn--ai" title="AI语气调整" @click="handleAiTool('tone')">
|
||||
🎭
|
||||
<span class="tb-tip">语气</span>
|
||||
</button>
|
||||
<button class="tb-btn tb-btn--ai" title="AI润色" @click="handleAiTool('polish')">
|
||||
✏️
|
||||
<span class="tb-tip">润色</span>
|
||||
</button>
|
||||
<button class="tb-btn tb-btn--ai" title="AI改写" @click="handleAiTool('rewrite')">
|
||||
🔄
|
||||
<span class="tb-tip">改写</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
|
||||
📎
|
||||
<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('quickReply')">
|
||||
⚡
|
||||
<span class="tb-tip">快速回复</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 输入行(圆角卡片:textarea + 语音按钮 + 发送按钮) -->
|
||||
@@ -94,7 +114,7 @@
|
||||
v-model="inputText"
|
||||
class="chat-input"
|
||||
:class="{ 'chat-input--listening': speechState.isListening }"
|
||||
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
|
||||
placeholder="输入回复内容... (Enter或Shift+Space发送,Shift+Enter换行)"
|
||||
:style="{ height: textareaHeight + 'px' }"
|
||||
@keydown="handleKeydown"
|
||||
@paste="handlePaste"
|
||||
@@ -548,8 +568,23 @@ function onResizeEnd(): void {
|
||||
/**
|
||||
* 处理键盘事件
|
||||
* Enter 发送消息,Shift+Enter 换行
|
||||
* Shift+Space 发送消息(左手快捷键,⚠️ IME冲突时用Enter fallback)
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// Shift+Space → 发送消息(左手快捷键)
|
||||
// 注意:useKeyboardShortcuts 的 document 级监听器也会处理 Shift+Space
|
||||
// 但当 textarea 聚焦时,这里先触发,stopPropagation 阻止 document 监听器重复触发
|
||||
// ⚠️ 用 event.code === 'Space' 替代 event.key === ' ':
|
||||
// 中文输入法下 Shift+Space 可能被 IME 转为全角空格,event.key 值不确定,
|
||||
// 但 event.code 始终是 'Space'(物理键码不受输入法影响)
|
||||
if ((event.key === ' ' || event.code === 'Space') && event.shiftKey && !event.ctrlKey && !event.altKey) {
|
||||
if (!event.isComposing) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleSend()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Enter 且没有按 Shift → 发送
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
@@ -857,6 +892,25 @@ async function handleFileSelect(event: Event): Promise<void> {
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 工具按钮点击处理(布局优化v2 新增)
|
||||
*
|
||||
* Q1 确认:AI 工具按钮的后端 API 属于"AI辅助消息框"项目范围,
|
||||
* 本期先预留位置,点击时用 ElMessage.info 提示"功能开发中"
|
||||
*
|
||||
* @param action - AI 工具类型:autocomplete(自动补齐) / tone(语气) / polish(润色) / rewrite(改写)
|
||||
*/
|
||||
function handleAiTool(action: 'autocomplete' | 'tone' | 'polish' | 'rewrite'): void {
|
||||
const actionLabelMap: Record<string, string> = {
|
||||
autocomplete: '自动补齐',
|
||||
tone: '语气调整',
|
||||
polish: '润色',
|
||||
rewrite: '改写',
|
||||
}
|
||||
const label = actionLabelMap[action] || action
|
||||
ElMessage.info(`AI ${label}功能开发中,敬请期待`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷工具栏按钮点击处理
|
||||
* 各功能对应的实际行为
|
||||
@@ -1045,6 +1099,35 @@ onUnmounted(() => {
|
||||
// 释放待发送图片的 ObjectURL 资源
|
||||
pendingImages.clearAll()
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 撤销方法(供父组件 ChatArea 通过 ref 调用)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 撤销选中回复
|
||||
* 清空输入框内容并失焦,使用户可以重新通过数字键选择回复
|
||||
*/
|
||||
function handleUndo(): void {
|
||||
inputText.value = ''
|
||||
inputRef.value?.blur()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 暴露方法(供父组件 ChatArea 通过 ref 调用)
|
||||
// ============================================================================
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* 发送消息(供 ChatArea 在 Shift+Space 快捷键触发时调用)
|
||||
*/
|
||||
handleSend,
|
||||
/**
|
||||
* 撤销选中回复(供 ChatArea 在 ESC 快捷键触发时调用)
|
||||
* 清空输入框内容并失焦,使数字键恢复生效
|
||||
*/
|
||||
handleUndo,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1072,7 +1155,7 @@ onUnmounted(() => {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* 快捷工具栏 */
|
||||
/* 快捷工具栏(布局优化v2:左右分区) */
|
||||
.chat-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1081,6 +1164,35 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 左侧常规工具区 */
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 3px 6px;
|
||||
background: var(--toolbar-conv-bg);
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 右侧 AI 工具区 */
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 3px 6px;
|
||||
background: var(--toolbar-ai-bg);
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* AI 工具按钮特殊样式 */
|
||||
.tb-btn--ai:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tb-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 回复建议区容器组件(布局优化v2 + 快捷键增强)
|
||||
// =============================================================================
|
||||
// 说明:位于消息列表与 ReplyBox 之间,承载 AI推荐 + 快速回复
|
||||
// 功能:
|
||||
// 1. Tab 切换"AI推荐" / "快速回复"两个视图
|
||||
// 2. AI推荐视图:展示 AiRecommendBar(横向 1-6 条推荐卡片)
|
||||
// 3. 快速回复视图:展示 QuickReplyBar(L1 分类 chip + L2 悬浮框)
|
||||
// 4. max-height: 120px,超出可滚动
|
||||
// 5. 选中内容统一通过 ConversationStore.pendingReplyText 填入输入框
|
||||
// 6. 键盘快捷键(通过 useKeyboardShortcuts 注册):
|
||||
// - 1~6: AI推荐选择(AI Tab + 输入框未聚焦)
|
||||
// - 1~6: 快速回复L1分类选择(Quick Tab + L2关闭 + 输入框未聚焦)
|
||||
// - 1~9: 快速回复L2条目选择(Quick Tab + L2打开 + 输入框未聚焦)
|
||||
// - Tab: 切换 AI推荐 ↔ 快速回复
|
||||
// - Shift+Space: 发送消息
|
||||
// - Enter: L2条目确认(输入框未聚焦时)
|
||||
// - Backspace: 关闭L2面板
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="reply-suggest-area" :style="{ background: activeBg }">
|
||||
<!-- Tab 切换 -->
|
||||
<div class="reply-suggest-area__tabs">
|
||||
<button
|
||||
class="reply-suggest-area__tab"
|
||||
:class="{ 'is-active': activeTab === 'ai' }"
|
||||
@click="switchTab('ai')"
|
||||
>
|
||||
🤖 AI推荐
|
||||
<span v-if="aiCount > 0" class="reply-suggest-area__badge">{{ aiCount }}</span>
|
||||
<kbd class="reply-suggest-area__kbd">Tab</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="reply-suggest-area__tab"
|
||||
:class="{ 'is-active': activeTab === 'quick' }"
|
||||
@click="switchTab('quick')"
|
||||
>
|
||||
⚡ 快速回复
|
||||
<kbd class="reply-suggest-area__kbd">Tab</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="reply-suggest-area__content">
|
||||
<!-- AI 推荐视图 -->
|
||||
<AiRecommendBar
|
||||
v-if="activeTab === 'ai'"
|
||||
ref="aiRecommendBarRef"
|
||||
:recommendations="recommendations"
|
||||
:loading="loading"
|
||||
@select="handleSelect"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
|
||||
<!-- 快速回复视图 -->
|
||||
<QuickReplyBar
|
||||
v-else
|
||||
ref="quickReplyBarRef"
|
||||
:templates="quickReplyTemplates"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AiRecommendBar from './AiRecommendBar.vue'
|
||||
import QuickReplyBar from './QuickReplyBar.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useQuickReplyStore } from '@/stores/quickReply'
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import { generateDraft } from '@/api/wingman'
|
||||
import type { DraftResult } from '@/api/wingman'
|
||||
import type { QuickReply } from '@/api/quickReply'
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
/** AI 推荐项类型 */
|
||||
interface AiRecommendation {
|
||||
/** 方案名称 */
|
||||
title: string
|
||||
/** 推荐内容 */
|
||||
content: string
|
||||
/** 置信度(0-1) */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 当前会话 ID */
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 选中某条建议/回复,填入输入框 */
|
||||
(e: 'select', content: string): void
|
||||
/** 发送消息(Shift+Space 触发) */
|
||||
(e: 'send'): void
|
||||
/** 撤销选中回复(ESC 触发,清空回复框) */
|
||||
(e: 'undo'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const quickReplyStore = useQuickReplyStore()
|
||||
|
||||
/** 当前激活的 Tab */
|
||||
const activeTab = ref<'ai' | 'quick'>('ai')
|
||||
|
||||
/** AI 推荐列表 */
|
||||
const recommendations = ref<AiRecommendation[]>([])
|
||||
|
||||
/** 是否正在加载 AI 推荐 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** AiRecommendBar 组件引用 */
|
||||
const aiRecommendBarRef = ref<InstanceType<typeof AiRecommendBar> | null>(null)
|
||||
|
||||
/** QuickReplyBar 组件引用 */
|
||||
const quickReplyBarRef = ref<InstanceType<typeof QuickReplyBar> | null>(null)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 快速回复模板列表 */
|
||||
const quickReplyTemplates = computed<QuickReply[]>(() => {
|
||||
return quickReplyStore.templates
|
||||
})
|
||||
|
||||
/** AI 推荐数量 */
|
||||
const aiCount = computed(() => recommendations.value.length)
|
||||
|
||||
/** 当前 Tab 背景色 */
|
||||
const activeBg = computed(() => {
|
||||
return activeTab.value === 'ai'
|
||||
? 'var(--suggest-ai-bg)'
|
||||
: 'var(--suggest-quick-bg)'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 选中推荐/回复内容 → 写入 ConversationStore.pendingReplyText
|
||||
*
|
||||
* @param content - 选中的文本内容
|
||||
*/
|
||||
function handleSelect(content: string): void {
|
||||
conversationStore.pendingReplyText = content
|
||||
emit('select', content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 Tab(Alt+Q 或鼠标点击)
|
||||
* 切换时自动关闭 L2 面板
|
||||
*/
|
||||
function switchTab(tab: 'ai' | 'quick'): void {
|
||||
// 从 quick 切走时关闭 L2 面板
|
||||
if (activeTab.value === 'quick' && tab === 'ai') {
|
||||
quickReplyBarRef.value?.closeL2Panel()
|
||||
}
|
||||
activeTab.value = tab
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速回复L1分类选择(数字键 1~6 触发)
|
||||
*
|
||||
* @param index - 分类索引(0-5)
|
||||
*/
|
||||
function selectL1Category(index: number): void {
|
||||
if (activeTab.value !== 'quick') return
|
||||
quickReplyBarRef.value?.selectL1ByIndex(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字键选择路由(1~9)
|
||||
* - AI Tab:选择对应AI推荐(1~6)
|
||||
* - Quick Tab + L2打开:选择对应L2条目(1~9)
|
||||
* - Quick Tab + L2关闭:选择对应L1分类(1~6)
|
||||
*
|
||||
* @param digit - 按下的数字键(1-9)
|
||||
*/
|
||||
function handleDigitSelect(digit: number): void {
|
||||
if (activeTab.value === 'ai') {
|
||||
// AI Tab: 数字选AI推荐(1~6)
|
||||
if (digit >= 1 && digit <= recommendations.value.length) {
|
||||
handleSelect(recommendations.value[digit - 1].content)
|
||||
}
|
||||
} else if (activeTab.value === 'quick') {
|
||||
if (quickReplyBarRef.value?.isL2Open()) {
|
||||
// L2面板打开 → 选择L2条目
|
||||
quickReplyBarRef.value?.selectL2ByDigit(digit)
|
||||
} else if (digit <= 6) {
|
||||
// L2面板关闭 → 1~6 选择L1分类
|
||||
selectL1Category(digit - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L2条目确认(Enter 触发,仅输入框未聚焦时)
|
||||
*/
|
||||
function confirmL2(): void {
|
||||
if (activeTab.value !== 'quick') return
|
||||
if (!quickReplyBarRef.value?.isL2Open()) return
|
||||
quickReplyBarRef.value?.confirmL2Selection()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭L2面板(Backspace/← 触发)
|
||||
*/
|
||||
function closeL2Panel(): void {
|
||||
if (activeTab.value !== 'quick') return
|
||||
quickReplyBarRef.value?.closeL2Panel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销选中回复(ESC 触发)
|
||||
* 分层剥离:
|
||||
* 1. L2面板打开 → 关闭L2
|
||||
* 2. L2已关闭 → 清空回复框 + 重开L2(如适用)
|
||||
*/
|
||||
function handleUndo(): void {
|
||||
// 优先级1: L2面板打开 → 关闭L2
|
||||
if (activeTab.value === 'quick' && quickReplyBarRef.value?.isL2Open()) {
|
||||
quickReplyBarRef.value?.closeL2Panel()
|
||||
return
|
||||
}
|
||||
// 优先级2: 撤销选中回复 → 清空回复框 + 重开L2(如适用)
|
||||
emit('undo')
|
||||
if (activeTab.value === 'quick') {
|
||||
quickReplyBarRef.value?.undoSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(Shift+Space 触发)
|
||||
* 通过 emit('send') 通知 ChatArea 调用 ReplyBox.handleSend()
|
||||
*/
|
||||
function triggerSend(): void {
|
||||
emit('send')
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 AI 推荐列表
|
||||
* 调用 Wingman API 生成新的草稿
|
||||
*/
|
||||
async function handleRefresh(): Promise<void> {
|
||||
if (!props.conversationId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result: DraftResult = await generateDraft(props.conversationId)
|
||||
|
||||
// 存入 Store 缓存(以 conversationId 作为 key)
|
||||
if (!conversationStore.aiDrafts.has(props.conversationId)) {
|
||||
conversationStore.aiDrafts.set(props.conversationId, new Map())
|
||||
}
|
||||
const draftKey = `draft_${Date.now()}`
|
||||
conversationStore.aiDrafts.get(props.conversationId)!.set(draftKey, result)
|
||||
|
||||
// 更新推荐列表
|
||||
loadRecommendations()
|
||||
|
||||
ElMessage.success('AI 推荐已刷新')
|
||||
} catch (error: any) {
|
||||
console.error('刷新 AI 推荐失败:', error)
|
||||
ElMessage.error(error?.message || '刷新 AI 推荐失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ConversationStore.aiDrafts 加载推荐数据
|
||||
*/
|
||||
function loadRecommendations(): void {
|
||||
const convId = props.conversationId
|
||||
if (!convId) {
|
||||
recommendations.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const convDrafts = conversationStore.aiDrafts.get(convId)
|
||||
if (convDrafts && convDrafts.size > 0) {
|
||||
const result: AiRecommendation[] = []
|
||||
convDrafts.forEach((draft: DraftResult) => {
|
||||
result.push({
|
||||
title: 'AI 回复建议',
|
||||
content: draft.content,
|
||||
confidence: draft.confidence,
|
||||
})
|
||||
})
|
||||
// 扩展到6条(原3条)
|
||||
recommendations.value = result.slice(0, 6)
|
||||
return
|
||||
}
|
||||
|
||||
// 无缓存数据时提供 Mock 推荐(开发/演示用)
|
||||
recommendations.value = [
|
||||
{
|
||||
title: '重置密码流程',
|
||||
content: '您好,密码重置可通过企业门户自助操作:访问 portal.company.com → 忘记密码 → 按提示完成重置。如无法自助,请提供工号,我帮您后台重置。',
|
||||
confidence: 0.92,
|
||||
},
|
||||
{
|
||||
title: 'VPN 连接故障排查',
|
||||
content: 'VPN 连接问题常见原因:1) 网络切换后未重连;2) 证书过期。请先尝试:断开 VPN → 重启客户端 → 重新连接。如仍失败,请提供错误截图。',
|
||||
confidence: 0.85,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 键盘快捷键注册
|
||||
// ============================================================================
|
||||
|
||||
useKeyboardShortcuts({
|
||||
// 数字键1~9: 上下文路由(AI推荐 / L1分类 / L2条目)
|
||||
onQuickReplyDigit: (digit: number) => handleDigitSelect(digit),
|
||||
// ESC: 撤销选中回复 / 关闭L2面板
|
||||
onUndo: () => handleUndo(),
|
||||
// Enter: L2条目确认(输入框未聚焦时)
|
||||
onQuickReplyConfirm: () => confirmL2(),
|
||||
// Backspace/←: 关闭L2面板
|
||||
onQuickReplyBack: () => closeL2Panel(),
|
||||
// Tab: 切换AI推荐↔快速回复
|
||||
onTabSwitch: () => switchTab(activeTab.value === 'ai' ? 'quick' : 'ai'),
|
||||
// Shift+Space: 发送消息
|
||||
onSend: () => triggerSend(),
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/** 会话切换时重新加载推荐数据 */
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadRecommendations()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 容器 */
|
||||
.reply-suggest-area {
|
||||
flex-shrink: 0;
|
||||
max-height: 120px;
|
||||
overflow: visible; /* visible允许L2悬浮框向上溢出,不被裁切 */
|
||||
border-top: 1px solid var(--border-light);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
/* Tab 切换栏 */
|
||||
.reply-suggest-area__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px 0;
|
||||
flex-shrink: 0;
|
||||
background: inherit;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.reply-suggest-area__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-suggest-area__tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reply-suggest-area__tab.is-active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
/* 推荐数量徽章 */
|
||||
.reply-suggest-area__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 快捷键提示标签 */
|
||||
.reply-suggest-area__kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
height: 14px;
|
||||
font-size: 9px;
|
||||
font-family: monospace;
|
||||
color: var(--text-placeholder);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.reply-suggest-area__content {
|
||||
padding: 2px 8px 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +1,101 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 排查步骤栏组件
|
||||
// 企微IT智能服务台 — 排查步骤栏组件(布局优化v2:新增图标条折叠态)
|
||||
// =============================================================================
|
||||
// 说明:位于聊天输入框下方,始终可见(不可整体收起)
|
||||
// 包含:
|
||||
// 1. 默认视图:横向路径方块(①→②→③→④→⑤)
|
||||
// 2. 展开视图:完整决策树(递归渲染)
|
||||
// 数据:从 troubleshooting API 获取模板,默认选中第一个
|
||||
// 说明:位于聊天输入框下方
|
||||
// 布局优化v2 新增两种显示状态:
|
||||
// 1. 图标条态(默认):仅显示 🔧 图标 + 当前步骤号,高度约 28px
|
||||
// 2. 完整栏态:标题 + 模板选择 + 内联路径 + 展开流程图
|
||||
// 点击图标条展开为完整栏,再次点击栏头收起回图标条态
|
||||
// ============================================================================= -->
|
||||
|
||||
<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>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 展开视图:决策树 -->
|
||||
<!-- 图标条态(isIconBarCollapsed = true,默认显示) -->
|
||||
<!-- ================================================================== -->
|
||||
<div
|
||||
class="troubleshoot-bar__flowchart"
|
||||
:class="{ 'is-expanded': isFlowchartExpanded }"
|
||||
v-if="isIconBarCollapsed"
|
||||
class="troubleshoot-bar__icon-bar"
|
||||
@click="toggleCollapse"
|
||||
title="点击展开排查步骤"
|
||||
>
|
||||
<div class="troubleshoot-bar__flowchart-inner">
|
||||
<FlowchartNode
|
||||
v-if="currentFlowchart"
|
||||
:node="currentFlowchart"
|
||||
:base-index="1"
|
||||
/>
|
||||
<div v-else class="troubleshoot-bar__flowchart-empty">
|
||||
暂无流程图数据
|
||||
<span class="troubleshoot-bar__icon-bar-symbol">🔧</span>
|
||||
<span class="troubleshoot-bar__icon-bar-label">
|
||||
排查 · 第{{ currentStepNumber }}步 / {{ currentPathSteps.length }}
|
||||
</span>
|
||||
<span class="troubleshoot-bar__icon-bar-arrow">▶</span>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 完整栏态(isIconBarCollapsed = false) -->
|
||||
<!-- ================================================================== -->
|
||||
<template v-else>
|
||||
<!-- 栏头(标题 + 模板选择 + 内联路径 + 三角切换) -->
|
||||
<div class="troubleshoot-bar__header" @click="toggleCollapse">
|
||||
<span class="troubleshoot-bar__title">🔧 排查步骤</span>
|
||||
|
||||
<!-- 模板选择下拉(点击不触发折叠) -->
|
||||
<el-select
|
||||
v-model="selectedTemplateId"
|
||||
size="small"
|
||||
style="width: 140px;"
|
||||
@click.stop
|
||||
@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" @click.stop>
|
||||
<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.stop="toggleFlowchart"
|
||||
:title="isFlowchartExpanded ? '收起全流程图' : '展开全流程图'"
|
||||
>{{ isFlowchartExpanded ? '▼' : '▶' }}</span>
|
||||
|
||||
<!-- 收起为图标条按钮 -->
|
||||
<span
|
||||
class="troubleshoot-bar__collapse-btn"
|
||||
@click.stop="collapseToIconBar"
|
||||
title="收起为图标条"
|
||||
>⤓</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,6 +121,9 @@ const conversationStore = useConversationStore()
|
||||
/** 是否展开全流程图 */
|
||||
const isFlowchartExpanded = ref(false)
|
||||
|
||||
/** 是否收起为图标条(布局优化v2:默认 true,图标条态更紧凑 ~28px) */
|
||||
const isIconBarCollapsed = ref(true)
|
||||
|
||||
/** 模板列表 */
|
||||
const templates = ref<TroubleshootingTemplate[]>([])
|
||||
|
||||
@@ -122,10 +150,36 @@ const currentFlowchart = computed(() => {
|
||||
return localFlowchart.value
|
||||
})
|
||||
|
||||
/** 当前步骤序号(用于图标条态显示) */
|
||||
const currentStepNumber = computed(() => {
|
||||
const idx = localPathSteps.value.findIndex(s => s.status === 'current')
|
||||
return idx >= 0 ? idx + 1 : 1
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 切换图标条/完整栏(布局优化v2)
|
||||
* 图标条态 → 完整栏态,完整栏态 → 图标条态
|
||||
*/
|
||||
function toggleCollapse(): void {
|
||||
isIconBarCollapsed.value = !isIconBarCollapsed.value
|
||||
if (isIconBarCollapsed.value) {
|
||||
// 收起为图标条时同步收起流程图
|
||||
isFlowchartExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅收起为图标条(点击栏头收起按钮时调用)
|
||||
*/
|
||||
function collapseToIconBar(): void {
|
||||
isIconBarCollapsed.value = true
|
||||
isFlowchartExpanded.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换全流程图展开/收起
|
||||
*/
|
||||
@@ -503,13 +557,14 @@ onMounted(() => {
|
||||
loadTemplates()
|
||||
})
|
||||
|
||||
// 切换会话时重置步骤进度
|
||||
// 切换会话时重置步骤进度(布局优化v2:同步重置为图标条态)
|
||||
watch(
|
||||
() => conversationStore.currentConversationId,
|
||||
() => {
|
||||
// 重新加载当前模板
|
||||
handleTemplateChange(selectedTemplateId.value)
|
||||
isFlowchartExpanded.value = false
|
||||
isIconBarCollapsed.value = true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -522,6 +577,63 @@ watch(
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== 布局优化v2:图标条态(~28px 高,默认显示) ===== */
|
||||
.troubleshoot-bar__icon-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
height: 28px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.troubleshoot-bar__icon-bar:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.troubleshoot-bar__icon-bar-symbol {
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.troubleshoot-bar__icon-bar-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.troubleshoot-bar__icon-bar-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== 收起为图标条按钮 ===== */
|
||||
.troubleshoot-bar__collapse-btn {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.troubleshoot-bar__collapse-btn:hover {
|
||||
background: var(--bg-accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 栏头 — 紧凑单行:标题 + 下拉 + 内联路径 + 三角图标 */
|
||||
.troubleshoot-bar__header {
|
||||
display: flex;
|
||||
|
||||
@@ -149,9 +149,9 @@
|
||||
🤝 摇人
|
||||
</el-button>
|
||||
|
||||
<!-- 结单 -->
|
||||
<!-- 结单 / 待确认 -->
|
||||
<el-button
|
||||
v-if="conversation?.status !== 'resolved'"
|
||||
v-if="conversation?.status === 'serving'"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="$emit('resolve')"
|
||||
@@ -159,6 +159,14 @@
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
结单
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="conversation?.status === 'pending_close'"
|
||||
type="warning"
|
||||
size="small"
|
||||
disabled
|
||||
>
|
||||
⏳ 等待员工确认
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,7 +340,7 @@ defineEmits<Emits>()
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 是否展开详情 */
|
||||
/** 是否展开详情(布局优化v2:确认默认折叠 false) */
|
||||
const isExpanded = ref(false)
|
||||
|
||||
/** 是否显示 IT 等级选择器 */
|
||||
@@ -556,13 +564,13 @@ defineExpose({ resetForNewConversation })
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 常驻区 */
|
||||
/* 常驻区(布局优化v2:优化折叠态紧凑度,减少 padding) */
|
||||
.user-info-bar__persistent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
@@ -578,25 +586,25 @@ defineExpose({ resetForNewConversation })
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
/* 头像(布局优化v2:折叠态紧凑,缩小到 32px) */
|
||||
.user-info-bar__avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
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-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 图片头像 */
|
||||
.user-info-bar__avatar-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
:class="{
|
||||
active: active,
|
||||
resolved: conversation.status === 'resolved',
|
||||
'pending-close': conversation.status === 'pending_close',
|
||||
'other-agent': !conversation.is_mine && conversation.status === 'serving',
|
||||
}"
|
||||
@click="$emit('click')"
|
||||
@@ -71,6 +72,13 @@
|
||||
>
|
||||
{{ conversation.assigned_agent_name }}
|
||||
</span>
|
||||
<!-- 待确认结单标签 -->
|
||||
<span
|
||||
v-if="conversation.status === 'pending_close'"
|
||||
class="tag-badge tag-badge-pending-close"
|
||||
>
|
||||
⏳ 待确认
|
||||
</span>
|
||||
<!-- 优先级图标组(右侧排列) -->
|
||||
<span class="priority-icons">
|
||||
<span
|
||||
@@ -448,6 +456,18 @@ function isHighRoleLevel(conv: Conversation): boolean {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 待确认结单样式:琥珀色左边框 */
|
||||
.conversation-item.pending-close {
|
||||
border-left: 3px solid var(--color-warning, #e6a23c);
|
||||
}
|
||||
|
||||
/* 待确认结单标签 */
|
||||
.tag-badge-pending-close {
|
||||
background: var(--warning-soft, #fdf6ec);
|
||||
color: var(--color-warning, #e6a23c);
|
||||
border: 1px solid var(--color-warning, #e6a23c);
|
||||
}
|
||||
|
||||
/* 图片头像样式 */
|
||||
.conversation-avatar-img {
|
||||
width: 48px;
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — 坐席端分诊详情面板
|
||||
=============================================================================
|
||||
说明:用户画像 + 问题描述 + AI分析结果 + 已收集上下文
|
||||
4个路由操作按钮(AI自答/转人工/自动审批/跳过)+ 备注
|
||||
排除选项功能(WS推送到H5)
|
||||
============================================================================= -->
|
||||
<template>
|
||||
<div class="triage-detail-panel">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!detail" class="empty-state">
|
||||
<el-empty description="请从左侧列表选择分诊记录" :image-size="120" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 用户画像 -->
|
||||
<div class="section">
|
||||
<div class="section-title">👤 用户画像</div>
|
||||
<div class="user-profile">
|
||||
<div class="profile-item">
|
||||
<span class="profile-label">姓名</span>
|
||||
<span class="profile-value">{{ detail.user_name || detail.user_id }}</span>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<span class="profile-label">部门</span>
|
||||
<span class="profile-value">{{ detail.user_dept || '-' }}</span>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<span class="profile-label">IT等级</span>
|
||||
<span class="profile-value">{{ detail.user_level || '-' }}</span>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<span class="profile-label">设备</span>
|
||||
<span class="profile-value">{{ detail.device_info || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 问题描述 -->
|
||||
<div class="section">
|
||||
<div class="section-title">📝 问题描述</div>
|
||||
<div class="problem-content">{{ detail.request_content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 分析结果 -->
|
||||
<div class="section">
|
||||
<div class="section-title">🤖 AI 分析结果</div>
|
||||
<div class="ai-analysis">
|
||||
<div class="analysis-grid">
|
||||
<div class="analysis-item">
|
||||
<span class="analysis-label">问题类型</span>
|
||||
<el-tag size="small" type="info">{{ detail.problem_type || '未分类' }}</el-tag>
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<span class="analysis-label">问题分类</span>
|
||||
<span class="analysis-value">{{ detail.problem_category || '-' }}</span>
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<span class="analysis-label">置信度</span>
|
||||
<div class="confidence-bar">
|
||||
<el-progress
|
||||
:percentage="Math.round((detail.confidence || 0) * 100)"
|
||||
:stroke-width="8"
|
||||
:color="confidenceColor(detail.confidence)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<span class="analysis-label">紧急度</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="urgencyTagType(detail.urgency)"
|
||||
effect="dark"
|
||||
>
|
||||
{{ urgencyLabel(detail.urgency) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<span class="analysis-label">AI建议</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="routeTagType(detail.suggested_route)"
|
||||
>
|
||||
{{ routeLabel(detail.suggested_route) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="analysis-item" v-if="detail.matched_knowledge">
|
||||
<span class="analysis-label">匹配知识</span>
|
||||
<span class="analysis-value">{{ detail.matched_knowledge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已收集上下文 -->
|
||||
<div class="section" v-if="detail.collected_context && detail.collected_context.length > 0">
|
||||
<div class="section-title">✅ 已收集上下文</div>
|
||||
<div class="context-tags">
|
||||
<el-tag
|
||||
v-for="(ctx, idx) in detail.collected_context"
|
||||
:key="idx"
|
||||
size="small"
|
||||
type="primary"
|
||||
effect="plain"
|
||||
>
|
||||
{{ ctx }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分诊步骤 -->
|
||||
<div class="section" v-if="detail.triage_steps && detail.triage_steps.length > 0">
|
||||
<div class="section-title">📋 分诊步骤</div>
|
||||
<div class="triage-steps">
|
||||
<div
|
||||
v-for="(step, idx) in detail.triage_steps"
|
||||
:key="idx"
|
||||
class="step-item"
|
||||
>
|
||||
<div class="step-question">
|
||||
<span class="step-num">{{ idx + 1 }}</span>
|
||||
{{ step.question }}
|
||||
</div>
|
||||
<div class="step-options">
|
||||
<div
|
||||
v-for="(opt, oidx) in step.options"
|
||||
:key="oidx"
|
||||
class="step-option"
|
||||
>
|
||||
<span class="option-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.probability !== undefined" class="option-prob">
|
||||
{{ Math.round(opt.probability * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 排除选项 -->
|
||||
<div class="section">
|
||||
<div class="section-title">🚫 排除选项(推送到H5)</div>
|
||||
<div class="exclude-section">
|
||||
<el-select
|
||||
v-model="excludedLabels"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
placeholder="选择或输入要排除的选项"
|
||||
style="width: 100%"
|
||||
size="small"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in allOptions"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="recommendedLabel"
|
||||
placeholder="推荐选项(可选)"
|
||||
size="small"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
plain
|
||||
style="margin-top: 8px"
|
||||
:disabled="!excludedLabels.length && !recommendedLabel"
|
||||
@click="handleExclude"
|
||||
>
|
||||
推送排除/推荐
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 路由操作 -->
|
||||
<div class="section route-section">
|
||||
<div class="section-title">⚡ 路由操作</div>
|
||||
<el-input
|
||||
v-model="routeNote"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="路由备注(可选)"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<div class="route-buttons">
|
||||
<el-button
|
||||
type="success"
|
||||
:icon="ChatDotRound"
|
||||
@click="handleRoute('ai_self')"
|
||||
>
|
||||
AI自答
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Switch"
|
||||
@click="handleRoute('human')"
|
||||
>
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
:icon="Check"
|
||||
@click="handleRoute('auto_approval')"
|
||||
>
|
||||
自动审批
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
:icon="RefreshLeft"
|
||||
@click="handleRoute('skip')"
|
||||
>
|
||||
跳过
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ChatDotRound, Switch, Check, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import {
|
||||
routeTriage,
|
||||
excludeTriageOptions,
|
||||
type TriageDetail,
|
||||
} from '@/api/triage'
|
||||
|
||||
const props = defineProps<{
|
||||
detail: TriageDetail | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'routed'): void
|
||||
}>()
|
||||
|
||||
// 路由备注
|
||||
const routeNote = ref('')
|
||||
|
||||
// 排除选项
|
||||
const excludedLabels = ref<string[]>([])
|
||||
const recommendedLabel = ref('')
|
||||
|
||||
// 所有可选选项(从分诊步骤中提取)
|
||||
const allOptions = computed<string[]>(() => {
|
||||
if (!props.detail?.triage_steps) return []
|
||||
const labels: string[] = []
|
||||
for (const step of props.detail.triage_steps) {
|
||||
if (step.options) {
|
||||
for (const opt of step.options) {
|
||||
if (opt.label) labels.push(opt.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels
|
||||
})
|
||||
|
||||
// 监听详情变化,重置状态
|
||||
watch(
|
||||
() => props.detail?.id,
|
||||
() => {
|
||||
routeNote.value = ''
|
||||
excludedLabels.value = []
|
||||
recommendedLabel.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
// 路由操作
|
||||
async function handleRoute(action: string) {
|
||||
if (!props.detail) return
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
ai_self: 'AI自答',
|
||||
human: '转人工',
|
||||
auto_approval: '自动审批',
|
||||
skip: '跳过',
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认执行「${actionLabels[action]}」操作?`,
|
||||
'路由确认',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await routeTriage(props.detail.id, {
|
||||
route_action: action,
|
||||
route_note: routeNote.value || undefined,
|
||||
})
|
||||
ElMessage.success(`已执行「${actionLabels[action]}」`)
|
||||
emit('routed')
|
||||
} catch (e) {
|
||||
ElMessage.error('路由操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 推送排除选项
|
||||
async function handleExclude() {
|
||||
if (!props.detail) return
|
||||
|
||||
try {
|
||||
await excludeTriageOptions(props.detail.id, {
|
||||
excluded_labels: excludedLabels.value,
|
||||
recommended_label: recommendedLabel.value || undefined,
|
||||
})
|
||||
ElMessage.success('排除选项已推送到H5')
|
||||
} catch (e) {
|
||||
ElMessage.error('推送排除选项失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function urgencyLabel(urgency: string): string {
|
||||
const map: Record<string, string> = { high: '紧急', medium: '中', low: '低' }
|
||||
return map[urgency] || urgency
|
||||
}
|
||||
|
||||
function urgencyTagType(urgency: string): string {
|
||||
const map: Record<string, string> = { high: 'danger', medium: 'warning', low: 'info' }
|
||||
return map[urgency] || 'info'
|
||||
}
|
||||
|
||||
function routeLabel(route: string | null): string {
|
||||
if (!route) return '-'
|
||||
const map: Record<string, string> = {
|
||||
ai_self: 'AI自答',
|
||||
human: '转人工',
|
||||
auto_approval: '自动审批',
|
||||
}
|
||||
return map[route] || route
|
||||
}
|
||||
|
||||
function routeTagType(route: string | null): string {
|
||||
if (!route) return 'info'
|
||||
const map: Record<string, string> = {
|
||||
ai_self: 'success',
|
||||
human: 'danger',
|
||||
auto_approval: 'warning',
|
||||
}
|
||||
return map[route] || 'info'
|
||||
}
|
||||
|
||||
function confidenceColor(conf: number | null): string {
|
||||
if (conf === null) return '#909399'
|
||||
if (conf >= 0.8) return '#67c23a'
|
||||
if (conf >= 0.5) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.triage-detail-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
/* 用户画像 */
|
||||
.user-profile {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.profile-value {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* 问题描述 */
|
||||
.problem-content {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.6;
|
||||
padding: 12px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* AI 分析 */
|
||||
.analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.analysis-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.analysis-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.analysis-value {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.confidence-bar {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* 上下文标签 */
|
||||
.context-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 分诊步骤 */
|
||||
.triage-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 10px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.step-question {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.step-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.option-prob {
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 排除选项 */
|
||||
.exclude-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 路由操作 */
|
||||
.route-section {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
padding-top: 12px;
|
||||
border-top: 2px solid #ebeef5;
|
||||
}
|
||||
|
||||
.route-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — 坐席端待分诊列表
|
||||
=============================================================================
|
||||
说明:待分诊列表,按紧急度排序
|
||||
紧急度高→红色标记,中→橙色,低→灰色
|
||||
支持按紧急度/问题类型筛选
|
||||
============================================================================= -->
|
||||
<template>
|
||||
<div class="triage-pending-list">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filterUrgency"
|
||||
placeholder="紧急度"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="低" value="low" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterProblemType"
|
||||
placeholder="问题类型"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="硬件" value="硬件" />
|
||||
<el-option label="软件" value="软件" />
|
||||
<el-option label="网络" value="网络" />
|
||||
<el-option label="安全" value="安全" />
|
||||
<el-option label="账号" value="账号" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="list-container" v-loading="loading">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state">
|
||||
<el-empty description="暂无待分诊记录" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
:class="{
|
||||
'list-item--active': item.id === selectedId,
|
||||
}"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<!-- 紧急度标记 -->
|
||||
<div class="list-item__urgency" :class="`urgency--${item.urgency}`">
|
||||
{{ urgencyLabel(item.urgency) }}
|
||||
</div>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<div class="list-item__content">
|
||||
<div class="list-item__title">{{ item.request_title }}</div>
|
||||
<div class="list-item__meta">
|
||||
<span class="meta-user">{{ item.user_name || item.user_id }}</span>
|
||||
<span v-if="item.user_dept" class="meta-dept">{{ item.user_dept }}</span>
|
||||
<el-tag
|
||||
v-if="item.problem_type"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
>
|
||||
{{ item.problem_type }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="item.suggested_route"
|
||||
size="small"
|
||||
:type="routeTagType(item.suggested_route)"
|
||||
effect="plain"
|
||||
>
|
||||
{{ routeLabel(item.suggested_route) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="list-item__time">{{ formatTime(item.created_at) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 置信度 -->
|
||||
<div v-if="item.confidence !== null" class="list-item__confidence">
|
||||
<el-progress
|
||||
:percentage="Math.round((item.confidence || 0) * 100)"
|
||||
:stroke-width="4"
|
||||
:show-text="false"
|
||||
:color="confidenceColor(item.confidence)"
|
||||
/>
|
||||
<span class="confidence-text">{{ Math.round((item.confidence || 0) * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getPendingTriage, type TriageSessionItem } from '@/api/triage'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: TriageSessionItem): void
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
const items = ref<TriageSessionItem[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const filterUrgency = ref('')
|
||||
const filterProblemType = ref('')
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getPendingTriage({
|
||||
urgency: filterUrgency.value || undefined,
|
||||
problem_type: filterProblemType.value || undefined,
|
||||
page: currentPage.value,
|
||||
page_size: pageSize,
|
||||
})
|
||||
items.value = result.items
|
||||
total.value = result.total
|
||||
} catch (e) {
|
||||
console.error('加载待分诊列表失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilter() {
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handlePageChange() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function urgencyLabel(urgency: string): string {
|
||||
const map: Record<string, string> = { high: '紧急', medium: '中', low: '低' }
|
||||
return map[urgency] || urgency
|
||||
}
|
||||
|
||||
function routeLabel(route: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ai_self: 'AI自答',
|
||||
human: '转人工',
|
||||
auto_approval: '自动审批',
|
||||
}
|
||||
return map[route] || route
|
||||
}
|
||||
|
||||
function routeTagType(route: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ai_self: 'success',
|
||||
human: 'danger',
|
||||
auto_approval: 'warning',
|
||||
}
|
||||
return map[route] || 'info'
|
||||
}
|
||||
|
||||
function confidenceColor(conf: number | null): string {
|
||||
if (conf === null) return '#909399'
|
||||
if (conf >= 0.8) return '#67c23a'
|
||||
if (conf >= 0.5) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string | null): string {
|
||||
if (!timeStr) return ''
|
||||
const d = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = (now.getTime() - d.getTime()) / 1000
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
// 暴露刷新方法
|
||||
defineExpose({
|
||||
refresh: loadData,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.triage-pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.list-item--active {
|
||||
background: #ecf5ff;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.list-item__urgency {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.urgency--high { background: #f56c6c; }
|
||||
.urgency--medium { background: #e6a23c; }
|
||||
.urgency--low { background: #909399; }
|
||||
|
||||
.list-item__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-item__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.list-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta-user {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.meta-dept {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.list-item__time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.list-item__confidence {
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.confidence-text {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — 坐席端分诊统计概要栏
|
||||
=============================================================================
|
||||
说明:6项统计卡片:待分诊数/今日已分诊/AI自答/转人工/自动审批/平均耗时
|
||||
============================================================================= -->
|
||||
<template>
|
||||
<div class="triage-stats-bar">
|
||||
<div
|
||||
v-for="item in statItems"
|
||||
:key="item.key"
|
||||
class="stat-card"
|
||||
:class="`stat-card--${item.color}`"
|
||||
>
|
||||
<div class="stat-card__value">{{ item.value }}</div>
|
||||
<div class="stat-card__label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TriageStats } from '@/api/triage'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: TriageStats | null
|
||||
}>()
|
||||
|
||||
const statItems = computed(() => {
|
||||
const s = props.stats
|
||||
return [
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待分诊',
|
||||
value: s?.pending_total ?? 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
key: 'today',
|
||||
label: '今日已分诊',
|
||||
value: s?.today_triaged ?? 0,
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
key: 'ai_self',
|
||||
label: 'AI自答',
|
||||
value: s?.ai_self_count ?? 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
key: 'human',
|
||||
label: '转人工',
|
||||
value: s?.human_count ?? 0,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
key: 'auto_approval',
|
||||
label: '自动审批',
|
||||
value: s?.auto_approval_count ?? 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
key: 'avg_duration',
|
||||
label: '平均耗时',
|
||||
value: formatDuration(s?.avg_duration_sec ?? 0),
|
||||
color: 'gray',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
if (sec < 60) return `${sec.toFixed(0)}s`
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}m${s}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.triage-stats-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 颜色主题 */
|
||||
.stat-card--blue .stat-card__value { color: #409eff; }
|
||||
.stat-card--cyan .stat-card__value { color: #13c2c2; }
|
||||
.stat-card--green .stat-card__value { color: #67c23a; }
|
||||
.stat-card--orange .stat-card__value { color: #e6a23c; }
|
||||
.stat-card--purple .stat-card__value { color: #9254de; }
|
||||
.stat-card--gray .stat-card__value { color: #909399; }
|
||||
</style>
|
||||
@@ -1,15 +1,21 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 快捷键管理组合式函数(v5.3 终版)
|
||||
// 企微IT智能服务台 — 快捷键管理组合式函数(v2.0 快捷键增强版)
|
||||
// =============================================================================
|
||||
// 快捷键列表:
|
||||
// Ctrl+1/2/3: AI 推荐填入
|
||||
// Alt+1~7: 快速回复一级分类切换
|
||||
// 数字键(1-9): 快速回复二/三级条目选择
|
||||
// ↑↓: 快速回复三级条目导航
|
||||
// Enter: 快速回复确认填入
|
||||
// ←/Backspace: 返回上一级
|
||||
// /: 聚焦搜索框
|
||||
// 规则:仅在输入框未聚焦时生效
|
||||
// 数字键 1~6: AI推荐选择(AI Tab激活时,输入框未聚焦)/ 快速回复L1分类(Quick Tab+L2关闭时)
|
||||
// 数字键 1~9: 快速回复L2条目选择(Quick Tab+L2面板打开时,输入框未聚焦)
|
||||
// Tab: 切换 AI推荐 ↔ 快速回复(左手,总是生效)
|
||||
// Shift+Space: 发送消息(左手,用event.code匹配,不受IME/全角空格影响)
|
||||
// ESC: 撤销选中回复 / 关闭L2面板(总是生效)
|
||||
// ↑↓: 快速回复条目导航
|
||||
// Enter: 快速回复确认填入(输入框未聚焦时)/ 发送消息(ReplyBox自己处理)
|
||||
// ←/Backspace: 返回上一级
|
||||
// /: 聚焦搜索框
|
||||
// Ctrl+Shift+A: 截图快捷键(P2 预留)
|
||||
// 规则:
|
||||
// - IME组字中(event.isComposing) → 所有快捷键不触发
|
||||
// - ScreenCapture打开时(.wechat-capture) → 所有快捷键不触发
|
||||
// - 输入框聚焦时:ESC + Tab + Shift+Space 生效,纯数字键不拦截(正常输入)
|
||||
// =============================================================================
|
||||
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
@@ -25,26 +31,30 @@ interface ShortcutEntry {
|
||||
alt?: boolean
|
||||
shift?: boolean
|
||||
key: string
|
||||
/** 物理键码(可选),用于 Space 等 IME 易拦截的按键 — code 不受输入法影响 */
|
||||
code?: string
|
||||
handler: ShortcutCallback
|
||||
}
|
||||
|
||||
interface UseKeyboardShortcutsOptions {
|
||||
/** AI 推荐填入(Ctrl+1/2/3) */
|
||||
onAiRecommend?: (index: number) => void
|
||||
/** 快速回复一级分类切换(Alt+1~7) */
|
||||
onQuickReplyCategory?: (index: number) => void
|
||||
/** 快速回复二/三级数字键选择(1-9) */
|
||||
/** 数字键选择(1~9,上下文路由:AI Tab选推荐/Quick Tab选L1分类或L2条目) */
|
||||
onQuickReplyDigit?: (digit: number) => void
|
||||
/** 撤销选中回复 / 关闭L2面板(ESC键,总是生效) */
|
||||
onUndo?: () => void
|
||||
/** 返回上级(←/Backspace) */
|
||||
onQuickReplyBack?: () => void
|
||||
/** 快速回复条目导航(↑↓) */
|
||||
onQuickReplyNavigate?: (direction: 'up' | 'down') => void
|
||||
/** 快速回复确认填入(Enter) */
|
||||
/** 快速回复确认填入(Enter,仅输入框未聚焦时) */
|
||||
onQuickReplyConfirm?: () => void
|
||||
/** 聚焦搜索框(/) */
|
||||
onFocusSearch?: () => void
|
||||
/** 截图快捷键(Ctrl+Shift+A,P2 预留) */
|
||||
onScreenshot?: () => void
|
||||
/** Tab切换 AI推荐↔快速回复(Tab键,总是生效) */
|
||||
onTabSwitch?: () => void
|
||||
/** 发送消息(Shift+Space,textarea聚焦时也生效) */
|
||||
onSend?: () => void
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -79,33 +89,13 @@ function isInputFocused(): boolean {
|
||||
* 快捷键管理组合式函数
|
||||
*
|
||||
* 在组件挂载时注册全局快捷键,卸载时自动清除。
|
||||
* 输入框聚焦时仅允许 Ctrl 组合键生效,避免与正常输入冲突。
|
||||
* 输入框聚焦时允许 ESC + Tab + Shift+Space,其他不生效。
|
||||
*/
|
||||
export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}): void {
|
||||
const shortcuts: ShortcutEntry[] = []
|
||||
|
||||
function registerShortcuts(): void {
|
||||
// Ctrl+1/2/3: AI 推荐填入
|
||||
if (options.onAiRecommend) {
|
||||
shortcuts.push(
|
||||
{ ctrl: true, key: '1', handler: () => options.onAiRecommend!(0) },
|
||||
{ ctrl: true, key: '2', handler: () => options.onAiRecommend!(1) },
|
||||
{ ctrl: true, key: '3', handler: () => options.onAiRecommend!(2) },
|
||||
)
|
||||
}
|
||||
|
||||
// Alt+1~7: 快速回复一级分类切换
|
||||
if (options.onQuickReplyCategory) {
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
shortcuts.push({
|
||||
alt: true,
|
||||
key: String(i),
|
||||
handler: () => options.onQuickReplyCategory!(i - 1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 数字键 1-9: 快速回复二/三级条目选择
|
||||
// 数字键 1-9: 上下文路由(AI推荐 / 快速回复L1分类 / L2条目)
|
||||
if (options.onQuickReplyDigit) {
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
shortcuts.push({
|
||||
@@ -115,6 +105,11 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}):
|
||||
}
|
||||
}
|
||||
|
||||
// ESC: 撤销选中回复 / 关闭L2面板
|
||||
if (options.onUndo) {
|
||||
shortcuts.push({ key: 'Escape', handler: () => options.onUndo!() })
|
||||
}
|
||||
|
||||
// ←/Backspace: 返回上一级
|
||||
if (options.onQuickReplyBack) {
|
||||
shortcuts.push(
|
||||
@@ -131,7 +126,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}):
|
||||
)
|
||||
}
|
||||
|
||||
// Enter: 确认填入
|
||||
// Enter: 确认填入(仅输入框未聚焦时生效)
|
||||
if (options.onQuickReplyConfirm) {
|
||||
shortcuts.push({ key: 'Enter', handler: () => options.onQuickReplyConfirm!() })
|
||||
}
|
||||
@@ -150,17 +145,37 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}):
|
||||
handler: () => options.onScreenshot!(),
|
||||
})
|
||||
}
|
||||
|
||||
// Tab: 切换 AI推荐 ↔ 快速回复(无修饰键,总是生效)
|
||||
if (options.onTabSwitch) {
|
||||
shortcuts.push({ key: 'Tab', handler: () => options.onTabSwitch!() })
|
||||
}
|
||||
|
||||
// Shift+Space: 发送消息(textarea聚焦时也生效,用code不受IME影响)
|
||||
if (options.onSend) {
|
||||
shortcuts.push({ shift: true, key: ' ', code: 'Space', handler: () => options.onSend!() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局键盘事件处理器
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// ── 守卫1: IME组字中不触发任何快捷键 ──
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
|
||||
// ── 守卫2: ScreenCapture打开时不触发回复区域快捷键 ──
|
||||
if (document.querySelector('.wechat-capture')) return
|
||||
|
||||
const inputActive = isInputFocused()
|
||||
|
||||
for (const entry of shortcuts) {
|
||||
// 主键匹配
|
||||
if (entry.key !== event.key) continue
|
||||
// 主键匹配:优先用 code(不受 IME 影响),回退到 key
|
||||
if (entry.code) {
|
||||
if (entry.code !== event.code) continue
|
||||
} else {
|
||||
if (entry.key !== event.key) continue
|
||||
}
|
||||
|
||||
// 修饰键匹配
|
||||
const ctrlMatch = entry.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey
|
||||
@@ -169,20 +184,30 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}):
|
||||
|
||||
if (!ctrlMatch || !altMatch || !shiftMatch) continue
|
||||
|
||||
// 输入框聚焦时的过滤规则:
|
||||
// - 总是允许 Ctrl 组合键(Ctrl+1/2/3)
|
||||
// - 不允许 Enter(与输入冲突)
|
||||
// - 不允许 / (在搜索框中输入 / 是正常的)
|
||||
// ── 输入框聚焦时的过滤规则 ──
|
||||
if (inputActive) {
|
||||
if (entry.ctrl) {
|
||||
// ESC键:总是允许(撤销选中回复 / 关闭L2面板)
|
||||
if (entry.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
entry.handler(event)
|
||||
return
|
||||
}
|
||||
// Tab键:总是允许(切换标签,不产生字符)
|
||||
if (entry.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
entry.handler(event)
|
||||
return
|
||||
}
|
||||
// Shift+Space:总是允许(发送消息,左手操作,用code匹配不受IME影响)
|
||||
if (entry.shift && entry.code === 'Space') {
|
||||
event.preventDefault()
|
||||
entry.handler(event)
|
||||
return
|
||||
}
|
||||
// 其他键在输入框中正常输入,不拦截
|
||||
if (entry.key === 'Enter') continue
|
||||
if (entry.key === '/') continue
|
||||
if (entry.key === 'Backspace') continue
|
||||
// 其他键在输入框中正常输入,不拦截
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,57 @@ export function useWebSocket() {
|
||||
// 心跳响应,不需要处理
|
||||
break
|
||||
|
||||
// ==================================================================
|
||||
// 结单确认流程事件(决策 G1:坐席发起→员工确认)
|
||||
// ==================================================================
|
||||
case 'pending_close_confirmed':
|
||||
// 员工确认结单:会话 pending_close → resolved
|
||||
if (msg.data) {
|
||||
conversationStore.handlePendingCloseConfirmed(msg.data)
|
||||
import('element-plus').then(({ ElNotification }) => {
|
||||
ElNotification({
|
||||
title: '✅ 员工已确认结单',
|
||||
message: `会话「${msg.data.employee_name || ''}」已确认解决`,
|
||||
type: 'success',
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
})
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'pending_close_rejected':
|
||||
// 员工拒绝结单:会话 pending_close → serving 恢复
|
||||
if (msg.data) {
|
||||
conversationStore.handlePendingCloseRejected(msg.data)
|
||||
import('element-plus').then(({ ElNotification }) => {
|
||||
ElNotification({
|
||||
title: '🔄 员工拒绝结单',
|
||||
message: `会话「${msg.data.employee_name || ''}」员工要求继续处理${msg.data.reason ? ':' + msg.data.reason : ''}`,
|
||||
type: 'warning',
|
||||
duration: 0, // 不自动关闭,坐席需注意
|
||||
position: 'top-right',
|
||||
})
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'conversation_resolved':
|
||||
// 会话已解决(AI 自助解决 / 员工主动关闭 / 超时自动关闭)
|
||||
if (msg.data) {
|
||||
conversationStore.handleConversationUpdated(msg.data)
|
||||
import('element-plus').then(({ ElNotification }) => {
|
||||
ElNotification({
|
||||
title: '📌 会话已关闭',
|
||||
message: `会话「${msg.data.employee_name || ''}」已关闭${msg.data.resolved_method ? '(' + msg.data.resolved_method + ')' : ''}`,
|
||||
type: 'info',
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
})
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'typing':
|
||||
// 输入指示器事件:某人正在输入
|
||||
// 过滤掉自己的 typing 事件,只显示其他人的
|
||||
@@ -393,6 +444,14 @@ export function useWebSocket() {
|
||||
}
|
||||
break
|
||||
|
||||
case 'ai_thinking':
|
||||
// AI 正在思考:员工发送消息后,AI 正在生成回复
|
||||
// 坐席端据此知道 AI 正在处理,避免误以为消息被忽略
|
||||
if (msg.data?.conversation_id) {
|
||||
conversationStore.handleAiThinking(msg.data)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`[WebSocket] 未知消息类型: ${msg.type}`)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,13 @@ const routes = [
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { title: '个人设置', requiresAuth: true },
|
||||
},
|
||||
// 知识库迭代 — 分诊看板
|
||||
{
|
||||
path: '/triage',
|
||||
name: 'TriageDashboard',
|
||||
component: () => import('@/views/TriageDashboard.vue'),
|
||||
meta: { title: '分诊看板', requiresAuth: true },
|
||||
},
|
||||
]
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -43,10 +43,11 @@ import { mockConversationListData, mockMessageListData } from '@/mock/data'
|
||||
// 排序规则(来自PRD):紧急→招手→需介入→活跃→AI处理中→已结单
|
||||
// 权重越小排越前面
|
||||
const STATUS_WEIGHT: Record<string, number> = {
|
||||
queued: 1, // 排队等待(招手等待区)— 最优先
|
||||
serving: 2, // 人工处理中
|
||||
ai_handling: 3, // AI处理中
|
||||
resolved: 4, // 已结单 — 最后
|
||||
queued: 1, // 排队等待(招手等待区)— 最优先
|
||||
serving: 2, // 人工处理中
|
||||
pending_close: 3, // 待员工确认结单 — 仍在处理但等待关闭
|
||||
ai_handling: 4, // AI处理中
|
||||
resolved: 5, // 已结单 — 最后
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,6 +129,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
/** 工作区视图模式:'chat' 为对话模式,'task' 为任务模式 */
|
||||
const workspaceView = ref<'chat' | 'task'>('chat')
|
||||
|
||||
/** 右栏面板模式:normal(260px) | expanded(560px),持久化到 localStorage */
|
||||
const panelMode = ref<'normal' | 'expanded'>(
|
||||
(localStorage.getItem('panelMode') as 'normal' | 'expanded') || 'normal'
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// 输入指示器(Typing Indicator)相关状态
|
||||
// ==========================================================================
|
||||
@@ -139,6 +145,14 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
*/
|
||||
const typingUsers = ref<Map<string, { sender_id: string; sender_name: string; timestamp: number }>>(new Map())
|
||||
|
||||
/**
|
||||
* AI 正在思考的会话集合:conversationId → { timestamp, status }
|
||||
* 做什么:记录 AI 正在生成回复的会话,在聊天区域显示"AI 正在思考..."提示
|
||||
* 为什么:坐席需要知道 AI 正在处理员工消息,避免误以为消息被忽略
|
||||
* 自动过期:30 秒内没有更新则自动清除(与后端 30 秒超时一致)
|
||||
*/
|
||||
const aiThinkingConversations = ref<Map<string, { timestamp: number; status?: string }>>(new Map())
|
||||
|
||||
// ==========================================================================
|
||||
// 消息去重相关状态(WS-06 修复)
|
||||
// ==========================================================================
|
||||
@@ -264,8 +278,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 📌 我的会话 — 合并:待接单 + 我的会话 + 协作会话
|
||||
* 📌 我的会话 — 合并:待接单 + 我的会话 + 协作会话 + 待确认结单
|
||||
* 对应左栏三段折叠的第一段(默认展开)
|
||||
* pending_close 会话仍在我的列表中,坐席可查看员工确认状态
|
||||
*/
|
||||
const myConversations = computed(() => {
|
||||
return sortedConversations.value.filter(c => {
|
||||
@@ -273,6 +288,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
if (c.status === 'queued') return true
|
||||
// 我的会话(状态 serving 且 is_mine)
|
||||
if (c.status === 'serving' && c.is_mine) return true
|
||||
// 我的待确认结单(状态 pending_close 且 is_mine)
|
||||
if (c.status === 'pending_close' && c.is_mine) return true
|
||||
// 协作会话
|
||||
if (c.is_collaborator && c.status === 'serving') return true
|
||||
return false
|
||||
@@ -424,22 +441,25 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 结单
|
||||
* 将会话状态改为 resolved
|
||||
* 坐席发起结单(触发员工确认流程)
|
||||
*
|
||||
* 改造说明(决策 G1):
|
||||
* - 原逻辑:直接将会话改为 resolved,清空当前选中
|
||||
* - 新逻辑:状态改为 pending_close,保持当前选中(坐席可继续查看等待状态)
|
||||
* - 员工确认后通过 WS 事件 pending_close_confirmed → resolved
|
||||
* - 员工拒绝后通过 WS 事件 pending_close_rejected → 恢复 serving
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @param resolveSummary - 结单摘要(问题类型+根因+解决方式)
|
||||
*/
|
||||
async function resolveConv(conversationId: string): Promise<void> {
|
||||
async function resolveConv(conversationId: string, resolveSummary: string): Promise<void> {
|
||||
try {
|
||||
await resolveConversation(conversationId)
|
||||
await resolveConversation(conversationId, resolveSummary)
|
||||
await fetchConversations()
|
||||
// 如果结单的是当前会话,清空选中
|
||||
if (currentConversationId.value === conversationId) {
|
||||
currentConversationId.value = null
|
||||
messages.value = []
|
||||
}
|
||||
// 不清空当前会话 — 坐席可查看 pending_close 状态
|
||||
} catch (error) {
|
||||
console.error('结单失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,6 +929,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
sender_type: string
|
||||
sender_id: string
|
||||
content: string
|
||||
msg_type?: string
|
||||
extra_data?: Record<string, any>
|
||||
urgency_score?: number
|
||||
tags?: any
|
||||
}): void {
|
||||
@@ -925,6 +947,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
// 记录此消息ID为"已处理"(更新到 Set 的最新位置)
|
||||
trackProcessedMessageId(data.message_id)
|
||||
|
||||
// 如果是 AI 回复消息,清除该会话的 AI 思考状态
|
||||
if (data.sender_type === 'ai') {
|
||||
clearAiThinking(data.conversation_id)
|
||||
}
|
||||
|
||||
// 如果当前正在查看这个会话,追加消息到消息列表
|
||||
if (currentConversationId.value === data.conversation_id) {
|
||||
// 检查消息是否已存在(避免轮询和 WS 推送重复)
|
||||
@@ -937,7 +964,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
sender_id: data.sender_id,
|
||||
sender_name: '', // WS 推送不含姓名,后续轮询会补充
|
||||
content: data.content,
|
||||
msg_type: 'text',
|
||||
msg_type: data.msg_type || 'text', // 使用 WS 推送的 msg_type,默认 text
|
||||
extra_data: data.extra_data, // 传递 extra_data(options/action 等)
|
||||
ai_suggestion: false,
|
||||
is_read: false,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -1003,6 +1031,66 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
fetchConversations()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 结单确认流程 WS 事件处理(决策 G1:坐席发起→员工确认)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 推送的"员工确认结单"事件
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 刷新会话列表(会话状态 pending_close → resolved)
|
||||
* 2. 如果是当前选中的会话,清空选中(已结单无需继续操作)
|
||||
* 3. 弹出通知提示坐席
|
||||
*
|
||||
* @param data - WS 事件数据
|
||||
* @param data.conversation_id - 会话ID
|
||||
* @param data.resolved_method - 解决方式(employee_confirm 等)
|
||||
*/
|
||||
function handlePendingCloseConfirmed(data: {
|
||||
conversation_id: string
|
||||
resolved_method?: string
|
||||
}): void {
|
||||
fetchConversations()
|
||||
// 如果是当前选中的会话,清空选中
|
||||
if (currentConversationId.value === data.conversation_id) {
|
||||
currentConversationId.value = null
|
||||
messages.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 推送的"员工拒绝结单"事件
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 刷新会话列表(会话状态 pending_close → serving 恢复)
|
||||
* 2. 弹出通知提示坐席员工拒绝了结单
|
||||
*
|
||||
* @param data - WS 事件数据
|
||||
* @param data.conversation_id - 会话ID
|
||||
* @param data.reason - 员工拒绝原因(可选)
|
||||
*/
|
||||
function handlePendingCloseRejected(_data: {
|
||||
conversation_id: string
|
||||
reason?: string
|
||||
}): void {
|
||||
fetchConversations()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 右栏面板模式(布局优化v2)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 设置右栏面板模式并持久化到 localStorage
|
||||
*
|
||||
* @param mode - 'normal'(260px) | 'expanded'(560px)
|
||||
*/
|
||||
function setPanelMode(mode: 'normal' | 'expanded'): void {
|
||||
panelMode.value = mode
|
||||
localStorage.setItem('panelMode', mode)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 输入指示器(Typing Indicator)方法
|
||||
// ==========================================================================
|
||||
@@ -1072,6 +1160,51 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
return `${info.sender_name}正在输入...`
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 AI 思考状态事件(WS: ai_thinking)
|
||||
*
|
||||
* 做什么:记录 AI 正在生成回复的会话,在聊天区域显示状态提示
|
||||
* 为什么:坐席需要知道 AI 正在处理员工消息
|
||||
*
|
||||
* @param data - { conversation_id, employee_id?, status? }
|
||||
* status="still_thinking" 表示 AI 已思考超过 15 秒
|
||||
*/
|
||||
function handleAiThinking(data: {
|
||||
conversation_id: string
|
||||
employee_id?: string
|
||||
status?: string
|
||||
}): void {
|
||||
if (!data.conversation_id) return
|
||||
aiThinkingConversations.value.set(data.conversation_id, {
|
||||
timestamp: Date.now(),
|
||||
status: data.status,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定会话的 AI 思考状态文本
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 状态文本,如 "AI 正在思考..." 或 "AI 仍在思考中...",无则返回空字符串
|
||||
*/
|
||||
function getAiThinkingText(conversationId: string): string {
|
||||
const info = aiThinkingConversations.value.get(conversationId)
|
||||
if (!info) return ''
|
||||
// 30 秒过期检查(与后端超时一致)
|
||||
if (Date.now() - info.timestamp > 30000) {
|
||||
aiThinkingConversations.value.delete(conversationId)
|
||||
return ''
|
||||
}
|
||||
return info.status === 'still_thinking'
|
||||
? 'AI 仍在思考中...'
|
||||
: 'AI 正在思考...'
|
||||
}
|
||||
|
||||
/** 清除指定会话的 AI 思考状态(收到 AI 回复后调用) */
|
||||
function clearAiThinking(conversationId: string): void {
|
||||
aiThinkingConversations.value.delete(conversationId)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 返回
|
||||
// ==========================================================================
|
||||
@@ -1084,6 +1217,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
loadingMessages,
|
||||
pendingReplyText,
|
||||
workspaceView,
|
||||
panelMode,
|
||||
|
||||
// AI Wingman 状态
|
||||
aiDrafts,
|
||||
@@ -1146,6 +1280,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
startAllPolling,
|
||||
stopAllPolling,
|
||||
|
||||
// 右栏面板模式
|
||||
setPanelMode,
|
||||
|
||||
// WebSocket 事件处理
|
||||
handleNewMessage,
|
||||
handleConversationUpdated,
|
||||
@@ -1153,9 +1290,17 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
handleCollaboratorChanged,
|
||||
handleParticipantInvited,
|
||||
handleParticipantChanged,
|
||||
handlePendingCloseConfirmed,
|
||||
handlePendingCloseRejected,
|
||||
handleTypingEvent,
|
||||
getTypingText,
|
||||
|
||||
// AI 思考状态
|
||||
handleAiThinking,
|
||||
getAiThinkingText,
|
||||
clearAiThinking,
|
||||
aiThinkingConversations,
|
||||
|
||||
// 输入指示器
|
||||
typingUsers,
|
||||
}
|
||||
|
||||
@@ -95,8 +95,15 @@
|
||||
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ---- 三栏布局尺寸 ---- */
|
||||
--sidebar-width: 280px;
|
||||
--assistant-panel-width: 320px;
|
||||
--sidebar-width: 260px;
|
||||
--assistant-panel-width: 260px;
|
||||
--assistant-panel-expanded: 560px;
|
||||
|
||||
/* ---- 布局优化v2 新增:回复建议区 / 工具栏背景色 ---- */
|
||||
--suggest-ai-bg: #FAECE7; /* 回复建议区 AI推荐 背景色 */
|
||||
--suggest-quick-bg: #EAF3DE; /* 回复建议区 快速回复 背景色 */
|
||||
--toolbar-conv-bg: #E1F5EE; /* 工具栏 常规区 背景色 */
|
||||
--toolbar-ai-bg: #FAEEDA; /* 工具栏 AI区 背景色 */
|
||||
}
|
||||
|
||||
/* ---- 深色主题覆盖(同步原型 v5.3) ---- */
|
||||
@@ -151,6 +158,12 @@
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.25);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.35);
|
||||
|
||||
/* ---- 布局优化v2 新增:回复建议区 / 工具栏背景色(暗色主题) ---- */
|
||||
--suggest-ai-bg: #3a2a26;
|
||||
--suggest-quick-bg: #2a3326;
|
||||
--toolbar-conv-bg: #1e3028;
|
||||
--toolbar-ai-bg: #3a3526;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — 坐席端分诊看板主页面
|
||||
=============================================================================
|
||||
说明:左列表+右详情双栏布局,统计栏在顶部
|
||||
============================================================================= -->
|
||||
<template>
|
||||
<div class="triage-dashboard">
|
||||
<!-- 页面标题 -->
|
||||
<div class="dashboard-header">
|
||||
<h2 class="dashboard-title">分诊看板</h2>
|
||||
<div class="header-actions">
|
||||
<el-radio-group v-model="activeTab" size="small">
|
||||
<el-radio-button label="pending">待分诊</el-radio-button>
|
||||
<el-radio-button label="history">历史记录</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button
|
||||
v-if="activeTab === 'history'"
|
||||
type="primary"
|
||||
size="small"
|
||||
plain
|
||||
@click="handleExport"
|
||||
>
|
||||
导出
|
||||
</el-button>
|
||||
<el-button size="small" @click="refreshAll">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计概要栏 -->
|
||||
<TriageStatsBar :stats="stats" />
|
||||
|
||||
<!-- 双栏布局 -->
|
||||
<div class="dashboard-body">
|
||||
<!-- 左侧列表 -->
|
||||
<div class="left-panel">
|
||||
<TriagePendingList
|
||||
v-if="activeTab === 'pending'"
|
||||
ref="pendingListRef"
|
||||
:selected-id="selectedItem?.id"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
<div v-else class="history-list">
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
size="small"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="loadHistory"
|
||||
/>
|
||||
<el-select
|
||||
v-model="historyRouteAction"
|
||||
placeholder="路由动作"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="loadHistory"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="AI自答" value="ai_self" />
|
||||
<el-option label="转人工" value="human" />
|
||||
<el-option label="自动审批" value="auto_approval" />
|
||||
<el-option label="跳过" value="skip" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="list-container" v-loading="historyLoading">
|
||||
<div v-if="historyItems.length === 0 && !historyLoading" class="empty-state">
|
||||
<el-empty description="暂无历史记录" :image-size="80" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in historyItems"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
:class="{ 'list-item--active': item.id === selectedDetail?.id }"
|
||||
@click="handleSelectHistory(item)"
|
||||
>
|
||||
<div class="list-item__urgency" :class="`urgency--${item.urgency}`">
|
||||
{{ urgencyShort(item.urgency) }}
|
||||
</div>
|
||||
<div class="list-item__content">
|
||||
<div class="list-item__title">{{ item.request_title }}</div>
|
||||
<div class="list-item__meta">
|
||||
<span class="meta-user">{{ item.user_name || item.user_id }}</span>
|
||||
<el-tag v-if="item.route_action" size="small" :type="routeTagType(item.route_action)">
|
||||
{{ routeLabel(item.route_action) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="list-item__time">{{ formatTime(item.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="historyTotal > 20" class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="historyPage"
|
||||
:page-size="20"
|
||||
:total="historyTotal"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="loadHistory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情 -->
|
||||
<div class="right-panel">
|
||||
<TriageDetailPanel
|
||||
:detail="selectedDetail"
|
||||
@routed="handleRouted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import TriageStatsBar from '@/components/triage/TriageStatsBar.vue'
|
||||
import TriagePendingList from '@/components/triage/TriagePendingList.vue'
|
||||
import TriageDetailPanel from '@/components/triage/TriageDetailPanel.vue'
|
||||
import {
|
||||
getTriageStats,
|
||||
getTriageDetail,
|
||||
getTriageHistory,
|
||||
exportTriage,
|
||||
type TriageStats,
|
||||
type TriageSessionItem,
|
||||
type TriageDetail,
|
||||
} from '@/api/triage'
|
||||
|
||||
// 状态
|
||||
const activeTab = ref<'pending' | 'history'>('pending')
|
||||
const stats = ref<TriageStats | null>(null)
|
||||
const selectedItem = ref<TriageSessionItem | null>(null)
|
||||
const selectedDetail = ref<TriageDetail | null>(null)
|
||||
const pendingListRef = ref<InstanceType<typeof TriagePendingList> | null>(null)
|
||||
|
||||
// 历史列表状态
|
||||
const historyItems = ref<TriageSessionItem[]>([])
|
||||
const historyLoading = ref(false)
|
||||
const historyTotal = ref(0)
|
||||
const historyPage = ref(1)
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const historyRouteAction = ref('')
|
||||
|
||||
// 加载统计
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats.value = await getTriageStats()
|
||||
} catch (e) {
|
||||
console.error('加载统计失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择待分诊项
|
||||
async function handleSelect(item: TriageSessionItem) {
|
||||
selectedItem.value = item
|
||||
try {
|
||||
selectedDetail.value = await getTriageDetail(item.id)
|
||||
} catch (e) {
|
||||
ElMessage.error('加载分诊详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 选择历史项
|
||||
async function handleSelectHistory(item: TriageSessionItem) {
|
||||
try {
|
||||
selectedDetail.value = await getTriageDetail(item.id)
|
||||
} catch (e) {
|
||||
ElMessage.error('加载分诊详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 路由操作完成
|
||||
function handleRouted() {
|
||||
selectedDetail.value = null
|
||||
selectedItem.value = null
|
||||
loadStats()
|
||||
if (activeTab.value === 'pending') {
|
||||
pendingListRef.value?.refresh()
|
||||
} else {
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载历史
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const result = await getTriageHistory({
|
||||
date_from: dateRange.value?.[0],
|
||||
date_to: dateRange.value?.[1],
|
||||
route_action: historyRouteAction.value || undefined,
|
||||
page: historyPage.value,
|
||||
page_size: 20,
|
||||
})
|
||||
historyItems.value = result.items
|
||||
historyTotal.value = result.total
|
||||
} catch (e) {
|
||||
console.error('加载历史失败:', e)
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
async function handleExport() {
|
||||
try {
|
||||
const blob = await exportTriage({
|
||||
date_from: dateRange.value?.[0],
|
||||
date_to: dateRange.value?.[1],
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `分诊记录_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (e) {
|
||||
ElMessage.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新
|
||||
function refreshAll() {
|
||||
loadStats()
|
||||
if (activeTab.value === 'pending') {
|
||||
pendingListRef.value?.refresh()
|
||||
} else {
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 tab 切换
|
||||
watch(activeTab, (val) => {
|
||||
if (val === 'history' && historyItems.value.length === 0) {
|
||||
loadHistory()
|
||||
}
|
||||
selectedDetail.value = null
|
||||
selectedItem.value = null
|
||||
})
|
||||
|
||||
// 工具函数
|
||||
function urgencyShort(urgency: string): string {
|
||||
const map: Record<string, string> = { high: '高', medium: '中', low: '低' }
|
||||
return map[urgency] || urgency
|
||||
}
|
||||
|
||||
function routeLabel(route: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ai_self: 'AI自答',
|
||||
human: '转人工',
|
||||
auto_approval: '自动审批',
|
||||
skip: '跳过',
|
||||
}
|
||||
return map[route] || route
|
||||
}
|
||||
|
||||
function routeTagType(route: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ai_self: 'success',
|
||||
human: 'danger',
|
||||
auto_approval: 'warning',
|
||||
skip: 'info',
|
||||
}
|
||||
return map[route] || 'info'
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string | null): string {
|
||||
if (!timeStr) return ''
|
||||
const d = new Date(timeStr)
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.triage-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 历史列表 */
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.list-item--active {
|
||||
background: #ecf5ff;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.list-item__urgency {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.urgency--high { background: #f56c6c; }
|
||||
.urgency--medium { background: #e6a23c; }
|
||||
.urgency--low { background: #909399; }
|
||||
|
||||
.list-item__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-item__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.list-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta-user {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.list-item__time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,12 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席工作台主页面(v5.4 拖拽手柄版)
|
||||
// 企微IT智能服务台 — 坐席工作台主页面(v5.4 拖拽手柄版 + 布局优化v2)
|
||||
// =============================================================================
|
||||
// 说明:坐席工作台主页面,三栏布局
|
||||
// 左栏(280px):会话列表
|
||||
// 左栏(260px):会话列表
|
||||
// 中栏(flex-1):对话区
|
||||
// 右栏(320px):AI助手面板
|
||||
// 右栏(260px/560px):AI助手面板(支持放大/缩小模式切换)
|
||||
// v5.4: 左右栏与中间栏之间添加拖拽手柄,可手动调整栏宽
|
||||
// v2.0: 右栏宽度绑定 panelMode 状态(normal/expanded),持久化到 localStorage
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
@@ -52,8 +53,16 @@
|
||||
></div>
|
||||
|
||||
<!-- 右栏:AI助手面板 -->
|
||||
<aside v-if="assistantVisible" class="workspace-assistant" ref="rightSidebarRef">
|
||||
<AiAssistantPanel />
|
||||
<aside
|
||||
v-if="assistantVisible"
|
||||
class="workspace-assistant"
|
||||
:class="{ 'is-expanded': conversationStore.panelMode === 'expanded' }"
|
||||
ref="rightSidebarRef"
|
||||
>
|
||||
<AiAssistantPanel
|
||||
:mode="conversationStore.panelMode"
|
||||
@toggle="togglePanelMode"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +148,7 @@ function startLeftResize(e: MouseEvent): void {
|
||||
function onLeftResizeMove(e: MouseEvent): void {
|
||||
if (!leftDragging.value || !leftSidebarRef.value) return
|
||||
const dx = e.clientX - leftStartX
|
||||
const newW = Math.max(200, Math.min(500, leftStartWidth + dx))
|
||||
const newW = Math.max(200, Math.min(560, leftStartWidth + dx))
|
||||
leftSidebarRef.value.style.width = newW + 'px'
|
||||
leftSidebarRef.value.style.minWidth = newW + 'px'
|
||||
}
|
||||
@@ -168,7 +177,8 @@ function startRightResize(e: MouseEvent): void {
|
||||
function onRightResizeMove(e: MouseEvent): void {
|
||||
if (!rightDragging.value || !rightSidebarRef.value) return
|
||||
const dx = rightStartX - e.clientX
|
||||
const newW = Math.max(220, Math.min(500, rightStartWidth + dx))
|
||||
// 右栏拖拽范围 200-560px(布局优化v2:下限200,上限560)
|
||||
const newW = Math.max(200, Math.min(560, rightStartWidth + dx))
|
||||
rightSidebarRef.value.style.width = newW + 'px'
|
||||
rightSidebarRef.value.style.minWidth = newW + 'px'
|
||||
}
|
||||
@@ -182,7 +192,18 @@ function onRightResizeEnd(): void {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// 右栏面板模式切换(布局优化v2)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 切换右栏面板模式:normal(260px) ↔ expanded(560px)
|
||||
* 通过 ConversationStore.setPanelMode 设置,Store 内部持久化到 localStorage
|
||||
*/
|
||||
function togglePanelMode(): void {
|
||||
const next = conversationStore.panelMode === 'normal' ? 'expanded' : 'normal'
|
||||
conversationStore.setPanelMode(next)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -233,4 +254,15 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 右栏放大模式:宽度由 CSS 变量控制,带 transition 动画 */
|
||||
.workspace-assistant {
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* expanded 模式覆盖宽度(优先于内联拖拽宽度,仅当未手动拖拽时) */
|
||||
.workspace-assistant.is-expanded {
|
||||
width: var(--assistant-panel-expanded) !important;
|
||||
min-width: var(--assistant-panel-expanded) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user