// ============================================================================= // 企微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() // 先添加缓存 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(null) /** 当前会话信息 */ const currentConversation = ref(null) /** 消息列表 */ const messages = ref([]) /** 是否正在加载(发送消息、摇人等操作时) */ const loading = ref(false) /** 招手是否正在进行中(防止重复点击) */ const shaking = ref(false) /** 是否可以呼叫人工坐席(AI 实质性回复 >= 3 次时显示按钮) */ const canCallAgent = ref(false) /** 坐席是否在线(通过 WebSocket 或轮询获取) */ const agentOnline = ref(true) // 默认在线,阶段一简化处理 /** 轮询定时器 ID */ const pollTimer = ref | null>(null) /** AI 助手面板是否展开(移动端使用) */ const assistantPanelVisible = ref(false) /** 审批流程链接列表 */ const approvalLinks = ref([]) /** 软件下载列表 */ const softwareDownloads = ref([]) /** 最后一条消息的 ID(用于增量轮询) */ const lastMessageId = ref('') /** 是否已初始化(完成数据加载) */ const initialized = ref(false) /** 排查步骤列表(从坐席端同步,通过 WebSocket 推送) */ const troubleshootingSteps = ref>([]) /** 排查步骤模板名称 */ const troubleshootingTemplateName = ref('') /** 排查流程图当前活跃节点(交互式 — 用户可点击选项) */ 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([]) /** 参与者面板是否展开 */ const participantPanelVisible = ref(false) // ========================================================================== // v2.0 新增:动态推荐状态(侧边栏卡片推送) // ========================================================================== /** * 动态推荐卡片列表(由后端 WS dynamic_recommend 推送) * 做什么:存储 AI 推荐的审批/操作入口卡片,供 RightPanel 的 DynamicRecommend 组件渲染 * 为什么:审批卡片从聊天流移到侧边栏,与文字回复强关联但不打断对话 * 最多保留 3 张,超过时移除最旧的 */ const dynamicRecommendations = ref>([]) /** 动态推荐未查看数量(用于 Badge 红点提示) */ const unreadRecommendCount = ref(0) // ========================================================================== // 资产推荐卡片(v3.0 新增,由 WS asset_recommend 事件推送) // ========================================================================== /** * 资产推荐卡片列表(由后端 asset_recommend_service 推送) * 做什么:存储 L1/L2/L3 分层推荐卡片,供 DynamicRecommend 组件渲染 * 数据结构:{ id, layer, layer_label, source, title, description, items, ... } */ const assetRecommendations = ref action_url?: string confidence?: number relevance?: string }>>([]) /** * 处理资产推荐卡片(v3.0 新增) * 做什么:将后端推送的资产推荐数据存入 assetRecommendations */ function handleAssetRecommend(data: { recommends: Array 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(null) // ========================================================================== // 消息去重相关状态(与 agent 端一致,WS-06 修复) // ========================================================================== /** * 已处理消息ID集合(用于消息去重) * 做什么:记录最近处理过的 message_id,防止网络抖动或将来接入 WS 后 * 收到重复消息导致前端重复显示 * 为什么:轮询和将来的 WS 推送可能同时到达,需要幂等去重 * 最多保留 500 条,超过时删除最旧的一条(FIFO) * ES2015+ 规范:Set 迭代顺序 = 插入顺序,delete 后重新 add 会移到最后 */ const processedMessageIds = ref>(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 = {} 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 = {} 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 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_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, // ★ 防御性类型保护:确保 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 { const employeeStore = useEmployeeStore() await employeeStore.handleOAuthCallback(code, state) } /** * 加载当前用户信息 * 从后端获取当前登录员工的详细信息 */ async function fetchUserInfo(): Promise { 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 { 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 { 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 推送到侧边栏,不再由前端独立调用 // ======================================================================== // 步骤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 { // 未登录或无活跃会话时不轮询 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 { // 未登录或无活跃会话时不获取 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 { // 防止重复点击 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 { 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 接口 // v4.0 P1-2:showApprovalCard/closeApprovalCard 已删除 // 原因:唯一调用方 InputBox.vue 是孤儿组件(已移除), // "快捷申请按钮"在当前产品中不存在(P0-3 误报确认) // 后端 /approval/all-categories-card 端点保留备用 /** * 加载软件下载列表 * 从后端获取所有可下载的软件列表 */ async function fetchSoftwareDownloads(): Promise { 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 { 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 { 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 { // 找到失败消息 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(), }) } // 累积 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 extra_data?: Record // 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 = { 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 { 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, 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 已删除(审批意图识别统一由后端处理) // v4.0 P1-2: showApprovalCard/closeApprovalCard 已删除(孤儿组件 InputBox 的唯一调用方) 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, } })