Voice-assistant.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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?.name }}</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?.age }}</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?.sex }}</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?.id_card }}</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?.address }}</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?.company_name }}</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?.call_detail_content }}</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?.acceptance_type }}</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?.push_classification }}</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?.hotspot_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?.event_purpose }}</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?.record_title }}</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?.appeal_detatls }}</p>
  89. </div>-->
  90. </el-scrollbar>
  91. <div class="flex-end mt20">
  92. <el-button @click="closeSearch" class="default-button"> 取消</el-button>
  93. <el-button type="primary" @click="fillSingle">一键填单</el-button>
  94. </div>
  95. </div>
  96. </transition>
  97. <el-button type="primary" @click="closeSearch" v-show="searchCol">
  98. 通话小结
  99. <SvgIcon name="ele-ArrowUp" class="ml3" size="18px" />
  100. </el-button>
  101. </div>
  102. </div>
  103. </template>
  104. <script setup lang="ts" name="orderAcceptVoiceAssistant">
  105. import { nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue';
  106. import { ElMessageBox } from 'element-plus';
  107. import { getImageUrl } from '@/utils/tools';
  108. import { useRoute } from 'vue-router';
  109. import { formatDate } from '@/utils/formatTime';
  110. const emit = defineEmits(['orderOverwrite']);
  111. import mittBus from '@/utils/mitt';
  112. import { voiceAssistantContent } from "@/api/todo/voiceAssistant";
  113. // 消息列表
  114. const messageList = ref<any>([
  115. /*{
  116. body: {
  117. content: {
  118. callSentenceInfo: {
  119. text: '你好,我是小智,有什么可以帮您的吗?',
  120. role: 'agent',
  121. },
  122. calledNumber: '1009',
  123. callerNumber: '19136073037',
  124. },
  125. },
  126. timestamps: new Date().getTime(),
  127. },
  128. {
  129. body: {
  130. content: {
  131. callSentenceInfo: {
  132. text: '你好,我是小智,有什么可以帮您的吗?',
  133. role: 'user',
  134. },
  135. },
  136. calledNumber: '1009',
  137. callerNumber: '19136073037',
  138. },
  139. timestamps: new Date().getTime(),
  140. },*/
  141. ]); // 消息列表
  142. const recognizeList = ref<EmptyObjectType>({}); // 识别信息
  143. const route = useRoute();
  144. const talkEnd = ref(false); // 通话结束
  145. const wsReceive = (message: any) => {
  146. try {
  147. const data = JSON.parse(message.data);
  148. if (data.body.bisType === 3) {
  149. // 转写消息
  150. if (data.body.content.action === 0) {
  151. // 通话开始
  152. console.log('通话转写开始了');
  153. talkEnd.value = false;
  154. }
  155. if (data.body.content.action === 1) {
  156. // 通话中
  157. if (messageList.value.length) {
  158. const item = messageList.value.find((item: any) => item.body.content.callSentenceInfo.index === data.body.content.callSentenceInfo.index);
  159. if (item) {
  160. item.body.content.callSentenceInfo.text = data.body.content.callSentenceInfo.text;
  161. } else {
  162. messageList.value.push(data);
  163. }
  164. } else {
  165. messageList.value.push(data);
  166. }
  167. scrollToBottom();
  168. console.log('通话消息转写内容:', messageList.value);
  169. }
  170. if (data.body.content.action === 4) {
  171. // 通话结束
  172. setTimeout(() => {
  173. //通话结束
  174. talkEnd.value = true;
  175. scrollToBottom(); //滚动到底部
  176. stopScroll(); //停止滚动
  177. }, 1000);
  178. console.log('通话转写结束了');
  179. }
  180. }
  181. } catch (error) {
  182. console.log('坐席辅助收到消息', message);
  183. }
  184. };
  185. const scrollbarRef = ref<RefType>();
  186. const chatBoxRef = ref<RefType>();
  187. const searchCol = ref(true); // 展开/收起
  188. // 展开/收起 通话小结
  189. const closeSearch = () => {
  190. if (!searchCol.value) {
  191. // 展开
  192. // 获取识别内容
  193. getRecognize();
  194. }
  195. searchCol.value = !searchCol.value;
  196. };
  197. // 获取识别内容
  198. const getRecognize = async () => {
  199. try {
  200. const callId = route.params.callId;
  201. const {result} = await voiceAssistantContent(typeof callId === "string" ? callId :'');
  202. recognizeList.value = result;
  203. }catch (error) {
  204. console.log('获取识别内容失败', error);
  205. }
  206. };
  207. // 停止滚动
  208. const stopScroll = () => {
  209. isScrollBottom.value = false;
  210. };
  211. // 滚动到底部
  212. const isScrollBottom = ref(true); // 是否需要滚动到底部
  213. const isAutoScroll = ref(true); // 是否自动滚动
  214. const scrollToBottom = async () => {
  215. if (!isScrollBottom.value) return;
  216. await nextTick(); // 等待 DOM 更新
  217. const max = chatBoxRef.value?.clientHeight;
  218. scrollbarRef.value?.setScrollTop(max);
  219. isAutoScroll.value = true;
  220. };
  221. watch(messageList.value, (val) => {
  222. scrollToBottom();
  223. });
  224. // 继续滚动
  225. const keepScroll = () => {
  226. isScrollBottom.value = true;
  227. scrollToBottom();
  228. };
  229. // 一键填单
  230. const fillSingle = () => {
  231. ElMessageBox.confirm(`确定要一键填单吗?填单后会覆盖之前填写的内容,请谨慎操作`, '提示', {
  232. confirmButtonText: '确认',
  233. cancelButtonText: '取消',
  234. type: 'warning',
  235. draggable: true,
  236. cancelButtonClass: 'default-button',
  237. autofocus: false,
  238. })
  239. .then(() => {
  240. emit('orderOverwrite', recognizeList.value);
  241. searchCol.value = !searchCol.value;
  242. })
  243. .catch(() => {});
  244. };
  245. watch(
  246. () => scrollbarRef.value?.wrapRef,
  247. () => {
  248. scrollbarRef.value?.wrapRef.addEventListener('mousewheel', () => {
  249. stopScroll();
  250. });
  251. }
  252. );
  253. // 订阅消息
  254. const subscribe = () => {
  255. // 接受消息
  256. mittBus.on('wsReceive', (message: any) => {
  257. const data = JSON.parse(message.data);
  258. if (data.body.content.callId === route.params.callId) {
  259. // 判断是否是当前通话
  260. wsReceive(message);
  261. }
  262. });
  263. };
  264. onMounted(() => {
  265. // 进入页面订阅
  266. subscribe();
  267. });
  268. onActivated(() => {
  269. // 缓存进入重新订阅
  270. subscribe();
  271. });
  272. onDeactivated(() => {
  273. // 缓存离开取消订阅
  274. mittBus.off('wsReceive');
  275. });
  276. </script>
  277. <style scoped lang="scss">
  278. .voice-assistant {
  279. width: 100%;
  280. height: 100%;
  281. min-height: 400px;
  282. position: relative;
  283. .keep-scroll {
  284. position: absolute;
  285. right: 10px;
  286. bottom: 30px;
  287. z-index: 100;
  288. }
  289. .chat-box {
  290. width: 100%;
  291. padding-bottom: 50px;
  292. .agent {
  293. justify-content: flex-end;
  294. }
  295. .chat-item {
  296. word-break: break-all;
  297. position: relative;
  298. color: var(--el-color-white);
  299. margin: 30px 10px 10px 10px;
  300. display: flex;
  301. .user {
  302. &-content {
  303. border-radius: var(--el-border-radius-base);
  304. background-color: var(--el-color-primary);
  305. padding: 10px;
  306. position: relative;
  307. max-width: 400px;
  308. box-shadow: 4px 0 10px 0 rgba(0, 0, 0, 0.3);
  309. margin-left: 60px;
  310. &::after {
  311. position: absolute;
  312. content: '';
  313. top: calc(50% - 10px);
  314. left: -10px;
  315. width: 0;
  316. height: 0;
  317. border-top: 10px solid transparent;
  318. border-right: 10px solid var(--el-color-primary);
  319. border-bottom: 10px solid transparent;
  320. }
  321. }
  322. &-name {
  323. font-size: var(--el-font-size-base);
  324. color: var(--el-text-color-regular);
  325. margin-bottom: 5px;
  326. margin-left: 65px;
  327. }
  328. &-date {
  329. font-size: var(--el-font-size-extra-small);
  330. color: var(--el-text-color-placeholder);
  331. position: absolute;
  332. bottom: -20px;
  333. left: 65px;
  334. }
  335. &-avatar {
  336. width: 40px;
  337. height: 40px;
  338. border-radius: 50%;
  339. position: absolute;
  340. left: 0;
  341. top: calc(50% - 15px);
  342. }
  343. }
  344. .agent {
  345. &-content {
  346. border-radius: var(--el-border-radius-base);
  347. background-color: var(--el-color-success);
  348. padding: 10px;
  349. position: relative;
  350. justify-content: flex-end;
  351. max-width: 400px;
  352. box-shadow: -4px 0 10px 0 rgba(0, 0, 0, 0.3);
  353. margin-right: 60px;
  354. &::after {
  355. position: absolute;
  356. content: '';
  357. top: calc(50% - 10px);
  358. right: -10px;
  359. width: 0;
  360. height: 0;
  361. border-top: 10px solid transparent;
  362. border-left: 10px solid var(--el-color-success);
  363. border-bottom: 10px solid transparent;
  364. }
  365. }
  366. &-name {
  367. font-size: var(--el-font-size-base);
  368. color: var(--el-text-color-regular);
  369. margin-bottom: 5px;
  370. text-align: right;
  371. margin-right: 65px;
  372. }
  373. &-date {
  374. font-size: var(--el-font-size-extra-small);
  375. color: var(--el-text-color-placeholder);
  376. position: absolute;
  377. bottom: -20px;
  378. right: 65px;
  379. }
  380. &-avatar {
  381. width: 40px;
  382. height: 40px;
  383. border-radius: 50%;
  384. position: absolute;
  385. right: 0;
  386. top: calc(50% - 15px);
  387. }
  388. }
  389. }
  390. .end-call {
  391. width: 100%;
  392. text-align: center;
  393. margin-top: 20px;
  394. }
  395. }
  396. .recognize-box {
  397. position: absolute;
  398. bottom: 0;
  399. width: 100%;
  400. background-color: var(--el-color-white);
  401. .call-item {
  402. margin-bottom: 10px;
  403. display: flex;
  404. align-content: center;
  405. .call-item-label {
  406. width: 100px;
  407. text-align: right;
  408. margin-right: 10px;
  409. }
  410. .call-item-value {
  411. flex: 1;
  412. }
  413. }
  414. .transition-box {
  415. position: absolute;
  416. bottom: 0;
  417. width: 100%;
  418. height: 300px;
  419. background-color: var(--el-color-primary-light-9);
  420. border-radius: var(--el-border-radius-base);
  421. padding: 10px;
  422. &-close {
  423. position: absolute;
  424. right: 10px;
  425. top: 10px;
  426. }
  427. &-title {
  428. border-bottom: var(--el-border);
  429. border-color: var(--el-color-info);
  430. padding-bottom: 10px;
  431. }
  432. .transition-box-content {
  433. border: var(--el-border);
  434. border-radius: var(--el-border-radius-base);
  435. border-color: var(--el-color-info);
  436. margin-top: 10px;
  437. height: 180px !important;
  438. padding: 10px;
  439. .transition-box-content-item {
  440. display: flex;
  441. align-items: center;
  442. margin-bottom: 10px;
  443. .transition-box-content-item-title {
  444. width: 75px;
  445. margin-right: 3px;
  446. }
  447. .transition-box-content-item-content {
  448. flex: 1;
  449. }
  450. }
  451. }
  452. }
  453. }
  454. }
  455. </style>