CustomerService.vue 40 KB

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