CustomerService.vue 31 KB

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