Voice-assistant.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. <template>
  2. <div class="voice-assistant">
  3. <!-- 聊天框 -->
  4. <el-scrollbar class="h100" noresize ref="scrollbarRef" max-height="400px" v-if="showMessageList.length" @click="stopScroll">
  5. <div class="chat-box" ref="chatBoxRef">
  6. <div v-for="(item, index) in showMessageList" :key="index" class="chat-item" :class="item.body?.content?.callSentenceInfo?.role">
  7. <div v-if="item.body?.content?.callSentenceInfo?.role === 'user'" class="user">
  8. <img v-lazy="getImageUrl('order/user.png')" alt="" class="user-avatar" src="" />
  9. <div class="user-name">
  10. {{ item.body?.content?.callerNumber }}
  11. <div class="user-date">{{ formatDate(item.timestamps, 'YYYY-mm-dd HH:MM:SS') }}</div>
  12. </div>
  13. <div class="user-content">{{ item.body?.content?.callSentenceInfo.text }}</div>
  14. <div class="user-tag">
  15. <el-tag v-for="(items, index) in item.body?.content?.callSentenceInfo.tags" size="small">{{items}}</el-tag>
  16. </div>
  17. </div>
  18. <div v-else class="agent">
  19. <img v-lazy="getImageUrl('order/service.png')" alt="" class="agent-avatar" src="" />
  20. <div class="agent-name">
  21. <div class="agent-date">{{ formatDate(item.timestamps, 'YYYY-mm-dd HH:MM:SS') }}</div>
  22. {{ item.body?.content?.calledNumber }}
  23. </div>
  24. <div class="agent-content">{{ item.body?.content?.callSentenceInfo.text }}</div>
  25. <div class="agent-tag">
  26. <el-tag v-for="(items, index) in item.body?.content?.callSentenceInfo.tags" size="small">{{items}}</el-tag>
  27. </div>
  28. </div>
  29. </div>
  30. <el-text class="end-call" tag="p" v-if="talkEnd">-- 通话结束 --</el-text>
  31. </div>
  32. </el-scrollbar>
  33. <Empty v-else />
  34. <el-button @click.stop="keepScroll" class="keep-scroll" title="回到底部" circle v-if="!isScrollBottom && !talkEnd">
  35. <SvgIcon name="ele-ArrowDown" size="18px" />
  36. </el-button>
  37. <!-- 识别内容 通话结束才展示-->
  38. <div class="recognize-box" v-if="talkEnd && showMessageList.length">
  39. <transition name="el-zoom-in-bottom">
  40. <div v-show="!searchCol" class="transition-box">
  41. <el-button link @click="closeSearch" class="transition-box-close">
  42. <SvgIcon name="ele-Close" class="ml3" size="18px" />
  43. </el-button>
  44. <div class="transition-box-title">
  45. <el-text tag="b" size="large">通话小结</el-text>
  46. </div>
  47. <el-scrollbar class="h100 transition-box-content" noresize ref="scrollbarRef" max-height="180px">
  48. <div class="call-item">
  49. <el-text tag="b" class="call-item-label">客户姓名:</el-text>
  50. <p class="call-item-value">{{ recognizeList?.name }}</p>
  51. </div>
  52. <!-- <div class="call-item">
  53. <el-text tag="b" class="call-item-label">年龄:</el-text>
  54. <p class="call-item-value">{{ recognizeList?.age }}</p>
  55. </div>
  56. <div class="call-item">
  57. <el-text tag="b" class="call-item-label">性别:</el-text>
  58. <p class="call-item-value">{{ recognizeList?.sex }}</p>
  59. </div>
  60. <div class="call-item">
  61. <el-text tag="b" class="call-item-label">身份证号:</el-text>
  62. <p class="call-item-value">{{ recognizeList?.id_card }}</p>
  63. </div>-->
  64. <div class="call-item">
  65. <el-text tag="b" class="call-item-label">地址:</el-text>
  66. <p class="call-item-value">{{ recognizeList?.address }}</p>
  67. </div>
  68. <!-- <div class="call-item">
  69. <el-text tag="b" class="call-item-label">公司名称:</el-text>
  70. <p class="call-item-value">{{ recognizeList?.company_name }}</p>
  71. </div>
  72. <div class="call-item">
  73. <el-text tag="b" class="call-item-label">通话小结:</el-text>
  74. <p class="call-item-value">{{ recognizeList?.call_detail_content }}</p>
  75. </div>
  76. <div class="call-item">
  77. <el-text tag="b" class="call-item-label">受理类型:</el-text>
  78. <p class="call-item-value">{{ recognizeList?.acceptance_type }}</p>
  79. </div>
  80. <div class="call-item">
  81. <el-text tag="b" class="call-item-label">推送分类:</el-text>
  82. <p class="call-item-value">{{ recognizeList?.push_classification }}</p>
  83. </div>
  84. <div class="call-item">
  85. <el-text tag="b" class="call-item-label">热点分类:</el-text>
  86. <p class="call-item-value">{{ recognizeList?.hotspot_classification }}</p>
  87. </div>
  88. <div class="call-item">
  89. <el-text tag="b" class="call-item-label">事件目的:</el-text>
  90. <p class="call-item-value">{{ recognizeList?.event_purpose }}</p>
  91. </div>
  92. <div class="call-item">
  93. <el-text tag="b" class="call-item-label">工单标题:</el-text>
  94. <p class="call-item-value">{{ recognizeList?.record_title }}</p>
  95. </div>
  96. <div class="call-item">
  97. <el-text tag="b" class="call-item-label">诉求详情:</el-text>
  98. <p class="call-item-value">{{ recognizeList?.appeal_detatls }}</p>
  99. </div>-->
  100. </el-scrollbar>
  101. <div class="flex-end mt20">
  102. <el-button @click="closeSearch" class="default-button"> 取消</el-button>
  103. <el-button type="primary" @click="fillSingle">一键填单</el-button>
  104. </div>
  105. </div>
  106. </transition>
  107. <el-button type="primary" @click="closeSearch" v-show="searchCol">
  108. 通话小结
  109. <SvgIcon name="ele-ArrowUp" class="ml3" size="18px" />
  110. </el-button>
  111. </div>
  112. </div>
  113. </template>
  114. <script setup lang="ts" name="orderAcceptVoiceAssistant">
  115. import { nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue';
  116. import { ElMessageBox } from 'element-plus';
  117. import { getImageUrl } from '@/utils/tools';
  118. import { useRoute } from 'vue-router';
  119. import { formatDate } from '@/utils/formatTime';
  120. import mittBus from '@/utils/mitt';
  121. import { voiceAssistantContent } from '@/api/todo/voiceAssistant';
  122. import other from '@/utils/other';
  123. const emit = defineEmits(['orderOverwrite']);
  124. // 消息列表
  125. const messageList = ref<any>([
  126. /* {
  127. body: {
  128. content: {
  129. callSentenceInfo: {
  130. text: '你好,我是小智,有什么可以帮您的吗?',
  131. role: 'agent',
  132. tags:['语速过快','情绪激动']
  133. },
  134. calledNumber: '1009',
  135. callerNumber: '19136073037',
  136. },
  137. },
  138. timestamps: new Date().getTime(),
  139. },
  140. {
  141. body: {
  142. content: {
  143. callSentenceInfo: {
  144. text: '12311?',
  145. role: 'user',
  146. tags:['语速过快','情绪激动']
  147. },
  148. calledNumber: '1009',
  149. callerNumber: '19136073037',
  150. },
  151. },
  152. timestamps: new Date().getTime(),
  153. },*/
  154. ]); // 消息列表
  155. const showMessageList = ref([
  156. /* {
  157. body: {
  158. content: {
  159. callSentenceInfo: {
  160. text: '你好,我是小智,有什么可以帮您的吗?',
  161. role: 'agent',
  162. tags:['语速过快','情绪激动']
  163. },
  164. calledNumber: '1009',
  165. callerNumber: '19136073037',
  166. },
  167. },
  168. timestamps: new Date().getTime(),
  169. },
  170. {
  171. body: {
  172. content: {
  173. callSentenceInfo: {
  174. text: '12311?',
  175. role: 'user',
  176. tags:['语速过快','情绪激动']
  177. },
  178. calledNumber: '1009',
  179. callerNumber: '19136073037',
  180. },
  181. },
  182. timestamps: new Date().getTime(),
  183. },*/
  184. ]);
  185. const recognizeList = ref<EmptyObjectType>({}); // 识别信息
  186. const route = useRoute();
  187. const talkEnd = ref(false); // 通话结束
  188. const wsReceive = (message: any) => {
  189. try {
  190. const data = JSON.parse(message.data);
  191. if (data.body.bisType === 3) {
  192. // 转写消息
  193. if (data.body.content.action === 0) {
  194. // 通话开始
  195. console.log('通话转写开始了');
  196. talkEnd.value = false;
  197. }
  198. if (data.body.content.action === 1) {
  199. // 通话中
  200. if (messageList.value.length) {
  201. const item = messageList.value.find((item: any) => item.body.content.callSentenceInfo.index === data.body.content.callSentenceInfo.index);
  202. if (item) {
  203. item.body.content.callSentenceInfo.text = data.body.content.callSentenceInfo.text;
  204. } else {
  205. messageList.value.push(data);
  206. }
  207. } else {
  208. messageList.value.push(data);
  209. }
  210. scrollToBottom();
  211. showMessageList.value = other.deepClone(messageList.value);
  212. console.log('通话消息转写内容:', messageList.value);
  213. }
  214. if (data.body.content.action === 4) {
  215. // 通话结束
  216. setTimeout(() => {
  217. //通话结束
  218. talkEnd.value = true;
  219. scrollToBottom(); //滚动到底部
  220. stopScroll(); //停止滚动
  221. }, 1000);
  222. console.log('通话转写结束了');
  223. }
  224. }
  225. } catch (error) {
  226. console.log('坐席辅助收到消息', message);
  227. }
  228. };
  229. const scrollbarRef = ref<RefType>();
  230. const chatBoxRef = ref<RefType>();
  231. const searchCol = ref(true); // 展开/收起
  232. // 展开/收起 通话小结
  233. const closeSearch = () => {
  234. if (!searchCol.value) {
  235. // 展开
  236. // 获取识别内容
  237. getRecognize();
  238. }
  239. searchCol.value = !searchCol.value;
  240. };
  241. // 获取识别内容
  242. const getRecognize = async () => {
  243. try {
  244. const callId = route.params.callId;
  245. const { result } = await voiceAssistantContent(typeof callId === 'string' ? callId : '');
  246. recognizeList.value = result;
  247. } catch (error) {
  248. console.log('获取识别内容失败', error);
  249. }
  250. };
  251. // 停止滚动
  252. const stopScroll = () => {
  253. isScrollBottom.value = false;
  254. };
  255. // 滚动到底部
  256. const isScrollBottom = ref(true); // 是否需要滚动到底部
  257. const isAutoScroll = ref(true); // 是否自动滚动
  258. const scrollToBottom = async () => {
  259. if (!isScrollBottom.value) return;
  260. await nextTick(); // 等待 DOM 更新
  261. const max = chatBoxRef.value?.clientHeight;
  262. scrollbarRef.value?.setScrollTop(max);
  263. isAutoScroll.value = true;
  264. };
  265. watch(messageList.value, (val) => {
  266. scrollToBottom();
  267. });
  268. // 继续滚动
  269. const keepScroll = () => {
  270. isScrollBottom.value = true;
  271. scrollToBottom();
  272. };
  273. // 一键填单
  274. const fillSingle = () => {
  275. ElMessageBox.confirm(`确定要一键填单吗?填单后会覆盖之前填写的内容,请谨慎操作`, '提示', {
  276. confirmButtonText: '确认',
  277. cancelButtonText: '取消',
  278. type: 'warning',
  279. draggable: true,
  280. cancelButtonClass: 'default-button',
  281. autofocus: false,
  282. })
  283. .then(() => {
  284. emit('orderOverwrite', recognizeList.value);
  285. searchCol.value = !searchCol.value;
  286. })
  287. .catch(() => {});
  288. };
  289. watch(
  290. () => scrollbarRef.value?.wrapRef,
  291. () => {
  292. scrollbarRef.value?.wrapRef.addEventListener('mousewheel', () => {
  293. stopScroll();
  294. });
  295. }
  296. );
  297. // 订阅消息
  298. const subscribe = () => {
  299. // 接受消息
  300. mittBus.on('wsReceive', (message: any) => {
  301. const data = JSON.parse(message.data);
  302. if (data.body.content.callId === route.params.callId) {
  303. // 判断是否是当前通话
  304. wsReceive(message);
  305. }
  306. });
  307. };
  308. // 消息筛选
  309. const staticMessage = other.deepClone(messageList.value);
  310. const filterMessage = (type: string) => {
  311. switch (type) {
  312. case '市民':
  313. showMessageList.value = staticMessage.filter((item: any) => item.body.content.callSentenceInfo.role === 'user');
  314. break;
  315. case '坐席':
  316. showMessageList.value = staticMessage.filter((item: any) => item.body.content.callSentenceInfo.role === 'agent');
  317. break;
  318. case '全部':
  319. showMessageList.value = staticMessage;
  320. break;
  321. default:
  322. showMessageList.value = staticMessage;
  323. break;
  324. }
  325. };
  326. onMounted(() => {
  327. // 进入页面订阅
  328. subscribe();
  329. });
  330. onActivated(() => {
  331. // 缓存进入重新订阅
  332. subscribe();
  333. });
  334. onDeactivated(() => {
  335. // 缓存离开取消订阅
  336. mittBus.off('wsReceive');
  337. });
  338. defineExpose({
  339. filterMessage,
  340. });
  341. </script>
  342. <style scoped lang="scss">
  343. .voice-assistant {
  344. width: 100%;
  345. height: 100%;
  346. min-height: 400px;
  347. position: relative;
  348. .keep-scroll {
  349. position: absolute;
  350. right: 10px;
  351. bottom: 30px;
  352. z-index: 100;
  353. }
  354. .chat-box {
  355. width: 100%;
  356. padding-bottom: 50px;
  357. .agent {
  358. justify-content: flex-end;
  359. }
  360. .chat-item {
  361. word-break: break-all;
  362. position: relative;
  363. color: var(--el-color-white);
  364. margin: 40px 10px 10px 10px;
  365. display: flex;
  366. .user {
  367. &-content {
  368. border-radius: var(--el-border-radius-base);
  369. background-color: var(--el-color-primary);
  370. padding: 10px;
  371. position: relative;
  372. max-width: 400px;
  373. box-shadow: 4px 0 10px 0 rgba(0, 0, 0, 0.3);
  374. margin-left: 60px;
  375. &::after {
  376. position: absolute;
  377. content: '';
  378. top: calc(50% - 10px);
  379. left: -10px;
  380. width: 0;
  381. height: 0;
  382. border-top: 10px solid transparent;
  383. border-right: 10px solid var(--el-color-primary);
  384. border-bottom: 10px solid transparent;
  385. }
  386. }
  387. &-name {
  388. font-size: var(--el-font-size-base);
  389. color: var(--el-text-color-regular);
  390. margin-bottom: 5px;
  391. margin-left: 65px;
  392. display: flex;
  393. justify-content: flex-end;
  394. align-items: center;
  395. }
  396. &-date {
  397. font-size: var(--el-font-size-extra-small);
  398. color: var(--el-text-color-placeholder);
  399. margin-left: 10px;
  400. }
  401. &-tag{
  402. position: absolute;
  403. bottom: -30px;
  404. left: 65px;
  405. :deep(.el-tag){
  406. margin-right: 10px;
  407. }
  408. }
  409. &-avatar {
  410. width: 40px;
  411. height: 40px;
  412. border-radius: 50%;
  413. position: absolute;
  414. left: 0;
  415. top: calc(50% - 15px);
  416. }
  417. }
  418. .agent {
  419. &-content {
  420. border-radius: var(--el-border-radius-base);
  421. background-color: var(--el-color-success);
  422. padding: 10px;
  423. position: relative;
  424. justify-content: flex-end;
  425. max-width: 400px;
  426. box-shadow: -4px 0 10px 0 rgba(0, 0, 0, 0.3);
  427. margin-right: 60px;
  428. &::after {
  429. position: absolute;
  430. content: '';
  431. top: calc(50% - 10px);
  432. right: -10px;
  433. width: 0;
  434. height: 0;
  435. border-top: 10px solid transparent;
  436. border-left: 10px solid var(--el-color-success);
  437. border-bottom: 10px solid transparent;
  438. }
  439. }
  440. &-name {
  441. font-size: var(--el-font-size-base);
  442. color: var(--el-text-color-regular);
  443. margin-bottom: 5px;
  444. text-align: right;
  445. margin-right: 65px;
  446. display: flex;
  447. justify-content: flex-end;
  448. align-items: center;
  449. }
  450. &-date {
  451. font-size: var(--el-font-size-extra-small);
  452. color: var(--el-text-color-placeholder);
  453. margin-right: 10px;
  454. }
  455. &-tag{
  456. position: absolute;
  457. bottom: -30px;
  458. right: 65px;
  459. :deep(.el-tag){
  460. margin-left: 10px;
  461. }
  462. }
  463. &-avatar {
  464. width: 40px;
  465. height: 40px;
  466. border-radius: 50%;
  467. position: absolute;
  468. right: 0;
  469. top: calc(50% - 15px);
  470. }
  471. }
  472. }
  473. .end-call {
  474. width: 100%;
  475. text-align: center;
  476. margin-top: 20px;
  477. }
  478. }
  479. .recognize-box {
  480. position: absolute;
  481. bottom: 0;
  482. width: 100%;
  483. background-color: var(--el-color-white);
  484. .call-item {
  485. margin-bottom: 10px;
  486. display: flex;
  487. align-content: center;
  488. .call-item-label {
  489. width: 100px;
  490. text-align: right;
  491. margin-right: 10px;
  492. }
  493. .call-item-value {
  494. flex: 1;
  495. }
  496. }
  497. .transition-box {
  498. position: absolute;
  499. bottom: 0;
  500. width: 100%;
  501. height: 300px;
  502. background-color: var(--el-color-primary-light-9);
  503. border-radius: var(--el-border-radius-base);
  504. padding: 10px;
  505. &-close {
  506. position: absolute;
  507. right: 10px;
  508. top: 10px;
  509. }
  510. &-title {
  511. border-bottom: var(--el-border);
  512. border-color: var(--el-color-info);
  513. padding-bottom: 10px;
  514. }
  515. .transition-box-content {
  516. border: var(--el-border);
  517. border-radius: var(--el-border-radius-base);
  518. border-color: var(--el-color-info);
  519. margin-top: 10px;
  520. height: 180px !important;
  521. padding: 10px;
  522. .transition-box-content-item {
  523. display: flex;
  524. align-items: center;
  525. margin-bottom: 10px;
  526. .transition-box-content-item-title {
  527. width: 75px;
  528. margin-right: 3px;
  529. }
  530. .transition-box-content-item-content {
  531. flex: 1;
  532. }
  533. }
  534. }
  535. }
  536. }
  537. }
  538. </style>