CustomerService.vue 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589
  1. <template>
  2. <div class="container">
  3. <el-card class="main-card" :body-style="{ padding: 0 }">
  4. <div class="header">
  5. <div class="toggle-link" @click="sidebarVisible = !sidebarVisible">
  6. ≡ {{ sidebarVisible ? '收起配置' : '展开配置' }}
  7. </div>
  8. <h1 class="title">智能客服</h1>
  9. <div class="logout-btn" @click="handleLogout">退出登录</div>
  10. </div>
  11. <div class="content-wrapper">
  12. <div class="main-content">
  13. <aside v-if="sidebarVisible" class="sidebar" :class="{ disabled: showChat }">
  14. <div class="collapse-section">
  15. <div class="collapse-header" @click="basicExpanded = !basicExpanded">
  16. <span>基础设置</span>
  17. <el-icon class="arrow">
  18. <ArrowUp v-if="basicExpanded" />
  19. <ArrowDown v-else />
  20. </el-icon>
  21. </div>
  22. <div v-if="basicExpanded" class="collapse-content">
  23. <el-form label-position="top" size="default">
  24. <el-form-item label="背景音">
  25. <el-radio-group v-model="config.bgSoundEnabled">
  26. <el-radio-button :value="true">开启</el-radio-button>
  27. <el-radio-button :value="false">关闭</el-radio-button>
  28. </el-radio-group>
  29. </el-form-item>
  30. </el-form>
  31. </div>
  32. </div>
  33. <div class="collapse-section">
  34. <div class="collapse-header" @click="otherExpanded = !otherExpanded">
  35. <span>其他设置</span>
  36. <el-icon class="arrow">
  37. <ArrowUp v-if="otherExpanded" />
  38. <ArrowDown v-else />
  39. </el-icon>
  40. </div>
  41. <div v-if="otherExpanded" class="collapse-content">
  42. <el-form label-position="top" size="default">
  43. <el-form-item label="语速" class="compact-slider">
  44. <div class="slider-value">{{ config.speed }}</div>
  45. <el-slider
  46. v-model="config.speed"
  47. :min="0"
  48. :max="100"
  49. :step="1"
  50. />
  51. <div class="slider-labels">
  52. <span>慢</span>
  53. <span>快</span>
  54. </div>
  55. </el-form-item>
  56. <el-form-item label="音调" class="compact-slider">
  57. <div class="slider-value">{{ config.pitch }}</div>
  58. <el-slider
  59. v-model="config.pitch"
  60. :min="0"
  61. :max="100"
  62. :step="1"
  63. />
  64. <div class="slider-labels">
  65. <span>低</span>
  66. <span>高</span>
  67. </div>
  68. </el-form-item>
  69. <el-form-item label="音量" class="compact-slider">
  70. <div class="slider-value">{{ config.volume }}</div>
  71. <el-slider
  72. v-model="config.volume"
  73. :min="0"
  74. :max="100"
  75. :step="1"
  76. />
  77. <div class="slider-labels">
  78. <span>小</span>
  79. <span>大</span>
  80. </div>
  81. </el-form-item>
  82. </el-form>
  83. </div>
  84. </div>
  85. </aside>
  86. <main class="right-panel" v-if="!showChat" @wheel="handleWheel">
  87. <div class="agents-container">
  88. <div class="agents-list">
  89. <div
  90. v-for="agent in paginatedAgents"
  91. :key="agent.id"
  92. class="agent-card"
  93. :class="{
  94. selected: selectedAgent === agent.id,
  95. disabled: agent.status === '2'
  96. }"
  97. @click="agent.status === '0' ? selectedAgent = agent.id : null"
  98. >
  99. <img v-if="agent.avatarUrl" :src="getAvatarUrl(agent.avatarUrl)" class="avatar-img" />
  100. <div v-else class="avatar" :class="agent.gender"></div>
  101. <div class="gender-icon" :class="agent.gender">{{ agent.gender === 'female' ? '♀' : '♂' }}</div>
  102. <div class="status-badge" :class="'status-' + agent.status">
  103. {{ agent.status === '0' ? '正常' : agent.status === '1' ? '停用' : '对话中' }}
  104. </div>
  105. <div class="agent-name">{{ agent.name }}</div>
  106. </div>
  107. </div>
  108. <div class="pagination-dots" v-if="totalPages > 1">
  109. <span
  110. v-for="page in totalPages"
  111. :key="page"
  112. class="dot"
  113. :class="{ active: currentPage === page - 1 }"
  114. @click="currentPage = page - 1"
  115. ></span>
  116. </div>
  117. </div>
  118. <div class="agents-footer">
  119. <el-button type="primary" round size="large" class="start-btn" @click="startChat">
  120. 开始对话
  121. </el-button>
  122. </div>
  123. </main>
  124. <main class="right-panel chat-panel" v-else>
  125. <!-- 对话历史 -->
  126. <div class="chat-content" ref="chatContent">
  127. <div
  128. v-for="(msg, index) in chatHistory"
  129. :key="index"
  130. class="message"
  131. :class="msg.type === 'user' ? 'user-message' : 'ai-message'"
  132. >
  133. <div class="message-text">
  134. {{ msg.content }}
  135. </div>
  136. </div>
  137. </div>
  138. <div class="chat-footer">
  139. <el-button class="keyboard-btn" @click="isTextInput = !isTextInput">
  140. <el-icon><ChatDotRound v-if="!isTextInput" /><Microphone v-else /></el-icon>
  141. </el-button>
  142. <el-input
  143. v-if="isTextInput"
  144. v-model="textMessage"
  145. placeholder="输入消息..."
  146. class="text-input"
  147. @keyup.enter="sendTextMessage"
  148. >
  149. <template #append>
  150. <el-button @click="sendTextMessage" :disabled="!textMessage.trim()">
  151. 发送
  152. </el-button>
  153. </template>
  154. </el-input>
  155. <div v-else class="voice-input-btn" @click="toggleMic">
  156. <el-icon class="mic-icon" :class="{ muted: isMicMuted, recording: isRecording }">
  157. <Microphone v-if="!isMicMuted" />
  158. <Mute v-else />
  159. </el-icon>
  160. <!-- 波形显示 -->
  161. <div v-if="isRecording" class="waveform">
  162. <div
  163. class="wave-bar"
  164. v-for="(height, index) in waveformData"
  165. :key="index"
  166. :style="{ height: height + 'px' }"
  167. ></div>
  168. </div>
  169. <div v-else class="input-placeholder"></div>
  170. </div>
  171. <el-button class="hangup-btn" @click="showChat = false">
  172. <el-icon><PhoneFilled /></el-icon>
  173. </el-button>
  174. </div>
  175. </main>
  176. </div>
  177. </div>
  178. </el-card>
  179. </div>
  180. </template>
  181. <script setup>
  182. import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
  183. import { ArrowUp, ArrowDown, Microphone, PhoneFilled, ChatDotRound, Mute } from '@element-plus/icons-vue'
  184. import { useVoiceRecognition } from './composables/useVoiceRecognition.js'
  185. import { ElMessage } from 'element-plus'
  186. // 获取请求头(包含token)
  187. const getHeaders = () => {
  188. const token = localStorage.getItem('talk_token')
  189. return {
  190. 'Content-Type': 'application/json',
  191. 'Authorization': token ? `Bearer ${token}` : '',
  192. 'clientid': 'talk-web'
  193. }
  194. }
  195. const showChat = ref(false)
  196. const isTextInput = ref(false)
  197. const isMicMuted = ref(false)
  198. const sidebarVisible = ref(true)
  199. const basicExpanded = ref(true)
  200. const otherExpanded = ref(false)
  201. const currentPage = ref(0)
  202. const chatContent = ref(null)
  203. // 语音识别
  204. const { isRecording, currentTranscription, tempTranscription, startRecording, stopRecording } = useVoiceRecognition()
  205. // 当前播放的音频对象
  206. const currentAudio = ref(null)
  207. // 停止音频播放
  208. const stopAudio = () => {
  209. if (currentAudio.value) {
  210. currentAudio.value.pause()
  211. currentAudio.value.currentTime = 0
  212. currentAudio.value = null
  213. }
  214. }
  215. // 音频播放
  216. const playAudio = (base64Audio) => {
  217. // 如果不在对话状态,不播放音频
  218. if (!showChat.value) {
  219. return
  220. }
  221. try {
  222. if (!base64Audio) {
  223. ElMessage.error('音频数据为空')
  224. return
  225. }
  226. const audioData = atob(base64Audio)
  227. const arrayBuffer = new ArrayBuffer(audioData.length)
  228. const view = new Uint8Array(arrayBuffer)
  229. for (let i = 0; i < audioData.length; i++) {
  230. view[i] = audioData.charCodeAt(i)
  231. }
  232. const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' })
  233. const audioUrl = URL.createObjectURL(blob)
  234. const audio = new Audio(audioUrl)
  235. // 停止之前的音频
  236. stopAudio()
  237. // 保存当前音频引用
  238. currentAudio.value = audio
  239. audio.onended = () => {
  240. URL.revokeObjectURL(audioUrl)
  241. if (currentAudio.value === audio) {
  242. currentAudio.value = null
  243. }
  244. }
  245. audio.onerror = (e) => {
  246. console.error('音频播放错误:', e)
  247. ElMessage.error('音频播放失败')
  248. URL.revokeObjectURL(audioUrl)
  249. if (currentAudio.value === audio) {
  250. currentAudio.value = null
  251. }
  252. }
  253. audio.play().catch(err => {
  254. console.error('播放失败:', err)
  255. ElMessage.error('音频播放失败: ' + err.message)
  256. })
  257. } catch (error) {
  258. console.error('播放音频失败:', error)
  259. ElMessage.error('音频处理失败: ' + error.message)
  260. }
  261. }
  262. // 对话历史
  263. const chatHistory = ref([])
  264. // 保存当前对话的 conversationId
  265. const currentConversationId = ref(null)
  266. // 滚动到底部
  267. const scrollToBottom = () => {
  268. nextTick(() => {
  269. if (chatContent.value) {
  270. chatContent.value.scrollTop = chatContent.value.scrollHeight
  271. }
  272. })
  273. }
  274. // 波形数据
  275. const waveformData = ref(Array(60).fill(20))
  276. let waveformInterval = null
  277. // 客服列表刷新定时器
  278. let agentRefreshInterval = null
  279. // 更新波形
  280. const updateWaveform = () => {
  281. waveformData.value = waveformData.value.map(() => {
  282. return Math.random() * 24 + 8
  283. })
  284. }
  285. // 监听转写结果变化,当有确定性结果时添加到对话历史
  286. watch(currentTranscription, async (newVal, oldVal) => {
  287. // 只有在录音状态下才处理语音识别结果
  288. if (!isRecording.value || isMicMuted.value) {
  289. return
  290. }
  291. if (newVal && newVal !== oldVal && newVal.length > oldVal.length) {
  292. const newContent = newVal.slice(oldVal.length).trim()
  293. if (newContent) {
  294. // 添加用户消息
  295. chatHistory.value.push({
  296. type: 'user',
  297. content: newContent
  298. })
  299. scrollToBottom()
  300. // 发送到后端处理
  301. try {
  302. const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
  303. const response = await fetch('http://localhost:8080/talk/message', {
  304. method: 'POST',
  305. headers: getHeaders(),
  306. body: JSON.stringify({
  307. message: newContent,
  308. agentId: selectedAgent.value,
  309. agentGender: selectedAgentData?.gender === 'male' ? '0' : '1',
  310. ttsVcnList: ttsVcnList.value,
  311. conversationId: currentConversationId.value
  312. })
  313. })
  314. const data = await response.json()
  315. // 保存 conversationId
  316. if (data.conversationId) {
  317. currentConversationId.value = data.conversationId
  318. }
  319. // 添加客服回复
  320. chatHistory.value.push({
  321. type: 'agent',
  322. content: data.reply
  323. })
  324. scrollToBottom()
  325. // 播放语音
  326. if (data.audio) {
  327. playAudio(data.audio)
  328. }
  329. } catch (error) {
  330. console.error('发送消息失败:', error)
  331. ElMessage.error('发送消息失败')
  332. }
  333. }
  334. }
  335. })
  336. // 监听 showChat 变化,开始对话时自动启动语音识别
  337. watch(showChat, async (newVal) => {
  338. if (newVal && !isRecording.value) {
  339. try {
  340. await startRecording()
  341. ElMessage.success('语音识别已启动')
  342. // 启动波形动画
  343. waveformInterval = setInterval(updateWaveform, 100)
  344. } catch (error) {
  345. ElMessage.error('启动语音识别失败: ' + error.message)
  346. }
  347. } else if (!newVal) {
  348. // 对话结束时,无论什么状态都执行清理操作
  349. if (isRecording.value) {
  350. stopRecording()
  351. }
  352. // 停止音频播放
  353. stopAudio()
  354. // 清理波形动画
  355. if (waveformInterval) {
  356. clearInterval(waveformInterval)
  357. waveformInterval = null
  358. }
  359. // 重置状态为初始值
  360. isTextInput.value = false
  361. isMicMuted.value = false
  362. currentConversationId.value = null
  363. // 对话结束,将客服状态改回正常
  364. if (selectedAgent.value) {
  365. try {
  366. await fetch(`http://localhost:8080/talk/agent/${selectedAgent.value}/hangup`, {
  367. method: 'POST',
  368. headers: getHeaders()
  369. })
  370. console.log('客服状态已恢复为正常')
  371. // 立即刷新客服列表,让用户看到状态变化
  372. await fetchAgents(true)
  373. } catch (error) {
  374. console.error('更新客服状态失败:', error)
  375. }
  376. }
  377. }
  378. })
  379. // 监听输入方式切换
  380. watch(isTextInput, async (newVal) => {
  381. if (newVal && isRecording.value) {
  382. // 切换到文本输入时停止语音识别
  383. stopRecording()
  384. isMicMuted.value = true
  385. if (waveformInterval) {
  386. clearInterval(waveformInterval)
  387. waveformInterval = null
  388. }
  389. } else if (!newVal && !isRecording.value && showChat.value) {
  390. // 切换回语音模式时重新启动语音识别
  391. try {
  392. await startRecording()
  393. isMicMuted.value = false
  394. waveformInterval = setInterval(updateWaveform, 100)
  395. } catch (error) {
  396. ElMessage.error('启动语音识别失败: ' + error.message)
  397. }
  398. }
  399. })
  400. // 切换麦克风静音状态
  401. const toggleMic = () => {
  402. if (isRecording.value) {
  403. // 当前正在录音,点击后停止并静音
  404. isMicMuted.value = true
  405. stopRecording()
  406. if (waveformInterval) {
  407. clearInterval(waveformInterval)
  408. waveformInterval = null
  409. }
  410. // 清空未完成的转写内容
  411. currentTranscription.value = ''
  412. tempTranscription.value = ''
  413. } else {
  414. // 当前未录音,点击后开始录音
  415. startRecording().then(() => {
  416. isMicMuted.value = false
  417. waveformInterval = setInterval(updateWaveform, 100)
  418. }).catch(err => {
  419. ElMessage.error('启动录音失败: ' + err.message)
  420. })
  421. }
  422. }
  423. // 文本输入
  424. const textMessage = ref('')
  425. // 发送文本消息
  426. const sendTextMessage = async () => {
  427. const content = textMessage.value.trim()
  428. if (!content) return
  429. // 添加用户消息
  430. chatHistory.value.push({
  431. type: 'user',
  432. content: content
  433. })
  434. scrollToBottom()
  435. // 清空输入框
  436. textMessage.value = ''
  437. // 发送到后端处理
  438. try {
  439. const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
  440. const response = await fetch('http://localhost:8080/talk/message', {
  441. method: 'POST',
  442. headers: getHeaders(),
  443. body: JSON.stringify({
  444. message: content,
  445. agentId: selectedAgent.value,
  446. agentGender: selectedAgentData?.gender === 'male' ? '0' : '1',
  447. ttsVcnList: ttsVcnList.value,
  448. conversationId: currentConversationId.value
  449. })
  450. })
  451. const data = await response.json()
  452. // 保存 conversationId
  453. if (data.conversationId) {
  454. currentConversationId.value = data.conversationId
  455. }
  456. console.log('解析后的audio字段长度:', data.audio ? data.audio.length : 0)
  457. // 添加客服回复
  458. chatHistory.value.push({
  459. type: 'agent',
  460. content: data.reply
  461. })
  462. scrollToBottom()
  463. // 播放语音
  464. if (data.audio) {
  465. playAudio(data.audio)
  466. }
  467. } catch (error) {
  468. console.error('发送消息失败:', error)
  469. ElMessage.error('发送消息失败')
  470. }
  471. }
  472. const agents = ref([])
  473. const ttsVcnList = ref([])
  474. const config = ref({
  475. bgSoundEnabled: false,
  476. speed: 50,
  477. pitch: 50,
  478. volume: 50
  479. })
  480. const selectedAgent = ref(null)
  481. // 获取发言人字典列表
  482. const fetchTtsVcnList = async () => {
  483. try {
  484. const response = await fetch('http://localhost:8080/talk/dict/ttsVcn', {
  485. headers: getHeaders()
  486. })
  487. const data = await response.json()
  488. ttsVcnList.value = data || []
  489. } catch (error) {
  490. console.error('获取发言人字典失败:', error)
  491. }
  492. }
  493. // 获取客服列表
  494. const fetchAgents = async (silent = false) => {
  495. try {
  496. const response = await fetch('http://localhost:8080/talk/agent/list', {
  497. headers: getHeaders()
  498. })
  499. const result = await response.json()
  500. if (result.code === 200 && result.rows) {
  501. // 只显示状态为0(正常)或2(对话中)的客服
  502. const previousSelectedAgent = selectedAgent.value
  503. agents.value = result.rows
  504. .filter(agent => agent.status === '0' || agent.status === '2')
  505. .map(agent => ({
  506. id: agent.id,
  507. name: agent.name,
  508. gender: agent.gender === '0' ? 'male' : 'female',
  509. avatarUrl: agent.avatarUrl,
  510. greetingMessage: agent.greetingMessage,
  511. ttsVcn: agent.ttsVcn,
  512. ttsSpeed: agent.ttsSpeed,
  513. ttsPitch: agent.ttsPitch,
  514. ttsVolume: agent.ttsVolume,
  515. ttsBgs: agent.ttsBgs,
  516. status: agent.status
  517. }))
  518. // 如果之前选中的客服不在列表中,清除选择
  519. if (previousSelectedAgent && !agents.value.find(a => a.id === previousSelectedAgent)) {
  520. selectedAgent.value = null
  521. }
  522. // 如果没有选中客服且列表不为空,选择第一个可用的客服
  523. if (!selectedAgent.value && agents.value.length > 0) {
  524. const availableAgent = agents.value.find(a => a.status === '0')
  525. selectedAgent.value = availableAgent ? availableAgent.id : agents.value[0].id
  526. }
  527. }
  528. } catch (error) {
  529. console.error('获取客服列表失败:', error)
  530. if (!silent) {
  531. ElMessage.error('获取客服列表失败')
  532. }
  533. }
  534. }
  535. // 启动客服列表定期刷新
  536. const startAgentRefresh = () => {
  537. if (!agentRefreshInterval) {
  538. agentRefreshInterval = setInterval(() => {
  539. fetchAgents(true)
  540. }, 5000)
  541. }
  542. }
  543. // 停止客服列表定期刷新
  544. const stopAgentRefresh = () => {
  545. if (agentRefreshInterval) {
  546. clearInterval(agentRefreshInterval)
  547. agentRefreshInterval = null
  548. }
  549. }
  550. // 退出登录
  551. const handleLogout = async () => {
  552. try {
  553. // 调用后端退出登录接口
  554. await fetch('http://localhost:8080/talk/auth/logout', {
  555. method: 'POST',
  556. headers: getHeaders()
  557. })
  558. } catch (error) {
  559. console.error('退出登录失败:', error)
  560. } finally {
  561. // 清除本地存储
  562. localStorage.removeItem('talk_token')
  563. localStorage.removeItem('talk_user')
  564. // 刷新页面返回登录页
  565. window.location.reload()
  566. }
  567. }
  568. // 组件挂载时获取客服列表和发言人字典
  569. onMounted(async () => {
  570. await fetchAgents()
  571. await fetchTtsVcnList()
  572. // 启动客服列表定期刷新
  573. startAgentRefresh()
  574. })
  575. // 组件卸载时清理定时器
  576. onUnmounted(() => {
  577. stopAgentRefresh()
  578. if (waveformInterval) {
  579. clearInterval(waveformInterval)
  580. }
  581. })
  582. // 获取头像完整URL
  583. const getAvatarUrl = (avatarUrl) => {
  584. if (!avatarUrl) return ''
  585. if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
  586. return avatarUrl
  587. }
  588. return 'http://localhost:8080' + avatarUrl
  589. }
  590. // 开始对话
  591. const startChat = async () => {
  592. if (!selectedAgent.value) {
  593. ElMessage.warning('请先选择一个客服')
  594. return
  595. }
  596. // 检查选中的客服是否可用
  597. const selectedAgentData = agents.value.find(a => a.id === selectedAgent.value)
  598. if (!selectedAgentData) {
  599. ElMessage.error('所选客服不存在')
  600. return
  601. }
  602. if (selectedAgentData.status === '2') {
  603. ElMessage.warning('该客服正在对话中,请选择其他客服')
  604. return
  605. }
  606. try {
  607. // 使用新的状态更新接口(带并发控制)
  608. const response = await fetch(`http://localhost:8080/talk/admin/agent/${selectedAgent.value}/status/2`, {
  609. method: 'PUT',
  610. headers: getHeaders()
  611. })
  612. const result = await response.json()
  613. // 检查后端返回结果,判断是否成功占用客服
  614. if (!response.ok || result.code !== 200) {
  615. ElMessage.error(result.msg || '该客服已被其他用户占用,请选择其他客服')
  616. // 刷新客服列表
  617. await fetchAgents(true)
  618. return
  619. }
  620. // 更新客服的TTS配置
  621. await fetch(`http://localhost:8080/talk/agent/${selectedAgent.value}`, {
  622. method: 'PUT',
  623. headers: getHeaders(),
  624. body: JSON.stringify({
  625. ttsSpeed: config.value.speed,
  626. ttsPitch: config.value.pitch,
  627. ttsVolume: config.value.volume,
  628. ttsBgs: config.value.bgSoundEnabled ? 1 : 0
  629. })
  630. })
  631. console.log('客服状态已更新为对话中')
  632. // 先切换到对话界面,确保音频可以播放
  633. showChat.value = true
  634. // 显示欢迎语并转语音
  635. if (selectedAgentData.greetingMessage) {
  636. chatHistory.value = [{
  637. type: 'agent',
  638. content: selectedAgentData.greetingMessage
  639. }]
  640. // 调用后端生成欢迎语语音(isGreeting=true 标识不发送到 dify 工作流)
  641. try {
  642. const ttsResponse = await fetch('http://localhost:8080/talk/message', {
  643. method: 'POST',
  644. headers: getHeaders(),
  645. body: JSON.stringify({
  646. message: selectedAgentData.greetingMessage,
  647. agentId: selectedAgent.value,
  648. isGreeting: true,
  649. conversationId: currentConversationId.value
  650. })
  651. })
  652. const ttsData = await ttsResponse.json()
  653. // 保存 conversationId
  654. if (ttsData.conversationId) {
  655. currentConversationId.value = ttsData.conversationId
  656. }
  657. if (ttsData.audio) {
  658. playAudio(ttsData.audio)
  659. }
  660. } catch (error) {
  661. console.error('生成欢迎语语音失败:', error)
  662. }
  663. } else {
  664. chatHistory.value = []
  665. }
  666. } catch (error) {
  667. console.error('更新客服配置失败:', error)
  668. ElMessage.error('开始对话失败,请重试')
  669. return
  670. }
  671. }
  672. const pageSize = 6
  673. const totalPages = computed(() => Math.ceil(agents.value.length / pageSize))
  674. const paginatedAgents = computed(() => {
  675. const start = currentPage.value * pageSize
  676. return agents.value.slice(start, start + pageSize)
  677. })
  678. const handleWheel = (e) => {
  679. e.preventDefault()
  680. // 支持垂直滚动(上下)和水平滚动(左右)
  681. const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
  682. if (delta > 0 && currentPage.value < totalPages.value - 1) {
  683. currentPage.value++
  684. } else if (delta < 0 && currentPage.value > 0) {
  685. currentPage.value--
  686. }
  687. }
  688. </script>
  689. <style scoped>
  690. :global(html),
  691. :global(body) {
  692. height: 100%;
  693. overflow: hidden;
  694. margin: 0;
  695. }
  696. :global(#app) {
  697. height: 100%;
  698. overflow: hidden;
  699. }
  700. .container {
  701. min-height: 100vh;
  702. height: 100vh;
  703. background: linear-gradient(to bottom, #bfdbfe, #dbeafe, #e0f2fe);
  704. display: flex;
  705. align-items: flex-start;
  706. justify-content: center;
  707. padding: 16px;
  708. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
  709. position: relative;
  710. overflow: hidden;
  711. box-sizing: border-box;
  712. }
  713. .container::before {
  714. content: '';
  715. position: absolute;
  716. top: -50%;
  717. right: -20%;
  718. width: 80%;
  719. height: 100%;
  720. background: linear-gradient(135deg, rgba(147, 197, 253, 0.3) 0%, rgba(191, 219, 254, 0.2) 100%);
  721. transform: rotate(-15deg);
  722. border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%;
  723. box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15);
  724. z-index: -1;
  725. }
  726. .container::after {
  727. content: '';
  728. position: absolute;
  729. bottom: -30%;
  730. left: -10%;
  731. width: 60%;
  732. height: 80%;
  733. background: linear-gradient(225deg, rgba(191, 219, 254, 0.25) 0%, rgba(224, 242, 254, 0.15) 100%);
  734. transform: rotate(25deg);
  735. border-radius: 70% 30% 30% 70% / 70% 70% 30% 30%;
  736. box-shadow: 0 8px 32px rgba(96, 165, 250, 0.12);
  737. z-index: -1;
  738. }
  739. .main-card {
  740. width: 100%;
  741. max-width: 1200px;
  742. border-radius: 24px !important;
  743. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1) !important;
  744. height: calc(100vh - 117px);
  745. display: flex;
  746. flex-direction: column;
  747. overflow: hidden;
  748. box-sizing: border-box;
  749. }
  750. :deep(.main-card .el-card__body) {
  751. height: 100%;
  752. display: flex;
  753. flex-direction: column;
  754. min-height: 0;
  755. }
  756. .header {
  757. padding: 16px 20px;
  758. text-align: center;
  759. border-bottom: 1px solid #e5e7eb;
  760. background: white;
  761. border-radius: 24px 24px 0 0;
  762. position: relative;
  763. }
  764. .toggle-link {
  765. position: absolute;
  766. left: 20px;
  767. top: 50%;
  768. transform: translateY(-50%);
  769. color: #9ca3af;
  770. font-size: 14px;
  771. cursor: pointer;
  772. user-select: none;
  773. transition: color 0.2s;
  774. }
  775. .toggle-link:hover {
  776. color: #6b7280;
  777. }
  778. .title {
  779. font-size: 20px;
  780. font-weight: 500;
  781. color: #1f2937;
  782. margin: 0;
  783. }
  784. .logout-btn {
  785. position: absolute;
  786. right: 20px;
  787. top: 50%;
  788. transform: translateY(-50%);
  789. padding: 6px 16px;
  790. background: white;
  791. color: #ef4444;
  792. border: 1px solid #ef4444;
  793. border-radius: 20px;
  794. font-size: 14px;
  795. cursor: pointer;
  796. user-select: none;
  797. transition: all 0.2s;
  798. }
  799. .logout-btn:hover {
  800. background: #ef4444;
  801. color: white;
  802. }
  803. .content-wrapper {
  804. position: relative;
  805. background: white;
  806. border-radius: 0 0 24px 24px;
  807. flex: 1;
  808. display: flex;
  809. min-height: 0;
  810. overflow: hidden;
  811. }
  812. .toggle-btn {
  813. position: absolute;
  814. top: 16px;
  815. left: 16px;
  816. z-index: 10;
  817. }
  818. .main-content {
  819. display: flex;
  820. gap: 8px;
  821. padding: 0 12px 0 12px;
  822. flex: 1;
  823. height: auto;
  824. min-height: 0;
  825. max-height: none;
  826. align-items: stretch;
  827. overflow: hidden;
  828. }
  829. .sidebar {
  830. width: 320px;
  831. flex-shrink: 0;
  832. padding: 20px;
  833. background: #ffffff;
  834. border-radius: 8px;
  835. height: 100%;
  836. overflow-y: auto;
  837. min-height: 0;
  838. max-height: 100%;
  839. box-sizing: border-box;
  840. }
  841. .sidebar.disabled :deep(.el-form) {
  842. opacity: 0.5;
  843. pointer-events: none;
  844. }
  845. .right-panel {
  846. flex: 1 1 0;
  847. display: flex;
  848. flex-direction: column;
  849. align-items: stretch;
  850. justify-content: flex-start;
  851. padding: 0;
  852. background: #f9fafb;
  853. border-radius: 8px;
  854. height: 100%;
  855. overflow: hidden;
  856. min-width: 0;
  857. position: relative;
  858. }
  859. .time-info {
  860. align-self: flex-end;
  861. color: #9ca3af;
  862. margin-bottom: 40px;
  863. font-size: 13px;
  864. }
  865. .agents-container {
  866. flex: 0 0 auto;
  867. width: 100%;
  868. display: flex;
  869. flex-direction: column;
  870. align-items: center;
  871. padding-top: 36px;
  872. padding-bottom: 0;
  873. overflow: visible;
  874. min-height: 0;
  875. position: relative;
  876. }
  877. .agents-container .pagination-dots {
  878. position: absolute;
  879. top: calc(36px + 220px + 16px + 220px + 24px);
  880. left: 50%;
  881. transform: translateX(-50%);
  882. z-index: 10;
  883. }
  884. .agents-footer {
  885. display: flex;
  886. flex-direction: column;
  887. align-items: center;
  888. padding: 0;
  889. flex-shrink: 0;
  890. position: absolute;
  891. left: 50%;
  892. transform: translateX(-50%);
  893. top: calc(36px + 220px + 16px + 220px + 24px + 40px);
  894. }
  895. .agents-list {
  896. display: grid;
  897. grid-template-columns: repeat(3, 220px);
  898. gap: 16px;
  899. width: 100%;
  900. max-width: 100%;
  901. padding: 0 12px;
  902. margin-top: 0;
  903. margin-bottom: 0;
  904. justify-content: center;
  905. }
  906. .agent-card {
  907. width: 220px;
  908. height: 220px;
  909. padding: 10px;
  910. border: 2px solid #e5e7eb;
  911. border-radius: 12px;
  912. text-align: center;
  913. cursor: pointer;
  914. position: relative;
  915. transition: all 0.3s ease;
  916. background: white;
  917. box-sizing: border-box;
  918. display: flex;
  919. flex-direction: column;
  920. align-items: center;
  921. justify-content: center;
  922. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
  923. }
  924. .agent-card:hover {
  925. border-color: #3b82f6;
  926. transform: translateY(-4px);
  927. box-shadow: 0 8px 20px rgba(59, 130, 246, 0.15);
  928. }
  929. .agent-card.selected {
  930. border-color: #3b82f6;
  931. background: #eff6ff;
  932. box-shadow: 0 0 0 1px #3b82f6, 0 8px 20px rgba(59, 130, 246, 0.2);
  933. }
  934. .agent-card.disabled {
  935. opacity: 0.5;
  936. cursor: not-allowed;
  937. pointer-events: none;
  938. }
  939. .agent-card.disabled:hover {
  940. transform: none;
  941. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
  942. }
  943. .delete-btn {
  944. position: absolute;
  945. top: 8px;
  946. right: 8px;
  947. width: 24px;
  948. height: 24px;
  949. border-radius: 50%;
  950. background: rgba(239, 68, 68, 0.9);
  951. color: white;
  952. display: flex;
  953. align-items: center;
  954. justify-content: center;
  955. opacity: 0;
  956. transition: opacity 0.2s;
  957. z-index: 10;
  958. }
  959. .agent-card:hover .delete-btn {
  960. opacity: 1;
  961. }
  962. .delete-btn:hover {
  963. background: rgba(220, 38, 38, 1);
  964. }
  965. .agent-card:hover {
  966. border-color: #3b82f6;
  967. transform: translateY(-4px);
  968. box-shadow: 0 12px 32px rgba(59, 130, 246, 0.25);
  969. }
  970. .agent-card.selected {
  971. border-color: #3b82f6;
  972. background: #eff6ff;
  973. box-shadow: 0 0 0 1px #3b82f6, 0 8px 24px rgba(59, 130, 246, 0.2);
  974. }
  975. .add-card {
  976. border: 2px dashed #d1d5db;
  977. background: transparent;
  978. display: flex;
  979. flex-direction: column;
  980. align-items: center;
  981. justify-content: center;
  982. min-height: 120px;
  983. }
  984. .add-card:hover {
  985. border-color: #3b82f6;
  986. background: #f9fafb;
  987. transform: translateY(-4px);
  988. }
  989. .add-icon {
  990. font-size: 36px;
  991. color: #9ca3af;
  992. margin-bottom: 8px;
  993. }
  994. .add-card:hover .add-icon {
  995. color: #3b82f6;
  996. }
  997. .add-text {
  998. font-size: 14px;
  999. color: #6b7280;
  1000. font-weight: 500;
  1001. }
  1002. .add-card:hover .add-text {
  1003. color: #3b82f6;
  1004. }
  1005. .avatar {
  1006. width: 120px;
  1007. height: 120px;
  1008. border-radius: 50%;
  1009. margin: 20px auto 12px;
  1010. border: 3px solid #e5e7eb;
  1011. }
  1012. .avatar-img {
  1013. width: 120px;
  1014. height: 120px;
  1015. border-radius: 50%;
  1016. margin: 20px auto 12px;
  1017. object-fit: cover;
  1018. border: 3px solid #e5e7eb;
  1019. background: #f9fafb;
  1020. }
  1021. .avatar.female {
  1022. background: linear-gradient(135deg, #fce7f3, #fbcfe8);
  1023. }
  1024. .avatar.male {
  1025. background: linear-gradient(135deg, #dbeafe, #bfdbfe);
  1026. }
  1027. .gender-icon {
  1028. position: absolute;
  1029. top: 110px;
  1030. right: calc(50% - 70px);
  1031. width: 28px;
  1032. height: 28px;
  1033. border-radius: 50%;
  1034. display: flex;
  1035. align-items: center;
  1036. justify-content: center;
  1037. color: white;
  1038. font-weight: 700;
  1039. font-size: 14px;
  1040. border: 2px solid white;
  1041. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  1042. }
  1043. .gender-icon.female {
  1044. background: #ec4899;
  1045. }
  1046. .gender-icon.male {
  1047. background: #3b82f6;
  1048. }
  1049. .status-badge {
  1050. position: absolute;
  1051. top: 8px;
  1052. left: 8px;
  1053. padding: 4px 12px;
  1054. border-radius: 20px;
  1055. font-size: 11px;
  1056. font-weight: 600;
  1057. z-index: 1;
  1058. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  1059. }
  1060. .status-badge.status-0 {
  1061. background: #10b981;
  1062. color: white;
  1063. }
  1064. .status-badge.status-1 {
  1065. background: #9ca3af;
  1066. color: white;
  1067. }
  1068. .status-badge.status-2 {
  1069. background: #ef4444;
  1070. color: white;
  1071. }
  1072. .agent-name {
  1073. font-size: 14px;
  1074. color: #1f2937;
  1075. font-weight: 600;
  1076. margin-top: 8px;
  1077. }
  1078. .start-btn {
  1079. font-size: 16px;
  1080. padding: 12px 48px;
  1081. align-self: center;
  1082. margin: 0;
  1083. }
  1084. .pagination-dots {
  1085. display: flex;
  1086. gap: 8px;
  1087. justify-content: center;
  1088. align-items: center;
  1089. padding: 0;
  1090. flex-shrink: 0;
  1091. }
  1092. .pagination-dots .dot {
  1093. width: 8px;
  1094. height: 8px;
  1095. border-radius: 50%;
  1096. background: #d1d5db;
  1097. cursor: pointer;
  1098. transition: all 0.3s;
  1099. }
  1100. .pagination-dots .dot:hover {
  1101. background: #9ca3af;
  1102. }
  1103. .pagination-dots .dot.active {
  1104. width: 24px;
  1105. border-radius: 4px;
  1106. background: #3b82f6;
  1107. }
  1108. .slider-value {
  1109. font-size: 14px;
  1110. color: #1f2937;
  1111. margin-bottom: 8px;
  1112. font-weight: 500;
  1113. }
  1114. .slider-labels {
  1115. display: flex;
  1116. justify-content: space-between;
  1117. font-size: 12px;
  1118. color: #9ca3af;
  1119. margin-top: 2px;
  1120. width: 100%;
  1121. }
  1122. /* 紧凑型滑块样式 */
  1123. .compact-slider {
  1124. margin-bottom: 12px;
  1125. }
  1126. .compact-slider :deep(.el-form-item__label) {
  1127. margin-bottom: 0px;
  1128. padding-bottom: 0;
  1129. }
  1130. .compact-slider .slider-value {
  1131. margin-bottom: 0px;
  1132. }
  1133. .compact-slider :deep(.el-form-item__content) {
  1134. width: 100%;
  1135. }
  1136. /* 自定义折叠组件样式 */
  1137. .collapse-section {
  1138. margin-bottom: 0;
  1139. }
  1140. .collapse-header {
  1141. display: flex;
  1142. justify-content: flex-start;
  1143. align-items: center;
  1144. padding: 16px 0;
  1145. cursor: pointer;
  1146. user-select: none;
  1147. font-weight: 600;
  1148. font-size: 15px;
  1149. color: #1f2937;
  1150. }
  1151. .collapse-header:hover {
  1152. color: #3b82f6;
  1153. }
  1154. .collapse-header .arrow {
  1155. font-size: 14px;
  1156. color: #6b7280;
  1157. margin-left: 8px;
  1158. }
  1159. .collapse-content {
  1160. padding-bottom: 16px;
  1161. }
  1162. /* Element Plus 样式覆盖 */
  1163. :deep(.el-collapse) {
  1164. border: none;
  1165. }
  1166. :deep(.el-collapse-item) {
  1167. margin-bottom: 0;
  1168. }
  1169. :deep(.el-collapse-item__header) {
  1170. font-weight: 600;
  1171. font-size: 15px;
  1172. color: #1f2937;
  1173. border: none;
  1174. padding: 16px 0;
  1175. height: auto;
  1176. line-height: 1.5;
  1177. background: transparent;
  1178. justify-content: flex-start;
  1179. }
  1180. :deep(.el-collapse-item__arrow) {
  1181. font-size: 14px;
  1182. color: #6b7280;
  1183. margin-left: 8px;
  1184. margin-right: 0;
  1185. }
  1186. :deep(.el-collapse-item__arrow.is-active) {
  1187. transform: rotate(180deg);
  1188. }
  1189. :deep(.el-collapse-item__wrap) {
  1190. border: none;
  1191. background: transparent;
  1192. }
  1193. :deep(.el-collapse-item__content) {
  1194. padding: 0 0 16px 0;
  1195. color: inherit;
  1196. }
  1197. :deep(.el-form-item) {
  1198. margin-bottom: 20px;
  1199. }
  1200. :deep(.el-form-item__label) {
  1201. color: #9ca3af;
  1202. font-size: 13px;
  1203. margin-bottom: 8px;
  1204. }
  1205. :deep(.el-select) {
  1206. width: 100%;
  1207. }
  1208. :deep(.el-radio-group) {
  1209. width: 100%;
  1210. display: flex;
  1211. }
  1212. :deep(.el-radio-button) {
  1213. flex: 1;
  1214. }
  1215. :deep(.el-radio-button__inner) {
  1216. width: 100%;
  1217. border-radius: 6px;
  1218. }
  1219. :deep(.el-slider__runway) {
  1220. height: 4px;
  1221. background-color: #e5e7eb;
  1222. }
  1223. :deep(.el-slider__bar) {
  1224. background-color: #3b82f6;
  1225. }
  1226. :deep(.el-slider__button) {
  1227. width: 16px;
  1228. height: 16px;
  1229. border: 2px solid #3b82f6;
  1230. }
  1231. :deep(.el-button--primary) {
  1232. background-color: #3b82f6;
  1233. border-color: #3b82f6;
  1234. }
  1235. :deep(.el-button--primary:hover) {
  1236. background-color: #2563eb;
  1237. border-color: #2563eb;
  1238. }
  1239. /* 对话界面样式 */
  1240. .chat-panel {
  1241. display: flex;
  1242. flex-direction: column;
  1243. padding: 0;
  1244. height: 100%;
  1245. }
  1246. .chat-content {
  1247. flex: 1;
  1248. overflow-y: auto;
  1249. padding: 20px;
  1250. display: flex;
  1251. flex-direction: column;
  1252. gap: 12px;
  1253. }
  1254. .message {
  1255. display: flex;
  1256. animation: slideIn 0.3s ease;
  1257. }
  1258. @keyframes slideIn {
  1259. from {
  1260. opacity: 0;
  1261. transform: translateY(10px);
  1262. }
  1263. to {
  1264. opacity: 1;
  1265. transform: translateY(0);
  1266. }
  1267. }
  1268. .ai-message {
  1269. justify-content: flex-start;
  1270. }
  1271. .ai-message .message-text {
  1272. background: white;
  1273. padding: 12px 16px;
  1274. border-radius: 12px;
  1275. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  1276. max-width: 70%;
  1277. }
  1278. .user-message {
  1279. justify-content: flex-end;
  1280. }
  1281. .user-message .message-text {
  1282. background: #3b82f6;
  1283. color: white;
  1284. padding: 12px 16px;
  1285. border-radius: 12px;
  1286. box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
  1287. max-width: 70%;
  1288. }
  1289. .quick-questions {
  1290. margin-top: 12px;
  1291. }
  1292. .question-item {
  1293. padding: 8px 0;
  1294. font-size: 14px;
  1295. color: #333;
  1296. }
  1297. .pause-icon {
  1298. position: absolute;
  1299. right: 12px;
  1300. bottom: 12px;
  1301. color: #3b82f6;
  1302. }
  1303. .chat-footer {
  1304. padding: 20px 16px;
  1305. background: white;
  1306. border-top: 1px solid #e5e7eb;
  1307. display: flex;
  1308. align-items: center;
  1309. gap: 16px;
  1310. flex-shrink: 0;
  1311. }
  1312. .keyboard-btn {
  1313. width: 48px;
  1314. height: 48px;
  1315. border: 1px solid #e5e7eb;
  1316. background: white;
  1317. flex-shrink: 0;
  1318. border-radius: 50%;
  1319. padding: 0;
  1320. min-width: 48px;
  1321. }
  1322. .voice-input-btn {
  1323. flex: 1;
  1324. display: flex;
  1325. align-items: center;
  1326. gap: 16px;
  1327. height: 48px;
  1328. background: white;
  1329. border: 1px solid #e5e7eb;
  1330. border-radius: 24px;
  1331. padding: 0 20px;
  1332. overflow: hidden;
  1333. }
  1334. .mic-icon {
  1335. font-size: 24px;
  1336. color: #6b7280;
  1337. flex-shrink: 0;
  1338. }
  1339. .text-input {
  1340. flex: 1;
  1341. }
  1342. .input-placeholder {
  1343. flex: 1;
  1344. height: 2px;
  1345. border-bottom: 2px dotted #d1d5db;
  1346. align-self: center;
  1347. }
  1348. .hangup-btn {
  1349. width: 56px;
  1350. height: 56px;
  1351. background: #ef4444;
  1352. border: none;
  1353. color: white;
  1354. flex-shrink: 0;
  1355. border-radius: 50%;
  1356. padding: 0;
  1357. min-width: 56px;
  1358. }
  1359. .hangup-btn:hover {
  1360. background: #dc2626;
  1361. }
  1362. /* 波形显示 */
  1363. .waveform {
  1364. flex: 1;
  1365. display: flex;
  1366. align-items: center;
  1367. justify-content: center;
  1368. gap: 2px;
  1369. height: 32px;
  1370. }
  1371. .wave-bar {
  1372. width: 3px;
  1373. background: #3b82f6;
  1374. border-radius: 2px;
  1375. transition: height 0.1s ease;
  1376. max-height: 32px;
  1377. }
  1378. .mic-icon.recording {
  1379. color: #ef4444;
  1380. animation: pulse 1.5s ease-in-out infinite;
  1381. }
  1382. .input-placeholder.recording {
  1383. border-bottom-color: #ef4444;
  1384. animation: pulse-border 1.5s ease-in-out infinite;
  1385. }
  1386. @keyframes pulse-border {
  1387. 0%, 100% {
  1388. border-bottom-color: #ef4444;
  1389. }
  1390. 50% {
  1391. border-bottom-color: #fca5a5;
  1392. }
  1393. }
  1394. </style>