| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236 |
- <!-- 代码已包含 CSS:使用 TailwindCSS , 安装 TailwindCSS 后方可看到布局样式效果 -->
- <template>
- <div class="min-h-screen-nav-height bg-gray-50">
- <div class="container mx-auto px-4 pt-4 pb-6 flex gap-6">
- <!-- 左侧信息区 -->
- <div class="w-[35%]">
- <el-tabs type="border-card" class="h-full" @tab-change="tabChange">
- <!-- 客户档案 -->
- <el-tab-pane>
- <template #label>
- <div class="flex items-center">
- <el-icon class="mr-2">
- <UserFilled />
- </el-icon>
- 客户档案
- </div>
- </template>
- <div class="space-y-4">
- <div class="bg-white rounded-lg p-4 shadow-sm">
- <div class="flex justify-between items-center mb-4">
- <h3 class="text-lg font-medium">基本信息</h3>
- <el-button type="primary" text :icon="Edit" @click="isEditing = !isEditing">
- {{ isEditing ? '保存' : '编辑' }}
- </el-button>
- </div>
- <div class="grid grid-cols-2 gap-4">
- <div class="flex flex-col">
- <span class="text-gray-500 text-sm">客户姓名</span>
- <template v-if="!isEditing">
- <span class="font-medium">{{ profile.name }}</span>
- </template>
- <template v-else>
- <el-input v-model="profile.name" size="small" class="w-full" />
- </template>
- </div>
- <div class="flex flex-col">
- <span class="text-gray-500 text-sm">客户性别</span>
- <template v-if="!isEditing">
- <span class="text-yellow-600 font-medium">{{ profile.sex }}</span>
- </template>
- <template v-else>
- <el-select v-model="profile.sex" size="small" class="w-full">
- <el-option label="男" value="男" />
- <el-option label="女" value="女" />
- <el-option label="未知" value="未知" />
- </el-select>
- </template>
- </div>
- <div class="flex flex-col">
- <span class="text-gray-500 text-sm">创建时间</span>
- <span>{{ profile.registerDate }}</span>
- <!-- <template v-if="!isEditing">
- <span>{{ profile.registerDate }}</span>
- </template>
- <template v-else>
- <el-date-picker v-model="profile.registerDate" type="date" size="small"
- style="width: 100%" value-format="YYYY-MM-DD" />
- </template> -->
- </div>
- <!-- <div class="flex flex-col">
- <span class="text-gray-500 text-sm">最近联系</span>
- <template v-if="!isEditing">
- <span>{{ profile.lastContact }}</span>
- </template>
- <template v-else>
- <el-date-picker v-model="profile.lastContact" type="date" size="small"
- style="width: 100%" value-format="YYYY-MM-DD" />
- </template>
- </div> -->
- </div>
- </div>
- <div class="bg-white rounded-lg p-4 shadow-sm">
- <h3 class="text-lg font-medium mb-4">联系方式</h3>
- <div class="space-y-3">
- <div class="flex items-center">
- <el-icon class="mr-2">
- <Phone />
- </el-icon>
- <span>{{ profile.phone }}</span>
- <!-- <template v-if="!isEditing">
- <span>{{ profile.phone }}</span>
- </template>
- <template v-else>
- <el-input v-model="profile.phone" size="small" class="w-full" />
- </template> -->
- </div>
- <!-- <div class="flex items-center">
- <el-icon class="mr-2">
- <Message />
- </el-icon>
- <template v-if="!isEditing">
- <span>{{ profile.email }}</span>
- </template>
- <template v-else>
- <el-input v-model="profile.email" size="small" class="w-full" />
- </template>
- </div> -->
- <div class="flex items-center">
- <el-icon class="mr-2">
- <Location />
- </el-icon>
- <template v-if="!isEditing">
- <span>{{ profile.address }}</span>
- </template>
- <template v-else>
- <el-input v-model="profile.address" size="small" class="w-full" />
- </template>
- </div>
- </div>
- </div>
- </div>
- </el-tab-pane>
- <!-- 坐席助手 -->
- <el-tab-pane v-if="showAsr">
- <template #label>
- <div class="flex items-center">
- <el-icon class="mr-2">
- <Service />
- </el-icon>
- 坐席助手
- </div>
- </template>
- <div class="space-y-4">
- <!-- 实时语音识别区域 -->
- <h3 class="text-lg font-medium">语音识别</h3>
- <div class="bg-white rounded-lg p-4 shadow-sm overflow-y-auto max-h-96 min-h-96">
- <div class="flex justify-between items-center mb-4">
- <!-- <div class="flex items-center space-x-2">
- <el-tag :type="voiceStatus === 'active' ? 'success' : 'info'" size="small">
- {{ voiceStatus === 'active' ? '识别中' : '未开始' }}
- </el-tag>
- </div> -->
- </div>
- <div class="relative mb-4">
- <div class="absolute left-0 top-0 w-full h-16 bg-gradient-to-b from
- from-white to-transparent pointer-events-none z-10"></div>
- <div ref="transcriptContainer" class="h-[500px] overflow-y-auto pr-4 space-y-3" @mouseenter="chatMouse(0)" @mouseleave="chatMouse(1)">
- <div v-for="(item, index) in transcripts" :key="index" class="p-3 rounded-lg"
- :class="[item.direction === 2 ? 'bg-blue-50' : 'bg-gray-50']">
- <div class="flex items-center mb-1">
- <span class="text-xs text-gray-500">{{ item.timestamp }}</span>
- <span class="text-xs font-medium ml-2"
- :class="[item.direction === 2 ? 'text-blue-600' : 'text-gray-600']">
- {{ item.direction === 2 ? '客户' : '坐席' }}
- </span>
- <div class="flex-1"></div>
- <!-- <el-tag size="small" effect="plain" class="ml-2"
- :type="item.emotion === 'positive' ? 'success' : item.emotion === 'negative' ? 'danger' : 'info'">
- {{ item.emotion === 'positive' ? '积极' : item.emotion === 'negative'
- ? '消极' : '中性' }}
- </el-tag> -->
- </div>
- <p class="text-sm text-gray-700">{{ item.page_content }}</p>
- </div>
- </div>
- <div
- class="absolute left-0 bottom-0 w-full h-16 bg-gradient-to-t from-white to-transparent pointer-events-none">
- </div>
- </div>
- </div>
- <!-- 关键词提示 -->
- <div class="space-y-2" v-if="keywords.length > 0">
- <h4 class="font-medium text-sm text-gray-600">关键词提示</h4>
- <div class="flex flex-wrap gap-2">
- <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"
- effect="plain" size="small" @click="getKnowledgeBaseList(keyword.text)">
- {{ keyword.text }}
- <span
- v-if="keyword.count > 1"
- class="absolute -top-1 -right-1 bg-blue-500 text-white text-[10px] rounded-full w-4 h-4 flex items-center justify-center">
- {{ keyword.count }}
- </span>
- </el-tag>
- </div>
- </div>
- <!-- 推荐知识 -->
- <div class="mt-4 space-y-4" v-if="recommendedKnowledge.length > 0">
- <h4 class="font-medium text-sm text-gray-600">推荐知识</h4>
- <div class="space-y-4">
- <div v-for="(item, idx) in recommendedKnowledge" :key="idx"
- class="p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-all cursor-pointer">
- <div class="flex items-center justify-between mb-2">
- <h5 class="font-medium" @click="openKnowledgeAi(item.docId)">{{ item.title }}</h5>
- <el-tag size="small" type="default">{{ item.directoryname }}</el-tag>
- </div>
- <p class="text-sm text-gray-600 line-clamp-2 mb-2">{{
- stripHtmlByRegex(item.content) }}</p>
- <div class="flex items-center justify-between text-xs text-gray-500">
- <span>发布时间:{{ item.createTime }}</span>
- <!-- <span>阅读:{{ item.reads }}</span> -->
- </div>
- </div>
- </div>
- </div>
- </div>
- </el-tab-pane>
- <!-- 通话记录 -->
- <el-tab-pane>
- <template #label>
- <div class="flex items-center">
- <el-icon class="mr-2">
- <Document />
- </el-icon>
- 通话记录
- </div>
- </template>
- <div class="space-y-4">
- <!-- <el-input v-model="workOrderSearchQuery" placeholder="搜索工单..." :prefix-icon="Search" /> -->
- <el-date-picker v-model="callSearchQuery" type="datetimerange" @change="callChange"
- range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
- <div class="space-y-2 overflow-y-auto" style="height: 100%;" ref="callContainer"
- @scroll="handleCallScroll">
- <div v-for="(item, index) in callData" :key="index"
- class="bg-white p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow mb-4 border border-gray-100">
- <div class="flex justify-between items-start">
- <div>
- <!-- <h4 class="font-medium">{{ getOfffixNuber(item.callType==1 ? item.caller : item.callee) }}</h4> -->
- <div class="flex items-center gap-2 mt-1">
- <span v-if="item.callerAgent || item.calleeAgent"
- style="margin-right: 15px;">
- 坐席工号:{{ item.callType == 2 ? item.callerAgent : item.calleeAgent }}
- </span>
- <span v-if="item.caller || item.callee">
- 分机号:{{ item.callType == 2 ? item.caller : item.callee }}
- </span>
- </div>
- </div>
- <span class="text-xs text-gray-500">{{ getCallSate(item.callType, item.isAnswer)
- }}</span>
- </div>
- <div class="text-sm text-gray-600 my-2 cursor-pointer">
- <div v-if="item.answerTime">通话开始时间:{{ item.answerTime }}</div>
- <div v-if="item.hangupTime">通话结束时间:{{ item.hangupTime }}</div>
- <div v-if="item.answerTime && item.hangupTime">
- 通话时长:{{ getTimeLimit(item.answerTime,
- item.hangupTime)}}</div>
- </div>
- </div>
- <div v-if="callLoading" class="flex justify-center items-center py-4">
- <el-icon class="animate-spin mr-2">
- <Loading />
- </el-icon>
- 加载中...
- </div>
- <div v-if="callNoMore" class="text-center text-gray-500 py-4">没有更多数据了</div>
- </div>
- </div>
- </el-tab-pane>
- <!-- 历史工单 -->
- <el-tab-pane>
- <template #label>
- <div class="flex items-center">
- <el-icon class="mr-2">
- <Document />
- </el-icon>
- 历史工单
- </div>
- </template>
- <div class="space-y-4">
- <el-date-picker v-model="orderSearchQuery" type="datetimerange" @change="orderChange"
- range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
- <!-- <el-input v-model="workOrderSearchQuery" placeholder="搜索工单..." :prefix-icon="Search" /> -->
- <div class="space-y-2 overflow-y-auto" style="height: 100%;" ref="workOrderContainer"
- @scroll="handleScroll">
- <div v-for="(item, index) in displayedActivities" :key="index"
- class="bg-white p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow mb-4 border border-gray-100">
- <div class="flex justify-between items-start">
- <div>
- <h4 class="font-medium">{{ item.workordercatename ?
- item.workordercatename.substring(item.workordercatename.lastIndexOf('/')
- + 1) : '' }}</h4>
- <!-- <div class="flex items-center gap-2 mt-1">
- <el-tag size="small" type="info" class="!rounded-full">{{ item.workordercatename }}</el-tag>
- </div> -->
- </div>
- <span class="text-xs text-gray-500">{{ item.createtime }}</span>
- </div>
- <div class="text-sm text-gray-600 my-2 cursor-pointer" :class="{
- 'line-clamp-2': !item.expanded || activeWorkOrder !== index
- }" @click="() => {
- activeWorkOrder = activeWorkOrder === index ? -1 : index;
- item.expanded = !item.expanded;
- }">{{ item.content }}</div>
- <template v-if="item.result">
- <div class="mt-2 border-t pt-2">
- <div class="flex items-center justify-between text-sm text-gray-600 mb-2">
- <div class="flex items-center">
- <!-- <el-icon class="mr-1">
- <Document />
- </el-icon> -->
- <span>办理结果</span>
- <span class="text-gray-400 ml-2">{{ item.endtime ?
- `[${item.endtime}]` : '' }}</span>
- </div>
- <!-- <div class="text-gray-400">处理部门:{{ item.department || '客服部' }}</div> -->
- </div>
- <div class="flex">
- <div class="w-1 bg-gray-200 mr-2"></div>
- <div class="flex flex-col space-y-2">
- <div class="text-sm text-gray-500 cursor-pointer" :class="{
- 'line-clamp-2': !item.expanded || activeWorkOrder !== index
- }" @click="() => {
- activeWorkOrder = activeWorkOrder === index ? -1 : index;
- item.expanded = !item.expanded;
- }">{{ item.result }}</div>
- </div>
- </div>
- </div>
- </template>
- <!-- <div class="flex items-center mt-2">
- <el-tag :type="getTagType(item.type)" size="small">{{ getStatusText(item.type)
- }}</el-tag>
- </div> -->
- </div>
- <div v-if="loading" class="flex justify-center items-center py-4">
- <el-icon class="animate-spin mr-2">
- <Loading />
- </el-icon>
- 加载中...
- </div>
- <div v-if="noMore" class="text-center text-gray-500 py-4">没有更多数据了</div>
- </div>
- </div>
- </el-tab-pane>
- <!-- 知识库 -->
- <el-tab-pane>
- <template #label>
- <div class="flex items-center">
- <el-icon class="mr-2">
- <Search />
- </el-icon>
- 知识库
- </div>
- </template>
- <div class="space-y-4">
- <knowledge-list :toExamine="2" :isSreen="1"></knowledge-list>
- <!-- <el-input v-model="searchQuery" placeholder="搜索解决方案..." :prefix-icon="Search" />
- <div class="space-y-2">
- <div v-for="(item, index) in knowledgeBase" :key="index"
- class="bg-white p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow cursor-pointer">
- <h4 class="font-medium">{{ item.title }}</h4>
- <p class="text-sm text-gray-600 mt-1">{{ item.description }}</p>
- </div>
- </div> -->
- </div>
- </el-tab-pane>
- </el-tabs>
- </div>
- <!-- 右侧工单区 -->
- <div class="w-[65%] space-y-6">
- <div class="bg-white rounded-lg p-6 shadow-sm">
- <div class="flex justify-between items-center mb-6">
- <h2 class="text-xl font-medium">工单信息</h2>
- <el-button :loading-icon="Eleme" v-if="showAI && showAsr" class="flex items-center gap-1"
- type="primary" link @click="aiSubmit" :loading="aiLoading">智能填单</el-button>
- </div>
- <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
- <el-row :gutter="20">
- <el-col :span="12">
- <el-form-item label="客户姓名" required prop="name">
- <el-input v-model="form.name" placeholder="请输入客户姓名" />
- </el-form-item>
- </el-col>
- <el-col :span="12">
- <el-form-item label="联系电话" required prop="phone">
- <el-input v-model="form.phone" placeholder="请输入联系电话" />
- </el-form-item>
- </el-col>
- </el-row>
- <el-form-item label="工单类型" required prop="type">
- <el-cascader ref="typeCascaderRef" class="w-full" v-model="form.type"
- :props="{ value: 'id', }" placeholder="请选择工单类型" :options="ticketTypes" filterable
- clearable @change="handleTypeChange" />
- </el-form-item>
- <el-form-item v-if="form.typeCode === 'complain'" label="投诉部门" required prop="department">
- <!-- <el-select v-model="form.department" placeholder="请选择投诉部门" class="w-full">
- <el-option v-for="item in departments" :key="item.value" :label="item.label"
- :value="item.value" />
- </el-select> -->
- <el-cascader ref="deptCascaderRef" class="w-full" v-model="form.department"
- :props="{ checkStrictly: true }" placeholder="请选择投诉科室" :options="departments" filterable
- @change="deptChange" />
- </el-form-item>
- <el-form-item label="问题描述" required prop="description">
- <el-input v-model="form.description" type="textarea" :rows="4" placeholder="请详细描述问题" />
- </el-form-item>
- <!-- <el-form-item label="优先级">
- <el-radio-group v-model="form.priority">
- <el-radio-button label="high">高</el-radio-button>
- <el-radio-button label="medium">中</el-radio-button>
- <el-radio-button label="low">低</el-radio-button>
- </el-radio-group>
- </el-form-item> -->
- <el-form-item label="处理方式" required prop="handleMethod">
- <el-select v-model="form.handleMethod" placeholder="请选择处理方式" class="w-full">
- <el-option v-for="item in handleMethods" :key="item.value" :label="item.label"
- :value="item.value" />
- </el-select>
- </el-form-item>
- <!-- <el-form-item label="备注">
- <el-input v-model="form.remarks" type="textarea" :rows="3" placeholder="请输入备注信息" />
- </el-form-item> -->
- <!-- 悬浮提交按钮 -->
- <div class="bottom-0 left-0 right-0 p-4 bg-white border-t flex justify-end">
- <el-button type="primary" class="w-32 !rounded-button whitespace-nowrap"
- @click="submit">提交工单</el-button>
- </div>
- </el-form>
- </div>
- </div>
- </div>
- <div class="aiDialog">
- <!-- title="Tips" -->
- <el-dialog v-model="dialogVisible" :close-on-click-modal="false" :close-on-press-escape="false"
- :modal="false" width="400"
- :style="{ 'background': 'url(' + base64data.aiBG + ') center center / 100% 100% no-repeat' }" draggable>
- <ai-dialog :knowledgeId="selectKnowledgeId"></ai-dialog>
- </el-dialog>
- </div>
- </div>
- </template>
- <script lang="ts" setup name="CallScreen">
- import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
- import moment from 'moment';
- import { findKeyword } from '@/utils/trie';
- import { base64data } from '@/utils/baseUrlData'
- import aiDialog from '@/components/main/Navbar/cpns/aiDialog/aiDialog.vue'
- import {
- UserFilled,
- Phone,
- Message,
- Location,
- Document,
- Search,
- Timer,
- Switch,
- Service,
- CircleClose,
- Operation,
- Close,
- VideoCamera,
- Microphone,
- Connection,
- List,
- Notebook,
- Edit,
- Eleme,
- Loading
- } from '@element-plus/icons-vue';
- import { hidePhone } from '@/utils/tools';
- import { ElMessage } from 'element-plus';
- import useSelectStore from '@/store/commonSelect/common';
- import useKeysStore from'@/store/modules/keys'
- import { getPageListData, createPageData } from '@/api/main/system/system';
- import { userDecryptToAsterisk } from '@/utils/aes';
- import knowledgeList from "@/views/main/knowledgeBase/knowledgeList/cpns/konwlegelist/konwlegelist";
- import { flatten } from 'lodash';
- import { getOfffixNuber, getCallSate } from '@/utils/index';
- import { stripHtmlByRegex } from '@/utils/tools';
- const router = useRouter()
- let { proxy } = getCurrentInstance()
- const showCallPanel = ref(false);
- const searchQuery = ref('');
- const aiLoading = ref(false);
- const isCanAutoScroll = ref(1);
- const dialogVisible = ref(false);
- const showAsr = ref(import.meta.env.VITE_APP_AI_ASR === 'true');
- const showAI = ref(import.meta.env.VITE_APP_AI_SEARCH === 'true');
- // console.log(proxy.$route.query.callNumber)
- const telNumber = ref(proxy.$route.query.phone || proxy.$route.query.callNumber || proxy.$route.params.callNumber);
- console.log(telNumber.value)
- proxy.$route.meta.title = telNumber.value || '来电弹屏';
- const callid = ref(proxy.$route.query.callid || 0);
- const createLog = (content = '') => {
- createPageData('/ai/aioperation', {
- operateType: 'ASR',
- operationContent: content,
- }).then(() => {
- }).catch(() => {
- })
- }
- console.log(callid, 'callid')
- if (callid.value) createLog('callid');
- const showDialpad = ref(false);
- const showContacts = ref(false);
- const handleRecords = () => {
- // 处理通话记录
- };
- const workOrderSearchQuery = ref('');
- const activeWorkOrder = ref(-1);
- const currentPage = ref(1);
- const pageSize = ref(10);
- const displayedActivities = ref<any[]>([]);
- const loading = ref(false);
- const noMore = ref(false);
- const pageIndex = ref(1);
- const workOrderContainer = ref<HTMLElement | null>(null);
- const callContainer = ref(null)
- const loadMoreData = async () => {
- if (loading.value || noMore.value) return;
- loading.value = true;
- await new Promise(resolve => setTimeout(resolve, 500)); // 模拟加载延迟
- const res = await getWorkOrders();
- if (res?.length === 0) {
- noMore.value = true;
- } else {
- pageIndex.value++;
- }
- loading.value = false;
- };
- const handleScroll = async (event: Event) => {
- const target = event.target as HTMLElement;
- const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
- console.log(scrollBottom)
- if (scrollBottom < 50) {
- await loadMoreData();
- }
- };
- // 通话记录开始
- const callData = ref([])
- const callLoading = ref(false)
- const callNoMore = ref(false);
- const loadMoreCallData = async () => {
- if (callLoading.value || callNoMore.value) return;
- callLoading.value = true;
- // await new Promise(resolve => setTimeout(resolve, 500)); // 模拟加载延迟
- const res = await getCalllog();
- console.log(res, 'res')
- if (res?.length === 0) {
- callNoMore.value = true;
- } else {
- // displayedActivities.value.push(...newItems);
- callPageIndex.value++;
- }
- callLoading.value = false;
- };
- const handleCallScroll = async (event: Event) => {
- const target = event.target as HTMLElement;
- const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
- console.log(scrollBottom)
- if (scrollBottom < 50) {
- await loadMoreCallData();
- }
- };
- const callPageSize = ref(10)
- const callPageIndex = ref(1)
- const callSearchQuery = ref()
- const callAnswerTime = ref()
- const callHangupTime = ref()
- function callChange(e) {
- console.log(e)
- if (e.length > 0) {
- callAnswerTime.value = moment(e[0]).format('YYYY-MM-DD HH:mm:ss')
- callHangupTime.value = moment(e[1]).format('YYYY-MM-DD HH:mm:ss')
- }
- callPageIndex.value = 1
- callData.value = []
- getCalllog()
- }
- const getCalllog = async () => {
- if (!telNumber.value) return;
- const params: any = {
- phone: telNumber.value,
- pageSize: callPageSize.value,
- pageNum: callPageIndex.value,
- }
- if (callAnswerTime.value) {
- params.answerTime = callAnswerTime.value
- }
- if (callHangupTime) {
- params.hangupTime = callHangupTime.value
- }
- const result = await getPageListData('/call/calllog', params);
- console.log(result, 'callData');
- if (result.state === 'success') {
- callData.value = callData.value.concat(result.data)
- if (callData.value.length === 0) {
- callNoMore.value = true;
- } else {
- callNoMore.value = false;
- }
- return result.data;
- }
- return [];
- }
- function getTimeLimit(startTime, endTime) {
- return proxy.getTimeDifference(startTime, endTime);
- }
- // 通话记录结束
- onMounted(() => {
- loadMoreData();
- loadMoreCallData()
- });
- const activities = ref([]);
- const getTagType = (type: string) => {
- const typeMap: { [key: string]: string } = {
- primary: '',
- success: 'success',
- warning: 'warning',
- info: 'info',
- danger: 'danger'
- };
- return typeMap[type] || '';
- };
- const getStatusText = (type: string) => {
- const statusMap: { [key: string]: string } = {
- primary: '处理中',
- success: '已完成',
- warning: '待处理',
- info: '已完成',
- danger: '已取消'
- };
- return statusMap[type] || '';
- };
- const knowledgeBase = [];
- const isEditing = ref(false);
- const profile = ref({
- name: '-',
- sex: '-',
- registerDate: '',
- // lastContact: '',
- phone: '',
- tel: '',
- // email: 'zhaomq@example.com',
- address: ''
- });
- const formRef: any = ref(null);
- const form = ref({
- name: '不详',
- phone: '',
- type: '',
- typeCode: '',
- description: '',
- // priority: 'medium',
- handleMethod: '1',
- // remarks: '',
- department: '',
- });
- const rules = {
- name: [
- { required: true, message: '请输入姓名', trigger: 'blur' },
- { min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
- ],
- phone: [
- { required: true, message: '请输入手机号', trigger: 'blur' },
- // { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
- ],
- type: [
- { required: true, message: '请选择工单类型', trigger: 'change' }
- ],
- description: [
- { required: true, message: '请输入问题描述', trigger: 'blur' },
- { min: 5, max: 2000, message: '问题描述5-2000个字符', trigger: 'blur' },
- ],
- handleMethod: [
- { required: true, message: '请选择处理方式', trigger: 'change' }
- ],
- };
- const ticketTypes = useSelectStore().dictOrderTypeList;
- const departments = useSelectStore().deptListData;
- const handleMethods = [
- { label: '立即完结', value: '1' },
- { label: '待交办', value: '0' },
- // { label: '转专员处理', value: 'transfer' },
- // { label: '现场支持', value: 'onsite' }
- ];
- const getUserInfo = async () => {
- if (!telNumber.value) {
- return;
- }
- telNumber.value = getOfffixNuber(telNumber.value)
- profile.value.phone = telNumber.value;
- form.value.phone = telNumber.value;
- const userResult = await getPageListData('/patient/patient', { phoneNumber: telNumber.value });
- console.log(userResult);
- if (userResult?.data?.length) {
- const userInfo = userResult.data[0];
- profile.value = {
- name: userInfo.name,
- registerDate: userInfo.createTime,
- phone: userDecryptToAsterisk(userInfo.phoneNumber),
- address: userInfo.address,
- sex: userInfo.sex === 1 ? '男' : userInfo.sex == 0 ? '女' : '-',
- tel: userDecryptToAsterisk(userInfo.tel),
- };
- form.value.name = userInfo.name;
- }
- }
- const typeCascaderRef: any = ref(null);
- const handleTypeChange = (value) => {
- if (typeCascaderRef.value) {
- const selectNode = typeCascaderRef.value.getCheckedNodes();
- form.value.typeCode = selectNode[selectNode.length - 1].data.code;
- }
- }
- // 投诉部门切换
- const deptCascaderRef: any = ref(null);
- const deptChange = (value) => {
- // if (deptCascaderRef.value) {
- // const selectNode = deptCascaderRef.value.getCheckedNodes()[0];
- // form.value.department = selectNode.data.label;
- // }
- }
- const resetForm = () => {
- form.value = {
- name: '',
- phone: '',
- type: '',
- typeCode: '',
- description: '',
- handleMethod: '1',
- department: '',
- };
- formRef.value.resetFields();
- }
- const submit = async () => {
- // 验证表单必填项
- formRef.value.validate(async (valid) => {
- if (valid) {
- const params: any = {
- caller: form.value.name,
- callnum: form.value.phone,
- content: form.value.description,
- customerno: form.value.phone,
- firsttype: 1000,
- isend: +form.value.handleMethod,
- workordercate: form.value.type[form.value.type.length - 1],
- };
- if (callid.value) params.callId = callid.value;
- // 提交表单
- createPageData('/order/workorder', params).then((data) => {
- console.log(data, 'submit');
- if (data.state === 'success') {
- ElMessage.success('提交成功');
- transcripts.value = []
-
- resetForm()
- close()
- } else {
- ElMessage.error(data.message || '提交失败');
- }
- })
- } else {
- ElMessage.error('请填写完整信息');
- }
- });
- }
- /** 关闭按钮 */
- function close() {
- const obj = { path: '/' }
- proxy.$tab.closeOpenPage(obj)
- }
- const orderSearchQuery = ref()
- const createtime = ref()
- const endtime = ref()
- function orderChange(e) {
- console.log(e)
- if (e.length > 0) {
- createtime.value = moment(e[0]).format('YYYY-MM-DD HH:mm:ss')
- endtime.value = moment(e[1]).format('YYYY-MM-DD HH:mm:ss')
- }
- pageIndex.value = 1
- displayedActivities.value = []
- getWorkOrders()
- }
- const getWorkOrders = async () => {
- if (!telNumber.value) return;
- const params: any = {
- listType: 0,
- callnum: telNumber.value,
- pageSize: pageSize.value,
- pageNum: pageIndex.value,
- }
- if (createtime.value) {
- params.createtime = createtime.value
- }
- if (endtime.value) {
- params.endtime = endtime.value
- }
- const result = await getPageListData('/order/workorder', params);
- console.log(result, 'getWorkOrders');
- if (result.state === 'success') {
- displayedActivities.value = displayedActivities.value.concat(result.data)
- console.log(displayedActivities.value, 'activities');
- if (displayedActivities.value.length === 0) {
- noMore.value = true;
- } else {
- noMore.value = false;
- }
- return result.data;
- }
- return [];
- }
- const tabChange = (name) => {
- console.log(name, 'tabChange');
- if (name === '1') {
- pageIndex.value = 1
- callPageIndex.value = 1
- callData.value = []
- getCalllog();
- } else if (name === '2') {
- pageIndex.value = 1
- displayedActivities.value = []
- getWorkOrders();
- }
- }
- // 语音识别状态
- const voiceStatus = ref('active');
- // 识别记录
- const transcripts: any = ref([
- // {
- // direction: 1,
- // timestamp: '14:30:24',
- // page_content: '你好,中国热线请假。',
- ])
- // 关键词提示
- const keywords: any = ref([
- // {
- // text: '强迫症',
- // type: 'primary',
- // count: 0,
- // },
-
- ]);
- // 建议话术
- const suggestions = ref([
- '很高兴能帮到您,如果还有其他问题随时询问',
- '这个功能确实很实用,您可以根据实际需求来调整配置',
- '感谢您的正面反馈,我们会继续努力提供更好的服务',
- '建议您也可以查看我们的帮助文档,里面有更详细的使用说明'
- ]);
- const transcriptContainer = ref<HTMLElement | null>(null);
- // 监听新消息,自动滚动到底部
- // 监听新消息和内容变化,自动滚动到底部
- // watch(
- // [
- // () => transcripts.value.length,
- // () => transcripts.value.map(t => t.page_content)
- // ],
- // () => {
- // nextTick(() => {
- // console.log(transcriptContainer.value, 'transcriptContainer');
- // if (transcriptContainer.value) {
- // transcriptContainer.value.scrollTop = transcriptContainer.value.scrollHeight;
- // }
- // });
- // },
- // { deep: true }
- // );
- window.addEventListener('AsrMessageEvent', (msg: any) => {
- console.log(msg.detail, '接收asr消息');
- if (!showAsr) return;
- if (!msg?.detail || !msg.detail.Result) return;
- const msgInfo = {
- direction: msg.detail.Number.toString().length > 4 ? 2 : 1,
- page_content: msg.detail.Speech || '',
- timestamp: '',
- };
- console.log(msg.detail.Time, 'msgInfo');
- if (msg.detail.Time) {
- const times = JSON.parse(msg.detail.Time);
- console.log(times, 'msgInfo');
- if (times?.length) {
- const time = flatten(times)?.[0];
- if (time) msgInfo.timestamp = formatMilliseconds(time);
- }
- }
- console.log(msgInfo, 'msgInfo');
- if (msgInfo.page_content.length > 2) checkKeys(msgInfo.page_content);
- transcripts.value.push(msgInfo);
- })
- // function formatMilliseconds(milliseconds) {
- // const totalSeconds = Math.floor(milliseconds / 1000);
- // const hours = Math.floor(totalSeconds / 3600).toString().padStart(2, '0');
- // const minutes = Math.floor((totalSeconds % 3600) / 60).toString().padStart(2, '0');
- // const seconds = (totalSeconds % 60).toString().padStart(2, '0');
- // return `${minutes}:${seconds}`;
- // }
- const aiSubmit = async () => {
- aiLoading.value = true;
- let text = transcripts.value.map((o) => o.page_content).join('');
- if (text?.length > 10) {
- const res = await getSearchDocs(text);
- console.log(res, 'res');
-
- res.split('\n').forEach((o) => {
- if (o.startsWith('【内容总结】')) form.value.description = o.replace('【内容总结】', '').replace(':', '');
- // if (o.startsWith('【事件地址】')) form.value.address = o.replace('【事件地址】', '').replace(':', '');
- // if (o.startsWith('【姓名】')) form.value.customerName = o.replace('【姓名】', '').replace(':', '');
-
- })
- aiLoading.value = false;
-
- } else {
- aiLoading.value = false;
- }
- }
- onMounted(() => {
- transcripts.value = [];
- getUserInfo();
- });
- onUnmounted(() => {
- });
- async function getSearchDocs (text) {
- const params = {
- "model": "deepseek-r1:32b",
- "messages": [
- {
- "role": "system",
- "content": `请从以下咨询对话中提取关键信息生成结构化工单:
- 地理位置提取:识别来访者明确提到的居住地或当前位置
- 格式:[省/市/区/街道/楼栋号/房间号](如未提及则标记"未知")
- 注意模糊表述(如"北方城市""江浙地区"等需保留原话)
- 通话内容总结:
- 用第三人称概括核心问题(100字到2000字之间)
- 保留以下关键要素:
- • 主要症状描述(情绪/躯体/行为表现)
- • 持续时间(使用"约X周/月/年"格式)
- • 社会功能影响(工作/学习/人际关系)
- • 既往病史(如提及)
- 过滤无关对话(寒暄、重复表述等)
- 工单类型分类:
- 根据DSM-5标准匹配最相关分类(单选):
- [抑郁障碍] [焦虑障碍] [强迫症] [创伤应激] [人格障碍] [适应障碍] [人际关系] [发展性问题] [其他]
- 【处理规范】
- 优先识别直接症状陈述(如"失眠三个月""不敢见人")
- 注意隐喻表达(如"心里压着石头""像被困住")
- 存在自伤/伤人表述时自动触发危机预警协议
- 多问题并存时按主诉优先级排序
- 示例对话:
- [咨询师]:可以说说最近困扰您的事吗?
- [来访者]:我在杭州工作两年了,最近三个月每天失眠,开会时手抖得厉害,上周在地铁里突然喘不过气...
- 示例输出:
- 【地理位置】:浙江省杭州市
- 【内容总结】:来访者诉持续三个月失眠症状,伴有社交场合手抖等躯体化表现,提及近期出现惊恐发作经历(地铁喘不过气),社会功能受损(工作受影响)
- 【工单类型】: 焦虑障碍`
- }, {
- "role": "user",
- "content": text
- }
- ],
- "stream": true
- };
- try {
- // 发送请求
- const url = import.meta.env.VITE_APP_AI_API || 'https://open.bigmodel.cn/api/paas/v4/chat/completions'
- let response = await fetch(url,
-
- {
- method: "post",
- responseType: "stream",
- headers: {
- "Content-Type": "application/json",
- "Authorization": `Bearer ${import.meta.env.VITE_APP_AI_API_KEY}`
- },
- body: JSON.stringify(params),
- }
- );
- let resultStr = '';
- // ok字段判断是否成功获取到数据流
- if (!response.ok) {
- throw new Error("Network response was not ok");
- }
- // 用来获取一个可读的流的读取器(Reader)以流的方式处理响应体数据
- const reader = response.body.getReader();
- // 将流中的字节数据解码为文本字符串
- const textDecoder = new TextDecoder();
- let result = true;
- let sqlValue = ''
- while (result) {
- // done表示流是否已经完成读取 value包含读取到的数据块
- const { done, value } = await reader.read();
- if (done) {
- result = false;
- // console.log(resultStr, 'resultStr');
- return resultStr;
- break;
- }
- // 拿到的value就是后端分段返回的数据,大多是以data:开头的字符串
- // 需要通过decode方法处理数据块,例如转换为文本或进行其他操作
- const chunkText = textDecoder.decode(value).split("\n").forEach((val) => {
- if (!val) return;
- try {
- // 后端返回的流式数据一般都是以data:开头的字符,排除掉data:后就是需要的数据
- // 具体返回结构可以跟后端约定
- let text = val?.replace("data:", "") || ""
- const resultData = JSON.parse(text)
- let resultText = resultData.choices[0].delta.content
-
- resultStr += resultText
- } catch (err) {
- // console.log(err)
- }
- });
- }
- } catch (err) {
- console.log(err)
- return err;
- }
- }
- // 推荐知识
- const recommendedKnowledge: any = ref([]);
- const keys = useKeysStore().knowledgeKeys;
- console.log(keys, 'keys')
- const getKnowledgeBaseList = (key) => {
- if (key.length < 2) {
- return
- }
- suggestions.value = []
-
- getPageListData('/km/doc', {
- keywords: key,
- pageNum: 1,
- pageSize: 5,
- }).then(({ data, total }) => {
- recommendedKnowledge.value = data;
- });
- }
- const checkKeys = (text: string) => {
- const list = findKeyword(text, keys);
- if (list?.length) {
- setKeys(list)
- }
- }
- const setKeys = (keys: Array<string>) => {
- const types = ['primary', 'success', 'warning', 'danger'];
- for (const key of keys) {
- const index = keywords.value.findIndex((item) => item.text === key);
- if (index > -1) {
- keywords.value[index].count++;
- keywords.value[index].type = types[keywords.value[index].count % types.length];
- } else {
-
- keywords.value.push({
- text: key,
- type: 'primary',
- count: 1,
- });
- }
- }
- keywords.value.sort((a, b) => b.count - a.count);
- }
- const selectKnowledgeId = ref(0);
- const openKnowledgeAi = (id) => {
- selectKnowledgeId.value = id;
- dialogVisible.value = true;
- }
- const chatMouse = (type) => {
- isCanAutoScroll.value = type;
- }
- const scrollToBottom = () => {
- if (transcriptContainer.value) {
- transcriptContainer.value.scrollTop = transcriptContainer.value.scrollHeight;
- }
- }
- watch(transcripts, () => {
- nextTick(() => {
- if (isCanAutoScroll.value) scrollToBottom();
- })
- }, {
- deep: true
- })
- </script>
- <style scoped lang="scss">
- * {
- transition: height 0.3s ease-out;
- }
- .scrollbar-hide {
- scrollbar-width: none;
- -ms-overflow-style: none;
- }
- .scrollbar-hide::-webkit-scrollbar {
- display: none;
- }
- .slide-enter-active,
- .slide-leave-active,
- .container {
- transition: all 0.3s ease-out;
- }
- .slide-enter-from,
- .slide-leave-to {
- transform: translateX(-20px);
- opacity: 0;
- }
- :deep(.el-avatar) {
- background-color: #f0f2f5;
- }
- :deep(.el-input__wrapper) {
- /* box-shadow: none !important;
- border: none !important; */
- }
- :deep(.el-button.is-circle) {
- width: 3rem;
- height: 3rem;
- }
- .el-tabs {
- height: calc(100vh - 8rem);
- transition: height 0.3s ease-out;
- }
- .el-timeline {
- max-height: calc(100vh - 12rem);
- overflow-y: auto;
- }
- :deep(.el-tabs__content) {
- height: calc(100% - 40px);
- overflow-y: auto;
- transition: height 0.3s ease-out;
- }
- :deep(.el-form-item__label) {
- font-weight: 500;
- }
- :deep(.el-button.is-circle) {
- width: 2.5rem;
- height: 2.5rem;
- }
- :deep(.el-icon) {
- font-size: 1.25rem;
- }
- .aiDialog {
- z-index: 2033;
- pointer-events: none;
- :deep(.el-dialog) {
- position: absolute;
- height: 700px;
- top: 50px;
- right: 0;
- margin: 0;
- left: unset;
- pointer-events: auto;
- }
- }
- /* 隐藏所有滚动条 */
- * {
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* Internet Explorer 10+ */
- }
- /* 针对Webkit浏览器(如Chrome和Safari) */
- *::-webkit-scrollbar {
- display: none;
- }
- </style>
|