Zhangbw преди 2 месеца
родител
ревизия
3fec3fc4a3
променени са 4 файла, в които са добавени 248 реда и са изтрити 93 реда
  1. 213 72
      src/CustomerService.vue
  2. 33 9
      src/composables/useVoiceRecognition.js
  3. 0 4
      target/classes/META-INF/mps/autoMapper
  4. 2 8
      target/classes/META-INF/mps/mappers

+ 213 - 72
src/CustomerService.vue

@@ -95,8 +95,11 @@
                   v-for="agent in paginatedAgents"
                   :key="agent.id"
                   class="agent-card"
-                  :class="{ selected: selectedAgent === agent.id }"
-                  @click="selectedAgent = agent.id"
+                  :class="{
+                    selected: selectedAgent === agent.id,
+                    disabled: agent.status === '2'
+                  }"
+                  @click="agent.status === '0' ? selectedAgent = agent.id : null"
                 >
                   <img v-if="agent.avatarUrl" :src="getAvatarUrl(agent.avatarUrl)" class="avatar-img" />
                   <div v-else class="avatar" :class="agent.gender"></div>
@@ -186,7 +189,7 @@
 </template>
 
 <script setup>
-import { ref, computed, watch, nextTick, onMounted } from 'vue'
+import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
 import { ArrowUp, ArrowDown, Microphone, PhoneFilled, ChatDotRound, Mute } from '@element-plus/icons-vue'
 import { useVoiceRecognition } from './composables/useVoiceRecognition.js'
 import { ElMessage } from 'element-plus'
@@ -213,8 +216,25 @@ const chatContent = ref(null)
 // 语音识别
 const { isRecording, currentTranscription, tempTranscription, startRecording, stopRecording } = useVoiceRecognition()
 
