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

909 lines
32 KiB
TypeScript
Raw Normal View History

// =============================================================================
// 企微IT智能服务台 — H5用户端会话状态管理(Pinia Store
// =============================================================================
// 说明:管理用户端的核心状态,包括:
// 1. 用户信息(从 employee store 获取)
// 2. 当前会话信息
// 3. 消息列表(含轮询逻辑)
// 4. 招手/敲桌子状态
// 5. AI 助手面板展开/收起状态
// 注意:OAuth2 认证逻辑已迁移至 @/stores/employee.ts
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
getUser,
getCurrentConversation,
sendMessage,
pollMessages,
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>('')
/** 软件下载列表 */
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_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: 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] 检测到审批关键词,弹窗后仍发送消息')
// 审批弹窗显示,但不阻止消息发送
}
// ========================================================================
// 步骤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
}
/**
* 加载软件下载列表
* 从后端获取所有可下载的软件列表
*/
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,
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,
fetchSoftwareDownloads,
toggleAssistantPanel,
switchToConversation,
leaveAsParticipant,
toggleParticipantPanel,
retryMessage,
updateParticipants,
handleRemovedFromConversation,
initialize,
cleanup,
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
handleNewMessage,
}
})