Files
wecom_it_smart_desk/frontend-h5/src/stores/conversation.ts
T

1796 lines
64 KiB
TypeScript
Raw Normal View History

// =============================================================================
// 企微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,
getMessages,
shake,
getApprovalLinks,
getSoftwareDownloads,
// v2.0: detectApprovalIntent 已移除(审批意图识别统一由后端处理)
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'
// v2.0: 导入 WS 发送函数(用于选项选择回传)
import { sendWsMessage } from '@/composables/useH5WebSocket'
// --------------------------------------------------------------------------
// 本地缓存配置
// --------------------------------------------------------------------------
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()
)
}
// --------------------------------------------------------------------------
// 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[]>([])
2026-06-15 09:32:41 +08:00
/** 审批卡片弹窗是否显示(关键词触发) */
const approvalCardVisible = ref<boolean>(false)
/** 触发审批卡片的关键词文本 */
const approvalCardTriggerText = ref<string>('')
/** 软件下载列表 */
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)
// ==========================================================================
// 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)
// ==========================================================================
// 资产推荐卡片(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 = []
}
// ==========================================================================
// 流式 AI 回复(打字机)临时状态 — 改造方案 A
// ==========================================================================
/**
* 当前正在流式接收的 AI 消息临时气泡 ID
* 做什么:记录打字机占位气泡的 message_id,流式 chunk 累积到该气泡,
* 收到 ai_reply 终态后替换为真实消息
* 为什么:员工端同时只有一个活跃会话,无需用 Map 存多会话
*/
const streamingAiMessageId = ref<string | null>(null)
// ==========================================================================
// 消息去重相关状态(与 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
// resolved: 已结单(后端状态)
return currentConversation.value.status !== 'resolved'
})
/** 当前用户是否为被邀请的参与者(非原始员工) */
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
// 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
}): 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_typeemployee/agent/ai/system),
// 而非 msg_typetext/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,
// ★ 防御性类型保护:确保 content 始终是 String
content: typeof data.content === 'string' ? data.content : (data.content ? JSON.stringify(data.content) : ''),
sender_name: data.sender_name || '',
// 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(),
})
// 更新最后消息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
}
// v2.0 改造(2026-07-13):删除前端 checkApprovalIntent 异步调用
// 审批意图识别已统一由后端 process_h5_ai_reply → get_structured_reply 处理
// 结果通过 WS dynamic_recommend 推送到侧边栏,不再由前端独立调用
2026-06-15 09:32:41 +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',
}
// 新增:更新去重追踪,避免轮询时重复添加
trackProcessedMessageId(resp.user_message.message_id)
// 新增:更新 lastMessageId,防止轮询重复拉取刚发送的消息
lastMessageId.value = resp.user_message.message_id
console.log('[Store] 乐观更新成功:临时消息已替换为真实消息')
// 更新本地缓存
const convId = currentConversation.value?.conversation_id
if (convId) {
saveMessagesToCache(convId, messages.value)
}
} else {
// 防御性:找不到临时消息时直接添加
messages.value.push({ ...resp.user_message, status: 'sent' })
// 新增:更新去重追踪,避免轮询时重复添加
trackProcessedMessageId(resp.user_message.message_id)
// 新增:更新 lastMessageId,防止轮询重复拉取刚发送的消息
lastMessageId.value = resp.user_message.message_id
}
// 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
// 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
// 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
// 更新「是否可呼叫坐席」标志
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
}
// 关键修复:如果发送消息前没有活跃会话(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)
}
}
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, '条')
// 更新本地缓存
const convId = currentConversation.value?.conversation_id
if (convId) {
saveMessagesToCache(convId, messages.value)
}
}
}
} catch (error) {
// 轮询失败不弹提示,静默处理避免干扰用户
console.error('[Store] 轮询消息失败:', error)
}
}
/**
* 获取消息列表(历史消息)
* 获取当前会话的完整消息历史,用于首次加载或翻页
* @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)
}
}
/**
* 启动消息轮询
* 使用 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 坐席
* 调用后端招手接口,返回趣味话术
* 将话术以系统消息形式插入对话列表
* @returns 分配结果: assigned(已分配坐席) / queued(排队中) / assign_failed(分配失败)
*/
async function shakeAgent(): Promise<string> {
// 防止重复点击
if (shaking.value) return 'pending'
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)
// 如果已分配坐席,更新话术内容
if (data.assign_result === 'assigned' && data.assigned_agent_id) {
// 2026-07-12 改造:去掉 🎉 emoji,文案与后端 funny_phrases.connected 保持一致
systemMsg.content = `${data.funny_phrase}\n\n坐席正在查看您的信息,请等待处理回复!`
// 更新会话状态
if (currentConversation.value) {
currentConversation.value.status = 'serving'
}
} else if (data.assign_result === 'queued') {
// 进入排队
systemMsg.content = `${data.funny_phrase}\n\n⏳ 当前无空闲坐席,您已进入排队,请耐心等待...`
}
// 如果招手后坐席已接入(status === 'serving'),刷新会话信息
if (data.conversation?.status === 'serving') {
await fetchCurrentConversation()
}
// 返回分配结果
return data.assign_result || 'queued'
} catch (error) {
console.error('[Store] 招手失败:', error)
return 'error'
} 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)
}
}
// 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-06-15 09:32:41 +08:00
function closeApprovalCard(): void {
approvalCardVisible.value = false
approvalCardTriggerText.value = ''
}
// v4.0 P0-3: 缓存全量审批卡片(首次点击拉取,后续复用)
let _allCategoriesCardCache: any = null
/**
* 显示审批卡片(快捷按钮触发)
* v4.0 P0-3: 改为 async 拉取后端 /approval/all-categories-card
* 修复旧版构造 {approval_type:'',confidence:0} 导致的空白气泡(F1
*
* @param _triggerText 触发文本(可选,新流程中不再使用关键词匹配)
*/
async function showApprovalCard(_triggerText: string = ''): Promise<void> {
// 首次拉取并缓存
if (!_allCategoriesCardCache) {
try {
const { getAllCategoriesCard } = await import('@/api/conversation')
_allCategoriesCardCache = await getAllCategoriesCard()
} catch (e) {
console.error('[Store] 拉取全量审批卡片失败:', e)
return
}
}
// 插入审批卡片消息(action.card_data 为后端标准化数据,前端纯渲染)
const approvalCardMessage: Message = {
message_id: `approval_card_${Date.now()}`,
conversation_id: currentConversation.value?.conversation_id || '',
message_type: 'system',
msg_type: 'approval_card',
content: '',
sender_name: '',
created_at: new Date().toISOString(),
extra_data: {
action: {
card_data: _allCategoriesCardCache,
},
},
}
messages.value.push(approvalCardMessage)
console.log('[Store] 手动插入审批卡片消息(快捷按钮触发,v4.0 全量卡片)')
2026-06-15 09:32:41 +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 理论上应返回刚加入的会话)
}
// 清空消息列表,重新加载历史消息
messages.value = []
lastMessageId.value = ''
// 获取完整的历史消息
await fetchMessages()
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()
}
/**
* 处理 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(),
})
}
// 累积 chunkVue3 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
extra_data?: Record<string, any> // v2.0: 结构化消息的 options/action
}): 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,
// ★ 防御性类型保护:确保 content 始终是 String
// 后端可能因 Dify 返回异常导致 content 为对象,此时转为 JSON 字符串
content: typeof data.content === 'string' ? data.content : (data.content ? JSON.stringify(data.content) : ''),
sender_name: data.sender_name || 'Duckula(达寇拉)',
created_at: new Date().toISOString(),
status: 'sent',
// v2.0: 传递结构化数据(options 按钮列表、action 卡片信息)
extra_data: data.extra_data || undefined,
}
// 用真实消息替换占位气泡(若存在)
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
}
// ==========================================================================
// v2.0 新增:AI 思考指示器 + 动态推荐 + 选项回传
// ==========================================================================
/**
* 处理 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)
}
/**
* 取消流式占位气泡
* 做什么:若 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
}
// ==========================================================================
// 新增:排队/关闭机制 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小时内可重新打开。'
}
/**
* 初始化应用
* 1. 获取用户信息
* 2. 获取当前会话
* 3. 获取消息历史(新增 fetchMessages
* 4. 加载审批链接和软件下载
* 5. 启动消息轮询
*/
async function initialize(): Promise<void> {
if (initialized.value) return
try {
console.log('[Store] 开始初始化应用...')
// ===== 步骤1:并行加载用户信息和会话 =====
await Promise.all([
fetchUserInfo(),
fetchCurrentConversation(),
])
// ===== 步骤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
// 从缓存更新最后消息ID
const lastCached = cached[cached.length - 1]
if (lastCached) {
lastMessageId.value = lastCached.message_id
}
}
}
// ===== 步骤3:获取历史消息(新增,使用 getMessages API =====
// 后台加载,不阻塞 UI
Promise.resolve().then(async () => {
try {
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
}
// 保存到缓存
if (convId) {
saveMessagesToCache(convId, messages.value)
}
console.log(`[Store] 历史消息已加载,共 ${messages.value.length}`)
}
}
} catch (e) {
console.warn('[Store] 加载历史消息失败:', e)
}
})
// ===== 步骤4:延迟加载右侧面板数据(不阻塞首屏) =====
// 使用 setTimeout 让它不阻塞消息加载
setTimeout(() => {
Promise.all([
fetchApprovalLinks(),
fetchSoftwareDownloads(),
]).catch(e => console.warn('[Store] 加载面板数据失败:', e))
}, 500)
// ===== 步骤5:启动轮询 =====
startPolling()
// ===== 步骤6:标记初始化完成(消息已显示) =====
initialized.value = true
console.log('[Store] 应用初始化完成(缓存优先显示)')
} catch (error) {
console.error('[Store] 应用初始化失败:', error)
}
}
/**
* 清理状态
* 在组件卸载时调用,停止轮询,清理定时器
*/
function cleanup(): void {
// 清除当前会话的缓存
if (currentConversation.value?.conversation_id) {
clearMessagesCache(currentConversation.value.conversation_id)
}
stopPolling()
}
// ==========================================================================
// 返回所有状态和方法
// ==========================================================================
return {
// 状态
userInfo,
currentConversation,
messages,
loading,
shaking,
canCallAgent,
agentOnline,
assistantPanelVisible,
approvalLinks,
2026-06-15 09:32:41 +08:00
approvalCardVisible,
approvalCardTriggerText,
softwareDownloads,
lastMessageId,
initialized,
troubleshootingSteps,
troubleshootingTemplateName,
troubleshootingCurrentNode,
participants,
participantPanelVisible,
// 计算属性
isLoggedIn,
hasActiveConversation,
isParticipant,
joinedParticipantCount,
approvalLinksByCategory,
softwareDownloadsByCategory,
// 方法
handleOAuthCallback,
fetchUserInfo,
fetchCurrentConversation,
fetchMessages,
sendNewMessage,
pollNewMessages,
startPolling,
stopPolling,
shakeAgent,
fetchApprovalLinks,
// v2.0: checkApprovalIntent 已删除(审批意图识别统一由后端处理)
2026-06-15 09:32:41 +08:00
closeApprovalCard,
showApprovalCard,
fetchSoftwareDownloads,
toggleAssistantPanel,
switchToConversation,
leaveAsParticipant,
toggleParticipantPanel,
retryMessage,
updateParticipants,
handleRemovedFromConversation,
initialize,
cleanup,
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
handleNewMessage,
// 流式 AI 回复(改造方案 A:打字机)
handleAiReplyChunk,
handleAiReply,
handleAiReplyFailed,
cancelStreamingBubble,
// v2.0 新增:AI 思考指示器 + 动态推荐 + 选项回传
handleAiThinking,
handleDynamicRecommend,
clearUnreadRecommend,
removeRecommend,
sendOptionSelect,
dynamicRecommendations,
unreadRecommendCount,
// v3.0 新增:资产推荐(分层展示)
handleAssetRecommend,
clearAssetRecommend,
assetRecommendations,
// 排队/关闭机制 WS 事件处理
queuePositionData,
pendingCloseRequest,
resolvedInfo,
handleQueuePositionUpdate,
handleConversationResolved,
handlePendingCloseRequest,
handleQuizDiagnosticAnswer,
clearPendingCloseRequest,
clearResolvedInfo,
}
})