Voice-assistant.vue 14 KB

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