|
|
@@ -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;
|