You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
customAgent/src/components/chat-main.vue

729 lines
23 KiB

<template>
<div class="chat wrapper">
<!-- <nav-bar :isWX="isWX" :tokenExist="tokenExist" @isNoticeHandle="isNoticeFn"></nav-bar> -->
<!-- 内容部分 -->
<div class="app-content" @touchend.stop="handleAppCont" ref="appContent" :class="elder ? 'elder-content' : ''">
<!--mescroll滚动区域的基本结构,为避免id重复导致的多次初始化,这里使用ref-->
<div ref="mescrollEl" class="mescroll" :class="{ mescrollTopPx: isWX, isNoticeShow: isNoticeShow }"
:style="{ top: ((source == '95290000' && appentry == '1') || notitle === '1') ? '0' : '' }">
<!-- 下拉历史记录 --->
<div class="app-content-main" v-if="tokenExist" :style="{
'padding-top':
tokenExist &&
showHotAndWelcome &&
(userInfo.eldCustomer || userInfo.vip)
? '10px'
: '20px',
}">
<div class="empty-tips opacity" v-if="nolist">{{ nolistTxt }}</div>
<div class="historicRecords" v-if="historylist.length > 0">
<chat-content v-if="tokenExist" :historylist="historylist" :isWX="isWX" :tokenExist="tokenExist"
:lostChatInfoNum="lostChatInfoNum"></chat-content>
</div>
<div class="this-news-box opacity" v-if="historylist.length > 0">
<div class="this-news-tips">以下是本次消息</div>
</div>
<!-- 是否老年人 -->
<!-- userInfo.eldCustomer || userInfo.vip 里面的的 btnType -->
<geriatric-discourse v-if="
tokenExist &&
showHotAndWelcome &&
(userInfo.eldCustomer || userInfo.vip)
"></geriatric-discourse>
<!--欢迎语...-->
<welcome-box v-if="tokenExist && showHotAndWelcome" @welcomeBtnChoose="handleWelcomeBtnChoose"></welcome-box>
<quick-access v-if="tokenExist && showHotAndWelcome && elder"></quick-access>
<!-- 热点问题 -->
<hot-question v-if="tokenExist && showHotAndWelcome"
@hotQuestionChoose="handleHotQuestionChoose"></hot-question>
<!-- 快捷入口 -->
<quick-access v-if="tokenExist && showHotAndWelcome && !elder"></quick-access>
<!-- 业务处理 转办专途-->
<business-handle v-if="
tokenExist &&
manualService != '2' &&
businessHandleInfo &&
(source == 95270000 || source == 95320000 || source == 95350000 || source == 95810000)
" :businessHandleInfo="businessHandleInfo"></business-handle>
<!--当前聊天记录 -->
<!--- 发送消息 -->
<send-message v-if="tokenExist" :isWX="isWX" :tokenExist="tokenExist" :lostChatInfoNum="lostChatInfoNum"
@actionSheetMethod="actionSheetMethod" @handleAppraise="appraiseIsClose"></send-message>
<!-- 接收消息--->
<!-- 保全测算&领取场景: 回到领取 悬浮窗 -->
<div v-if="isBqCalcClaim && manualService != 2 && manualService != '2'" class="back-to-claim-float"
@click="handleBackToClaim">
<span class="back-to-claim-text">回到领取</span>
</div>
</div>
</div>
</div>
<!-- 底部模块 -->
<div class="app-footer" ref="footerHeight">
<component :is="currentFooter" ref="footerComponent" v-if="tokenExist" :tokenExist="tokenExist"></component>
</div>
<!-- 留言 -->
<leave-message :isMessageShow="isLeaveMessage" v-if="isLeaveMessage" :tokenExist="tokenExist"></leave-message>
<!-- 是否删除popu -->
<van-action-sheet v-model:show="actionShow" :actions="actionsOptions" :duration="0.2" cancel-text="取消"
description="您要删除此条消息吗?" @select="actionSelect" teleport="body" />
<!-- 问题反馈 -->
<van-popup class="problem-feedback-popup" v-model:show="problemShow" teleport="body" position="bottom" duration="0.2"
round :close-on-click-overlay="false" safe-area-inset-bottom>
<problem-feedback @handleClose="clickClose"></problem-feedback>
</van-popup>
<!-- 评价 解决没有帮助 -->
<van-overlay :show="appraiseShow" z-index="10" class-name="appraise-popup-box" duration="0.2">
<appraise-popup v-if="appraiseShow" @handleAppraiseClose="appraiseIsClose"
:baseHandle="baseHandle"></appraise-popup>
</van-overlay>
<!-- 高温用户弹窗 -->
<hot-user-popup class="hot-user-popup" v-model:show="hotUserPopupShow"
@close="handleHotUserPopupClose"></hot-user-popup>
</div>
</template>
<script setup>
// 原 Vue2 Options API 组件 chat-main(keep-alive 缓存名沿用 "chat")
defineOptions({ name: 'chat' })
import { ref, computed, watch, onMounted, onUnmounted, onActivated, nextTick } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { storeToRefs } from 'pinia'
import { showToast } from 'vant'
import wx from 'weixin-js-sdk'
import _ from 'lodash'
import { useAppStore } from '@/store/app.js'
import footerBar from '@/components/footer-bar.vue'
import footerBarInput from '@/components/footer-bar-input.vue'
import MeScroll from 'mescroll.js'
import 'mescroll.js/mescroll.min.css'
import WelcomeBox from '@/components/welcomes.vue'
import QuickAccess from '@/components/quick-access.vue'
import HotQuestion from '@/components/hot-question.vue'
import LeaveMessage from '@/components/leave-message.vue'
import ChatContent from '@/components/chat-content.vue'
import SendMessage from '@/components/send-message.vue'
import problemFeedbackComp from '@/components/problem-feedback.vue'
import GeriatricDiscourse from '@/components/geriatric-discourse.vue'
import appraisePopup from '@/components/appraise-popup.vue'
import NavBar from '@/components/nav-bar.vue'
import BusinessHandle from '@/components/business-handle.vue'
import HotUserPopup from '@/components/hot-user-popup.vue'
import { getHiddenProp, getVisibilityState } from '@/utils'
import { emitter } from '@/utils/mitt'
import Utils from '@/utils/utils'
import { fetchPost } from '@/utils/https'
import wsConnection from '@/utils/socket'
import { channel95500, SJHLCmdList } from '@/utils/constant'
// 投诉直连渠道号(原 Vue.prototype.$webSocketChannel)
const webSocketChannel = '95460000'
const props = defineProps({
tokenExist: { type: Boolean, default: false },
isWX: { type: Boolean, default: false },
lostChatInfoNum: { type: [Number, String], default: 0 },
})
// —— Pinia store(原 mapState / mapMutations / this.$store)——
const store = useAppStore()
const {
userId,
source,
sessionId,
mescrollContent,
isLeaveMessage,
chatList,
isLostMessage,
manualService,
complaint,
navigationCorpus,
problemFeedback,
userInfo,
appentry,
notitle,
hotUser,
hotUserData,
integratedUser,
isBqCalcClaim,
elder,
} = storeToRefs(store)
// —— 本地状态(原 data)——
const footer_bar_type = ref('footerBar') //footerBar footerBarInput
const historylist = ref([]) //历史列表
const pageNum = ref(1) //当前页
const pageSize = ref(10) //每页条数
const nolist = ref(false)
const nolistTxt = ref('没有更多数据了')
const actionShow = ref(false)
const delItemIndex = ref(0) //删除的索引
const delFailId = ref(0) //删除发送失败的消息
const actionsOptions = [{ name: '删除', color: '#d81e06' }]
const problemShow = ref(false)
const isNoticeShow = ref(false)
const showHotAndWelcome = ref(false) //是否隐藏欢迎语和热点问题
const appraiseShow = ref(false) //评价 弹层
const baseHandle = ref('') //基础的事件 置灰
const businessHandleInfo = ref('') // business-handle 数据详情
const hotUserPopupShow = ref(false) // 高温用户弹窗
// —— 模板 ref ——
const appContent = ref(null)
const mescrollEl = ref(null)
const footerHeight = ref(null)
const footerComponent = ref(null)
// mescroll 实例(非响应式)
let mescroll = null
// 动态底部组件映射(原 <component :is="footer_bar_type">,字符串名注册在 Vue3 已失效)
const footerComps = { footerBar, footerBarInput }
const currentFooter = computed(() => footerComps[footer_bar_type.value] || footerBar)
// 判断是否应该显示高温用户弹窗
const shouldShowHotUserPopup = computed(() => hotUser.value && hotUserData.value)
// 判断第一次进入是否是人工 //隐藏欢迎语和热点问题(原 created)
if (manualService.value == 2) {
showHotAndWelcome.value = false
} else {
showHotAndWelcome.value = true
}
if ((source.value == '95290000' || source.value == '95460000' || source.value == '95830000') && elder.value) {
showHotAndWelcome.value = false
}
// —— keep-alive 激活:恢复滚动位置(原 activated + beforeRouteEnter 的恢复逻辑合并于此)——
onActivated(() => {
nextTick(() => {
window.scrollTo(0, mescrollContent.value)
})
if (mescroll) {
// 恢复到之前设置的isBounce状态
if (mescroll.lastBounce != null) mescroll.setBounce(mescroll.lastBounce)
// 滚动到之前列表的位置 (注意:路由使用keep-alive才生效)
if (mescroll.lastScrollTop) {
mescroll.setScrollTop(mescroll.lastScrollTop)
setTimeout(() => {
// 需延时,因为setScrollTop内部会触发onScroll,可能会渐显回到顶部按钮
mescroll.setTopBtnFadeDuration(0) // 设置回到顶部按钮显示时无渐显动画
}, 16)
}
}
})
// —— 离开路由时,记录列表状态(原 beforeRouteLeave)——
onBeforeRouteLeave(() => {
if (mescroll) {
mescroll.lastBounce = mescroll.optUp.isBounce // 记录当前是否禁止ios回弹
mescroll.setBounce(false) // 允许bounce
mescroll.lastScrollTop = mescroll.getScrollTop() // 记录当前滚动条的位置
mescroll.hideTopBtn(0) // 隐藏回到顶部按钮,无渐隐动画
}
})
// —— 页面显示隐藏监听(原 mounted 中注册)——
let visibilityHandler = null
function bindVisibilityChange() {
const visProp = getHiddenProp()
if (!visProp) return
const evtname = visProp.replace(/[H|h]idden/, '') + 'visibilitychange'
visibilityHandler = () => {
const isHidden = document[getVisibilityState()]
console.log('页面显示隐藏的状态', isHidden, manualService.value)
if (isHidden === 'visible' && manualService.value == 2) {
//重新下拉历史记录
nolistTxt.value = ''
pageNum.value = 1
historylist.value = []
store.delall_chatList() //清空当前聊天消息
showHotAndWelcome.value = false //隐藏欢迎语和热点问题
//主动触发一次
mescroll && mescroll.triggerDownScroll()
}
}
document.addEventListener(evtname, visibilityHandler, false)
}
onMounted(() => {
nextTick(() => {
initMescroll()
//向子input组件传个方法(原 Bus.$emit)
emitter.emit('mescroll', mescrollEl.value)
store.set_mescrollContent(mescrollEl.value)
// 页面显示隐藏
bindVisibilityChange()
// 95500渠道进入主动提问
setTimeout(() => {
if (complaint.value && source.value === channel95500 && manualService.value != '2') {
const param = {
reqAndResType: '1', //1文本 2图片 3问提评价
msgText: complaint.value,
navigationCorpus: navigationCorpus.value !== 'undefined' ? navigationCorpus.value : '',
msgSource: manualService.value, //1机器人 2人工
}
store
.complaintCall(param)
.then(() => {
// console.log("95500渠道进入主动提问成功");
})
.catch((err) => {
console.log(err)
})
}
}, 800)
//客户之声 主动调用一次投诉接口
setTimeout(() => {
if (props.tokenExist && complaint.value && source.value === '95320000' && manualService.value != '2') {
const param = {
reqAndResType: '1', //1文本 2图片 3问提评价
msgText: complaint.value,
msgSource: manualService.value, //1机器人 2人工
}
store
.complaintCall(param)
.then(() => {
console.log('投诉成功')
})
.catch((err) => {
console.log(err)
})
}
}, 800)
if (manualService.value != 2) {
console.log('===================主动服务触发')
businessHandleFn()
} else {
console.log('===================主动服务未触发')
}
})
})
onUnmounted(() => {
//页面销毁时关闭长连接(原 destroyed)
try {
if (wsConnection.$ws) {
wsConnection.$ws.close()
}
} catch (error) {
console.log(error.message)
}
//移除页面显示隐藏监听
if (visibilityHandler) {
const visProp = getHiddenProp()
if (visProp) {
const evtname = visProp.replace(/[H|h]idden/, '') + 'visibilitychange'
document.removeEventListener(evtname, visibilityHandler, false)
}
visibilityHandler = null
}
})
// —— 方法(原 methods)——
// 保全测算&领取场景: 回到领取
function handleBackToClaim() {
try {
if (typeof wx !== 'undefined' && wx.miniProgram && typeof wx.miniProgram.navigateBack === 'function') {
wx.miniProgram.navigateBack({ delta: 1 })
} else if (window.wx && window.wx.miniProgram && typeof window.wx.miniProgram.navigateBack === 'function') {
window.wx.miniProgram.navigateBack({ delta: 1 })
} else {
window.history.back()
}
} catch (error) {
console.log('navigateBack异常:', error)
window.history.back()
}
}
function actionSheetMethod(show, delIndex, failId) {
delItemIndex.value = delIndex
delFailId.value = failId
actionShow.value = show
}
function actionSelect(item) {
store.del_chatList(delItemIndex.value)
store.del_set_sendFailList(delFailId.value)
actionShow.value = false
}
function isNoticeFn(val) {
isNoticeShow.value = val
}
function changePresonMob() {
let baseSize = document.querySelector('html').style.fontSize
if (baseSize) {
baseSize = baseSize.replace('px', '')
document.querySelector('html').style.fontSize = baseSize * 1.135 + 'px'
}
}
//关闭 表情和上传图片部分
function handleAppCont() {
const fc = footerComponent.value
if (!fc) return
if (typeof fc.offEmoticonsPictures === 'function') {
fc.offEmoticonsPictures()
} else if (typeof fc.offEmoticonsPic === 'function') {
fc.offEmoticonsPic()
}
}
function initMescroll() {
let isLostMessageFlag =
isLostMessage.value && (isLostMessage.value == '1' || isLostMessage.value == '3') ? true : false
console.log('是否下拉刷新', isLostMessage.value)
// 投诉直连渠道首次进入需要刷新
const isTouSuZhiLian = source.value === webSocketChannel || source.value === webSocketChannel
isLostMessageFlag = isTouSuZhiLian ? true : isLostMessageFlag
//创建MeScroll对象
mescroll = new MeScroll(mescrollEl.value, {
//在mounted初始化mescroll,确保此处配置的ref有值
//下拉刷新的配置
down: {
isBounce: false,
auto: isLostMessageFlag,
callback: upCallback,
htmlContent: '<p class="downwarp-progress"></p><p class="downwarp-tip">没有数据了</p>',
hardwareClass: 'mescroll-hardware',
warpClass: 'mescroll-downwarp', //下拉刷新的布局容器样式
offset: 70, // 触发刷新的距离,默认80
mustToTop: false,
autoShowLoading: true, // 如果在初始化完毕之后自动执行下拉回调,是否显示下拉刷新进度; 默认false
textInOffset: '下拉加载历史记录', // 下拉的距离在offset范围内的提示文本
textOutOffset: '释放更新', // 下拉的距离大于offset范围的提示文本
textLoading: '加载中 ...', // 加载中的提示文本
onMoving: upOnMoving,
},
up: {
use: false,
},
})
}
function upOnMoving(mescroll, rate, downHight) {
nolist.value = false
}
//下拉回调 拉取历史记录
function upCallback(msc) {
const params = {
page: pageNum.value,
pageSize: pageSize.value,
sessionId: sessionId.value,
userId: userId.value,
}
// console.log("历史消息入参", params);
setTimeout(() => {
fetchPost('history/chatInfo', params)
.then((response) => {
// console.log("历史列表====", response)
if (response.code && response.code != 0) {
showToast(response.msg)
return
}
//成功返回
if (response.data.list.length > 0) {
const dataList = response.data.list
pageNum.value++
// 先隐藏下拉刷新的状态
mescroll.endSuccess()
// 不满一页,说明已经无更多消息
if (dataList.length < pageSize.value) {
nolist.value = true // 标记已无更多消息
nolistTxt.value = '没有更多数据了'
}
// 生成VIEW_ID,大写,避免污染源数据
dataList.forEach((val) => {
val.VIEW_ID = 'msg' + val.timestamp // 不以数字开头
})
// 获取当前最顶部的VIEW_ID (注意是写在data.concat前面)
let topMsg = historylist.value[0]
//处理隐藏的数据
if (topMsg && !topMsg.msgText) {
topMsg = historylist.value[1]
if (topMsg && !topMsg.msgText) {
topMsg = historylist.value[2]
}
}
//设置列表数据
historylist.value = dataList.concat(historylist.value) // 注意不是this.msgList.concat
_.map(historylist.value, (item) => {
// 历史记录不展示数据互联
if (SJHLCmdList.includes(item.cmd) && !_.isEmpty(item.VIEW_ID)) {
item.msgText = ''
}
// 历史问句去掉&符号
if (item.fromFlag === 1 && item.msgText.indexOf('&') !== -1) {
item.msgText = item.msgText.replace(/&/g, '')
}
})
//更新未读状态
updateReadStatusFn(dataList)
nextTick(() => {
if (pageNum.value <= 2) {
if (isLostMessage.value === 3) {
//提示多少条未读消息
showToast('您有新消息未读,请及时查看历史消息')
}
setTimeout(() => {
mescroll.scrollTo(99999, 0)
}, 150)
} else if (topMsg) {
// 保持顶部消息的位置
const view = document.querySelector('#' + topMsg.VIEW_ID)
console.log(topMsg.VIEW_ID + ' 节点离页面顶部的距离=' + view.offsetTop)
setTimeout(() => {
mescroll.scrollTo(view.offsetTop - 20, 0) // 减去上偏移量20
}, 150)
}
})
} else {
mescroll.endErr()
nolist.value = true
nolistTxt.value = pageNum.value === 1 ? '当前还没有您的聊天记录' : '没有更多数据了'
}
})
.catch((e) => {
pageNum.value--
mescroll.endErr()
nolist.value = true
})
}, 600)
}
//未读消息提醒
function unreadTips() {
const param = {
fromFlag: 'unreadtips',
msgText: '以下是您的未读消息',
}
historylist.value.forEach((item, index) => {
if (item.fromFlag === 'unreadtips') {
historylist.value.splice(index, 1)
}
})
historylist.value.splice(0, 0, param)
}
//欢迎语按钮
function handleWelcomeBtnChoose(item) {
// console.log("欢迎语", item)
}
//热门问题选择
function handleHotQuestionChoose(item) {
// console.log("热门问题", item)
}
//关闭页面
function closeBack() {
Utils.closeWindow()
}
function clickClose(val) {
problemShow.value = val
store.set_problemFeedback(val)
}
//更新未读状态
function updateReadStatusFn(datalist) {
if (isLostMessage.value != 1) {
//是不是当通会话
return
}
if (datalist && datalist.length > 0) {
const readedStatus = []
datalist.forEach((item) => {
if (item.chatId) {
if (sessionId.value == item.sessionId && item.fromFlag == 2) {
readedStatus.push(item.chatId)
}
}
})
//提交到后台
store
.updateReadStatus(readedStatus)
.then(() => {
console.log('更新成功', readedStatus)
})
.catch((err) => {
console.log(err)
})
}
}
function appraiseIsClose(val, param) {
// console.log('评价解决没有帮助', val)
appraiseShow.value = val
baseHandle.value = param || ''
}
// 高温用户弹窗关闭
function handleHotUserPopupClose() {
hotUserPopupShow.value = false
}
// 业务处理 转办专途 主动服务
function businessHandleFn() {
console.log('source', source.value)
if (
source.value == '95270000' ||
source.value == '95320000' ||
source.value == '95350000' ||
source.value == '95810000'
) {
fetchPost('notify/info', { userInfoId: userId.value })
.then((res) => {
console.log(res)
if (res.data.contents.length > 0) {
businessHandleInfo.value = res.data
}
})
.catch((error) => {
console.log(error)
})
}
}
// —— watch(原 watch)——
watch(problemFeedback, (newVal) => {
if (newVal) {
problemShow.value = true
}
})
watch(manualService, (newVal, oldVal) => {
if (oldVal == '2') {
if (integratedUser.value) {
return
}
}
if (newVal) {
if (newVal == 2) {
showHotAndWelcome.value = false
} else {
showHotAndWelcome.value = true
}
}
})
// 监听高温用户弹窗显示条件
watch(
shouldShowHotUserPopup,
(newVal) => {
if (newVal) {
hotUserPopupShow.value = true
}
},
{ immediate: true }
)
</script>
<style scoped>
@import "@/assets/style/chat.css";
.historylist-box {
font-size: 16px;
text-align: center;
}
.empty-tips {
font-family: PingFangSC-Regular;
font-size: 11px;
line-height: 16px;
color: #666666;
text-align: center;
padding: 0 0 15px 0;
}
.van-overlay.appraise-popup-box {
background: rgba(0, 0, 0, 0.5) !important;
}
.elder-content :deep(.downwarp-tip),
.elder-content :deep(.empty-tips) {
font-family: PingFSC-Medium;
font-size: 18px;
color: #7e849f;
}
/* 保全测算&领取场景 右下角悬浮窗样式 */
.back-to-claim-float {
position: fixed;
right: 16px;
bottom: 85px;
z-index: 99;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
background: linear-gradient(135deg, #0076ff 0%, #0052d9 100%);
color: #ffffff;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 82, 217, 0.35);
font-family: PingFangSC-Medium, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.back-to-claim-float:active {
transform: scale(0.95);
box-shadow: 0 2px 8px rgba(0, 82, 217, 0.25);
opacity: 0.9;
}
.back-to-claim-text {
line-height: 1.2;
white-space: nowrap;
}
</style>