| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589 |
- <template>
- <div class="container">
- <el-card class="main-card" :body-style="{ padding: 0 }">
- <div class="header">
- <div class="toggle-link" @click="sidebarVisible = !sidebarVisible">
- ≡ {{ sidebarVisible ? '收起配置' : '展开配置' }}
- </div>
- <h1 class="title">智能客服</h1>
- <div class="logout-btn" @click="handleLogout">退出登录</div>
- </div>
- <div class="content-wrapper">
- <div class="main-content">
- <aside v-if="sidebarVisible" class="sidebar" :class="{ disabled: showChat }">
- <div class="collapse-section">
- <div class="collapse-header" @click="basicExpanded = !basicExpanded">
- <span>基础设置</span>
- <el-icon class="arrow">
- <ArrowUp v-if="basicExpanded" />
- <ArrowDown v-else />
- </el-icon>
- </div>
- <div v-if="basicExpanded" class="collapse-content">
- <el-form label-position="top" size="default">
- <el-form-item label="背景音">
- <el-radio-group v-model="config.bgSoundEnabled">
- <el-radio-button :value="true">开启</el-radio-button>
- <el-radio-button :value="false">关闭</el-radio-button>
- </el-radio-group>
- </el-form-item>
- </el-form>
- </div>
- </div>
- <div class="collapse-section">
- <div class="collapse-header" @click="otherExpanded = !otherExpanded">
- <span>其他设置</span>
- <el-icon class="arrow">
- <ArrowUp v-if="otherExpanded" />
- <ArrowDown v-else />
- </el-icon>
- </div>
- <div v-if="otherExpanded" class="collapse-content">
- <el-form label-position="top" size="default">
- <el-form-item label="语速" class="compact-slider">
- <div class="slider-value">{{ config.speed }}</div>
- <el-slider
- v-model="config.speed"
- :min="0"
- :max="100"
- :step="1"
- />
- <div class="slider-labels">
- <span>慢</span>
- <span>快</span>
- </div>
- </el-form-item>
- <el-form-item label="音调" class="compact-slider">
- <div class="slider-value">{{ config.pitch }}</div>
- <el-slider
- v-model="config.pitch"
- :min="0"
- :max="100"
- :step="1"
- />
- <div class="slider-labels">
- <span>低</span>
- <span>高</span>
- </div>
- </el-form-item>
- <el-form-item label="音量" class="compact-slider">
- <div class="slider-value">{{ config.volume }}</div>
- <el-slider
- v-model="config.volume"
- :min="0"
- :max="100"
- :step="1"
- />
- <div class="slider-labels">
- <span>小</span>
- <span>大</span>
- </div>
- </el-form-item>
- </el-form>
- </div>
- </div>
- </aside>
- <main class="right-panel" v-if="!showChat" @wheel="handleWheel">
- <div class="agents-container">
- <div class="agents-list">
- <div
- v-for="agent in paginatedAgents"
- :key="agent.id"
- class="agent-card"
- :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>
- <div class="gender-icon" :class="agent.gender">{{ agent.gender === 'female' ? '♀' : '♂' }}</div>
- <div class="status-badge" :class="'status-' + agent.status">
- {{ agent.status === '0' ? '正常' : agent.status === '1' ? '停用' : '对话中' }}
- </div>
- <div class="agent-name">{{ agent.name }}</div>
- </div>
- </div>
- <div class="pagination-dots" v-if="totalPages > 1">
- <span
- v-for="page in totalPages"
- :key="page"
- class="dot"
- :class="{ active: currentPage === page - 1 }"
- @click="currentPage = page - 1"
- ></span>
- </div>
- </div>
- <div class="agents-footer">
- <el-button type="primary" round size="large" class="start-btn" @click="startChat">
- 开始对话
- </el-button>
- </div>
- </main>
- <main class="right-panel chat-panel" v-else>
- <!-- 对话历史 -->
- <div class="chat-content" ref="chatContent">
- <div
- v-for="(msg, index) in chatHistory"
- :key="index"
- class="message"
- :class="msg.type === 'user' ? 'user-message' : 'ai-message'"
- >
- <div class="message-text">
- {{ msg.content }}
- </div>
- </div>
- </div>
- <div class="chat-footer">
- <el-button class="keyboard-btn" @click="isTextInput = !isTextInput">
- <el-icon><ChatDotRound v-if="!isTextInput" /><Microphone v-else /></el-icon>
- </el-button>
- <el-input
- v-if="isTextInput"
- v-model="textMessage"
- placeholder="输入消息..."
- class="text-input"
- @keyup.enter="sendTextMessage"
- >
- <template #append>
- <el-button @click="sendTextMessage" :disabled="!textMessage.trim()">
- 发送
- </el-button>
- </template>
- </el-input>
- <div v-else class="voice-input-btn" @click="toggleMic">
- <el-icon class="mic-icon" :class="{ muted: isMicMuted, recording: isRecording }">
- <Microphone v-if="!isMicMuted" />
- <Mute v-else />
- </el-icon>
- <!-- 波形显示 -->
- <div v-if="isRecording" class="waveform">
- <div
- class="wave-bar"
- v-for="(height, index) in waveformData"
- :key="index"
- :style="{ height: height + 'px' }"
- ></div>
- </div>
- <div v-else class="input-placeholder"></div>
- </div>
- <el-button class="hangup-btn" @click="showChat = false">
- <el-icon><PhoneFilled /></el-icon>
- </el-button>
- </div>
- </main>
- </div>
- </div>
- </el-card>
- </div>
- </template>
- <script setup>
- 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'
- // 获取请求头(包含token)
- const getHeaders = () => {
- const token = localStorage.getItem('talk_token')
- return {
- 'Content-Type': 'application/json',
- 'Authorization': token ? `Bearer ${token}` : '',
- 'clientid': 'talk-web'
- }
- }
- const showChat = ref(false)
- const isTextInput = ref(false)
- const isMicMuted = ref(false)
- const sidebarVisible = ref(true)
- const basicExpanded = ref(true)
- const otherExpanded = ref(false)
- const currentPage = ref(0)
- 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('音频数据为空')
- return
- }
- const audioData = atob(base64Audio)
- const arrayBuffer = new ArrayBuffer(audioData.length)
- const view = new Uint8Array(arrayBuffer)
- for (let i = 0; i < audioData.length; i++) {
- view[i] = audioData.charCodeAt(i)
- }
- const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' })
- 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 => {
- console.error('播放失败:', err)
- ElMessage.error('音频播放失败: ' + err.message)
- })
- } catch (error) {
- console.error('播放音频失败:', error)
- ElMessage.error('音频处理失败: ' + error.message)
- }
- }
- // 对话历史
- const chatHistory = ref([])
- // 保存当前对话的 conversationId
- const currentConversationId = ref(null)
- // 滚动到底部
- const scrollToBottom = () => {
- nextTick(() => {
- if (chatContent.value) {
- chatContent.value.scrollTop = chatContent.value.scrollHeight
- }
- })
- }
- // 波形数据
- const waveformData = ref(Array(60).fill(20))
- let waveformInterval = null
- // 客服列表刷新定时器
- let agentRefreshInterval = null
- // 更新波形
- const updateWaveform = () => {
- waveformData.value = waveformData.value.map(() => {
- return Math.random() * 24 + 8
- })
- }
- // 监听转写结果变化,当有确定性结果时添加到对话历史
- watch(currentTranscription, async (newVal, oldVal) => {
- // 只有在录音状态下才处理语音识别结果
- if (!isRecording.value || isMicMuted.value) {
- return
- }
- if (newVal && newVal !== oldVal && newVal.length > oldVal.length) {
- const newContent = newVal.slice(oldVal.length).trim()
- if (newContent) {
- // 添加用户消息
- chatHistory.value.push({
- type: 'user',
- content: newContent
- })
- scrollToBottom()
- // 发送到后端处理
- try {
- const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
- const response = await fetch('http://localhost:8080/talk/message', {
- method: 'POST',
- headers: getHeaders(),
- body: JSON.stringify({
- message: newContent,
- agentId: selectedAgent.value,
- agentGender: selectedAgentData?.gender === 'male' ? '0' : '1',
- ttsVcnList: ttsVcnList.value,
- conversationId: currentConversationId.value
- })
- })
- const data = await response.json()
- // 保存 conversationId
- if (data.conversationId) {
- currentConversationId.value = data.conversationId
- }
- // 添加客服回复
- chatHistory.value.push({
- type: 'agent',
- content: data.reply
- })
- scrollToBottom()
- // 播放语音
- if (data.audio) {
- playAudio(data.audio)
- }
- } catch (error) {
- console.error('发送消息失败:', error)
- ElMessage.error('发送消息失败')
- }
- }
- }
- })
- // 监听 showChat 变化,开始对话时自动启动语音识别
- watch(showChat, async (newVal) => {
- if (newVal && !isRecording.value) {
- try {
- await startRecording()
- ElMessage.success('语音识别已启动')
- // 启动波形动画
- waveformInterval = setInterval(updateWaveform, 100)
- } catch (error) {
- ElMessage.error('启动语音识别失败: ' + error.message)
- }
- } 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}/hangup`, {
- method: 'POST',
- headers: getHeaders()
- })
- console.log('客服状态已恢复为正常')
- // 立即刷新客服列表,让用户看到状态变化
- await fetchAgents(true)
- } catch (error) {
- console.error('更新客服状态失败:', error)
- }
- }
- }
- })
- // 监听输入方式切换
- watch(isTextInput, async (newVal) => {
- if (newVal && isRecording.value) {
- // 切换到文本输入时停止语音识别
- stopRecording()
- isMicMuted.value = true
- if (waveformInterval) {
- clearInterval(waveformInterval)
- waveformInterval = null
- }
- } else if (!newVal && !isRecording.value && showChat.value) {
- // 切换回语音模式时重新启动语音识别
- try {
- await startRecording()
- isMicMuted.value = false
- waveformInterval = setInterval(updateWaveform, 100)
- } catch (error) {
- ElMessage.error('启动语音识别失败: ' + error.message)
- }
- }
- })
- // 切换麦克风静音状态
- const toggleMic = () => {
- if (isRecording.value) {
- // 当前正在录音,点击后停止并静音
- isMicMuted.value = true
- stopRecording()
- if (waveformInterval) {
- clearInterval(waveformInterval)
- waveformInterval = null
- }
- // 清空未完成的转写内容
- currentTranscription.value = ''
- tempTranscription.value = ''
- } else {
- // 当前未录音,点击后开始录音
- startRecording().then(() => {
- isMicMuted.value = false
- waveformInterval = setInterval(updateWaveform, 100)
- }).catch(err => {
- ElMessage.error('启动录音失败: ' + err.message)
- })
- }
- }
- // 文本输入
- const textMessage = ref('')
- // 发送文本消息
- const sendTextMessage = async () => {
- const content = textMessage.value.trim()
- if (!content) return
- // 添加用户消息
- chatHistory.value.push({
- type: 'user',
- content: content
- })
- scrollToBottom()
- // 清空输入框
- textMessage.value = ''
- // 发送到后端处理
- try {
- const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
- const response = await fetch('http://localhost:8080/talk/message', {
- method: 'POST',
- headers: getHeaders(),
- body: JSON.stringify({
- message: content,
- agentId: selectedAgent.value,
- agentGender: selectedAgentData?.gender === 'male' ? '0' : '1',
- 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)
- // 添加客服回复
- chatHistory.value.push({
- type: 'agent',
- content: data.reply
- })
- scrollToBottom()
- // 播放语音
- if (data.audio) {
- playAudio(data.audio)
- }
- } catch (error) {
- console.error('发送消息失败:', error)
- ElMessage.error('发送消息失败')
- }
- }
- const agents = ref([])
- const ttsVcnList = ref([])
- const config = ref({
- bgSoundEnabled: false,
- speed: 50,
- pitch: 50,
- volume: 50
- })
- const selectedAgent = ref(null)
- // 获取发言人字典列表
- const fetchTtsVcnList = async () => {
- try {
- const response = await fetch('http://localhost:8080/talk/dict/ttsVcn', {
- headers: getHeaders()
- })
- const data = await response.json()
- ttsVcnList.value = data || []
- } catch (error) {
- console.error('获取发言人字典失败:', error)
- }
- }
- // 获取客服列表
- const fetchAgents = async (silent = false) => {
- try {
- const response = await fetch('http://localhost:8080/talk/agent/list', {
- headers: getHeaders()
- })
- const result = await response.json()
- if (result.code === 200 && result.rows) {
- // 只显示状态为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',
- avatarUrl: agent.avatarUrl,
- greetingMessage: agent.greetingMessage,
- ttsVcn: agent.ttsVcn,
- ttsSpeed: agent.ttsSpeed,
- ttsPitch: agent.ttsPitch,
- ttsVolume: agent.ttsVolume,
- ttsBgs: agent.ttsBgs,
- status: agent.status
- }))
- // 如果之前选中的客服不在列表中,清除选择
- 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)
- if (!silent) {
- ElMessage.error('获取客服列表失败')
- }
- }
- }
- // 启动客服列表定期刷新
- const startAgentRefresh = () => {
- if (!agentRefreshInterval) {
- agentRefreshInterval = setInterval(() => {
- fetchAgents(true)
- }, 5000)
- }
- }
- // 停止客服列表定期刷新
- const stopAgentRefresh = () => {
- if (agentRefreshInterval) {
- clearInterval(agentRefreshInterval)
- agentRefreshInterval = null
- }
- }
- // 退出登录
- const handleLogout = async () => {
- try {
- // 调用后端退出登录接口
- await fetch('http://localhost:8080/talk/auth/logout', {
- method: 'POST',
- headers: getHeaders()
- })
- } catch (error) {
- console.error('退出登录失败:', error)
- } finally {
- // 清除本地存储
- localStorage.removeItem('talk_token')
- localStorage.removeItem('talk_user')
- // 刷新页面返回登录页
- window.location.reload()
- }
- }
- // 组件挂载时获取客服列表和发言人字典
- onMounted(async () => {
- await fetchAgents()
- await fetchTtsVcnList()
- // 启动客服列表定期刷新
- startAgentRefresh()
- })
- // 组件卸载时清理定时器
- onUnmounted(() => {
- stopAgentRefresh()
- if (waveformInterval) {
- clearInterval(waveformInterval)
- }
- })
- // 获取头像完整URL
- const getAvatarUrl = (avatarUrl) => {
- if (!avatarUrl) return ''
- if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
- return avatarUrl
- }
- return 'http://localhost:8080' + avatarUrl
- }
- // 开始对话
- const startChat = async () => {
- if (!selectedAgent.value) {
- ElMessage.warning('请先选择一个客服')
- 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 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
- })
- })
- console.log('客服状态已更新为对话中')
- // 先切换到对话界面,确保音频可以播放
- 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()
- // 保存 conversationId
- if (ttsData.conversationId) {
- currentConversationId.value = ttsData.conversationId
- }
- if (ttsData.audio) {
- playAudio(ttsData.audio)
- }
- } catch (error) {
- console.error('生成欢迎语语音失败:', error)
- }
- } else {
- chatHistory.value = []
- }
- } catch (error) {
- console.error('更新客服配置失败:', error)
- ElMessage.error('开始对话失败,请重试')
- return
- }
- }
- const pageSize = 6
- const totalPages = computed(() => Math.ceil(agents.value.length / pageSize))
- const paginatedAgents = computed(() => {
- const start = currentPage.value * pageSize
- return agents.value.slice(start, start + pageSize)
- })
- const handleWheel = (e) => {
- e.preventDefault()
- // 支持垂直滚动(上下)和水平滚动(左右)
- const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
- if (delta > 0 && currentPage.value < totalPages.value - 1) {
- currentPage.value++
- } else if (delta < 0 && currentPage.value > 0) {
- currentPage.value--
- }
- }
- </script>
- <style scoped>
- :global(html),
- :global(body) {
- height: 100%;
- overflow: hidden;
- margin: 0;
- }
- :global(#app) {
- height: 100%;
- overflow: hidden;
- }
- .container {
- min-height: 100vh;
- height: 100vh;
- background: linear-gradient(to bottom, #bfdbfe, #dbeafe, #e0f2fe);
- display: flex;
- align-items: flex-start;
- justify-content: center;
- padding: 16px;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
- position: relative;
- overflow: hidden;
- box-sizing: border-box;
- }
- .container::before {
- content: '';
- position: absolute;
- top: -50%;
- right: -20%;
- width: 80%;
- height: 100%;
- background: linear-gradient(135deg, rgba(147, 197, 253, 0.3) 0%, rgba(191, 219, 254, 0.2) 100%);
- transform: rotate(-15deg);
- border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%;
- box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15);
- z-index: -1;
- }
- .container::after {
- content: '';
- position: absolute;
- bottom: -30%;
- left: -10%;
- width: 60%;
- height: 80%;
- background: linear-gradient(225deg, rgba(191, 219, 254, 0.25) 0%, rgba(224, 242, 254, 0.15) 100%);
- transform: rotate(25deg);
- border-radius: 70% 30% 30% 70% / 70% 70% 30% 30%;
- box-shadow: 0 8px 32px rgba(96, 165, 250, 0.12);
- z-index: -1;
- }
- .main-card {
- width: 100%;
- max-width: 1200px;
- border-radius: 24px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1) !important;
- height: calc(100vh - 117px);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- box-sizing: border-box;
- }
- :deep(.main-card .el-card__body) {
- height: 100%;
- display: flex;
- flex-direction: column;
- min-height: 0;
- }
- .header {
- padding: 16px 20px;
- text-align: center;
- border-bottom: 1px solid #e5e7eb;
- background: white;
- border-radius: 24px 24px 0 0;
- position: relative;
- }
- .toggle-link {
- position: absolute;
- left: 20px;
- top: 50%;
- transform: translateY(-50%);
- color: #9ca3af;
- font-size: 14px;
- cursor: pointer;
- user-select: none;
- transition: color 0.2s;
- }
- .toggle-link:hover {
- color: #6b7280;
- }
- .title {
- font-size: 20px;
- font-weight: 500;
- color: #1f2937;
- margin: 0;
- }
- .logout-btn {
- position: absolute;
- right: 20px;
- top: 50%;
- transform: translateY(-50%);
- padding: 6px 16px;
- background: white;
- color: #ef4444;
- border: 1px solid #ef4444;
- border-radius: 20px;
- font-size: 14px;
- cursor: pointer;
- user-select: none;
- transition: all 0.2s;
- }
- .logout-btn:hover {
- background: #ef4444;
- color: white;
- }
- .content-wrapper {
- position: relative;
- background: white;
- border-radius: 0 0 24px 24px;
- flex: 1;
- display: flex;
- min-height: 0;
- overflow: hidden;
- }
- .toggle-btn {
- position: absolute;
- top: 16px;
- left: 16px;
- z-index: 10;
- }
- .main-content {
- display: flex;
- gap: 8px;
- padding: 0 12px 0 12px;
- flex: 1;
- height: auto;
- min-height: 0;
- max-height: none;
- align-items: stretch;
- overflow: hidden;
- }
- .sidebar {
- width: 320px;
- flex-shrink: 0;
- padding: 20px;
- background: #ffffff;
- border-radius: 8px;
- height: 100%;
- overflow-y: auto;
- min-height: 0;
- max-height: 100%;
- box-sizing: border-box;
- }
- .sidebar.disabled :deep(.el-form) {
- opacity: 0.5;
- pointer-events: none;
- }
- .right-panel {
- flex: 1 1 0;
- display: flex;
- flex-direction: column;
- align-items: stretch;
- justify-content: flex-start;
- padding: 0;
- background: #f9fafb;
- border-radius: 8px;
- height: 100%;
- overflow: hidden;
- min-width: 0;
- position: relative;
- }
- .time-info {
- align-self: flex-end;
- color: #9ca3af;
- margin-bottom: 40px;
- font-size: 13px;
- }
- .agents-container {
- flex: 0 0 auto;
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- padding-top: 36px;
- padding-bottom: 0;
- overflow: visible;
- min-height: 0;
- position: relative;
- }
- .agents-container .pagination-dots {
- position: absolute;
- top: calc(36px + 220px + 16px + 220px + 24px);
- left: 50%;
- transform: translateX(-50%);
- z-index: 10;
- }
- .agents-footer {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 0;
- flex-shrink: 0;
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- top: calc(36px + 220px + 16px + 220px + 24px + 40px);
- }
- .agents-list {
- display: grid;
- grid-template-columns: repeat(3, 220px);
- gap: 16px;
- width: 100%;
- max-width: 100%;
- padding: 0 12px;
- margin-top: 0;
- margin-bottom: 0;
- justify-content: center;
- }
- .agent-card {
- width: 220px;
- height: 220px;
- padding: 10px;
- border: 2px solid #e5e7eb;
- border-radius: 12px;
- text-align: center;
- cursor: pointer;
- position: relative;
- transition: all 0.3s ease;
- background: white;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
- }
- .agent-card:hover {
- border-color: #3b82f6;
- transform: translateY(-4px);
- box-shadow: 0 8px 20px rgba(59, 130, 246, 0.15);
- }
- .agent-card.selected {
- border-color: #3b82f6;
- background: #eff6ff;
- 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;
- right: 8px;
- width: 24px;
- height: 24px;
- border-radius: 50%;
- background: rgba(239, 68, 68, 0.9);
- color: white;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- transition: opacity 0.2s;
- z-index: 10;
- }
- .agent-card:hover .delete-btn {
- opacity: 1;
- }
- .delete-btn:hover {
- background: rgba(220, 38, 38, 1);
- }
- .agent-card:hover {
- border-color: #3b82f6;
- transform: translateY(-4px);
- box-shadow: 0 12px 32px rgba(59, 130, 246, 0.25);
- }
- .agent-card.selected {
- border-color: #3b82f6;
- background: #eff6ff;
- box-shadow: 0 0 0 1px #3b82f6, 0 8px 24px rgba(59, 130, 246, 0.2);
- }
- .add-card {
- border: 2px dashed #d1d5db;
- background: transparent;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- min-height: 120px;
- }
- .add-card:hover {
- border-color: #3b82f6;
- background: #f9fafb;
- transform: translateY(-4px);
- }
- .add-icon {
- font-size: 36px;
- color: #9ca3af;
- margin-bottom: 8px;
- }
- .add-card:hover .add-icon {
- color: #3b82f6;
- }
- .add-text {
- font-size: 14px;
- color: #6b7280;
- font-weight: 500;
- }
- .add-card:hover .add-text {
- color: #3b82f6;
- }
- .avatar {
- width: 120px;
- height: 120px;
- border-radius: 50%;
- margin: 20px auto 12px;
- border: 3px solid #e5e7eb;
- }
- .avatar-img {
- width: 120px;
- height: 120px;
- border-radius: 50%;
- margin: 20px auto 12px;
- object-fit: cover;
- border: 3px solid #e5e7eb;
- background: #f9fafb;
- }
- .avatar.female {
- background: linear-gradient(135deg, #fce7f3, #fbcfe8);
- }
- .avatar.male {
- background: linear-gradient(135deg, #dbeafe, #bfdbfe);
- }
- .gender-icon {
- position: absolute;
- top: 110px;
- right: calc(50% - 70px);
- width: 28px;
- height: 28px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- color: white;
- font-weight: 700;
- font-size: 14px;
- border: 2px solid white;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- }
- .gender-icon.female {
- background: #ec4899;
- }
- .gender-icon.male {
- background: #3b82f6;
- }
- .status-badge {
- position: absolute;
- top: 8px;
- left: 8px;
- padding: 4px 12px;
- border-radius: 20px;
- font-size: 11px;
- font-weight: 600;
- z-index: 1;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- }
- .status-badge.status-0 {
- background: #10b981;
- color: white;
- }
- .status-badge.status-1 {
- background: #9ca3af;
- color: white;
- }
- .status-badge.status-2 {
- background: #ef4444;
- color: white;
- }
- .agent-name {
- font-size: 14px;
- color: #1f2937;
- font-weight: 600;
- margin-top: 8px;
- }
- .start-btn {
- font-size: 16px;
- padding: 12px 48px;
- align-self: center;
- margin: 0;
- }
- .pagination-dots {
- display: flex;
- gap: 8px;
- justify-content: center;
- align-items: center;
- padding: 0;
- flex-shrink: 0;
- }
- .pagination-dots .dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: #d1d5db;
- cursor: pointer;
- transition: all 0.3s;
- }
- .pagination-dots .dot:hover {
- background: #9ca3af;
- }
- .pagination-dots .dot.active {
- width: 24px;
- border-radius: 4px;
- background: #3b82f6;
- }
- .slider-value {
- font-size: 14px;
- color: #1f2937;
- margin-bottom: 8px;
- font-weight: 500;
- }
- .slider-labels {
- display: flex;
- justify-content: space-between;
- font-size: 12px;
- color: #9ca3af;
- margin-top: 2px;
- width: 100%;
- }
- /* 紧凑型滑块样式 */
- .compact-slider {
- margin-bottom: 12px;
- }
- .compact-slider :deep(.el-form-item__label) {
- margin-bottom: 0px;
- padding-bottom: 0;
- }
- .compact-slider .slider-value {
- margin-bottom: 0px;
- }
- .compact-slider :deep(.el-form-item__content) {
- width: 100%;
- }
- /* 自定义折叠组件样式 */
- .collapse-section {
- margin-bottom: 0;
- }
- .collapse-header {
- display: flex;
- justify-content: flex-start;
- align-items: center;
- padding: 16px 0;
- cursor: pointer;
- user-select: none;
- font-weight: 600;
- font-size: 15px;
- color: #1f2937;
- }
- .collapse-header:hover {
- color: #3b82f6;
- }
- .collapse-header .arrow {
- font-size: 14px;
- color: #6b7280;
- margin-left: 8px;
- }
- .collapse-content {
- padding-bottom: 16px;
- }
- /* Element Plus 样式覆盖 */
- :deep(.el-collapse) {
- border: none;
- }
- :deep(.el-collapse-item) {
- margin-bottom: 0;
- }
- :deep(.el-collapse-item__header) {
- font-weight: 600;
- font-size: 15px;
- color: #1f2937;
- border: none;
- padding: 16px 0;
- height: auto;
- line-height: 1.5;
- background: transparent;
- justify-content: flex-start;
- }
- :deep(.el-collapse-item__arrow) {
- font-size: 14px;
- color: #6b7280;
- margin-left: 8px;
- margin-right: 0;
- }
- :deep(.el-collapse-item__arrow.is-active) {
- transform: rotate(180deg);
- }
- :deep(.el-collapse-item__wrap) {
- border: none;
- background: transparent;
- }
- :deep(.el-collapse-item__content) {
- padding: 0 0 16px 0;
- color: inherit;
- }
- :deep(.el-form-item) {
- margin-bottom: 20px;
- }
- :deep(.el-form-item__label) {
- color: #9ca3af;
- font-size: 13px;
- margin-bottom: 8px;
- }
- :deep(.el-select) {
- width: 100%;
- }
- :deep(.el-radio-group) {
- width: 100%;
- display: flex;
- }
- :deep(.el-radio-button) {
- flex: 1;
- }
- :deep(.el-radio-button__inner) {
- width: 100%;
- border-radius: 6px;
- }
- :deep(.el-slider__runway) {
- height: 4px;
- background-color: #e5e7eb;
- }
- :deep(.el-slider__bar) {
- background-color: #3b82f6;
- }
- :deep(.el-slider__button) {
- width: 16px;
- height: 16px;
- border: 2px solid #3b82f6;
- }
- :deep(.el-button--primary) {
- background-color: #3b82f6;
- border-color: #3b82f6;
- }
- :deep(.el-button--primary:hover) {
- background-color: #2563eb;
- border-color: #2563eb;
- }
- /* 对话界面样式 */
- .chat-panel {
- display: flex;
- flex-direction: column;
- padding: 0;
- height: 100%;
- }
- .chat-content {
- flex: 1;
- overflow-y: auto;
- padding: 20px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .message {
- display: flex;
- animation: slideIn 0.3s ease;
- }
- @keyframes slideIn {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
- .ai-message {
- justify-content: flex-start;
- }
- .ai-message .message-text {
- background: white;
- padding: 12px 16px;
- border-radius: 12px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
- max-width: 70%;
- }
- .user-message {
- justify-content: flex-end;
- }
- .user-message .message-text {
- background: #3b82f6;
- color: white;
- padding: 12px 16px;
- border-radius: 12px;
- box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
- max-width: 70%;
- }
- .quick-questions {
- margin-top: 12px;
- }
- .question-item {
- padding: 8px 0;
- font-size: 14px;
- color: #333;
- }
- .pause-icon {
- position: absolute;
- right: 12px;
- bottom: 12px;
- color: #3b82f6;
- }
- .chat-footer {
- padding: 20px 16px;
- background: white;
- border-top: 1px solid #e5e7eb;
- display: flex;
- align-items: center;
- gap: 16px;
- flex-shrink: 0;
- }
- .keyboard-btn {
- width: 48px;
- height: 48px;
- border: 1px solid #e5e7eb;
- background: white;
- flex-shrink: 0;
- border-radius: 50%;
- padding: 0;
- min-width: 48px;
- }
- .voice-input-btn {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 16px;
- height: 48px;
- background: white;
- border: 1px solid #e5e7eb;
- border-radius: 24px;
- padding: 0 20px;
- overflow: hidden;
- }
- .mic-icon {
- font-size: 24px;
- color: #6b7280;
- flex-shrink: 0;
- }
- .text-input {
- flex: 1;
- }
- .input-placeholder {
- flex: 1;
- height: 2px;
- border-bottom: 2px dotted #d1d5db;
- align-self: center;
- }
- .hangup-btn {
- width: 56px;
- height: 56px;
- background: #ef4444;
- border: none;
- color: white;
- flex-shrink: 0;
- border-radius: 50%;
- padding: 0;
- min-width: 56px;
- }
- .hangup-btn:hover {
- background: #dc2626;
- }
- /* 波形显示 */
- .waveform {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 2px;
- height: 32px;
- }
- .wave-bar {
- width: 3px;
- background: #3b82f6;
- border-radius: 2px;
- transition: height 0.1s ease;
- max-height: 32px;
- }
- .mic-icon.recording {
- color: #ef4444;
- animation: pulse 1.5s ease-in-out infinite;
- }
- .input-placeholder.recording {
- border-bottom-color: #ef4444;
- animation: pulse-border 1.5s ease-in-out infinite;
- }
- @keyframes pulse-border {
- 0%, 100% {
- border-bottom-color: #ef4444;
- }
- 50% {
- border-bottom-color: #fca5a5;
- }
- }
- </style>
|