+// 当前播放的音频对象
+const currentAudio = ref(null)
+
+// 停止音频播放
+const stopAudio = () => {
+  if (currentAudio.value) {
+    currentAudio.value.pause()
+    currentAudio.value.currentTime = 0
+    currentAudio.value = null
+  }
+}
+
 // 音频播放
 const playAudio = (base64Audio) => {
+  // 如果不在对话状态,不播放音频
+  if (!showChat.value) {
+    return
+  }
+
   try {
     if (!base64Audio) {
       ElMessage.error('音频数据为空')
@@ -231,14 +251,26 @@ const playAudio = (base64Audio) => {
     const audioUrl = URL.createObjectURL(blob)
     const audio = new Audio(audioUrl)
 
+    // 停止之前的音频
+    stopAudio()
+
+    // 保存当前音频引用
+    currentAudio.value = audio
+
     audio.onended = () => {
       URL.revokeObjectURL(audioUrl)
+      if (currentAudio.value === audio) {
+        currentAudio.value = null
+      }
     }
 
     audio.onerror = (e) => {
       console.error('音频播放错误:', e)
       ElMessage.error('音频播放失败')
       URL.revokeObjectURL(audioUrl)
+      if (currentAudio.value === audio) {
+        currentAudio.value = null
+      }
     }
 
     audio.play().catch(err => {
@@ -254,6 +286,9 @@ const playAudio = (base64Audio) => {
 // 对话历史
 const chatHistory = ref([])
 
+// 保存当前对话的 conversationId
+const currentConversationId = ref(null)
+
 // 滚动到底部
 const scrollToBottom = () => {
   nextTick(() => {
@@ -267,6 +302,9 @@ const scrollToBottom = () => {
 const waveformData = ref(Array(60).fill(20))
 let waveformInterval = null
 
+// 客服列表刷新定时器
+let agentRefreshInterval = null
+
 // 更新波形
 const updateWaveform = () => {
   waveformData.value = waveformData.value.map(() => {
@@ -301,12 +339,18 @@ watch(currentTranscription, async (newVal, oldVal) => {
             message: newContent,
             agentId: selectedAgent.value,
             agentGender: selectedAgentData?.gender === 'male' ? '0' : '1',
-            ttsVcnList: ttsVcnList.value
+            ttsVcnList: ttsVcnList.value,
+            conversationId: currentConversationId.value
           })
         })
 
         const data = await response.json()
 
+        // 保存 conversationId
+        if (data.conversationId) {
+          currentConversationId.value = data.conversationId
+        }
+
         // 添加客服回复
         chatHistory.value.push({
           type: 'agent',
@@ -338,25 +382,37 @@ watch(showChat, async (newVal) => {
     } catch (error) {
       ElMessage.error('启动语音识别失败: ' + error.message)
     }
-  } else if (!newVal && isRecording.value) {
-    stopRecording()
+  } else if (!newVal) {
+    // 对话结束时,无论什么状态都执行清理操作
+    if (isRecording.value) {
+      stopRecording()
+    }
+
+    // 停止音频播放
+    stopAudio()
+
+    // 清理波形动画
     if (waveformInterval) {
       clearInterval(waveformInterval)
       waveformInterval = null
     }
 
+    // 重置状态为初始值
+    isTextInput.value = false
+    isMicMuted.value = false
+    currentConversationId.value = null
+
     // 对话结束,将客服状态改回正常
     if (selectedAgent.value) {
       try {
-        await fetch('http://localhost:8080/talk/agent/' + selectedAgent.value, {
-          method: 'PUT',
-          headers: getHeaders(),
-          body: JSON.stringify({
-            id: selectedAgent.value,
-            status: '0'
-          })
+        await fetch(`http://localhost:8080/talk/agent/${selectedAgent.value}/hangup`, {
+          method: 'POST',
+          headers: getHeaders()
         })
         console.log('客服状态已恢复为正常')
+
+        // 立即刷新客服列表,让用户看到状态变化
+        await fetchAgents(true)
       } catch (error) {
         console.error('更新客服状态失败:', error)
       }
@@ -433,18 +489,22 @@ const sendTextMessage = async () => {
     const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
     const response = await fetch('http://localhost:8080/talk/message', {
       method: 'POST',
-      headers: {
-        'Content-Type': 'application/json'
-      },
+      headers: getHeaders(),
       body: JSON.stringify({
         message: content,
         agentId: selectedAgent.value,
         agentGender: selectedAgentData?.gender === 'male' ? '0' : '1',
-        ttsVcnList: ttsVcnList.value
+        ttsVcnList: ttsVcnList.value,
+        conversationId: currentConversationId.value
       })
     })
 
     const data = await response.json()
+
+    // 保存 conversationId
+    if (data.conversationId) {
+      currentConversationId.value = data.conversationId
+    }
     console.log('解析后的audio字段长度:', data.audio ? data.audio.length : 0)
 
     // 添加客服回复
@@ -490,14 +550,18 @@ const fetchTtsVcnList = async () => {
 }
 
 // 获取客服列表
-const fetchAgents = async () => {
+const fetchAgents = async (silent = false) => {
   try {
-    const response = await fetch('http://localhost:8080/talk/agent/list?status=0', {
+    const response = await fetch('http://localhost:8080/talk/agent/list', {
       headers: getHeaders()
     })
     const result = await response.json()
     if (result.code === 200 && result.rows) {
-      agents.value = result.rows.map(agent => ({
+      // 只显示状态为0(正常)或2(对话中)的客服
+      const previousSelectedAgent = selectedAgent.value
+      agents.value = result.rows
+        .filter(agent => agent.status === '0' || agent.status === '2')
+        .map(agent => ({
         id: agent.id,
         name: agent.name,
         gender: agent.gender === '0' ? 'male' : 'female',
@@ -510,13 +574,40 @@ const fetchAgents = async () => {
         ttsBgs: agent.ttsBgs,
         status: agent.status
       }))
-      if (agents.value.length > 0) {
-        selectedAgent.value = agents.value[0].id
+
+      // 如果之前选中的客服不在列表中,清除选择
+      if (previousSelectedAgent && !agents.value.find(a => a.id === previousSelectedAgent)) {
+        selectedAgent.value = null
+      }
+
+      // 如果没有选中客服且列表不为空,选择第一个可用的客服
+      if (!selectedAgent.value && agents.value.length > 0) {
+        const availableAgent = agents.value.find(a => a.status === '0')
+        selectedAgent.value = availableAgent ? availableAgent.id : agents.value[0].id
       }
     }
   } catch (error) {
     console.error('获取客服列表失败:', error)
-    ElMessage.error('获取客服列表失败')
+    if (!silent) {
+      ElMessage.error('获取客服列表失败')
+    }
+  }
+}
+
+// 启动客服列表定期刷新
+const startAgentRefresh = () => {
+  if (!agentRefreshInterval) {
+    agentRefreshInterval = setInterval(() => {
+      fetchAgents(true)
+    }, 5000)
+  }
+}
+
+// 停止客服列表定期刷新
+const stopAgentRefresh = () => {
+  if (agentRefreshInterval) {
+    clearInterval(agentRefreshInterval)
+    agentRefreshInterval = null
   }
 }
 
@@ -540,9 +631,20 @@ const handleLogout = async () => {
 }
 
 // 组件挂载时获取客服列表和发言人字典
-onMounted(() => {
-  fetchAgents()
-  fetchTtsVcnList()
+onMounted(async () => {
+  await fetchAgents()
+  await fetchTtsVcnList()
+
+  // 启动客服列表定期刷新
+  startAgentRefresh()
+})
+
+// 组件卸载时清理定时器
+onUnmounted(() => {
+  stopAgentRefresh()
+  if (waveformInterval) {
+    clearInterval(waveformInterval)
+  }
 })
 
 // 获取头像完整URL
@@ -561,64 +663,92 @@ const startChat = async () => {
     return
   }
 
+  // 检查选中的客服是否可用
+  const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
+  if (!selectedAgentData) {
+    ElMessage.error('所选客服不存在')
+    return
+  }
+
+  if (selectedAgentData.status === '2') {
+    ElMessage.warning('该客服正在对话中,请选择其他客服')
+    return
+  }
+
   try {
-    // 更新客服配置
-    const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
-    if (selectedAgentData) {
-      const response = await fetch('http://localhost:8080/talk/agent/' + selectedAgent.value, {
-        method: 'PUT',
-        headers: getHeaders(),
-        body: JSON.stringify({
-          id: selectedAgent.value,
-          ttsSpeed: config.value.speed,
-          ttsPitch: config.value.pitch,
-          ttsVolume: config.value.volume,
-          ttsBgs: config.value.bgSoundEnabled ? 1 : 0,
-          status: '2'  // 设置状态为对话中
-        })
+    // 使用新的状态更新接口(带并发控制)
+    const response = await fetch(`http://localhost:8080/talk/admin/agent/${selectedAgent.value}/status/2`, {
+      method: 'PUT',
+      headers: getHeaders()
+    })
+
+    const result = await response.json()
+
+    // 检查后端返回结果,判断是否成功占用客服
+    if (!response.ok || result.code !== 200) {
+      ElMessage.error(result.msg || '该客服已被其他用户占用,请选择其他客服')
+      // 刷新客服列表
+      await fetchAgents(true)
+      return
+    }
+
+    // 更新客服的TTS配置
+    await fetch(`http://localhost:8080/talk/agent/${selectedAgent.value}`, {
+      method: 'PUT',
+      headers: getHeaders(),
+      body: JSON.stringify({
+        ttsSpeed: config.value.speed,
+        ttsPitch: config.value.pitch,
+        ttsVolume: config.value.volume,
+        ttsBgs: config.value.bgSoundEnabled ? 1 : 0
       })
+    })
 
-      if (response.ok) {
-        console.log('客服配置已更新')
-      }
+    console.log('客服状态已更新为对话中')
 
-      // 显示欢迎语并转语音
-      if (selectedAgentData.greetingMessage) {
-        chatHistory.value = [{
-          type: 'agent',
-          content: selectedAgentData.greetingMessage
-        }]
-
-        // 调用后端生成欢迎语语音
-        try {
-          const ttsResponse = await fetch('http://localhost:8080/talk/message', {
-            method: 'POST',
-            headers: {
-              'Content-Type': 'application/json'
-            },
-            body: JSON.stringify({
-              message: selectedAgentData.greetingMessage,
-              agentId: selectedAgent.value,
-              isGreeting: true
-            })
+    // 先切换到对话界面,确保音频可以播放
+    showChat.value = true
+
+    // 显示欢迎语并转语音
+    if (selectedAgentData.greetingMessage) {
+      chatHistory.value = [{
+        type: 'agent',
+        content: selectedAgentData.greetingMessage
+      }]
+
+      // 调用后端生成欢迎语语音(isGreeting=true 标识不发送到 dify 工作流)
+      try {
+        const ttsResponse = await fetch('http://localhost:8080/talk/message', {
+          method: 'POST',
+          headers: getHeaders(),
+          body: JSON.stringify({
+            message: selectedAgentData.greetingMessage,
+            agentId: selectedAgent.value,
+            isGreeting: true,
+            conversationId: currentConversationId.value
           })
+        })
 
-          const ttsData = await ttsResponse.json()
-          if (ttsData.audio) {
-            playAudio(ttsData.audio)
-          }
-        } catch (error) {
-          console.error('生成欢迎语语音失败:', error)
+        const ttsData = await ttsResponse.json()
+
+        // 保存 conversationId
+        if (ttsData.conversationId) {
+          currentConversationId.value = ttsData.conversationId
+        }
+        if (ttsData.audio) {
+          playAudio(ttsData.audio)
         }
-      } else {
-        chatHistory.value = []
+      } catch (error) {
+        console.error('生成欢迎语语音失败:', error)
       }
+    } else {
+      chatHistory.value = []
     }
   } catch (error) {
     console.error('更新客服配置失败:', error)
+    ElMessage.error('开始对话失败,请重试')
+    return
   }
-
-  showChat.value = true
 }
 
 const pageSize = 6
@@ -913,6 +1043,17 @@ const handleWheel = (e) => {
   box-shadow: 0 0 0 1px #3b82f6, 0 8px 20px rgba(59, 130, 246, 0.2);
 }
 
+.agent-card.disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+  pointer-events: none;
+}
+
+.agent-card.disabled:hover {
+  transform: none;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
 .delete-btn {
   position: absolute;
   top: 8px;

+ 33 - 9
src/composables/useVoiceRecognition.js

@@ -1,14 +1,33 @@
 import { ref, onUnmounted } from 'vue'
 import CryptoJS from 'crypto-js'
 
-// 科大讯飞配置
-const XFYUN_CONFIG = {
-  APPID: '0b53c170',
-  ACCESS_KEY_ID: '3915b5e04c7e118fea615889a1c94794',
-  ACCESS_KEY_SECRET: 'ZTZjZmU3YzczMjdmNmQwMTc0ZDU3OTEw',
+// 科大讯飞配置(从后端获取)
+let XFYUN_CONFIG = {
+  APPID: '',
+  ACCESS_KEY_ID: '',
+  ACCESS_KEY_SECRET: '',
   API_URL: 'wss://office-api-ast-dx.iflyaisol.com/ast/communicate/v1'
 }
 
+// 从后端获取配置
+const loadConfig = async () => {
+  try {
+    const token = localStorage.getItem('talk_token')
+    const response = await fetch('http://localhost:8080/talk/config/xunfei', {
+      headers: {
+        'Authorization': token ? `Bearer ${token}` : '',
+        'clientid': 'talk-web'
+      }
+    })
+    const config = await response.json()
+    XFYUN_CONFIG.APPID = config.appId || ''
+    XFYUN_CONFIG.ACCESS_KEY_ID = config.apiKey || ''
+    XFYUN_CONFIG.ACCESS_KEY_SECRET = config.apiSecret || ''
+  } catch (error) {
+    console.error('加载讯飞配置失败:', error)
+  }
+}
+
 export function useVoiceRecognition() {
   const isRecording = ref(false)
   const currentTranscription = ref('')
@@ -29,7 +48,12 @@ export function useVoiceRecognition() {
   }
 
   // 生成 WebSocket URL
-  const getWebSocketUrl = () => {
+  const getWebSocketUrl = async () => {
+    // 确保配置已加载
+    if (!XFYUN_CONFIG.APPID) {
+      await loadConfig()
+    }
+
     const appId = XFYUN_CONFIG.APPID
     const accessKeyId = XFYUN_CONFIG.ACCESS_KEY_ID
     const accessKeySecret = XFYUN_CONFIG.ACCESS_KEY_SECRET
@@ -66,9 +90,9 @@ export function useVoiceRecognition() {
   }
 
   // 连接 WebSocket
-  const connectWebSocket = () => {
-    return new Promise((resolve, reject) => {
-      const wsUrl = getWebSocketUrl()
+  const connectWebSocket = async () => {
+    return new Promise(async (resolve, reject) => {
+      const wsUrl = await getWebSocketUrl()
       console.log('连接 WebSocket:', wsUrl)
 
       websocket = new WebSocket(wsUrl)

+ 0 - 4
target/classes/META-INF/mps/autoMapper

@@ -1,6 +1,2 @@
-org.dromara.talk.domain.bo.TalkSessionBo
 org.dromara.talk.domain.vo.TalkAgentVo
-org.dromara.talk.domain.vo.TalkSessionVo
-org.dromara.talk.domain.vo.TalkUserVo
-org.dromara.talk.domain.bo.TalkAgentBo
 org.dromara.talk.domain.bo.TalkUserBo

+ 2 - 8
target/classes/META-INF/mps/mappers

@@ -1,8 +1,2 @@
-org.dromara.talk.domain.TalkUserToTalkUserVoMapper
-org.dromara.talk.domain.vo.TalkUserVoToTalkUserMapper__3
-org.dromara.talk.domain.TalkUserToTalkUserVoMapper__4
-org.dromara.talk.domain.bo.TalkUserBoToTalkUserMapper__4
-org.dromara.talk.domain.TalkUserToTalkUserVoMapper__3
-org.dromara.talk.domain.vo.TalkUserVoToTalkUserMapper
-org.dromara.talk.domain.bo.TalkUserBoToTalkUserMapper
-org.dromara.talk.domain.vo.TalkUserVoToTalkUserMapper__4
+org.dromara.web.domain.vo.TenantListVoToSysTenantVoMapper
+org.dromara.system.domain.vo.SysTenantVoToTenantListVoMapper