2026-06-14 16:49:18 +08:00
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// 企微IT智能服务台 — H5用户端会话状态管理(Pinia Store)
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// 说明:管理用户端的核心状态,包括:
|
|
|
|
|
|
// 1. 用户信息(从 employee store 获取)
|
|
|
|
|
|
// 2. 当前会话信息
|
|
|
|
|
|
// 3. 消息列表(含轮询逻辑)
|
|
|
|
|
|
// 4. 招手/敲桌子状态
|
|
|
|
|
|
// 5. AI 助手面板展开/收起状态
|
|
|
|
|
|
// 注意:OAuth2 认证逻辑已迁移至 @/stores/employee.ts
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
import { defineStore } from 'pinia'
|
|
|
|
|
|
import { ref, computed } from 'vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
getUser,
|
|
|
|
|
|
getCurrentConversation,
|
|
|
|
|
|
sendMessage,
|
|
|
|
|
|
pollMessages,
|
2026-07-07 22:56:49 +08:00
|
|
|
|
getMessages,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
shake,
|
|
|
|
|
|
getApprovalLinks,
|
|
|
|
|
|
getSoftwareDownloads,
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// v2.0: detectApprovalIntent 已移除(审批意图识别统一由后端处理)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
leaveAsParticipant as leaveAsParticipantApi,
|
|
|
|
|
|
type UserInfo,
|
|
|
|
|
|
type ConversationInfo,
|
|
|
|
|
|
type Message,
|
|
|
|
|
|
type MessageType,
|
|
|
|
|
|
type SendMessageRequest,
|
|
|
|
|
|
type SendMessageResponse,
|
|
|
|
|
|
type MsgContentType,
|
|
|
|
|
|
type ApprovalLink,
|
|
|
|
|
|
type SoftwareDownload,
|
|
|
|
|
|
type ParticipantItem,
|
|
|
|
|
|
} from '@/api/conversation'
|
|
|
|
|
|
import { useEmployeeStore } from '@/stores/employee'
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// v2.0: 导入 WS 发送函数(用于选项选择回传)
|
|
|
|
|
|
import { sendWsMessage } from '@/composables/useH5WebSocket'
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
2026-07-04 21:01:39 +08:00
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
|
// 本地缓存配置
|
|
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
|
const MESSAGES_CACHE_KEY = 'h5_messages_cache' // 消息缓存 key 前缀
|
|
|
|
|
|
const MESSAGES_CACHE_EXPIRE_DAYS = 7 // 缓存过期天数
|
|
|
|
|
|
const MESSAGES_CACHE_MAX = 100 // 单会话最多缓存消息数
|
|
|
|
|
|
|
|
|
|
|
|
/** 获取消息缓存 key */
|
|
|
|
|
|
function getCacheKey(conversationId: string): string {
|
|
|
|
|
|
return `${MESSAGES_CACHE_KEY}_${conversationId}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 从 localStorage 读取消息缓存 */
|
|
|
|
|
|
function loadMessagesFromCache(conversationId: string): Message[] | null {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const key = getCacheKey(conversationId)
|
|
|
|
|
|
const cached = localStorage.getItem(key)
|
|
|
|
|
|
if (!cached) return null
|
|
|
|
|
|
|
|
|
|
|
|
const data = JSON.parse(cached)
|
|
|
|
|
|
// 检查是否过期
|
|
|
|
|
|
const cacheTime = data.timestamp || 0
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
const expireMs = MESSAGES_CACHE_EXPIRE_DAYS * 24 * 60 * 60 * 1000
|
|
|
|
|
|
if (now - cacheTime > expireMs) {
|
|
|
|
|
|
localStorage.removeItem(key)
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return data.messages || null
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Store] 读取消息缓存失败:', e)
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 保存消息到 localStorage */
|
|
|
|
|
|
function saveMessagesToCache(conversationId: string, messages: Message[]): void {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const key = getCacheKey(conversationId)
|
|
|
|
|
|
// 只保留最近 N 条消息
|
|
|
|
|
|
const trimmed = messages.slice(-MESSAGES_CACHE_MAX)
|
|
|
|
|
|
localStorage.setItem(key, JSON.stringify({
|
|
|
|
|
|
messages: trimmed,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Store] 保存消息缓存失败:', e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清除指定会话的缓存 */
|
|
|
|
|
|
function clearMessagesCache(conversationId: string): void {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const key = getCacheKey(conversationId)
|
|
|
|
|
|
localStorage.removeItem(key)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Store] 清除消息缓存失败:', e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 合并缓存和新消息(去重) */
|
|
|
|
|
|
function mergeMessages(cached: Message[], fresh: Message[]): Message[] {
|
|
|
|
|
|
const map = new Map<string, Message>()
|
|
|
|
|
|
// 先添加缓存
|
|
|
|
|
|
cached.forEach(m => map.set(m.message_id, m))
|
|
|
|
|
|
// 再添加新消息(覆盖缓存)
|
|
|
|
|
|
fresh.forEach(m => map.set(m.message_id, m))
|
|
|
|
|
|
// 按时间排序
|
|
|
|
|
|
return Array.from(map.values()).sort((a, b) =>
|
|
|
|
|
|
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
|
// Store 定义
|
|
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
|
export const useConversationStore = defineStore('conversation', () => {
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 响应式状态
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前用户信息(通过 /h5/user 接口获取,兼容旧逻辑) */
|
|
|
|
|
|
const userInfo = ref<UserInfo | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前会话信息 */
|
|
|
|
|
|
const currentConversation = ref<ConversationInfo | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
/** 消息列表 */
|
|
|
|
|
|
const messages = ref<Message[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否正在加载(发送消息、摇人等操作时) */
|
|
|
|
|
|
const loading = ref<boolean>(false)
|
|
|
|
|
|
|
|
|
|
|
|
/** 招手是否正在进行中(防止重复点击) */
|
|
|
|
|
|
const shaking = ref<boolean>(false)
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否可以呼叫人工坐席(AI 实质性回复 >= 3 次时显示按钮) */
|
|
|
|
|
|
const canCallAgent = ref<boolean>(false)
|
|
|
|
|
|
|
|
|
|
|
|
/** 坐席是否在线(通过 WebSocket 或轮询获取) */
|
|
|
|
|
|
const agentOnline = ref<boolean>(true) // 默认在线,阶段一简化处理
|
|
|
|
|
|
|
|
|
|
|
|
/** 轮询定时器 ID */
|
|
|
|
|
|
const pollTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
/** AI 助手面板是否展开(移动端使用) */
|
|
|
|
|
|
const assistantPanelVisible = ref<boolean>(false)
|
|
|
|
|
|
|
|
|
|
|
|
/** 审批流程链接列表 */
|
|
|
|
|
|
const approvalLinks = ref<ApprovalLink[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
/** 软件下载列表 */
|
|
|
|
|
|
const softwareDownloads = ref<SoftwareDownload[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
/** 最后一条消息的 ID(用于增量轮询) */
|
|
|
|
|
|
const lastMessageId = ref<string>('')
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否已初始化(完成数据加载) */
|
|
|
|
|
|
const initialized = ref<boolean>(false)
|
|
|
|
|
|
|
|
|
|
|
|
/** 排查步骤列表(从坐席端同步,通过 WebSocket 推送) */
|
|
|
|
|
|
const troubleshootingSteps = ref<Array<{ label: string; status: 'done' | 'current' | 'pending' }>>([])
|
|
|
|
|
|
|
|
|
|
|
|
/** 排查步骤模板名称 */
|
|
|
|
|
|
const troubleshootingTemplateName = ref<string>('')
|
|
|
|
|
|
|
|
|
|
|
|
/** 排查流程图当前活跃节点(交互式 — 用户可点击选项) */
|
|
|
|
|
|
const troubleshootingCurrentNode = ref<{
|
|
|
|
|
|
id: string
|
|
|
|
|
|
type: 'step' | 'decision'
|
|
|
|
|
|
label: string
|
|
|
|
|
|
status?: 'done' | 'current' | 'pending'
|
|
|
|
|
|
children?: any[]
|
|
|
|
|
|
yes_branch?: any
|
|
|
|
|
|
no_branch?: any
|
|
|
|
|
|
} | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
/** 参与者列表(邀请功能 P0-09~P0-11,从会话信息同步) */
|
|
|
|
|
|
const participants = ref<ParticipantItem[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
/** 参与者面板是否展开 */
|
|
|
|
|
|
const participantPanelVisible = ref<boolean>(false)
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// v2.0 新增:动态推荐状态(侧边栏卡片推送)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 动态推荐卡片列表(由后端 WS dynamic_recommend 推送)
|
|
|
|
|
|
* 做什么:存储 AI 推荐的审批/操作入口卡片,供 RightPanel 的 DynamicRecommend 组件渲染
|
|
|
|
|
|
* 为什么:审批卡片从聊天流移到侧边栏,与文字回复强关联但不打断对话
|
|
|
|
|
|
* 最多保留 3 张,超过时移除最旧的
|
|
|
|
|
|
*/
|
|
|
|
|
|
const dynamicRecommendations = ref<Array<{
|
|
|
|
|
|
recommend_id: string
|
|
|
|
|
|
card_type: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
description: string
|
|
|
|
|
|
approval_type?: string
|
|
|
|
|
|
confidence: number
|
|
|
|
|
|
message_id: string
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
}>>([])
|
|
|
|
|
|
|
|
|
|
|
|
/** 动态推荐未查看数量(用于 Badge 红点提示) */
|
|
|
|
|
|
const unreadRecommendCount = ref<number>(0)
|
|
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 资产推荐卡片(v3.0 新增,由 WS asset_recommend 事件推送)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 资产推荐卡片列表(由后端 asset_recommend_service 推送)
|
|
|
|
|
|
* 做什么:存储 L1/L2/L3 分层推荐卡片,供 DynamicRecommend 组件渲染
|
|
|
|
|
|
* 数据结构:{ id, layer, layer_label, source, title, description, items, ... }
|
|
|
|
|
|
*/
|
|
|
|
|
|
const assetRecommendations = ref<Array<{
|
|
|
|
|
|
id: string
|
|
|
|
|
|
layer: string
|
|
|
|
|
|
layer_label: string
|
|
|
|
|
|
source: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
description?: string
|
|
|
|
|
|
icon?: string
|
|
|
|
|
|
items?: Array<{
|
|
|
|
|
|
label: string
|
|
|
|
|
|
type: string
|
|
|
|
|
|
url?: string
|
|
|
|
|
|
value?: string
|
|
|
|
|
|
copyable?: boolean
|
|
|
|
|
|
approval_type?: string
|
|
|
|
|
|
}>
|
|
|
|
|
|
action_url?: string
|
|
|
|
|
|
confidence?: number
|
|
|
|
|
|
relevance?: string
|
|
|
|
|
|
}>>([])
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理资产推荐卡片(v3.0 新增)
|
|
|
|
|
|
* 做什么:将后端推送的资产推荐数据存入 assetRecommendations
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleAssetRecommend(data: {
|
|
|
|
|
|
recommends: Array<any>
|
|
|
|
|
|
layered?: boolean
|
|
|
|
|
|
push_type?: string
|
|
|
|
|
|
}): void {
|
|
|
|
|
|
if (!data.recommends || data.recommends.length === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
// 添加到推荐列表(最多 5 张,超过时移除最旧的)
|
|
|
|
|
|
assetRecommendations.value.push(...data.recommends)
|
|
|
|
|
|
if (assetRecommendations.value.length > 5) {
|
|
|
|
|
|
assetRecommendations.value.splice(0, assetRecommendations.value.length - 5)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 增加未读计数
|
|
|
|
|
|
unreadRecommendCount.value += data.recommends.length
|
|
|
|
|
|
console.log('[Store] 资产推荐已接收:', data.recommends.length, '条, 未读:', unreadRecommendCount.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清除所有资产推荐
|
|
|
|
|
|
*/
|
|
|
|
|
|
function clearAssetRecommend(): void {
|
|
|
|
|
|
assetRecommendations.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:49:50 +08:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 流式 AI 回复(打字机)临时状态 — 改造方案 A
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 当前正在流式接收的 AI 消息临时气泡 ID
|
|
|
|
|
|
* 做什么:记录打字机占位气泡的 message_id,流式 chunk 累积到该气泡,
|
|
|
|
|
|
* 收到 ai_reply 终态后替换为真实消息
|
|
|
|
|
|
* 为什么:员工端同时只有一个活跃会话,无需用 Map 存多会话
|
|
|
|
|
|
*/
|
|
|
|
|
|
const streamingAiMessageId = ref<string | null>(null)
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 消息去重相关状态(与 agent 端一致,WS-06 修复)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 已处理消息ID集合(用于消息去重)
|
|
|
|
|
|
* 做什么:记录最近处理过的 message_id,防止网络抖动或将来接入 WS 后
|
|
|
|
|
|
* 收到重复消息导致前端重复显示
|
|
|
|
|
|
* 为什么:轮询和将来的 WS 推送可能同时到达,需要幂等去重
|
|
|
|
|
|
* 最多保留 500 条,超过时删除最旧的一条(FIFO)
|
|
|
|
|
|
* ES2015+ 规范:Set 迭代顺序 = 插入顺序,delete 后重新 add 会移到最后
|
|
|
|
|
|
*/
|
|
|
|
|
|
const processedMessageIds = ref<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 计算属性
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否已登录(委托给 employee store) */
|
|
|
|
|
|
const isLoggedIn = computed(() => {
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
return employeeStore.isAuthenticated
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否有活跃会话(会话未结单) */
|
|
|
|
|
|
const hasActiveConversation = computed(() => {
|
|
|
|
|
|
if (!currentConversation.value) return false
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// resolved: 已结单(后端状态)
|
|
|
|
|
|
return currentConversation.value.status !== 'resolved'
|
2026-06-14 16:49:18 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前用户是否为被邀请的参与者(非原始员工) */
|
|
|
|
|
|
const isParticipant = computed(() => {
|
|
|
|
|
|
if (!currentConversation.value || !userInfo.value) return false
|
|
|
|
|
|
// 如果当前用户 ID 等于会话的 employee_id,说明是原始员工,不是被邀请人
|
|
|
|
|
|
if (currentConversation.value.employee_id === userInfo.value.employee_id) return false
|
|
|
|
|
|
// 检查是否在 participants 列表中
|
|
|
|
|
|
return participants.value.some(p => p.id === userInfo.value?.employee_id)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 已加入的参与者数量(用于横幅展示 "👥 N人参与") */
|
|
|
|
|
|
const joinedParticipantCount = computed(() => {
|
|
|
|
|
|
// 原始员工 + 坐席 + 已加入的参与者
|
|
|
|
|
|
const joinedParticipants = participants.value.filter(p => p.joined)
|
|
|
|
|
|
return joinedParticipants.length
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 审批链接按分类分组 */
|
|
|
|
|
|
const approvalLinksByCategory = computed(() => {
|
|
|
|
|
|
const grouped: Record<string, ApprovalLink[]> = {}
|
|
|
|
|
|
for (const link of approvalLinks.value) {
|
|
|
|
|
|
if (!grouped[link.category]) {
|
|
|
|
|
|
grouped[link.category] = []
|
|
|
|
|
|
}
|
|
|
|
|
|
grouped[link.category].push(link)
|
|
|
|
|
|
}
|
|
|
|
|
|
return grouped
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 软件下载按分类分组 */
|
|
|
|
|
|
const softwareDownloadsByCategory = computed(() => {
|
|
|
|
|
|
const grouped: Record<string, SoftwareDownload[]> = {}
|
|
|
|
|
|
for (const item of softwareDownloads.value) {
|
|
|
|
|
|
if (!grouped[item.category]) {
|
|
|
|
|
|
grouped[item.category] = []
|
|
|
|
|
|
}
|
|
|
|
|
|
grouped[item.category].push(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
return grouped
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 消息去重辅助函数(WS-06 修复,与 agent 端一致)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 记录已处理的消息ID(用于去重)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 做什么:将 message_id 加入 processedMessageIds,
|
|
|
|
|
|
* 如果已存在则先删除再重新添加(移到"最新"位置),
|
|
|
|
|
|
* 超过 500 条时删除最旧的一条(FIFO)。
|
|
|
|
|
|
* 为什么:防止网络抖动或将来 WS 重连导致重复处理同一条消息,
|
|
|
|
|
|
* 使用 Set + 插入顺序(ES2015+ 规范)实现 FIFO 淘汰。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param messageId - 消息ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
function trackProcessedMessageId(messageId: string): void {
|
|
|
|
|
|
const set = processedMessageIds.value
|
|
|
|
|
|
// 如果已存在,先删除(ES2015+:重新 add 会移到插入顺序末尾)
|
|
|
|
|
|
set.delete(messageId)
|
|
|
|
|
|
set.add(messageId)
|
|
|
|
|
|
// 超过 500 条时,删除最旧的一条(Set 迭代第一个 = 最早插入)
|
|
|
|
|
|
if (set.size > 500) {
|
|
|
|
|
|
const first = set.values().next().value as string
|
|
|
|
|
|
if (first) set.delete(first)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 WebSocket 推送的新消息事件(将来接入 WS 时使用,与 agent 端对齐)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 做什么:
|
|
|
|
|
|
* 1. 【WS-06去重】先检查 message_id 是否已处理过,已处理则跳过
|
|
|
|
|
|
* 2. 将新消息追加到消息列表
|
|
|
|
|
|
* 3. 更新最后消息ID
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么需要:
|
|
|
|
|
|
* - 将来接入 WebSocket 后,WS 推送比轮询更实时
|
|
|
|
|
|
* - 需要消息去重,防止 WS 重连后后端重发导致重复显示
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param data - WebSocket 推送的消息数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleNewMessage(data: {
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
message_id: string
|
|
|
|
|
|
sender_type: string
|
|
|
|
|
|
sender_id: string
|
|
|
|
|
|
sender_name?: string
|
|
|
|
|
|
content: string
|
|
|
|
|
|
msg_type?: string
|
2026-07-18 00:05:59 +08:00
|
|
|
|
// v3.2 P0-5: 全字段透传(后端 messages.py:278 已下发,修复坐席图片/文件不渲染)
|
|
|
|
|
|
media_url?: string
|
|
|
|
|
|
file_name?: string
|
|
|
|
|
|
file_size?: number
|
|
|
|
|
|
extra_data?: Record<string, any>
|
|
|
|
|
|
reply_to_id?: string
|
|
|
|
|
|
created_at?: string
|
2026-06-14 16:49:18 +08:00
|
|
|
|
}): void {
|
|
|
|
|
|
// WS-06 消息去重:检查 message_id 是否已处理过
|
|
|
|
|
|
if (processedMessageIds.value.has(data.message_id)) {
|
|
|
|
|
|
console.log(`[H5 WS去重] 跳过重复消息: ${data.message_id}`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 记录此消息ID为"已处理"
|
|
|
|
|
|
trackProcessedMessageId(data.message_id)
|
|
|
|
|
|
|
|
|
|
|
|
// 追加消息到本地列表
|
|
|
|
|
|
// 修复:message_type 应使用 sender_type(employee/agent/ai/system),
|
|
|
|
|
|
// 而非 msg_type(text/image/file),否则 MessageBubble 无法识别消息类型
|
|
|
|
|
|
messages.value.push({
|
|
|
|
|
|
message_id: data.message_id,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: (data.sender_type || 'system') as MessageType,
|
|
|
|
|
|
msg_type: (data.msg_type || 'text') as MsgContentType,
|
2026-07-17 23:08:59 +08:00
|
|
|
|
// ★ 防御性类型保护:确保 content 始终是 String
|
|
|
|
|
|
content: typeof data.content === 'string' ? data.content : (data.content ? JSON.stringify(data.content) : ''),
|
2026-06-14 16:49:18 +08:00
|
|
|
|
sender_name: data.sender_name || '',
|
2026-07-18 00:05:59 +08:00
|
|
|
|
// v3.2 P0-5: 全字段透传(created_at 用服务端值,不用 new Date())
|
|
|
|
|
|
media_url: data.media_url,
|
|
|
|
|
|
file_name: data.file_name,
|
|
|
|
|
|
file_size: data.file_size,
|
|
|
|
|
|
extra_data: data.extra_data,
|
|
|
|
|
|
reply_to_id: data.reply_to_id,
|
|
|
|
|
|
created_at: data.created_at || new Date().toISOString(),
|
2026-06-14 16:49:18 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 更新最后消息ID
|
|
|
|
|
|
lastMessageId.value = data.message_id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 操作方法
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 OAuth2 授权回调
|
|
|
|
|
|
* 委托给 employee store 处理
|
|
|
|
|
|
* @param code 企微 OAuth2 授权码
|
|
|
|
|
|
* @param state 企微 OAuth2 state 参数(可选)
|
|
|
|
|
|
* @deprecated 请使用 employeeStore.handleOAuthCallback() 替代
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function handleOAuthCallback(code: string, state?: string): Promise<void> {
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
await employeeStore.handleOAuthCallback(code, state)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 加载当前用户信息
|
|
|
|
|
|
* 从后端获取当前登录员工的详细信息
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function fetchUserInfo(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 优先使用 employee store 的信息
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
if (employeeStore.employeeInfo) {
|
|
|
|
|
|
userInfo.value = {
|
|
|
|
|
|
employee_id: employeeStore.employeeInfo.employee_id,
|
|
|
|
|
|
employee_name: employeeStore.employeeInfo.employee_name,
|
|
|
|
|
|
department: employeeStore.employeeInfo.department,
|
|
|
|
|
|
position: employeeStore.employeeInfo.position,
|
|
|
|
|
|
level: '',
|
|
|
|
|
|
is_vip: employeeStore.employeeInfo.is_vip,
|
|
|
|
|
|
avatar_url: employeeStore.employeeInfo.avatar,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 同时从 /h5/user 获取最新信息(包含 is_vip 等)
|
|
|
|
|
|
const data = await getUser()
|
|
|
|
|
|
userInfo.value = data
|
|
|
|
|
|
console.log('[Store] 获取用户信息成功:', data.employee_name)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 获取用户信息失败:', error)
|
|
|
|
|
|
// 开发模式:API 失败时使用 mock 数据,不阻塞初始化
|
|
|
|
|
|
if (!import.meta.env.VITE_WECOM_CORP_ID) {
|
|
|
|
|
|
console.warn('[Store] 开发模式:使用 mock 用户信息')
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
userInfo.value = {
|
|
|
|
|
|
employee_id: employeeStore.employeeId || 'dev_test_employee',
|
|
|
|
|
|
employee_name: employeeStore.employeeName || '开发测试用户',
|
|
|
|
|
|
department: 'IT部',
|
|
|
|
|
|
position: '开发工程师',
|
|
|
|
|
|
level: '',
|
|
|
|
|
|
is_vip: false,
|
|
|
|
|
|
avatar_url: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 加载当前会话
|
|
|
|
|
|
* 获取当前员工正在进行的会话
|
|
|
|
|
|
* 同步 participants 到 store(邀请功能 P0-09~P0-11)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function fetchCurrentConversation(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getCurrentConversation()
|
|
|
|
|
|
currentConversation.value = data
|
|
|
|
|
|
// 同步 can_call_agent 状态
|
|
|
|
|
|
canCallAgent.value = data?.can_call_agent ?? false
|
|
|
|
|
|
// 同步 participants(邀请功能)
|
|
|
|
|
|
participants.value = data?.participants || []
|
|
|
|
|
|
console.log('[Store] 获取当前会话:', data ? data.conversation_id : '无活跃会话', '参与者:', participants.value.length)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 获取当前会话失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送消息(含 AI 自动回复)- 乐观更新 UI
|
|
|
|
|
|
* 在当前会话中发送一条文本消息。
|
|
|
|
|
|
* 后端会自动生成 AI 回复,返回用户消息 + AI 回复。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 乐观更新流程:
|
|
|
|
|
|
* 1. 发送前生成临时消息,立即添加到列表显示"发送中..."
|
|
|
|
|
|
* 2. API 返回成功后用真实消息替换,状态改为"已发送"
|
|
|
|
|
|
* 3. API 失败时状态改为"发送失败",用户可点击重试
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param content 消息内容
|
|
|
|
|
|
* @param tempMessageId 临时消息ID(用于重试时定位)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function sendNewMessage(
|
|
|
|
|
|
content: string,
|
|
|
|
|
|
options?: {
|
|
|
|
|
|
msg_type?: MsgContentType
|
|
|
|
|
|
media_url?: string
|
|
|
|
|
|
file_name?: string
|
|
|
|
|
|
file_size?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
console.log('[Store] sendNewMessage 开始执行, content:', content, 'options:', options)
|
|
|
|
|
|
if (!content.trim()) {
|
|
|
|
|
|
console.warn('[Store] content 为空,直接返回')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// v2.0 改造(2026-07-13):删除前端 checkApprovalIntent 异步调用
|
|
|
|
|
|
// 审批意图识别已统一由后端 process_h5_ai_reply → get_structured_reply 处理
|
|
|
|
|
|
// 结果通过 WS dynamic_recommend 推送到侧边栏,不再由前端独立调用
|
2026-06-15 09:32:41 +08:00
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 步骤1:乐观更新 - 立即添加临时消息到列表
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
const tempMessageId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
|
|
|
|
const tempMessage: Message = {
|
|
|
|
|
|
message_id: tempMessageId,
|
|
|
|
|
|
conversation_id: currentConversation.value?.conversation_id || '',
|
|
|
|
|
|
message_type: 'employee',
|
|
|
|
|
|
msg_type: options?.msg_type || 'text',
|
|
|
|
|
|
content: content.trim(),
|
|
|
|
|
|
sender_name: employeeStore.employeeName || '我',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sending', // 乐观更新:发送中状态
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(tempMessage)
|
|
|
|
|
|
console.log('[Store] 乐观更新:临时消息已添加到列表, tempMessageId:', tempMessageId)
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 步骤2:调用后端 API
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 构建请求参数:文本消息只传 content,非文本消息额外传 msg_type/media_url 等
|
|
|
|
|
|
const reqData: SendMessageRequest = {
|
|
|
|
|
|
content: content.trim(),
|
|
|
|
|
|
}
|
|
|
|
|
|
if (options?.msg_type) {
|
|
|
|
|
|
reqData.msg_type = options.msg_type
|
|
|
|
|
|
reqData.media_url = options.media_url
|
|
|
|
|
|
reqData.file_name = options.file_name
|
|
|
|
|
|
reqData.file_size = options.file_size
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('[Store] 请求参数:', JSON.stringify(reqData))
|
|
|
|
|
|
|
|
|
|
|
|
const resp: SendMessageResponse = await sendMessage(reqData)
|
|
|
|
|
|
console.log('[Store] API 响应:', resp)
|
|
|
|
|
|
|
|
|
|
|
|
// 防御性检查:确保 resp 和必要字段存在
|
|
|
|
|
|
if (!resp) {
|
|
|
|
|
|
console.error('[Store] API 响应为空')
|
|
|
|
|
|
throw new Error('API 响应为空')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!resp.user_message) {
|
|
|
|
|
|
console.error('[Store] API 响应缺少 user_message')
|
|
|
|
|
|
throw new Error('API 响应格式错误:缺少 user_message')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 步骤3:乐观更新成功 - 用真实消息替换临时消息
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 找到临时消息并替换为真实消息
|
|
|
|
|
|
const tempIndex = messages.value.findIndex(m => m.message_id === tempMessageId)
|
|
|
|
|
|
if (tempIndex !== -1) {
|
|
|
|
|
|
// 用真实消息替换,状态改为"已发送"
|
|
|
|
|
|
messages.value[tempIndex] = {
|
|
|
|
|
|
...resp.user_message,
|
|
|
|
|
|
status: 'sent',
|
|
|
|
|
|
}
|
2026-07-11 23:13:10 +08:00
|
|
|
|
// 新增:更新去重追踪,避免轮询时重复添加
|
|
|
|
|
|
trackProcessedMessageId(resp.user_message.message_id)
|
|
|
|
|
|
// 新增:更新 lastMessageId,防止轮询重复拉取刚发送的消息
|
|
|
|
|
|
lastMessageId.value = resp.user_message.message_id
|
2026-06-14 16:49:18 +08:00
|
|
|
|
console.log('[Store] 乐观更新成功:临时消息已替换为真实消息')
|
2026-07-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新本地缓存
|
|
|
|
|
|
const convId = currentConversation.value?.conversation_id
|
|
|
|
|
|
if (convId) {
|
|
|
|
|
|
saveMessagesToCache(convId, messages.value)
|
|
|
|
|
|
}
|
2026-06-14 16:49:18 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 防御性:找不到临时消息时直接添加
|
|
|
|
|
|
messages.value.push({ ...resp.user_message, status: 'sent' })
|
2026-07-11 23:13:10 +08:00
|
|
|
|
// 新增:更新去重追踪,避免轮询时重复添加
|
|
|
|
|
|
trackProcessedMessageId(resp.user_message.message_id)
|
|
|
|
|
|
// 新增:更新 lastMessageId,防止轮询重复拉取刚发送的消息
|
|
|
|
|
|
lastMessageId.value = resp.user_message.message_id
|
2026-06-14 16:49:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:49:50 +08:00
|
|
|
|
// 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
|
|
|
|
|
|
// 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
|
|
|
|
|
|
// 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新「是否可呼叫坐席」标志
|
|
|
|
|
|
canCallAgent.value = resp.can_call_agent ?? false
|
|
|
|
|
|
|
|
|
|
|
|
// 如果会话信息中也有更新,保持同步
|
|
|
|
|
|
if (currentConversation.value) {
|
|
|
|
|
|
currentConversation.value.can_call_agent = resp.can_call_agent ?? false
|
|
|
|
|
|
currentConversation.value.ai_substantive_reply_count = resp.ai_reply_count ?? 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
// 关键修复:如果发送消息前没有活跃会话(currentConversation 为 null),
|
|
|
|
|
|
// 说明这是第一条消息创建了新会话。此时必须重新获取会话信息,
|
|
|
|
|
|
// 否则 currentConversation.value?.conversation_id 为 undefined,
|
|
|
|
|
|
// 所有后续 WS 事件(ai_reply / ai_thinking / dynamic_recommend 等)
|
|
|
|
|
|
// 都会因 conversation_id 不匹配(undefined !== actual_id)被静默丢弃。
|
|
|
|
|
|
if (!currentConversation.value && resp.user_message?.conversation_id) {
|
|
|
|
|
|
console.log('[Store] 发送消息后 currentConversation 为 null,重新获取会话信息...')
|
|
|
|
|
|
await fetchCurrentConversation()
|
|
|
|
|
|
if (currentConversation.value) {
|
|
|
|
|
|
// 获取到新会话后,同步状态
|
|
|
|
|
|
currentConversation.value.can_call_agent = resp.can_call_agent ?? false
|
|
|
|
|
|
currentConversation.value.ai_substantive_reply_count = resp.ai_reply_count ?? 0
|
|
|
|
|
|
console.log('[Store] 新会话已获取:', currentConversation.value.conversation_id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
console.log(
|
|
|
|
|
|
'[Store] 消息发送成功, AI回复计数:',
|
|
|
|
|
|
resp.ai_reply_count,
|
|
|
|
|
|
'可呼叫坐席:',
|
|
|
|
|
|
resp.can_call_agent,
|
|
|
|
|
|
'是否引导:',
|
|
|
|
|
|
resp.is_guidance
|
|
|
|
|
|
)
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('[Store] 消息发送失败:', error)
|
|
|
|
|
|
console.error('[Store] 错误详情:', error?.message, error?.response?.data, error?.stack)
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 步骤4:乐观更新失败 - 将临时消息状态改为"发送失败"
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
const tempIndex = messages.value.findIndex(m => m.message_id === tempMessageId)
|
|
|
|
|
|
if (tempIndex !== -1) {
|
|
|
|
|
|
// 状态改为"发送失败",用户可点击重试
|
|
|
|
|
|
messages.value[tempIndex].status = 'failed'
|
|
|
|
|
|
console.log('[Store] 乐观更新失败:临时消息状态已改为 failed')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 向上抛出错误,让调用方(InputBar)能感知并提示用户
|
|
|
|
|
|
throw error
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
console.log('[Store] sendNewMessage finally 执行完成')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 轮询新消息
|
|
|
|
|
|
* 增量获取当前会话中 lastMessageId 之后的新消息
|
|
|
|
|
|
* 获取到新消息后追加到本地列表,并更新 lastMessageId
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function pollNewMessages(): Promise<void> {
|
|
|
|
|
|
// 未登录或无活跃会话时不轮询
|
|
|
|
|
|
if (!isLoggedIn.value || !hasActiveConversation.value) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = lastMessageId.value
|
|
|
|
|
|
? { after_message_id: lastMessageId.value }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
const newMessages = await pollMessages(params)
|
|
|
|
|
|
|
|
|
|
|
|
if (newMessages && newMessages.length > 0) {
|
|
|
|
|
|
// ======================================================================
|
|
|
|
|
|
// WS-06 消息去重:过滤掉已处理过的消息
|
|
|
|
|
|
// ======================================================================
|
|
|
|
|
|
// 为什么:轮询和将来的 WS 推送可能同时到达同一条消息,
|
|
|
|
|
|
// 需要先做 message_id 幂等检查,避免重复显示
|
|
|
|
|
|
const uniqueNewMessages = newMessages.filter(msg => {
|
|
|
|
|
|
// 检查是否已处理过
|
|
|
|
|
|
if (processedMessageIds.value.has(msg.message_id)) {
|
|
|
|
|
|
console.log(`[H5轮询去重] 跳过重复消息: ${msg.message_id}`)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
// 记录此消息ID为"已处理"(更新到 Set 的最新位置)
|
|
|
|
|
|
trackProcessedMessageId(msg.message_id)
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (uniqueNewMessages.length > 0) {
|
|
|
|
|
|
// 追加新消息到本地列表
|
|
|
|
|
|
messages.value.push(...uniqueNewMessages)
|
|
|
|
|
|
// 更新最后消息 ID
|
|
|
|
|
|
lastMessageId.value = uniqueNewMessages[uniqueNewMessages.length - 1].message_id
|
|
|
|
|
|
console.log('[Store] 轮询到新消息:', uniqueNewMessages.length, '条')
|
2026-07-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新本地缓存
|
|
|
|
|
|
const convId = currentConversation.value?.conversation_id
|
|
|
|
|
|
if (convId) {
|
|
|
|
|
|
saveMessagesToCache(convId, messages.value)
|
|
|
|
|
|
}
|
2026-06-14 16:49:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// 轮询失败不弹提示,静默处理避免干扰用户
|
|
|
|
|
|
console.error('[Store] 轮询消息失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 22:56:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取消息列表(历史消息)
|
|
|
|
|
|
* 获取当前会话的完整消息历史,用于首次加载或翻页
|
|
|
|
|
|
* @param params 查询参数(limit 和 before)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function fetchMessages(params?: { limit?: number; before?: string }): Promise<void> {
|
|
|
|
|
|
// 未登录或无活跃会话时不获取
|
|
|
|
|
|
if (!isLoggedIn.value || !hasActiveConversation.value) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getMessages(params)
|
|
|
|
|
|
if (data.items && data.items.length > 0) {
|
|
|
|
|
|
// 消息去重
|
|
|
|
|
|
const uniqueMessages = data.items.filter(msg => {
|
|
|
|
|
|
if (processedMessageIds.value.has(msg.message_id)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
trackProcessedMessageId(msg.message_id)
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (uniqueMessages.length > 0) {
|
|
|
|
|
|
// 如果没有 before 参数(全量加载),直接替换消息列表
|
|
|
|
|
|
// 如果有 before 参数(翻页),追加到列表末尾
|
|
|
|
|
|
if (!params?.before) {
|
|
|
|
|
|
messages.value = uniqueMessages
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messages.value.push(...uniqueMessages)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新最后消息 ID
|
|
|
|
|
|
const lastMsg = uniqueMessages[uniqueMessages.length - 1]
|
|
|
|
|
|
if (lastMsg) {
|
|
|
|
|
|
lastMessageId.value = lastMsg.message_id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Store] 获取到历史消息:', uniqueMessages.length, '条')
|
|
|
|
|
|
|
|
|
|
|
|
// 保存到缓存
|
|
|
|
|
|
const convId = currentConversation.value?.conversation_id
|
|
|
|
|
|
if (convId) {
|
|
|
|
|
|
saveMessagesToCache(convId, messages.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 获取历史消息失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 启动消息轮询
|
|
|
|
|
|
* 使用 setInterval 每 3 秒轮询一次新消息
|
|
|
|
|
|
* 在组件挂载时调用,组件卸载时务必调用 stopPolling 停止
|
|
|
|
|
|
*/
|
|
|
|
|
|
function startPolling(): void {
|
|
|
|
|
|
// 防止重复启动
|
|
|
|
|
|
if (pollTimer.value) return
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Store] 启动消息轮询(3秒间隔)')
|
|
|
|
|
|
pollTimer.value = setInterval(() => {
|
|
|
|
|
|
pollNewMessages()
|
|
|
|
|
|
}, 3000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 停止消息轮询
|
|
|
|
|
|
* 在组件卸载或会话结束时调用
|
|
|
|
|
|
*/
|
|
|
|
|
|
function stopPolling(): void {
|
|
|
|
|
|
if (pollTimer.value) {
|
|
|
|
|
|
console.log('[Store] 停止消息轮询')
|
|
|
|
|
|
clearInterval(pollTimer.value)
|
|
|
|
|
|
pollTimer.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 招手/敲桌子 — 呼叫 IT 坐席
|
|
|
|
|
|
* 调用后端招手接口,返回趣味话术
|
|
|
|
|
|
* 将话术以系统消息形式插入对话列表
|
2026-07-07 22:56:49 +08:00
|
|
|
|
* @returns 分配结果: assigned(已分配坐席) / queued(排队中) / assign_failed(分配失败)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
*/
|
2026-07-07 22:56:49 +08:00
|
|
|
|
async function shakeAgent(): Promise<string> {
|
2026-06-14 16:49:18 +08:00
|
|
|
|
// 防止重复点击
|
2026-07-07 22:56:49 +08:00
|
|
|
|
if (shaking.value) return 'pending'
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
shaking.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 从 employee store 获取当前员工信息
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
const data = await shake({
|
|
|
|
|
|
employee_id: employeeStore.employeeId,
|
|
|
|
|
|
employee_name: employeeStore.employeeName,
|
|
|
|
|
|
})
|
|
|
|
|
|
console.log('[Store] 招手成功:', data)
|
|
|
|
|
|
|
|
|
|
|
|
// 将趣味话术以系统消息形式插入对话列表
|
|
|
|
|
|
const systemMsg: Message = {
|
|
|
|
|
|
message_id: `sys_shake_${Date.now()}`,
|
|
|
|
|
|
conversation_id: currentConversation.value?.conversation_id || '',
|
|
|
|
|
|
message_type: 'system',
|
|
|
|
|
|
content: data.funny_phrase,
|
|
|
|
|
|
sender_name: '系统',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(systemMsg)
|
|
|
|
|
|
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// 如果已分配坐席,更新话术内容
|
|
|
|
|
|
if (data.assign_result === 'assigned' && data.assigned_agent_id) {
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// 2026-07-12 改造:去掉 🎉 emoji,文案与后端 funny_phrases.connected 保持一致
|
|
|
|
|
|
systemMsg.content = `${data.funny_phrase}\n\n坐席正在查看您的信息,请等待处理回复!`
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// 更新会话状态
|
|
|
|
|
|
if (currentConversation.value) {
|
|
|
|
|
|
currentConversation.value.status = 'serving'
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (data.assign_result === 'queued') {
|
|
|
|
|
|
// 进入排队
|
|
|
|
|
|
systemMsg.content = `${data.funny_phrase}\n\n⏳ 当前无空闲坐席,您已进入排队,请耐心等待...`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
// 如果招手后坐席已接入(status === 'serving'),刷新会话信息
|
|
|
|
|
|
if (data.conversation?.status === 'serving') {
|
|
|
|
|
|
await fetchCurrentConversation()
|
|
|
|
|
|
}
|
2026-07-07 22:56:49 +08:00
|
|
|
|
|
|
|
|
|
|
// 返回分配结果
|
|
|
|
|
|
return data.assign_result || 'queued'
|
2026-06-14 16:49:18 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 招手失败:', error)
|
2026-07-07 22:56:49 +08:00
|
|
|
|
return 'error'
|
2026-06-14 16:49:18 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
shaking.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 加载审批流程链接
|
|
|
|
|
|
* 从后端获取所有可用的审批流程链接
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function fetchApprovalLinks(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getApprovalLinks()
|
|
|
|
|
|
approvalLinks.value = data
|
|
|
|
|
|
console.log('[Store] 获取审批链接成功:', data.length, '条')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 获取审批链接失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// v2.0 改造(2026-07-13):checkApprovalIntent() 已删除
|
|
|
|
|
|
// 审批意图识别统一由后端 process_h5_ai_reply → get_structured_reply 处理
|
|
|
|
|
|
// 结果通过 WS dynamic_recommend 推送到侧边栏 DynamicRecommend 组件
|
|
|
|
|
|
// 前端不再独立调用 /approval/detect-intent 接口
|
2026-06-15 09:32:41 +08:00
|
|
|
|
|
2026-07-18 01:17:08 +08:00
|
|
|
|
// v4.0 P1-2:showApprovalCard/closeApprovalCard 已删除
|
|
|
|
|
|
// 原因:唯一调用方 InputBox.vue 是孤儿组件(已移除),
|
|
|
|
|
|
// "快捷申请按钮"在当前产品中不存在(P0-3 误报确认)
|
|
|
|
|
|
// 后端 /approval/all-categories-card 端点保留备用
|
2026-06-15 09:32:41 +08:00
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 加载软件下载列表
|
|
|
|
|
|
* 从后端获取所有可下载的软件列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function fetchSoftwareDownloads(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getSoftwareDownloads()
|
|
|
|
|
|
softwareDownloads.value = data
|
|
|
|
|
|
console.log('[Store] 获取软件下载列表成功:', data.length, '条')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 获取软件下载列表失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换 AI 助手面板展开/收起(移动端使用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toggleAssistantPanel(): void {
|
|
|
|
|
|
assistantPanelVisible.value = !assistantPanelVisible.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换到指定会话(邀请链接加入后使用)
|
|
|
|
|
|
* 做什么:加入邀请会话后,将当前视图切换到该会话
|
|
|
|
|
|
* 为什么:被邀请人可能已有自己的会话,需要切换到邀请的会话
|
|
|
|
|
|
*
|
|
|
|
|
|
* 修复(2026-06-12):原实现仅调用 fetchCurrentConversation() 重新获取
|
|
|
|
|
|
* "当前会话",未使用 conversationId 参数。如果后端不会自动切换
|
|
|
|
|
|
* current conversation,则拿到的仍是用户原来的会话。
|
|
|
|
|
|
* 现改为:先刷新当前会话(加入后后端会更新 current conversation),
|
|
|
|
|
|
* 再验证会话ID是否匹配,确保切换成功。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param conversationId - 目标会话ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function switchToConversation(conversationId: string): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 重新获取当前会话(加入后后端会更新 current conversation)
|
|
|
|
|
|
await fetchCurrentConversation()
|
|
|
|
|
|
|
|
|
|
|
|
// 验证:当前会话是否已切换到目标会话
|
|
|
|
|
|
const conv = currentConversation.value
|
|
|
|
|
|
if (conv && conv.conversation_id !== conversationId) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
'[Store] 当前会话ID不匹配, 期望:', conversationId,
|
|
|
|
|
|
'实际:', conv.conversation_id
|
|
|
|
|
|
)
|
|
|
|
|
|
// 后端未自动切换时,仍按当前获取到的会话展示
|
|
|
|
|
|
// (后端 /h5/conversations/current 理论上应返回刚加入的会话)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// 清空消息列表,重新加载历史消息
|
2026-06-14 16:49:18 +08:00
|
|
|
|
messages.value = []
|
|
|
|
|
|
lastMessageId.value = ''
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// 获取完整的历史消息
|
|
|
|
|
|
await fetchMessages()
|
2026-06-14 16:49:18 +08:00
|
|
|
|
console.log('[Store] 已切换到邀请会话:', conversationId)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 切换会话失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 参与者主动退出会话(邀请功能 P0-11)
|
|
|
|
|
|
* 做什么:被邀请人退出当前会话
|
|
|
|
|
|
* 为什么:参与者不再需要参与时,可自行退出
|
|
|
|
|
|
* 副作用:退出后清空当前会话状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function leaveAsParticipant(): Promise<void> {
|
|
|
|
|
|
if (!currentConversation.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const convId = currentConversation.value.conversation_id
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// H5 专用端点通过 Token 认证获取 employee_id,无需前端传递
|
|
|
|
|
|
await leaveAsParticipantApi(convId)
|
|
|
|
|
|
console.log('[Store] 已退出会话:', convId)
|
|
|
|
|
|
// 退出后清空当前会话状态
|
|
|
|
|
|
currentConversation.value = null
|
|
|
|
|
|
participants.value = []
|
|
|
|
|
|
messages.value = []
|
|
|
|
|
|
lastMessageId.value = ''
|
|
|
|
|
|
// 停止轮询
|
|
|
|
|
|
stopPolling()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 退出会话失败:', error)
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换参与者面板展开/收起
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toggleParticipantPanel(): void {
|
|
|
|
|
|
participantPanelVisible.value = !participantPanelVisible.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 重试发送失败的消息
|
|
|
|
|
|
* 做什么:当用户点击"发送失败"消息的重试按钮时,删除失败消息并重新发送
|
|
|
|
|
|
* 为什么:乐观更新失败时允许用户手动重试发送
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param messageId 失败消息的 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function retryMessage(messageId: string): Promise<void> {
|
|
|
|
|
|
// 找到失败消息
|
|
|
|
|
|
const msgIndex = messages.value.findIndex(m => m.message_id === messageId)
|
|
|
|
|
|
if (msgIndex === -1) {
|
|
|
|
|
|
console.warn('[Store] 重试消息找不到:', messageId)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const failedMessage = messages.value[msgIndex]
|
|
|
|
|
|
if (failedMessage.status !== 'failed') {
|
|
|
|
|
|
console.warn('[Store] 消息状态不是 failed,无法重试:', messageId)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const content = failedMessage.content
|
|
|
|
|
|
// 删除失败消息
|
|
|
|
|
|
messages.value.splice(msgIndex, 1)
|
|
|
|
|
|
console.log('[Store] 删除失败消息:', messageId)
|
|
|
|
|
|
|
|
|
|
|
|
// 重新发送
|
|
|
|
|
|
await sendNewMessage(content, {
|
|
|
|
|
|
msg_type: failedMessage.msg_type,
|
|
|
|
|
|
media_url: failedMessage.media_url,
|
|
|
|
|
|
file_name: failedMessage.file_name,
|
|
|
|
|
|
file_size: failedMessage.file_size,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// WebSocket 事件处理方法(H5 员工端)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通过 WS 推送直接更新参与者列表
|
|
|
|
|
|
* 做什么:用后端 WS 推送的 participants 数据直接替换 store 中的列表
|
|
|
|
|
|
* 为什么:比等3秒轮询更实时,参与者变更(加入/退出/被移除)立即可见
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param newParticipants - 后端推送的最新参与者列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
function updateParticipants(newParticipants: ParticipantItem[]): void {
|
|
|
|
|
|
participants.value = newParticipants
|
|
|
|
|
|
// 同步到 currentConversation(保持一致性)
|
|
|
|
|
|
if (currentConversation.value) {
|
|
|
|
|
|
currentConversation.value.participants = newParticipants
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('[Store] 参与者列表已通过WS更新:', newParticipants.length, '人')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 当前用户被主责坐席从会话中移除
|
|
|
|
|
|
* 做什么:清空当前会话状态,回到无会话状态
|
|
|
|
|
|
* 为什么:被移除后不应再查看会话消息
|
|
|
|
|
|
* 触发条件:WS 收到 participant_removed 事件,且 changed 中包含当前用户
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleRemovedFromConversation(): void {
|
|
|
|
|
|
console.log('[Store] 当前用户被移除会话,清空状态')
|
|
|
|
|
|
currentConversation.value = null
|
|
|
|
|
|
participants.value = []
|
|
|
|
|
|
messages.value = []
|
|
|
|
|
|
lastMessageId.value = ''
|
|
|
|
|
|
participantPanelVisible.value = false
|
|
|
|
|
|
// 停止轮询(WS 会继续监听,下次有新会话时会自动更新)
|
|
|
|
|
|
stopPolling()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:49:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 处理 WS 流式 AI 回复 chunk(打字机效果)
|
|
|
|
|
|
* 做什么:首个 chunk 时创建临时 AI 气泡,后续 chunk 累积到该气泡 content
|
|
|
|
|
|
* 为什么:后端 AI 推理(Dify)流式返回,逐字推送以获得打字机体验,
|
|
|
|
|
|
* 避免整段等待(原同步方案卡"发送中"3~15s)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param data - { conversation_id, chunk }
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleAiReplyChunk(data: { conversation_id: string; chunk: string }): void {
|
|
|
|
|
|
// 仅处理当前会话,避免串台
|
|
|
|
|
|
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
|
|
|
|
|
if (!data.chunk) return
|
|
|
|
|
|
|
|
|
|
|
|
// 首个 chunk:创建占位气泡(不设置 status,避免误显示"发送中")
|
|
|
|
|
|
if (!streamingAiMessageId.value) {
|
|
|
|
|
|
const tempId = `ai_stream_${data.conversation_id}_${Date.now()}`
|
|
|
|
|
|
streamingAiMessageId.value = tempId
|
|
|
|
|
|
messages.value.push({
|
|
|
|
|
|
message_id: tempId,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: 'ai',
|
|
|
|
|
|
msg_type: 'text',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
sender_name: 'Duckula(达寇拉)',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 累积 chunk(Vue3 ref 深层响应式,直接改属性即可触发重渲染 → 打字机)
|
|
|
|
|
|
const idx = messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
messages.value[idx].content += data.chunk
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 WS AI 回复终态
|
|
|
|
|
|
* 做什么:用真实 AI 消息(含 DB message_id)替换打字机占位气泡,
|
|
|
|
|
|
* 登记 message_id 去重,同步计数 / 可呼叫坐席 / 会话状态
|
|
|
|
|
|
* 为什么:打字机结束后需落定为真实消息,且防止轮询兜底重复添加同一消息
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param data - 后端 _persist_and_push 推送的 ai_reply 数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleAiReply(data: {
|
|
|
|
|
|
message_id: string
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
sender_type: string
|
|
|
|
|
|
sender_id: string
|
|
|
|
|
|
sender_name: string
|
|
|
|
|
|
content: string
|
|
|
|
|
|
msg_type: string
|
|
|
|
|
|
is_guidance: boolean
|
|
|
|
|
|
ai_reply_count: number
|
|
|
|
|
|
can_call_agent: boolean
|
|
|
|
|
|
conversation_status: string
|
2026-07-13 02:17:03 +08:00
|
|
|
|
extra_data?: Record<string, any> // v2.0: 结构化消息的 options/action
|
2026-07-09 11:49:50 +08:00
|
|
|
|
}): void {
|
|
|
|
|
|
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
|
|
|
|
|
|
|
|
|
|
|
const finalMessage: Message = {
|
|
|
|
|
|
message_id: data.message_id,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: (data.sender_type || 'ai') as MessageType,
|
|
|
|
|
|
msg_type: (data.msg_type || 'text') as MsgContentType,
|
2026-07-17 23:08:59 +08:00
|
|
|
|
// ★ 防御性类型保护:确保 content 始终是 String
|
|
|
|
|
|
// 后端可能因 Dify 返回异常导致 content 为对象,此时转为 JSON 字符串
|
|
|
|
|
|
content: typeof data.content === 'string' ? data.content : (data.content ? JSON.stringify(data.content) : ''),
|
2026-07-09 11:49:50 +08:00
|
|
|
|
sender_name: data.sender_name || 'Duckula(达寇拉)',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sent',
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// v2.0: 传递结构化数据(options 按钮列表、action 卡片信息)
|
|
|
|
|
|
extra_data: data.extra_data || undefined,
|
2026-07-09 11:49:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 用真实消息替换占位气泡(若存在)
|
|
|
|
|
|
const idx = streamingAiMessageId.value
|
|
|
|
|
|
? messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
|
|
|
|
|
: -1
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
messages.value[idx] = finalMessage
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 没有占位气泡(如 WS 重连后首条 ai_reply),直接追加
|
|
|
|
|
|
messages.value.push(finalMessage)
|
|
|
|
|
|
}
|
|
|
|
|
|
streamingAiMessageId.value = null
|
|
|
|
|
|
|
|
|
|
|
|
// 去重登记:防止轮询兜底重复添加同一 AI 消息
|
|
|
|
|
|
trackProcessedMessageId(data.message_id)
|
|
|
|
|
|
lastMessageId.value = data.message_id
|
|
|
|
|
|
|
|
|
|
|
|
// 同步计数与可呼叫坐席状态
|
|
|
|
|
|
canCallAgent.value = data.can_call_agent ?? false
|
|
|
|
|
|
if (currentConversation.value) {
|
|
|
|
|
|
currentConversation.value.can_call_agent = data.can_call_agent ?? false
|
|
|
|
|
|
currentConversation.value.ai_substantive_reply_count = data.ai_reply_count ?? 0
|
|
|
|
|
|
// 后端状态值(ai_handling/queued/serving/resolved)映射到 H5 识别的子集
|
|
|
|
|
|
// H5 ConversationInfo.status 仅接受 waiting/serving/resolved,
|
|
|
|
|
|
// ai_handling/queued 均表示"进行中未结单",映射为 waiting
|
|
|
|
|
|
const statusMap: Record<string, 'waiting' | 'serving' | 'resolved'> = {
|
|
|
|
|
|
ai_handling: 'waiting',
|
|
|
|
|
|
queued: 'waiting',
|
|
|
|
|
|
serving: 'serving',
|
|
|
|
|
|
resolved: 'resolved',
|
|
|
|
|
|
}
|
|
|
|
|
|
currentConversation.value.status =
|
|
|
|
|
|
statusMap[data.conversation_status] || currentConversation.value.status
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 持久化缓存
|
|
|
|
|
|
const convId = currentConversation.value?.conversation_id
|
|
|
|
|
|
if (convId) saveMessagesToCache(convId, messages.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 WS AI 回复失败(兜底)
|
|
|
|
|
|
* 做什么:将占位气泡(或新气泡)替换为错误提示,引导用户转人工
|
|
|
|
|
|
* 为什么:Dify 异常时不应让用户一直看到空白打字气泡
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param data - { conversation_id, message }
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleAiReplyFailed(data: { conversation_id: string; message: string }): void {
|
|
|
|
|
|
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
|
|
|
|
|
|
|
|
|
|
|
const errorMsg: Message = {
|
|
|
|
|
|
message_id: `ai_failed_${data.conversation_id}_${Date.now()}`,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: 'ai',
|
|
|
|
|
|
msg_type: 'text',
|
|
|
|
|
|
content: data.message || '⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。',
|
|
|
|
|
|
sender_name: 'Duckula(达寇拉)',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sent',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 若仍有占位气泡,替换之;否则直接追加
|
|
|
|
|
|
const idx = streamingAiMessageId.value
|
|
|
|
|
|
? messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
|
|
|
|
|
: -1
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
messages.value[idx] = errorMsg
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messages.value.push(errorMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
streamingAiMessageId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// v2.0 新增:AI 思考指示器 + 动态推荐 + 选项回传
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
2026-07-09 11:49:50 +08:00
|
|
|
|
/**
|
2026-07-13 02:17:03 +08:00
|
|
|
|
* 处理 WS ai_thinking 消息(v2.0 新增)
|
|
|
|
|
|
* 做什么:在消息列表中创建/更新一个"正在思考..."的 AI 占位气泡
|
|
|
|
|
|
* 为什么:blocking 模式下 Dify 返回需要 3-8 秒,需要即时反馈让用户知道 AI 在工作
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param data - { conversation_id, status? }
|
|
|
|
|
|
* - status 未定义:首次思考指示,创建占位气泡显示"正在思考..."
|
|
|
|
|
|
* - status = "still_thinking":15 秒后更新为"仍在思考..."
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleAiThinking(data: { conversation_id: string; status?: string }): void {
|
|
|
|
|
|
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
|
|
|
|
|
|
|
|
|
|
|
// 如果已有占位气泡(streamingAiMessageId 或 thinking 气泡),更新文字
|
|
|
|
|
|
const thinkingId = streamingAiMessageId.value
|
|
|
|
|
|
const idx = thinkingId
|
|
|
|
|
|
? messages.value.findIndex(m => m.message_id === thinkingId)
|
|
|
|
|
|
: -1
|
|
|
|
|
|
|
|
|
|
|
|
const thinkingText = data.status === 'still_thinking'
|
|
|
|
|
|
? '仍在思考中,请稍候...'
|
|
|
|
|
|
: '正在思考...'
|
|
|
|
|
|
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
// 更新已有占位气泡的文字
|
|
|
|
|
|
messages.value[idx].content = thinkingText
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 创建新的占位气泡
|
|
|
|
|
|
const tempId = `ai_thinking_${data.conversation_id}_${Date.now()}`
|
|
|
|
|
|
streamingAiMessageId.value = tempId
|
|
|
|
|
|
messages.value.push({
|
|
|
|
|
|
message_id: tempId,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: 'ai',
|
|
|
|
|
|
msg_type: 'text',
|
|
|
|
|
|
content: thinkingText,
|
|
|
|
|
|
sender_name: 'Duckula(达寇拉)',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 WS dynamic_recommend 消息(v2.0 新增)
|
|
|
|
|
|
* 做什么:将 AI 推荐的操作卡片数据存入 dynamicRecommendations,
|
|
|
|
|
|
* RightPanel 的 DynamicRecommend 组件会响应式渲染
|
|
|
|
|
|
* 为什么:审批/操作入口从聊天流移到侧边栏,与文字回复强关联但不打断对话
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param data - 推荐卡片数据 { recommend_id, card_type, title, description, ... }
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleDynamicRecommend(data: {
|
|
|
|
|
|
recommend_id: string
|
|
|
|
|
|
card_type: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
description: string
|
|
|
|
|
|
approval_type?: string
|
|
|
|
|
|
confidence: number
|
|
|
|
|
|
message_id: string
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
}): void {
|
|
|
|
|
|
// 添加到推荐列表(最多 3 张,超过时移除最旧的)
|
|
|
|
|
|
dynamicRecommendations.value.push(data)
|
|
|
|
|
|
if (dynamicRecommendations.value.length > 3) {
|
|
|
|
|
|
dynamicRecommendations.value.shift()
|
|
|
|
|
|
}
|
|
|
|
|
|
// 增加未读计数(用于 Badge 红点提示)
|
|
|
|
|
|
unreadRecommendCount.value += 1
|
|
|
|
|
|
console.log('[Store] 动态推荐已接收:', data.title, '未读:', unreadRecommendCount.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清除未读推荐计数(用户查看侧边栏推荐时调用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function clearUnreadRecommend(): void {
|
|
|
|
|
|
unreadRecommendCount.value = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 移除指定的推荐卡片(用户点击 × 关闭时调用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function removeRecommend(recommendId: string): void {
|
|
|
|
|
|
const idx = dynamicRecommendations.value.findIndex(r => r.recommend_id === recommendId)
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
dynamicRecommendations.value.splice(idx, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送选项选择(v2.0 新增)
|
|
|
|
|
|
* 做什么:用户点击聊天气泡中的选项按钮后,通过 WS 发送 option_select 消息
|
|
|
|
|
|
* 后端接收后转化为 Dify user message,继续对话
|
|
|
|
|
|
* 为什么:实现交互式排查的闭环——AI 提问 → 用户点选项 → AI 继续推理
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param optionValue - 选项的 value 字段(如 "has_code")
|
|
|
|
|
|
* @param optionLabel - 选项的 label 字段(如 "有错误代码"),作为用户消息显示
|
|
|
|
|
|
*/
|
|
|
|
|
|
function sendOptionSelect(optionValue: string, optionLabel: string): void {
|
|
|
|
|
|
const convId = currentConversation.value?.conversation_id
|
|
|
|
|
|
if (!convId) return
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 在消息列表中显示用户的选择(作为员工消息)
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
messages.value.push({
|
|
|
|
|
|
message_id: `option_select_${Date.now()}`,
|
|
|
|
|
|
conversation_id: convId,
|
|
|
|
|
|
message_type: 'employee',
|
|
|
|
|
|
msg_type: 'text',
|
|
|
|
|
|
content: optionLabel,
|
|
|
|
|
|
sender_name: employeeStore.employeeName || '我',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sent',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 通过 WS 发送 option_select 消息(v2.0 已接入)
|
|
|
|
|
|
// 后端接收后转化为 Dify user message,继续对话
|
|
|
|
|
|
// WS 消息格式:{ type: "option_select", data: { conversation_id, option_value, option_label } }
|
|
|
|
|
|
const sent = sendWsMessage({
|
|
|
|
|
|
type: 'option_select',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
conversation_id: convId,
|
|
|
|
|
|
option_value: optionValue,
|
|
|
|
|
|
option_label: optionLabel,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!sent) {
|
|
|
|
|
|
// WS 未连接,降级为普通消息发送(HTTP API)
|
|
|
|
|
|
console.warn('[Store] WS 未连接,选项选择降级为 HTTP 消息发送')
|
|
|
|
|
|
// 通过现有的 sendMessage API 发送,后端会走正常 AI 回复流程
|
|
|
|
|
|
sendMessage({ content: optionLabel, msg_type: 'text' }).catch((err: unknown) => {
|
|
|
|
|
|
console.error('[Store] 选项选择 HTTP 降级发送失败:', err)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Store] 选项选择已发送:', optionValue, optionLabel)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 取消流式占位气泡
|
2026-07-09 11:49:50 +08:00
|
|
|
|
* 做什么:若 WS 在流式推送中途断开,移除未完成的占位气泡,
|
|
|
|
|
|
* 交由 3 秒轮询兜底重新拉取完整 AI 消息
|
|
|
|
|
|
* 为什么:避免断连后留下半成品打字气泡
|
|
|
|
|
|
*/
|
|
|
|
|
|
function cancelStreamingBubble(): void {
|
|
|
|
|
|
if (!streamingAiMessageId.value) return
|
|
|
|
|
|
const idx = messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
messages.value.splice(idx, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
streamingAiMessageId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 新增:排队/关闭机制 WS 事件处理
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/** 排队位置更新数据(来自 WS queue_position_update 事件) */
|
|
|
|
|
|
const queuePositionData = ref<{
|
|
|
|
|
|
position: number
|
|
|
|
|
|
segment: string
|
|
|
|
|
|
ahead_count: number
|
|
|
|
|
|
queue_priority: number
|
|
|
|
|
|
} | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
/** 坐席结单请求(来自 WS pending_close_request 事件) */
|
|
|
|
|
|
const pendingCloseRequest = ref<{
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
resolve_summary: string
|
|
|
|
|
|
agent_name: string
|
|
|
|
|
|
} | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
/** 会话已关闭信息(来自 WS conversation_resolved 事件) */
|
|
|
|
|
|
const resolvedInfo = ref<{
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
resolved_by: string
|
|
|
|
|
|
resolved_method: string
|
|
|
|
|
|
resolve_summary: string
|
|
|
|
|
|
} | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理排队位置更新事件
|
|
|
|
|
|
* WS event: queue_position_update
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleQueuePositionUpdate(data: {
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
position: number
|
|
|
|
|
|
segment: string
|
|
|
|
|
|
ahead_count: number
|
|
|
|
|
|
queue_priority: number
|
|
|
|
|
|
}): void {
|
|
|
|
|
|
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
|
|
|
|
|
queuePositionData.value = {
|
|
|
|
|
|
position: data.position,
|
|
|
|
|
|
segment: data.segment,
|
|
|
|
|
|
ahead_count: data.ahead_count,
|
|
|
|
|
|
queue_priority: data.queue_priority,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理会话关闭事件
|
|
|
|
|
|
* WS event: conversation_resolved
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleConversationResolved(data: {
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
status: string
|
|
|
|
|
|
resolved_by: string
|
|
|
|
|
|
resolved_method: string
|
|
|
|
|
|
resolve_summary: string
|
|
|
|
|
|
}): void {
|
|
|
|
|
|
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
|
|
|
|
|
|
|
|
|
|
|
// 更新会话状态
|
|
|
|
|
|
if (currentConversation.value) {
|
|
|
|
|
|
currentConversation.value.status = 'resolved'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resolvedInfo.value = {
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
resolved_by: data.resolved_by,
|
|
|
|
|
|
resolved_method: data.resolved_method,
|
|
|
|
|
|
resolve_summary: data.resolve_summary || '',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清除结单请求卡片
|
|
|
|
|
|
pendingCloseRequest.value = null
|
|
|
|
|
|
|
|
|
|
|
|
// 添加系统消息
|
|
|
|
|
|
const resolveMsg: Message = {
|
|
|
|
|
|
message_id: `resolve_${data.conversation_id}_${Date.now()}`,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: 'system',
|
|
|
|
|
|
msg_type: 'text',
|
|
|
|
|
|
content: getResolveMessageText(data.resolved_method),
|
|
|
|
|
|
sender_name: '系统',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sent',
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(resolveMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理坐席结单请求
|
|
|
|
|
|
* WS event: pending_close_request
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handlePendingCloseRequest(data: {
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
resolve_summary: string
|
|
|
|
|
|
agent_name: string
|
|
|
|
|
|
}): void {
|
|
|
|
|
|
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
|
|
|
|
|
|
|
|
|
|
|
// 更新会话状态为 pending_close
|
|
|
|
|
|
if (currentConversation.value) {
|
|
|
|
|
|
currentConversation.value.status = 'waiting' as any // pending_close 映射
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pendingCloseRequest.value = {
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
resolve_summary: data.resolve_summary || '',
|
|
|
|
|
|
agent_name: data.agent_name || '坐席',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加系统消息提示
|
|
|
|
|
|
const systemMsg: Message = {
|
|
|
|
|
|
message_id: `pending_close_${data.conversation_id}_${Date.now()}`,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: 'system',
|
|
|
|
|
|
msg_type: 'text',
|
|
|
|
|
|
content: `📋 ${data.agent_name || '坐席'}发起了结单请求,请确认`,
|
|
|
|
|
|
sender_name: '系统',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sent',
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(systemMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理诊断答题答案附加到上下文
|
|
|
|
|
|
* WS event: quiz_diagnostic_answer
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleQuizDiagnosticAnswer(data: {
|
|
|
|
|
|
question: string
|
|
|
|
|
|
selected_answer: string
|
|
|
|
|
|
conversation_id: string
|
|
|
|
|
|
}): void {
|
|
|
|
|
|
// 添加系统消息,告知用户诊断信息已传递
|
|
|
|
|
|
const systemMsg: Message = {
|
|
|
|
|
|
message_id: `quiz_diag_${data.conversation_id}_${Date.now()}`,
|
|
|
|
|
|
conversation_id: data.conversation_id,
|
|
|
|
|
|
message_type: 'system',
|
|
|
|
|
|
msg_type: 'text',
|
|
|
|
|
|
content: `📝 诊断信息已传递给坐席:${data.question} → ${data.selected_answer}`,
|
|
|
|
|
|
sender_name: '系统',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sent',
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(systemMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清除结单请求(确认或拒绝后调用) */
|
|
|
|
|
|
function clearPendingCloseRequest(): void {
|
|
|
|
|
|
pendingCloseRequest.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清除关闭信息(重开会话后调用) */
|
|
|
|
|
|
function clearResolvedInfo(): void {
|
|
|
|
|
|
resolvedInfo.value = null
|
|
|
|
|
|
queuePositionData.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据关闭方式生成系统消息文本
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getResolveMessageText(resolvedMethod: string): string {
|
|
|
|
|
|
if (resolvedMethod === 'ai_self') {
|
|
|
|
|
|
return '✅ 问题已解决,会话已关闭。24小时内可重新打开。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (resolvedMethod === 'agent_confirm') {
|
|
|
|
|
|
return '✅ 坐席已结单,会话已关闭。24小时内可重新打开。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (resolvedMethod === 'employee_initiative') {
|
|
|
|
|
|
return '✅ 会话已关闭。24小时内可重新打开。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (resolvedMethod === 'system_timeout') {
|
|
|
|
|
|
return '⏰ 会话因长时间无响应已自动关闭。24小时内可重新打开。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '✅ 会话已关闭。24小时内可重新打开。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 初始化应用
|
|
|
|
|
|
* 1. 获取用户信息
|
|
|
|
|
|
* 2. 获取当前会话
|
2026-07-07 22:56:49 +08:00
|
|
|
|
* 3. 获取消息历史(新增 fetchMessages)
|
|
|
|
|
|
* 4. 加载审批链接和软件下载
|
|
|
|
|
|
* 5. 启动消息轮询
|
2026-06-14 16:49:18 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async function initialize(): Promise<void> {
|
|
|
|
|
|
if (initialized.value) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('[Store] 开始初始化应用...')
|
2026-07-04 21:01:39 +08:00
|
|
|
|
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// ===== 步骤1:并行加载用户信息和会话 =====
|
2026-06-14 16:49:18 +08:00
|
|
|
|
await Promise.all([
|
2026-07-04 21:01:39 +08:00
|
|
|
|
fetchUserInfo(),
|
|
|
|
|
|
fetchCurrentConversation(),
|
2026-06-14 16:49:18 +08:00
|
|
|
|
])
|
2026-07-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
// ===== 步骤2:加载本地缓存(如果有) =====
|
|
|
|
|
|
const convId = currentConversation.value?.conversation_id
|
|
|
|
|
|
if (convId) {
|
|
|
|
|
|
const cached = loadMessagesFromCache(convId)
|
|
|
|
|
|
if (cached && cached.length > 0) {
|
|
|
|
|
|
console.log(`[Store] 加载缓存消息 ${cached.length} 条`)
|
|
|
|
|
|
messages.value = cached
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// 从缓存更新最后消息ID
|
|
|
|
|
|
const lastCached = cached[cached.length - 1]
|
|
|
|
|
|
if (lastCached) {
|
|
|
|
|
|
lastMessageId.value = lastCached.message_id
|
|
|
|
|
|
}
|
2026-07-04 21:01:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 22:56:49 +08:00
|
|
|
|
// ===== 步骤3:获取历史消息(新增,使用 getMessages API) =====
|
|
|
|
|
|
// 后台加载,不阻塞 UI
|
2026-07-04 21:01:39 +08:00
|
|
|
|
Promise.resolve().then(async () => {
|
|
|
|
|
|
try {
|
2026-07-07 22:56:49 +08:00
|
|
|
|
const data = await getMessages({ limit: 50 })
|
|
|
|
|
|
if (data.items && data.items.length > 0) {
|
|
|
|
|
|
// 消息去重
|
|
|
|
|
|
const uniqueMessages = data.items.filter(msg => {
|
|
|
|
|
|
if (processedMessageIds.value.has(msg.message_id)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
trackProcessedMessageId(msg.message_id)
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (uniqueMessages.length > 0) {
|
|
|
|
|
|
// 合并缓存和历史消息
|
|
|
|
|
|
if (convId && messages.value.length > 0) {
|
|
|
|
|
|
messages.value = mergeMessages(messages.value, uniqueMessages)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messages.value = uniqueMessages
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新最后消息ID
|
|
|
|
|
|
const lastMsg = uniqueMessages[uniqueMessages.length - 1]
|
|
|
|
|
|
if (lastMsg) {
|
|
|
|
|
|
lastMessageId.value = lastMsg.message_id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 21:01:39 +08:00
|
|
|
|
// 保存到缓存
|
2026-07-07 22:56:49 +08:00
|
|
|
|
if (convId) {
|
|
|
|
|
|
saveMessagesToCache(convId, messages.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[Store] 历史消息已加载,共 ${messages.value.length} 条`)
|
2026-07-04 21:01:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2026-07-07 22:56:49 +08:00
|
|
|
|
console.warn('[Store] 加载历史消息失败:', e)
|
2026-07-04 21:01:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 步骤4:延迟加载右侧面板数据(不阻塞首屏) =====
|
|
|
|
|
|
// 使用 setTimeout 让它不阻塞消息加载
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
Promise.all([
|
|
|
|
|
|
fetchApprovalLinks(),
|
|
|
|
|
|
fetchSoftwareDownloads(),
|
|
|
|
|
|
]).catch(e => console.warn('[Store] 加载面板数据失败:', e))
|
|
|
|
|
|
}, 500)
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 步骤5:启动轮询 =====
|
2026-06-14 16:49:18 +08:00
|
|
|
|
startPolling()
|
2026-07-04 21:01:39 +08:00
|
|
|
|
|
|
|
|
|
|
// ===== 步骤6:标记初始化完成(消息已显示) =====
|
2026-06-14 16:49:18 +08:00
|
|
|
|
initialized.value = true
|
2026-07-04 21:01:39 +08:00
|
|
|
|
console.log('[Store] 应用初始化完成(缓存优先显示)')
|
2026-06-14 16:49:18 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 应用初始化失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理状态
|
|
|
|
|
|
* 在组件卸载时调用,停止轮询,清理定时器
|
|
|
|
|
|
*/
|
|
|
|
|
|
function cleanup(): void {
|
2026-07-04 21:01:39 +08:00
|
|
|
|
// 清除当前会话的缓存
|
|
|
|
|
|
if (currentConversation.value?.conversation_id) {
|
|
|
|
|
|
clearMessagesCache(currentConversation.value.conversation_id)
|
|
|
|
|
|
}
|
2026-06-14 16:49:18 +08:00
|
|
|
|
stopPolling()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 返回所有状态和方法
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
return {
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
userInfo,
|
|
|
|
|
|
currentConversation,
|
|
|
|
|
|
messages,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
shaking,
|
|
|
|
|
|
canCallAgent,
|
|
|
|
|
|
agentOnline,
|
|
|
|
|
|
assistantPanelVisible,
|
|
|
|
|
|
approvalLinks,
|
|
|
|
|
|
softwareDownloads,
|
|
|
|
|
|
lastMessageId,
|
|
|
|
|
|
initialized,
|
|
|
|
|
|
troubleshootingSteps,
|
|
|
|
|
|
troubleshootingTemplateName,
|
|
|
|
|
|
troubleshootingCurrentNode,
|
|
|
|
|
|
participants,
|
|
|
|
|
|
participantPanelVisible,
|
|
|
|
|
|
|
|
|
|
|
|
// 计算属性
|
|
|
|
|
|
isLoggedIn,
|
|
|
|
|
|
hasActiveConversation,
|
|
|
|
|
|
isParticipant,
|
|
|
|
|
|
joinedParticipantCount,
|
|
|
|
|
|
approvalLinksByCategory,
|
|
|
|
|
|
softwareDownloadsByCategory,
|
|
|
|
|
|
|
|
|
|
|
|
// 方法
|
|
|
|
|
|
handleOAuthCallback,
|
|
|
|
|
|
fetchUserInfo,
|
|
|
|
|
|
fetchCurrentConversation,
|
2026-07-07 22:56:49 +08:00
|
|
|
|
fetchMessages,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
sendNewMessage,
|
|
|
|
|
|
pollNewMessages,
|
|
|
|
|
|
startPolling,
|
|
|
|
|
|
stopPolling,
|
|
|
|
|
|
shakeAgent,
|
|
|
|
|
|
fetchApprovalLinks,
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// v2.0: checkApprovalIntent 已删除(审批意图识别统一由后端处理)
|
2026-07-18 01:17:08 +08:00
|
|
|
|
// v4.0 P1-2: showApprovalCard/closeApprovalCard 已删除(孤儿组件 InputBox 的唯一调用方)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
fetchSoftwareDownloads,
|
|
|
|
|
|
toggleAssistantPanel,
|
|
|
|
|
|
switchToConversation,
|
|
|
|
|
|
leaveAsParticipant,
|
|
|
|
|
|
toggleParticipantPanel,
|
|
|
|
|
|
retryMessage,
|
|
|
|
|
|
updateParticipants,
|
|
|
|
|
|
handleRemovedFromConversation,
|
|
|
|
|
|
initialize,
|
|
|
|
|
|
cleanup,
|
|
|
|
|
|
|
|
|
|
|
|
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
|
|
|
|
|
|
handleNewMessage,
|
2026-07-09 11:49:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 流式 AI 回复(改造方案 A:打字机)
|
|
|
|
|
|
handleAiReplyChunk,
|
|
|
|
|
|
handleAiReply,
|
|
|
|
|
|
handleAiReplyFailed,
|
|
|
|
|
|
cancelStreamingBubble,
|
2026-07-13 02:17:03 +08:00
|
|
|
|
|
|
|
|
|
|
// v2.0 新增:AI 思考指示器 + 动态推荐 + 选项回传
|
|
|
|
|
|
handleAiThinking,
|
|
|
|
|
|
handleDynamicRecommend,
|
|
|
|
|
|
clearUnreadRecommend,
|
|
|
|
|
|
removeRecommend,
|
|
|
|
|
|
sendOptionSelect,
|
|
|
|
|
|
dynamicRecommendations,
|
|
|
|
|
|
unreadRecommendCount,
|
|
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
// v3.0 新增:资产推荐(分层展示)
|
|
|
|
|
|
handleAssetRecommend,
|
|
|
|
|
|
clearAssetRecommend,
|
|
|
|
|
|
assetRecommendations,
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
// 排队/关闭机制 WS 事件处理
|
|
|
|
|
|
queuePositionData,
|
|
|
|
|
|
pendingCloseRequest,
|
|
|
|
|
|
resolvedInfo,
|
|
|
|
|
|
handleQueuePositionUpdate,
|
|
|
|
|
|
handleConversationResolved,
|
|
|
|
|
|
handlePendingCloseRequest,
|
|
|
|
|
|
handleQuizDiagnosticAnswer,
|
|
|
|
|
|
clearPendingCloseRequest,
|
|
|
|
|
|
clearResolvedInfo,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|