标准版政企呼叫中心业务系统

index.vue 51KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236
  1. <!-- 代码已包含 CSS:使用 TailwindCSS , 安装 TailwindCSS 后方可看到布局样式效果 -->
  2. <template>
  3. <div class="min-h-screen-nav-height bg-gray-50">
  4. <div class="container mx-auto px-4 pt-4 pb-6 flex gap-6">
  5. <!-- 左侧信息区 -->
  6. <div class="w-[35%]">
  7. <el-tabs type="border-card" class="h-full" @tab-change="tabChange">
  8. <!-- 客户档案 -->
  9. <el-tab-pane>
  10. <template #label>
  11. <div class="flex items-center">
  12. <el-icon class="mr-2">
  13. <UserFilled />
  14. </el-icon>
  15. 客户档案
  16. </div>
  17. </template>
  18. <div class="space-y-4">
  19. <div class="bg-white rounded-lg p-4 shadow-sm">
  20. <div class="flex justify-between items-center mb-4">
  21. <h3 class="text-lg font-medium">基本信息</h3>
  22. <el-button type="primary" text :icon="Edit" @click="isEditing = !isEditing">
  23. {{ isEditing ? '保存' : '编辑' }}
  24. </el-button>
  25. </div>
  26. <div class="grid grid-cols-2 gap-4">
  27. <div class="flex flex-col">
  28. <span class="text-gray-500 text-sm">客户姓名</span>
  29. <template v-if="!isEditing">
  30. <span class="font-medium">{{ profile.name }}</span>
  31. </template>
  32. <template v-else>
  33. <el-input v-model="profile.name" size="small" class="w-full" />
  34. </template>
  35. </div>
  36. <div class="flex flex-col">
  37. <span class="text-gray-500 text-sm">客户性别</span>
  38. <template v-if="!isEditing">
  39. <span class="text-yellow-600 font-medium">{{ profile.sex }}</span>
  40. </template>
  41. <template v-else>
  42. <el-select v-model="profile.sex" size="small" class="w-full">
  43. <el-option label="男" value="男" />
  44. <el-option label="女" value="女" />
  45. <el-option label="未知" value="未知" />
  46. </el-select>
  47. </template>
  48. </div>
  49. <div class="flex flex-col">
  50. <span class="text-gray-500 text-sm">创建时间</span>
  51. <span>{{ profile.registerDate }}</span>
  52. <!-- <template v-if="!isEditing">
  53. <span>{{ profile.registerDate }}</span>
  54. </template>
  55. <template v-else>
  56. <el-date-picker v-model="profile.registerDate" type="date" size="small"
  57. style="width: 100%" value-format="YYYY-MM-DD" />
  58. </template> -->
  59. </div>
  60. <!-- <div class="flex flex-col">
  61. <span class="text-gray-500 text-sm">最近联系</span>
  62. <template v-if="!isEditing">
  63. <span>{{ profile.lastContact }}</span>
  64. </template>
  65. <template v-else>
  66. <el-date-picker v-model="profile.lastContact" type="date" size="small"
  67. style="width: 100%" value-format="YYYY-MM-DD" />
  68. </template>
  69. </div> -->
  70. </div>
  71. </div>
  72. <div class="bg-white rounded-lg p-4 shadow-sm">
  73. <h3 class="text-lg font-medium mb-4">联系方式</h3>
  74. <div class="space-y-3">
  75. <div class="flex items-center">
  76. <el-icon class="mr-2">
  77. <Phone />
  78. </el-icon>
  79. <span>{{ profile.phone }}</span>
  80. <!-- <template v-if="!isEditing">
  81. <span>{{ profile.phone }}</span>
  82. </template>
  83. <template v-else>
  84. <el-input v-model="profile.phone" size="small" class="w-full" />
  85. </template> -->
  86. </div>
  87. <!-- <div class="flex items-center">
  88. <el-icon class="mr-2">
  89. <Message />
  90. </el-icon>
  91. <template v-if="!isEditing">
  92. <span>{{ profile.email }}</span>
  93. </template>
  94. <template v-else>
  95. <el-input v-model="profile.email" size="small" class="w-full" />
  96. </template>
  97. </div> -->
  98. <div class="flex items-center">
  99. <el-icon class="mr-2">
  100. <Location />
  101. </el-icon>
  102. <template v-if="!isEditing">
  103. <span>{{ profile.address }}</span>
  104. </template>
  105. <template v-else>
  106. <el-input v-model="profile.address" size="small" class="w-full" />
  107. </template>
  108. </div>
  109. </div>
  110. </div>
  111. </div>
  112. </el-tab-pane>
  113. <!-- 坐席助手 -->
  114. <el-tab-pane v-if="showAsr">
  115. <template #label>
  116. <div class="flex items-center">
  117. <el-icon class="mr-2">
  118. <Service />
  119. </el-icon>
  120. 坐席助手
  121. </div>
  122. </template>
  123. <div class="space-y-4">
  124. <!-- 实时语音识别区域 -->
  125. <h3 class="text-lg font-medium">语音识别</h3>
  126. <div class="bg-white rounded-lg p-4 shadow-sm overflow-y-auto max-h-96 min-h-96">
  127. <div class="flex justify-between items-center mb-4">
  128. <!-- <div class="flex items-center space-x-2">
  129. <el-tag :type="voiceStatus === 'active' ? 'success' : 'info'" size="small">
  130. {{ voiceStatus === 'active' ? '识别中' : '未开始' }}
  131. </el-tag>
  132. </div> -->
  133. </div>
  134. <div class="relative mb-4">
  135. <div class="absolute left-0 top-0 w-full h-16 bg-gradient-to-b from
  136. from-white to-transparent pointer-events-none z-10"></div>
  137. <div ref="transcriptContainer" class="h-[500px] overflow-y-auto pr-4 space-y-3" @mouseenter="chatMouse(0)" @mouseleave="chatMouse(1)">
  138. <div v-for="(item, index) in transcripts" :key="index" class="p-3 rounded-lg"
  139. :class="[item.direction === 2 ? 'bg-blue-50' : 'bg-gray-50']">
  140. <div class="flex items-center mb-1">
  141. <span class="text-xs text-gray-500">{{ item.timestamp }}</span>
  142. <span class="text-xs font-medium ml-2"
  143. :class="[item.direction === 2 ? 'text-blue-600' : 'text-gray-600']">
  144. {{ item.direction === 2 ? '客户' : '坐席' }}
  145. </span>
  146. <div class="flex-1"></div>
  147. <!-- <el-tag size="small" effect="plain" class="ml-2"
  148. :type="item.emotion === 'positive' ? 'success' : item.emotion === 'negative' ? 'danger' : 'info'">
  149. {{ item.emotion === 'positive' ? '积极' : item.emotion === 'negative'
  150. ? '消极' : '中性' }}
  151. </el-tag> -->
  152. </div>
  153. <p class="text-sm text-gray-700">{{ item.page_content }}</p>
  154. </div>
  155. </div>
  156. <div
  157. class="absolute left-0 bottom-0 w-full h-16 bg-gradient-to-t from-white to-transparent pointer-events-none">
  158. </div>
  159. </div>
  160. </div>
  161. <!-- 关键词提示 -->
  162. <div class="space-y-2" v-if="keywords.length > 0">
  163. <h4 class="font-medium text-sm text-gray-600">关键词提示</h4>
  164. <div class="flex flex-wrap gap-2">
  165. <el-tag v-for="(keyword, idx) in keywords" class="relative transition-transform duration-300 ease-in-out hover:scale-105" :key="idx" :type="keyword.type"
  166. effect="plain" size="small" @click="getKnowledgeBaseList(keyword.text)">
  167. {{ keyword.text }}
  168. <span
  169. v-if="keyword.count > 1"
  170. class="absolute -top-1 -right-1 bg-blue-500 text-white text-[10px] rounded-full w-4 h-4 flex items-center justify-center">
  171. {{ keyword.count }}
  172. </span>
  173. </el-tag>
  174. </div>
  175. </div>
  176. <!-- 推荐知识 -->
  177. <div class="mt-4 space-y-4" v-if="recommendedKnowledge.length > 0">
  178. <h4 class="font-medium text-sm text-gray-600">推荐知识</h4>
  179. <div class="space-y-4">
  180. <div v-for="(item, idx) in recommendedKnowledge" :key="idx"
  181. class="p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-all cursor-pointer">
  182. <div class="flex items-center justify-between mb-2">
  183. <h5 class="font-medium" @click="openKnowledgeAi(item.docId)">{{ item.title }}</h5>
  184. <el-tag size="small" type="default">{{ item.directoryname }}</el-tag>
  185. </div>
  186. <p class="text-sm text-gray-600 line-clamp-2 mb-2">{{
  187. stripHtmlByRegex(item.content) }}</p>
  188. <div class="flex items-center justify-between text-xs text-gray-500">
  189. <span>发布时间:{{ item.createTime }}</span>
  190. <!-- <span>阅读:{{ item.reads }}</span> -->
  191. </div>
  192. </div>
  193. </div>
  194. </div>
  195. </div>
  196. </el-tab-pane>
  197. <!-- 通话记录 -->
  198. <el-tab-pane>
  199. <template #label>
  200. <div class="flex items-center">
  201. <el-icon class="mr-2">
  202. <Document />
  203. </el-icon>
  204. 通话记录
  205. </div>
  206. </template>
  207. <div class="space-y-4">
  208. <!-- <el-input v-model="workOrderSearchQuery" placeholder="搜索工单..." :prefix-icon="Search" /> -->
  209. <el-date-picker v-model="callSearchQuery" type="datetimerange" @change="callChange"
  210. range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
  211. <div class="space-y-2 overflow-y-auto" style="height: 100%;" ref="callContainer"
  212. @scroll="handleCallScroll">
  213. <div v-for="(item, index) in callData" :key="index"
  214. class="bg-white p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow mb-4 border border-gray-100">
  215. <div class="flex justify-between items-start">
  216. <div>
  217. <!-- <h4 class="font-medium">{{ getOfffixNuber(item.callType==1 ? item.caller : item.callee) }}</h4> -->
  218. <div class="flex items-center gap-2 mt-1">
  219. <span v-if="item.callerAgent || item.calleeAgent"
  220. style="margin-right: 15px;">
  221. 坐席工号:{{ item.callType == 2 ? item.callerAgent : item.calleeAgent }}
  222. </span>
  223. <span v-if="item.caller || item.callee">
  224. 分机号:{{ item.callType == 2 ? item.caller : item.callee }}
  225. </span>
  226. </div>
  227. </div>
  228. <span class="text-xs text-gray-500">{{ getCallSate(item.callType, item.isAnswer)
  229. }}</span>
  230. </div>
  231. <div class="text-sm text-gray-600 my-2 cursor-pointer">
  232. <div v-if="item.answerTime">通话开始时间:{{ item.answerTime }}</div>
  233. <div v-if="item.hangupTime">通话结束时间:{{ item.hangupTime }}</div>
  234. <div v-if="item.answerTime && item.hangupTime">
  235. 通话时长:{{ getTimeLimit(item.answerTime,
  236. item.hangupTime)}}</div>
  237. </div>
  238. </div>
  239. <div v-if="callLoading" class="flex justify-center items-center py-4">
  240. <el-icon class="animate-spin mr-2">
  241. <Loading />
  242. </el-icon>
  243. 加载中...
  244. </div>
  245. <div v-if="callNoMore" class="text-center text-gray-500 py-4">没有更多数据了</div>
  246. </div>
  247. </div>
  248. </el-tab-pane>
  249. <!-- 历史工单 -->
  250. <el-tab-pane>
  251. <template #label>
  252. <div class="flex items-center">
  253. <el-icon class="mr-2">
  254. <Document />
  255. </el-icon>
  256. 历史工单
  257. </div>
  258. </template>
  259. <div class="space-y-4">
  260. <el-date-picker v-model="orderSearchQuery" type="datetimerange" @change="orderChange"
  261. range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
  262. <!-- <el-input v-model="workOrderSearchQuery" placeholder="搜索工单..." :prefix-icon="Search" /> -->
  263. <div class="space-y-2 overflow-y-auto" style="height: 100%;" ref="workOrderContainer"
  264. @scroll="handleScroll">
  265. <div v-for="(item, index) in displayedActivities" :key="index"
  266. class="bg-white p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow mb-4 border border-gray-100">
  267. <div class="flex justify-between items-start">
  268. <div>
  269. <h4 class="font-medium">{{ item.workordercatename ?
  270. item.workordercatename.substring(item.workordercatename.lastIndexOf('/')
  271. + 1) : '' }}</h4>
  272. <!-- <div class="flex items-center gap-2 mt-1">
  273. <el-tag size="small" type="info" class="!rounded-full">{{ item.workordercatename }}</el-tag>
  274. </div> -->
  275. </div>
  276. <span class="text-xs text-gray-500">{{ item.createtime }}</span>
  277. </div>
  278. <div class="text-sm text-gray-600 my-2 cursor-pointer" :class="{
  279. 'line-clamp-2': !item.expanded || activeWorkOrder !== index
  280. }" @click="() => {
  281. activeWorkOrder = activeWorkOrder === index ? -1 : index;
  282. item.expanded = !item.expanded;
  283. }">{{ item.content }}</div>
  284. <template v-if="item.result">
  285. <div class="mt-2 border-t pt-2">
  286. <div class="flex items-center justify-between text-sm text-gray-600 mb-2">
  287. <div class="flex items-center">
  288. <!-- <el-icon class="mr-1">
  289. <Document />
  290. </el-icon> -->
  291. <span>办理结果</span>
  292. <span class="text-gray-400 ml-2">{{ item.endtime ?
  293. `[${item.endtime}]` : '' }}</span>
  294. </div>
  295. <!-- <div class="text-gray-400">处理部门:{{ item.department || '客服部' }}</div> -->
  296. </div>
  297. <div class="flex">
  298. <div class="w-1 bg-gray-200 mr-2"></div>
  299. <div class="flex flex-col space-y-2">
  300. <div class="text-sm text-gray-500 cursor-pointer" :class="{
  301. 'line-clamp-2': !item.expanded || activeWorkOrder !== index
  302. }" @click="() => {
  303. activeWorkOrder = activeWorkOrder === index ? -1 : index;
  304. item.expanded = !item.expanded;
  305. }">{{ item.result }}</div>
  306. </div>
  307. </div>
  308. </div>
  309. </template>
  310. <!-- <div class="flex items-center mt-2">
  311. <el-tag :type="getTagType(item.type)" size="small">{{ getStatusText(item.type)
  312. }}</el-tag>
  313. </div> -->
  314. </div>
  315. <div v-if="loading" class="flex justify-center items-center py-4">
  316. <el-icon class="animate-spin mr-2">
  317. <Loading />
  318. </el-icon>
  319. 加载中...
  320. </div>
  321. <div v-if="noMore" class="text-center text-gray-500 py-4">没有更多数据了</div>
  322. </div>
  323. </div>
  324. </el-tab-pane>
  325. <!-- 知识库 -->
  326. <el-tab-pane>
  327. <template #label>
  328. <div class="flex items-center">
  329. <el-icon class="mr-2">
  330. <Search />
  331. </el-icon>
  332. 知识库
  333. </div>
  334. </template>
  335. <div class="space-y-4">
  336. <knowledge-list :toExamine="2" :isSreen="1"></knowledge-list>
  337. <!-- <el-input v-model="searchQuery" placeholder="搜索解决方案..." :prefix-icon="Search" />
  338. <div class="space-y-2">
  339. <div v-for="(item, index) in knowledgeBase" :key="index"
  340. class="bg-white p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow cursor-pointer">
  341. <h4 class="font-medium">{{ item.title }}</h4>
  342. <p class="text-sm text-gray-600 mt-1">{{ item.description }}</p>
  343. </div>
  344. </div> -->
  345. </div>
  346. </el-tab-pane>
  347. </el-tabs>
  348. </div>
  349. <!-- 右侧工单区 -->
  350. <div class="w-[65%] space-y-6">
  351. <div class="bg-white rounded-lg p-6 shadow-sm">
  352. <div class="flex justify-between items-center mb-6">
  353. <h2 class="text-xl font-medium">工单信息</h2>
  354. <el-button :loading-icon="Eleme" v-if="showAI && showAsr" class="flex items-center gap-1"
  355. type="primary" link @click="aiSubmit" :loading="aiLoading">智能填单</el-button>
  356. </div>
  357. <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
  358. <el-row :gutter="20">
  359. <el-col :span="12">
  360. <el-form-item label="客户姓名" required prop="name">
  361. <el-input v-model="form.name" placeholder="请输入客户姓名" />
  362. </el-form-item>
  363. </el-col>
  364. <el-col :span="12">
  365. <el-form-item label="联系电话" required prop="phone">
  366. <el-input v-model="form.phone" placeholder="请输入联系电话" />
  367. </el-form-item>
  368. </el-col>
  369. </el-row>
  370. <el-form-item label="工单类型" required prop="type">
  371. <el-cascader ref="typeCascaderRef" class="w-full" v-model="form.type"
  372. :props="{ value: 'id', }" placeholder="请选择工单类型" :options="ticketTypes" filterable
  373. clearable @change="handleTypeChange" />
  374. </el-form-item>
  375. <el-form-item v-if="form.typeCode === 'complain'" label="投诉部门" required prop="department">
  376. <!-- <el-select v-model="form.department" placeholder="请选择投诉部门" class="w-full">
  377. <el-option v-for="item in departments" :key="item.value" :label="item.label"
  378. :value="item.value" />
  379. </el-select> -->
  380. <el-cascader ref="deptCascaderRef" class="w-full" v-model="form.department"
  381. :props="{ checkStrictly: true }" placeholder="请选择投诉科室" :options="departments" filterable
  382. @change="deptChange" />
  383. </el-form-item>
  384. <el-form-item label="问题描述" required prop="description">
  385. <el-input v-model="form.description" type="textarea" :rows="4" placeholder="请详细描述问题" />
  386. </el-form-item>
  387. <!-- <el-form-item label="优先级">
  388. <el-radio-group v-model="form.priority">
  389. <el-radio-button label="high">高</el-radio-button>
  390. <el-radio-button label="medium">中</el-radio-button>
  391. <el-radio-button label="low">低</el-radio-button>
  392. </el-radio-group>
  393. </el-form-item> -->
  394. <el-form-item label="处理方式" required prop="handleMethod">
  395. <el-select v-model="form.handleMethod" placeholder="请选择处理方式" class="w-full">
  396. <el-option v-for="item in handleMethods" :key="item.value" :label="item.label"
  397. :value="item.value" />
  398. </el-select>
  399. </el-form-item>
  400. <!-- <el-form-item label="备注">
  401. <el-input v-model="form.remarks" type="textarea" :rows="3" placeholder="请输入备注信息" />
  402. </el-form-item> -->
  403. <!-- 悬浮提交按钮 -->
  404. <div class="bottom-0 left-0 right-0 p-4 bg-white border-t flex justify-end">
  405. <el-button type="primary" class="w-32 !rounded-button whitespace-nowrap"
  406. @click="submit">提交工单</el-button>
  407. </div>
  408. </el-form>
  409. </div>
  410. </div>
  411. </div>
  412. <div class="aiDialog">
  413. <!-- title="Tips" -->
  414. <el-dialog v-model="dialogVisible" :close-on-click-modal="false" :close-on-press-escape="false"
  415. :modal="false" width="400"
  416. :style="{ 'background': 'url(' + base64data.aiBG + ') center center / 100% 100% no-repeat' }" draggable>
  417. <ai-dialog :knowledgeId="selectKnowledgeId"></ai-dialog>
  418. </el-dialog>
  419. </div>
  420. </div>
  421. </template>
  422. <script lang="ts" setup name="CallScreen">
  423. import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
  424. import moment from 'moment';
  425. import { findKeyword } from '@/utils/trie';
  426. import { base64data } from '@/utils/baseUrlData'
  427. import aiDialog from '@/components/main/Navbar/cpns/aiDialog/aiDialog.vue'
  428. import {
  429. UserFilled,
  430. Phone,
  431. Message,
  432. Location,
  433. Document,
  434. Search,
  435. Timer,
  436. Switch,
  437. Service,
  438. CircleClose,
  439. Operation,
  440. Close,
  441. VideoCamera,
  442. Microphone,
  443. Connection,
  444. List,
  445. Notebook,
  446. Edit,
  447. Eleme,
  448. Loading
  449. } from '@element-plus/icons-vue';
  450. import { hidePhone } from '@/utils/tools';
  451. import { ElMessage } from 'element-plus';
  452. import useSelectStore from '@/store/commonSelect/common';
  453. import useKeysStore from'@/store/modules/keys'
  454. import { getPageListData, createPageData } from '@/api/main/system/system';
  455. import { userDecryptToAsterisk } from '@/utils/aes';
  456. import knowledgeList from "@/views/main/knowledgeBase/knowledgeList/cpns/konwlegelist/konwlegelist";
  457. import { flatten } from 'lodash';
  458. import { getOfffixNuber, getCallSate } from '@/utils/index';
  459. import { stripHtmlByRegex } from '@/utils/tools';
  460. const router = useRouter()
  461. let { proxy } = getCurrentInstance()
  462. const showCallPanel = ref(false);
  463. const searchQuery = ref('');
  464. const aiLoading = ref(false);
  465. const isCanAutoScroll = ref(1);
  466. const dialogVisible = ref(false);
  467. const showAsr = ref(import.meta.env.VITE_APP_AI_ASR === 'true');
  468. const showAI = ref(import.meta.env.VITE_APP_AI_SEARCH === 'true');
  469. // console.log(proxy.$route.query.callNumber)
  470. const telNumber = ref(proxy.$route.query.phone || proxy.$route.query.callNumber || proxy.$route.params.callNumber);
  471. console.log(telNumber.value)
  472. proxy.$route.meta.title = telNumber.value || '来电弹屏';
  473. const callid = ref(proxy.$route.query.callid || 0);
  474. const createLog = (content = '') => {
  475. createPageData('/ai/aioperation', {
  476. operateType: 'ASR',
  477. operationContent: content,
  478. }).then(() => {
  479. }).catch(() => {
  480. })
  481. }
  482. console.log(callid, 'callid')
  483. if (callid.value) createLog('callid');
  484. const showDialpad = ref(false);
  485. const showContacts = ref(false);
  486. const handleRecords = () => {
  487. // 处理通话记录
  488. };
  489. const workOrderSearchQuery = ref('');
  490. const activeWorkOrder = ref(-1);
  491. const currentPage = ref(1);
  492. const pageSize = ref(10);
  493. const displayedActivities = ref<any[]>([]);
  494. const loading = ref(false);
  495. const noMore = ref(false);
  496. const pageIndex = ref(1);
  497. const workOrderContainer = ref<HTMLElement | null>(null);
  498. const callContainer = ref(null)
  499. const loadMoreData = async () => {
  500. if (loading.value || noMore.value) return;
  501. loading.value = true;
  502. await new Promise(resolve => setTimeout(resolve, 500)); // 模拟加载延迟
  503. const res = await getWorkOrders();
  504. if (res?.length === 0) {
  505. noMore.value = true;
  506. } else {
  507. pageIndex.value++;
  508. }
  509. loading.value = false;
  510. };
  511. const handleScroll = async (event: Event) => {
  512. const target = event.target as HTMLElement;
  513. const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
  514. console.log(scrollBottom)
  515. if (scrollBottom < 50) {
  516. await loadMoreData();
  517. }
  518. };
  519. // 通话记录开始
  520. const callData = ref([])
  521. const callLoading = ref(false)
  522. const callNoMore = ref(false);
  523. const loadMoreCallData = async () => {
  524. if (callLoading.value || callNoMore.value) return;
  525. callLoading.value = true;
  526. // await new Promise(resolve => setTimeout(resolve, 500)); // 模拟加载延迟
  527. const res = await getCalllog();
  528. console.log(res, 'res')
  529. if (res?.length === 0) {
  530. callNoMore.value = true;
  531. } else {
  532. // displayedActivities.value.push(...newItems);
  533. callPageIndex.value++;
  534. }
  535. callLoading.value = false;
  536. };
  537. const handleCallScroll = async (event: Event) => {
  538. const target = event.target as HTMLElement;
  539. const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
  540. console.log(scrollBottom)
  541. if (scrollBottom < 50) {
  542. await loadMoreCallData();
  543. }
  544. };
  545. const callPageSize = ref(10)
  546. const callPageIndex = ref(1)
  547. const callSearchQuery = ref()
  548. const callAnswerTime = ref()
  549. const callHangupTime = ref()
  550. function callChange(e) {
  551. console.log(e)
  552. if (e.length > 0) {
  553. callAnswerTime.value = moment(e[0]).format('YYYY-MM-DD HH:mm:ss')
  554. callHangupTime.value = moment(e[1]).format('YYYY-MM-DD HH:mm:ss')
  555. }
  556. callPageIndex.value = 1
  557. callData.value = []
  558. getCalllog()
  559. }
  560. const getCalllog = async () => {
  561. if (!telNumber.value) return;
  562. const params: any = {
  563. phone: telNumber.value,
  564. pageSize: callPageSize.value,
  565. pageNum: callPageIndex.value,
  566. }
  567. if (callAnswerTime.value) {
  568. params.answerTime = callAnswerTime.value
  569. }
  570. if (callHangupTime) {
  571. params.hangupTime = callHangupTime.value
  572. }
  573. const result = await getPageListData('/call/calllog', params);
  574. console.log(result, 'callData');
  575. if (result.state === 'success') {
  576. callData.value = callData.value.concat(result.data)
  577. if (callData.value.length === 0) {
  578. callNoMore.value = true;
  579. } else {
  580. callNoMore.value = false;
  581. }
  582. return result.data;
  583. }
  584. return [];
  585. }
  586. function getTimeLimit(startTime, endTime) {
  587. return proxy.getTimeDifference(startTime, endTime);
  588. }
  589. // 通话记录结束
  590. onMounted(() => {
  591. loadMoreData();
  592. loadMoreCallData()
  593. });
  594. const activities = ref([]);
  595. const getTagType = (type: string) => {
  596. const typeMap: { [key: string]: string } = {
  597. primary: '',
  598. success: 'success',
  599. warning: 'warning',
  600. info: 'info',
  601. danger: 'danger'
  602. };
  603. return typeMap[type] || '';
  604. };
  605. const getStatusText = (type: string) => {
  606. const statusMap: { [key: string]: string } = {
  607. primary: '处理中',
  608. success: '已完成',
  609. warning: '待处理',
  610. info: '已完成',
  611. danger: '已取消'
  612. };
  613. return statusMap[type] || '';
  614. };
  615. const knowledgeBase = [];
  616. const isEditing = ref(false);
  617. const profile = ref({
  618. name: '-',
  619. sex: '-',
  620. registerDate: '',
  621. // lastContact: '',
  622. phone: '',
  623. tel: '',
  624. // email: 'zhaomq@example.com',
  625. address: ''
  626. });
  627. const formRef: any = ref(null);
  628. const form = ref({
  629. name: '不详',
  630. phone: '',
  631. type: '',
  632. typeCode: '',
  633. description: '',
  634. // priority: 'medium',
  635. handleMethod: '1',
  636. // remarks: '',
  637. department: '',
  638. });
  639. const rules = {
  640. name: [
  641. { required: true, message: '请输入姓名', trigger: 'blur' },
  642. { min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
  643. ],
  644. phone: [
  645. { required: true, message: '请输入手机号', trigger: 'blur' },
  646. // { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
  647. ],
  648. type: [
  649. { required: true, message: '请选择工单类型', trigger: 'change' }
  650. ],
  651. description: [
  652. { required: true, message: '请输入问题描述', trigger: 'blur' },
  653. { min: 5, max: 2000, message: '问题描述5-2000个字符', trigger: 'blur' },
  654. ],
  655. handleMethod: [
  656. { required: true, message: '请选择处理方式', trigger: 'change' }
  657. ],
  658. };
  659. const ticketTypes = useSelectStore().dictOrderTypeList;
  660. const departments = useSelectStore().deptListData;
  661. const handleMethods = [
  662. { label: '立即完结', value: '1' },
  663. { label: '待交办', value: '0' },
  664. // { label: '转专员处理', value: 'transfer' },
  665. // { label: '现场支持', value: 'onsite' }
  666. ];
  667. const getUserInfo = async () => {
  668. if (!telNumber.value) {
  669. return;
  670. }
  671. telNumber.value = getOfffixNuber(telNumber.value)
  672. profile.value.phone = telNumber.value;
  673. form.value.phone = telNumber.value;
  674. const userResult = await getPageListData('/patient/patient', { phoneNumber: telNumber.value });
  675. console.log(userResult);
  676. if (userResult?.data?.length) {
  677. const userInfo = userResult.data[0];
  678. profile.value = {
  679. name: userInfo.name,
  680. registerDate: userInfo.createTime,
  681. phone: userDecryptToAsterisk(userInfo.phoneNumber),
  682. address: userInfo.address,
  683. sex: userInfo.sex === 1 ? '男' : userInfo.sex == 0 ? '女' : '-',
  684. tel: userDecryptToAsterisk(userInfo.tel),
  685. };
  686. form.value.name = userInfo.name;
  687. }
  688. }
  689. const typeCascaderRef: any = ref(null);
  690. const handleTypeChange = (value) => {
  691. if (typeCascaderRef.value) {
  692. const selectNode = typeCascaderRef.value.getCheckedNodes();
  693. form.value.typeCode = selectNode[selectNode.length - 1].data.code;
  694. }
  695. }
  696. // 投诉部门切换
  697. const deptCascaderRef: any = ref(null);
  698. const deptChange = (value) => {
  699. // if (deptCascaderRef.value) {
  700. // const selectNode = deptCascaderRef.value.getCheckedNodes()[0];
  701. // form.value.department = selectNode.data.label;
  702. // }
  703. }
  704. const resetForm = () => {
  705. form.value = {
  706. name: '',
  707. phone: '',
  708. type: '',
  709. typeCode: '',
  710. description: '',
  711. handleMethod: '1',
  712. department: '',
  713. };
  714. formRef.value.resetFields();
  715. }
  716. const submit = async () => {
  717. // 验证表单必填项
  718. formRef.value.validate(async (valid) => {
  719. if (valid) {
  720. const params: any = {
  721. caller: form.value.name,
  722. callnum: form.value.phone,
  723. content: form.value.description,
  724. customerno: form.value.phone,
  725. firsttype: 1000,
  726. isend: +form.value.handleMethod,
  727. workordercate: form.value.type[form.value.type.length - 1],
  728. };
  729. if (callid.value) params.callId = callid.value;
  730. // 提交表单
  731. createPageData('/order/workorder', params).then((data) => {
  732. console.log(data, 'submit');
  733. if (data.state === 'success') {
  734. ElMessage.success('提交成功');
  735. transcripts.value = []
  736. resetForm()
  737. close()
  738. } else {
  739. ElMessage.error(data.message || '提交失败');
  740. }
  741. })
  742. } else {
  743. ElMessage.error('请填写完整信息');
  744. }
  745. });
  746. }
  747. /** 关闭按钮 */
  748. function close() {
  749. const obj = { path: '/' }
  750. proxy.$tab.closeOpenPage(obj)
  751. }
  752. const orderSearchQuery = ref()
  753. const createtime = ref()
  754. const endtime = ref()
  755. function orderChange(e) {
  756. console.log(e)
  757. if (e.length > 0) {
  758. createtime.value = moment(e[0]).format('YYYY-MM-DD HH:mm:ss')
  759. endtime.value = moment(e[1]).format('YYYY-MM-DD HH:mm:ss')
  760. }
  761. pageIndex.value = 1
  762. displayedActivities.value = []
  763. getWorkOrders()
  764. }
  765. const getWorkOrders = async () => {
  766. if (!telNumber.value) return;
  767. const params: any = {
  768. listType: 0,
  769. callnum: telNumber.value,
  770. pageSize: pageSize.value,
  771. pageNum: pageIndex.value,
  772. }
  773. if (createtime.value) {
  774. params.createtime = createtime.value
  775. }
  776. if (endtime.value) {
  777. params.endtime = endtime.value
  778. }
  779. const result = await getPageListData('/order/workorder', params);
  780. console.log(result, 'getWorkOrders');
  781. if (result.state === 'success') {
  782. displayedActivities.value = displayedActivities.value.concat(result.data)
  783. console.log(displayedActivities.value, 'activities');
  784. if (displayedActivities.value.length === 0) {
  785. noMore.value = true;
  786. } else {
  787. noMore.value = false;
  788. }
  789. return result.data;
  790. }
  791. return [];
  792. }
  793. const tabChange = (name) => {
  794. console.log(name, 'tabChange');
  795. if (name === '1') {
  796. pageIndex.value = 1
  797. callPageIndex.value = 1
  798. callData.value = []
  799. getCalllog();
  800. } else if (name === '2') {
  801. pageIndex.value = 1
  802. displayedActivities.value = []
  803. getWorkOrders();
  804. }
  805. }
  806. // 语音识别状态
  807. const voiceStatus = ref('active');
  808. // 识别记录
  809. const transcripts: any = ref([
  810. // {
  811. // direction: 1,
  812. // timestamp: '14:30:24',
  813. // page_content: '你好,中国热线请假。',
  814. ])
  815. // 关键词提示
  816. const keywords: any = ref([
  817. // {
  818. // text: '强迫症',
  819. // type: 'primary',
  820. // count: 0,
  821. // },
  822. ]);
  823. // 建议话术
  824. const suggestions = ref([
  825. '很高兴能帮到您,如果还有其他问题随时询问',
  826. '这个功能确实很实用,您可以根据实际需求来调整配置',
  827. '感谢您的正面反馈,我们会继续努力提供更好的服务',
  828. '建议您也可以查看我们的帮助文档,里面有更详细的使用说明'
  829. ]);
  830. const transcriptContainer = ref<HTMLElement | null>(null);
  831. // 监听新消息,自动滚动到底部
  832. // 监听新消息和内容变化,自动滚动到底部
  833. // watch(
  834. // [
  835. // () => transcripts.value.length,
  836. // () => transcripts.value.map(t => t.page_content)
  837. // ],
  838. // () => {
  839. // nextTick(() => {
  840. // console.log(transcriptContainer.value, 'transcriptContainer');
  841. // if (transcriptContainer.value) {
  842. // transcriptContainer.value.scrollTop = transcriptContainer.value.scrollHeight;
  843. // }
  844. // });
  845. // },
  846. // { deep: true }
  847. // );
  848. window.addEventListener('AsrMessageEvent', (msg: any) => {
  849. console.log(msg.detail, '接收asr消息');
  850. if (!showAsr) return;
  851. if (!msg?.detail || !msg.detail.Result) return;
  852. const msgInfo = {
  853. direction: msg.detail.Number.toString().length > 4 ? 2 : 1,
  854. page_content: msg.detail.Speech || '',
  855. timestamp: '',
  856. };
  857. console.log(msg.detail.Time, 'msgInfo');
  858. if (msg.detail.Time) {
  859. const times = JSON.parse(msg.detail.Time);
  860. console.log(times, 'msgInfo');
  861. if (times?.length) {
  862. const time = flatten(times)?.[0];
  863. if (time) msgInfo.timestamp = formatMilliseconds(time);
  864. }
  865. }
  866. console.log(msgInfo, 'msgInfo');
  867. if (msgInfo.page_content.length > 2) checkKeys(msgInfo.page_content);
  868. transcripts.value.push(msgInfo);
  869. })
  870. // function formatMilliseconds(milliseconds) {
  871. // const totalSeconds = Math.floor(milliseconds / 1000);
  872. // const hours = Math.floor(totalSeconds / 3600).toString().padStart(2, '0');
  873. // const minutes = Math.floor((totalSeconds % 3600) / 60).toString().padStart(2, '0');
  874. // const seconds = (totalSeconds % 60).toString().padStart(2, '0');
  875. // return `${minutes}:${seconds}`;
  876. // }
  877. const aiSubmit = async () => {
  878. aiLoading.value = true;
  879. let text = transcripts.value.map((o) => o.page_content).join('');
  880. if (text?.length > 10) {
  881. const res = await getSearchDocs(text);
  882. console.log(res, 'res');
  883. res.split('\n').forEach((o) => {
  884. if (o.startsWith('【内容总结】')) form.value.description = o.replace('【内容总结】', '').replace(':', '');
  885. // if (o.startsWith('【事件地址】')) form.value.address = o.replace('【事件地址】', '').replace(':', '');
  886. // if (o.startsWith('【姓名】')) form.value.customerName = o.replace('【姓名】', '').replace(':', '');
  887. })
  888. aiLoading.value = false;
  889. } else {
  890. aiLoading.value = false;
  891. }
  892. }
  893. onMounted(() => {
  894. transcripts.value = [];
  895. getUserInfo();
  896. });
  897. onUnmounted(() => {
  898. });
  899. async function getSearchDocs (text) {
  900. const params = {
  901. "model": "deepseek-r1:32b",
  902. "messages": [
  903. {
  904. "role": "system",
  905. "content": `请从以下咨询对话中提取关键信息生成结构化工单:
  906. 地理位置提取:识别来访者明确提到的居住地或当前位置
  907. 格式:[省/市/区/街道/楼栋号/房间号](如未提及则标记"未知")
  908. 注意模糊表述(如"北方城市""江浙地区"等需保留原话)
  909. 通话内容总结:
  910. 用第三人称概括核心问题(100字到2000字之间)
  911. 保留以下关键要素:
  912. • 主要症状描述(情绪/躯体/行为表现)
  913. • 持续时间(使用"约X周/月/年"格式)
  914. • 社会功能影响(工作/学习/人际关系)
  915. • 既往病史(如提及)
  916. 过滤无关对话(寒暄、重复表述等)
  917. 工单类型分类:
  918. 根据DSM-5标准匹配最相关分类(单选):
  919. [抑郁障碍] [焦虑障碍] [强迫症] [创伤应激] [人格障碍] [适应障碍] [人际关系] [发展性问题] [其他]
  920. 【处理规范】
  921. 优先识别直接症状陈述(如"失眠三个月""不敢见人")
  922. 注意隐喻表达(如"心里压着石头""像被困住")
  923. 存在自伤/伤人表述时自动触发危机预警协议
  924. 多问题并存时按主诉优先级排序
  925. 示例对话:
  926. [咨询师]:可以说说最近困扰您的事吗?
  927. [来访者]:我在杭州工作两年了,最近三个月每天失眠,开会时手抖得厉害,上周在地铁里突然喘不过气...
  928. 示例输出:
  929. 【地理位置】:浙江省杭州市
  930. 【内容总结】:来访者诉持续三个月失眠症状,伴有社交场合手抖等躯体化表现,提及近期出现惊恐发作经历(地铁喘不过气),社会功能受损(工作受影响)
  931. 【工单类型】: 焦虑障碍`
  932. }, {
  933. "role": "user",
  934. "content": text
  935. }
  936. ],
  937. "stream": true
  938. };
  939. try {
  940. // 发送请求
  941. const url = import.meta.env.VITE_APP_AI_API || 'https://open.bigmodel.cn/api/paas/v4/chat/completions'
  942. let response = await fetch(url,
  943. {
  944. method: "post",
  945. responseType: "stream",
  946. headers: {
  947. "Content-Type": "application/json",
  948. "Authorization": `Bearer ${import.meta.env.VITE_APP_AI_API_KEY}`
  949. },
  950. body: JSON.stringify(params),
  951. }
  952. );
  953. let resultStr = '';
  954. // ok字段判断是否成功获取到数据流
  955. if (!response.ok) {
  956. throw new Error("Network response was not ok");
  957. }
  958. // 用来获取一个可读的流的读取器(Reader)以流的方式处理响应体数据
  959. const reader = response.body.getReader();
  960. // 将流中的字节数据解码为文本字符串
  961. const textDecoder = new TextDecoder();
  962. let result = true;
  963. let sqlValue = ''
  964. while (result) {
  965. // done表示流是否已经完成读取 value包含读取到的数据块
  966. const { done, value } = await reader.read();
  967. if (done) {
  968. result = false;
  969. // console.log(resultStr, 'resultStr');
  970. return resultStr;
  971. break;
  972. }
  973. // 拿到的value就是后端分段返回的数据,大多是以data:开头的字符串
  974. // 需要通过decode方法处理数据块,例如转换为文本或进行其他操作
  975. const chunkText = textDecoder.decode(value).split("\n").forEach((val) => {
  976. if (!val) return;
  977. try {
  978. // 后端返回的流式数据一般都是以data:开头的字符,排除掉data:后就是需要的数据
  979. // 具体返回结构可以跟后端约定
  980. let text = val?.replace("data:", "") || ""
  981. const resultData = JSON.parse(text)
  982. let resultText = resultData.choices[0].delta.content
  983. resultStr += resultText
  984. } catch (err) {
  985. // console.log(err)
  986. }
  987. });
  988. }
  989. } catch (err) {
  990. console.log(err)
  991. return err;
  992. }
  993. }
  994. // 推荐知识
  995. const recommendedKnowledge: any = ref([]);
  996. const keys = useKeysStore().knowledgeKeys;
  997. console.log(keys, 'keys')
  998. const getKnowledgeBaseList = (key) => {
  999. if (key.length < 2) {
  1000. return
  1001. }
  1002. suggestions.value = []
  1003. getPageListData('/km/doc', {
  1004. keywords: key,
  1005. pageNum: 1,
  1006. pageSize: 5,
  1007. }).then(({ data, total }) => {
  1008. recommendedKnowledge.value = data;
  1009. });
  1010. }
  1011. const checkKeys = (text: string) => {
  1012. const list = findKeyword(text, keys);
  1013. if (list?.length) {
  1014. setKeys(list)
  1015. }
  1016. }
  1017. const setKeys = (keys: Array<string>) => {
  1018. const types = ['primary', 'success', 'warning', 'danger'];
  1019. for (const key of keys) {
  1020. const index = keywords.value.findIndex((item) => item.text === key);
  1021. if (index > -1) {
  1022. keywords.value[index].count++;
  1023. keywords.value[index].type = types[keywords.value[index].count % types.length];
  1024. } else {
  1025. keywords.value.push({
  1026. text: key,
  1027. type: 'primary',
  1028. count: 1,
  1029. });
  1030. }
  1031. }
  1032. keywords.value.sort((a, b) => b.count - a.count);
  1033. }
  1034. const selectKnowledgeId = ref(0);
  1035. const openKnowledgeAi = (id) => {
  1036. selectKnowledgeId.value = id;
  1037. dialogVisible.value = true;
  1038. }
  1039. const chatMouse = (type) => {
  1040. isCanAutoScroll.value = type;
  1041. }
  1042. const scrollToBottom = () => {
  1043. if (transcriptContainer.value) {
  1044. transcriptContainer.value.scrollTop = transcriptContainer.value.scrollHeight;
  1045. }
  1046. }
  1047. watch(transcripts, () => {
  1048. nextTick(() => {
  1049. if (isCanAutoScroll.value) scrollToBottom();
  1050. })
  1051. }, {
  1052. deep: true
  1053. })
  1054. </script>
  1055. <style scoped lang="scss">
  1056. * {
  1057. transition: height 0.3s ease-out;
  1058. }
  1059. .scrollbar-hide {
  1060. scrollbar-width: none;
  1061. -ms-overflow-style: none;
  1062. }
  1063. .scrollbar-hide::-webkit-scrollbar {
  1064. display: none;
  1065. }
  1066. .slide-enter-active,
  1067. .slide-leave-active,
  1068. .container {
  1069. transition: all 0.3s ease-out;
  1070. }
  1071. .slide-enter-from,
  1072. .slide-leave-to {
  1073. transform: translateX(-20px);
  1074. opacity: 0;
  1075. }
  1076. :deep(.el-avatar) {
  1077. background-color: #f0f2f5;
  1078. }
  1079. :deep(.el-input__wrapper) {
  1080. /* box-shadow: none !important;
  1081. border: none !important; */
  1082. }
  1083. :deep(.el-button.is-circle) {
  1084. width: 3rem;
  1085. height: 3rem;
  1086. }
  1087. .el-tabs {
  1088. height: calc(100vh - 8rem);
  1089. transition: height 0.3s ease-out;
  1090. }
  1091. .el-timeline {
  1092. max-height: calc(100vh - 12rem);
  1093. overflow-y: auto;
  1094. }
  1095. :deep(.el-tabs__content) {
  1096. height: calc(100% - 40px);
  1097. overflow-y: auto;
  1098. transition: height 0.3s ease-out;
  1099. }
  1100. :deep(.el-form-item__label) {
  1101. font-weight: 500;
  1102. }
  1103. :deep(.el-button.is-circle) {
  1104. width: 2.5rem;
  1105. height: 2.5rem;
  1106. }
  1107. :deep(.el-icon) {
  1108. font-size: 1.25rem;
  1109. }
  1110. .aiDialog {
  1111. z-index: 2033;
  1112. pointer-events: none;
  1113. :deep(.el-dialog) {
  1114. position: absolute;
  1115. height: 700px;
  1116. top: 50px;
  1117. right: 0;
  1118. margin: 0;
  1119. left: unset;
  1120. pointer-events: auto;
  1121. }
  1122. }
  1123. /* 隐藏所有滚动条 */
  1124. * {
  1125. scrollbar-width: none; /* Firefox */
  1126. -ms-overflow-style: none; /* Internet Explorer 10+ */
  1127. }
  1128. /* 针对Webkit浏览器(如Chrome和Safari) */
  1129. *::-webkit-scrollbar {
  1130. display: none;
  1131. }
  1132. </style>