2026-06-14 16:49:18 +08:00
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// 企微IT智能服务台 — H5用户端会话状态管理(Pinia Store)
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// 说明:管理用户端的核心状态,包括:
|
|
|
|
|
|
// 1. 用户信息(从 employee store 获取)
|
|
|
|
|
|
// 2. 当前会话信息
|
|
|
|
|
|
// 3. 消息列表(含轮询逻辑)
|
|
|
|
|
|
// 4. 招手/敲桌子状态
|
|
|
|
|
|
// 5. AI 助手面板展开/收起状态
|
|
|
|
|
|
// 注意:OAuth2 认证逻辑已迁移至 @/stores/employee.ts
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
import { defineStore } from 'pinia'
|
|
|
|
|
|
import { ref, computed } from 'vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
getUser,
|
|
|
|
|
|
getCurrentConversation,
|
|
|
|
|
|
sendMessage,
|
|
|
|
|
|
pollMessages,
|
|
|
|
|
|
shake,
|
|
|
|
|
|
getApprovalLinks,
|
|
|
|
|
|
getSoftwareDownloads,
|
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
|
// 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>('')
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
/** 软件下载列表 */
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 消息去重相关状态(与 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
|
|
|
|
|
|
return currentConversation.value.status !== 'closed'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前用户是否为被邀请的参与者(非原始员工) */
|
|
|
|
|
|
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
|
|
|
|
|
|
}): 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: data.content,
|
|
|
|
|
|
sender_name: data.sender_name || '',
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 09:32:41 +08:00
|
|
|
|
// 检查是否包含审批关键词,如果包含则先弹出卡片
|
|
|
|
|
|
const hasApprovalKeyword = checkApprovalKeywords(content)
|
|
|
|
|
|
if (hasApprovalKeyword) {
|
|
|
|
|
|
console.log('[Store] 检测到审批关键词,弹窗后仍发送消息')
|
|
|
|
|
|
// 审批弹窗显示,但不阻止消息发送
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 步骤1:乐观更新 - 立即添加临时消息到列表
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
const employeeStore = useEmployeeStore()
|
|
|
|
|
|
const tempMessageId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
|
|
|
|
const tempMessage: Message = {
|
|
|
|
|
|
message_id: tempMessageId,
|
|
|
|
|
|
conversation_id: currentConversation.value?.conversation_id || '',
|
|
|
|
|
|
message_type: 'employee',
|
|
|
|
|
|
msg_type: options?.msg_type || 'text',
|
|
|
|
|
|
content: content.trim(),
|
|
|
|
|
|
sender_name: employeeStore.employeeName || '我',
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
status: 'sending', // 乐观更新:发送中状态
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(tempMessage)
|
|
|
|
|
|
console.log('[Store] 乐观更新:临时消息已添加到列表, tempMessageId:', tempMessageId)
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 步骤2:调用后端 API
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 构建请求参数:文本消息只传 content,非文本消息额外传 msg_type/media_url 等
|
|
|
|
|
|
const reqData: SendMessageRequest = {
|
|
|
|
|
|
content: content.trim(),
|
|
|
|
|
|
}
|
|
|
|
|
|
if (options?.msg_type) {
|
|
|
|
|
|
reqData.msg_type = options.msg_type
|
|
|
|
|
|
reqData.media_url = options.media_url
|
|
|
|
|
|
reqData.file_name = options.file_name
|
|
|
|
|
|
reqData.file_size = options.file_size
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('[Store] 请求参数:', JSON.stringify(reqData))
|
|
|
|
|
|
|
|
|
|
|
|
const resp: SendMessageResponse = await sendMessage(reqData)
|
|
|
|
|
|
console.log('[Store] API 响应:', resp)
|
|
|
|
|
|
|
|
|
|
|
|
// 防御性检查:确保 resp 和必要字段存在
|
|
|
|
|
|
if (!resp) {
|
|
|
|
|
|
console.error('[Store] API 响应为空')
|
|
|
|
|
|
throw new Error('API 响应为空')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!resp.user_message) {
|
|
|
|
|
|
console.error('[Store] API 响应缺少 user_message')
|
|
|
|
|
|
throw new Error('API 响应格式错误:缺少 user_message')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 步骤3:乐观更新成功 - 用真实消息替换临时消息
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// 找到临时消息并替换为真实消息
|
|
|
|
|
|
const tempIndex = messages.value.findIndex(m => m.message_id === tempMessageId)
|
|
|
|
|
|
if (tempIndex !== -1) {
|
|
|
|
|
|
// 用真实消息替换,状态改为"已发送"
|
|
|
|
|
|
messages.value[tempIndex] = {
|
|
|
|
|
|
...resp.user_message,
|
|
|
|
|
|
status: 'sent',
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('[Store] 乐观更新成功:临时消息已替换为真实消息')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 防御性:找不到临时消息时直接添加
|
|
|
|
|
|
messages.value.push({ ...resp.user_message, status: 'sent' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 将 AI 回复追加到本地列表(如果存在)
|
|
|
|
|
|
if (resp.ai_reply) {
|
|
|
|
|
|
messages.value.push(resp.ai_reply)
|
|
|
|
|
|
lastMessageId.value = resp.ai_reply.message_id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新「是否可呼叫坐席」标志
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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, '条')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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 坐席
|
|
|
|
|
|
* 调用后端招手接口,返回趣味话术
|
|
|
|
|
|
* 将话术以系统消息形式插入对话列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function shakeAgent(): Promise<void> {
|
|
|
|
|
|
// 防止重复点击
|
|
|
|
|
|
if (shaking.value) return
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// 如果招手后坐席已接入(status === 'serving'),刷新会话信息
|
|
|
|
|
|
if (data.conversation?.status === 'serving') {
|
|
|
|
|
|
await fetchCurrentConversation()
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 招手失败:', 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 09:32:41 +08:00
|
|
|
|
// 审批关键词列表(静态配置,后续可从API获取)
|
|
|
|
|
|
const APPROVAL_KEYWORDS = ['申请', '资源', '设备', '电脑', '笔记本']
|
|
|
|
|
|
|
|
|
|
|
|
/** 检查文本是否包含审批关键词,触发审批卡片弹窗 */
|
|
|
|
|
|
function checkApprovalKeywords(text: string): boolean {
|
|
|
|
|
|
const lowerText = text.toLowerCase()
|
|
|
|
|
|
const hasKeyword = APPROVAL_KEYWORDS.some((kw) => lowerText.includes(kw))
|
|
|
|
|
|
|
|
|
|
|
|
if (hasKeyword) {
|
|
|
|
|
|
approvalCardTriggerText.value = text
|
|
|
|
|
|
approvalCardVisible.value = true
|
|
|
|
|
|
console.log('[Store] 检测到审批关键词,触发卡片弹窗')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return hasKeyword
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 关闭审批卡片弹窗 */
|
|
|
|
|
|
function closeApprovalCard(): void {
|
|
|
|
|
|
approvalCardVisible.value = false
|
|
|
|
|
|
approvalCardTriggerText.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 显示审批卡片弹窗(快捷按钮触发) */
|
|
|
|
|
|
function showApprovalCard(triggerText: string = ''): void {
|
|
|
|
|
|
approvalCardTriggerText.value = triggerText
|
|
|
|
|
|
approvalCardVisible.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 加载软件下载列表
|
|
|
|
|
|
* 从后端获取所有可下载的软件列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function fetchSoftwareDownloads(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getSoftwareDownloads()
|
|
|
|
|
|
softwareDownloads.value = data
|
|
|
|
|
|
console.log('[Store] 获取软件下载列表成功:', data.length, '条')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 获取软件下载列表失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换 AI 助手面板展开/收起(移动端使用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toggleAssistantPanel(): void {
|
|
|
|
|
|
assistantPanelVisible.value = !assistantPanelVisible.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换到指定会话(邀请链接加入后使用)
|
|
|
|
|
|
* 做什么:加入邀请会话后,将当前视图切换到该会话
|
|
|
|
|
|
* 为什么:被邀请人可能已有自己的会话,需要切换到邀请的会话
|
|
|
|
|
|
*
|
|
|
|
|
|
* 修复(2026-06-12):原实现仅调用 fetchCurrentConversation() 重新获取
|
|
|
|
|
|
* "当前会话",未使用 conversationId 参数。如果后端不会自动切换
|
|
|
|
|
|
* current conversation,则拿到的仍是用户原来的会话。
|
|
|
|
|
|
* 现改为:先刷新当前会话(加入后后端会更新 current conversation),
|
|
|
|
|
|
* 再验证会话ID是否匹配,确保切换成功。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param conversationId - 目标会话ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function switchToConversation(conversationId: string): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 重新获取当前会话(加入后后端会更新 current conversation)
|
|
|
|
|
|
await fetchCurrentConversation()
|
|
|
|
|
|
|
|
|
|
|
|
// 验证:当前会话是否已切换到目标会话
|
|
|
|
|
|
const conv = currentConversation.value
|
|
|
|
|
|
if (conv && conv.conversation_id !== conversationId) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
'[Store] 当前会话ID不匹配, 期望:', conversationId,
|
|
|
|
|
|
'实际:', conv.conversation_id
|
|
|
|
|
|
)
|
|
|
|
|
|
// 后端未自动切换时,仍按当前获取到的会话展示
|
|
|
|
|
|
// (后端 /h5/conversations/current 理论上应返回刚加入的会话)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清空消息列表,重新加载
|
|
|
|
|
|
messages.value = []
|
|
|
|
|
|
lastMessageId.value = ''
|
|
|
|
|
|
// 立即拉取一次消息,避免等3秒轮询
|
|
|
|
|
|
await pollNewMessages()
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 初始化应用
|
|
|
|
|
|
* 1. 获取用户信息
|
|
|
|
|
|
* 2. 获取当前会话
|
|
|
|
|
|
* 3. 加载审批链接和软件下载
|
|
|
|
|
|
* 4. 启动消息轮询
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function initialize(): Promise<void> {
|
|
|
|
|
|
if (initialized.value) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('[Store] 开始初始化应用...')
|
|
|
|
|
|
// 获取用户信息
|
|
|
|
|
|
await fetchUserInfo()
|
|
|
|
|
|
// 获取当前会话
|
|
|
|
|
|
await fetchCurrentConversation()
|
|
|
|
|
|
// 加载右侧面板数据
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
|
fetchApprovalLinks(),
|
|
|
|
|
|
fetchSoftwareDownloads(),
|
|
|
|
|
|
])
|
|
|
|
|
|
// 启动消息轮询
|
|
|
|
|
|
startPolling()
|
|
|
|
|
|
initialized.value = true
|
|
|
|
|
|
console.log('[Store] 应用初始化完成')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Store] 应用初始化失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理状态
|
|
|
|
|
|
* 在组件卸载时调用,停止轮询,清理定时器
|
|
|
|
|
|
*/
|
|
|
|
|
|
function cleanup(): void {
|
|
|
|
|
|
stopPolling()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// 返回所有状态和方法
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
return {
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
userInfo,
|
|
|
|
|
|
currentConversation,
|
|
|
|
|
|
messages,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
shaking,
|
|
|
|
|
|
canCallAgent,
|
|
|
|
|
|
agentOnline,
|
|
|
|
|
|
assistantPanelVisible,
|
|
|
|
|
|
approvalLinks,
|
2026-06-15 09:32:41 +08:00
|
|
|
|
approvalCardVisible,
|
|
|
|
|
|
approvalCardTriggerText,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
softwareDownloads,
|
|
|
|
|
|
lastMessageId,
|
|
|
|
|
|
initialized,
|
|
|
|
|
|
troubleshootingSteps,
|
|
|
|
|
|
troubleshootingTemplateName,
|
|
|
|
|
|
troubleshootingCurrentNode,
|
|
|
|
|
|
participants,
|
|
|
|
|
|
participantPanelVisible,
|
|
|
|
|
|
|
|
|
|
|
|
// 计算属性
|
|
|
|
|
|
isLoggedIn,
|
|
|
|
|
|
hasActiveConversation,
|
|
|
|
|
|
isParticipant,
|
|
|
|
|
|
joinedParticipantCount,
|
|
|
|
|
|
approvalLinksByCategory,
|
|
|
|
|
|
softwareDownloadsByCategory,
|
|
|
|
|
|
|
|
|
|
|
|
// 方法
|
|
|
|
|
|
handleOAuthCallback,
|
|
|
|
|
|
fetchUserInfo,
|
|
|
|
|
|
fetchCurrentConversation,
|
|
|
|
|
|
sendNewMessage,
|
|
|
|
|
|
pollNewMessages,
|
|
|
|
|
|
startPolling,
|
|
|
|
|
|
stopPolling,
|
|
|
|
|
|
shakeAgent,
|
|
|
|
|
|
fetchApprovalLinks,
|
2026-06-15 09:32:41 +08:00
|
|
|
|
checkApprovalKeywords,
|
|
|
|
|
|
closeApprovalCard,
|
|
|
|
|
|
showApprovalCard,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
fetchSoftwareDownloads,
|
|
|
|
|
|
toggleAssistantPanel,
|
|
|
|
|
|
switchToConversation,
|
|
|
|
|
|
leaveAsParticipant,
|
|
|
|
|
|
toggleParticipantPanel,
|
|
|
|
|
|
retryMessage,
|
|
|
|
|
|
updateParticipants,
|
|
|
|
|
|
handleRemovedFromConversation,
|
|
|
|
|
|
initialize,
|
|
|
|
|
|
cleanup,
|
|
|
|
|
|
|
|
|
|
|
|
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
|
|
|
|
|
|
handleNewMessage,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|