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

1745 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// 企微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[]>([])
/** 软件下载列表 */
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 推送到侧边栏,不再由前端独立调用
// ========================================================================
// 步骤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 接口
// v4.0 P1-2showApprovalCard/closeApprovalCard 已删除
// 原因:唯一调用方 InputBox.vue 是孤儿组件(已移除),
// "快捷申请按钮"在当前产品中不存在(P0-3 误报确认)
// 后端 /approval/all-categories-card 端点保留备用
/**
* 加载软件下载列表
* 从后端获取所有可下载的软件列表
*/
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,
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,
}
})