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-new.vue

735 lines
21 KiB

<script setup>
import { ref, computed, inject, watch, nextTick, onMounted, onActivated, onDeactivated } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { showToast } from 'vant'
import _ from 'lodash'
import { showImagePreview } from 'vant'
import xss from 'xss'
import footerBar from '@/components/footer-bar.vue'
import hotQuestionNew from '@/components/hot-question-new.vue'
import welcomes from '@/components/welcomes.vue'
import abstract from '@/components/abstract.vue'
import chatContent from '@/components/chat-content.vue'
import appraisePopup from '@/components/appraise-popup.vue'
import DynamicImage from '@/components/common/DynamicImage.vue'
import bussinessHandle from '@/components/business-handle.vue'
import imageService from '@/utils/imageService'
import { messageSend } from '@/utils/api'
import { Post } from '@/utils/https'
import { SJHLCmdList } from '@/utils/constant'
defineOptions({
name: 'chat-new',
})
const { isWX, tokenExist, lostChatInfoNum } = defineProps({
isWX: {
type: Boolean,
default: false,
},
tokenExist: {
type: Boolean,
default: false,
},
lostChatInfoNum: {
type: Number,
default: 0,
},
})
const store = inject('appStore', null)
const chatArr = inject('chatArr', ref([]))
const isChatLoading = inject('isChatLoading', ref(false))
const sessionId = inject('sessionId', ref(''))
const userId = inject('userId', ref(''))
const isLostMessage = inject('isLostMessage', ref(null))
const mainContainerRef = ref(null)
const appraiseShow = ref(false) // 有帮助/没帮助评价弹层
const baseHandle = ref('') // 评价弹层基础数据(含被点按钮 event)
function appraiseIsClose(val, param) {
appraiseShow.value = val
baseHandle.value = param || ''
}
// 参考 liics/chat-main 的 tokenExist:token 获取成功后才渲染聊天主内容
const historylist = ref([])
const historyLoading = ref(false)
const historyReady = ref(true)
const introReadyCount = ref(0)
const businessHandleRef = ref(null)
const businessClick = () => {
businessHandleRef.value.swiperClick()
console.log('businessHandleRef');
}
const scrollMainToBottom = async () => {
// 返回恢复滚动中时,避免欢迎区/摘要 ready 再次强制滚到底部
if (isRestoringScroll.value || shouldRestoreScroll.value) return
await nextTick()
const el = mainContainerRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
const onIntroReady = async () => {
introReadyCount.value += 1
if (introReadyCount.value < 2) return
await scrollMainToBottom()
// 内容渲染(尤其 abstract 列表)可能晚一帧,再补一次
setTimeout(() => {
scrollMainToBottom()
}, 150)
}
const pageNum = ref(1)
const pageSize = ref(10)
const nolist = ref(false)
const nolistTxt = ref('没有更多数据了')
function getMessageOptions(item) {
return item?.message?.[0]?.options || {}
}
function isImageMessage(item) {
const raw = item?.raw || item || {}
const options = getMessageOptions(item)
const reqAndResType = String(raw.reqAndResType || '')
const msgType = String(raw.msgType || '')
const fromFlag = String(raw.fromFlag || '')
const isProtocolImage =
(reqAndResType === '2' && fromFlag === '1' && msgType !== '01') ||
(reqAndResType === '08' && msgType === '02')
const isTextAnswer = Boolean(raw.msgText) && (msgType === '01' || fromFlag !== '1') && !isProtocolImage
return !isTextAnswer && (
item?.message?.[0]?.type === 'image' ||
isProtocolImage ||
Boolean((item?.imageUrl || item?.imageKey || options.imageUrl || options.imageKey || raw.imgUrl || raw.imgKey || raw.fileKey) && !raw.msgText)
)
}
function getItemImage(item) {
const raw = item?.raw || item || {}
const options = getMessageOptions(item)
const reqAndResType = String(raw.reqAndResType || '')
const msgType = String(raw.msgType || '')
const fromFlag = String(raw.fromFlag || '')
const imageMessage =
(reqAndResType === '2' && fromFlag === '1' && msgType !== '01') ||
(reqAndResType === '08' && msgType === '02')
const directImage = [item?.imageUrl, options.imageUrl, raw.imgUrl]
.map((value) => String(value || ''))
.find((value) => /^(data:|blob:|https?:\/\/)/i.test(value))
return String(
directImage ||
item?.imageKey ||
options.imageKey ||
raw.imgKey ||
raw.fileKey ||
item?.imageUrl ||
options.imageUrl ||
raw.imgUrl ||
(imageMessage ? raw.msgText : '') ||
'',
)
}
function getItemText(item) {
if (isImageMessage(item)) return ''
return String(getMessageOptions(item).label || '').replace(/\\n/g, '\n')
}
function isImageUploading(item) {
return isImageMessage(item) && item?.uploadStatus === 'uploading'
}
function isImageFailed(item) {
return isImageMessage(item) && item?.uploadStatus === 'failed'
}
function getItemHtml(item) {
const content = getItemText(item)
if (!content) return ''
// liics renders msgText as sanitized HTML and converts escaped newlines to
// line breaks. Keep the same behavior for msgType=01 and rich msgType=02.
return xss(content.replace(/\\"/g, '"').replace(/\n/g, '<br/>'))
}
async function previewImage(item) {
if (isImageUploading(item) || isImageFailed(item)) return
const imageKey = getItemImage(item)
if (!imageKey) return
const imageUrl = await imageService.getImageByKey(imageKey)
if (typeof imageUrl === 'string' && imageUrl) showImagePreview([imageUrl])
}
const showTyping = computed(
() =>
isChatLoading.value &&
store.chatArr.length > 0 &&
store.chatArr[store.chatArr.length - 1].type === 'user',
)
function sendText(text) {
if (!text) return
store?.pushUserMessage?.(text)
messageSend(text).catch((err) => {
console.log(err)
store?.pushUserMessage?.('服务异常,请您稍后再试~', 'bot')
})
}
function handleAskClick(item) {
sendText(item)
}
// 更新未读状态
function updateReadStatus(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)
})
}
}
const CHAT_SCROLL_KEY = 'yykf_chat_main_scroll_top'
// 模块级缓存:避免 keep-alive 隐藏 DOM 后 scrollTop 被置 0 覆盖掉真实位置
let memoryScrollTop = 0
function getMainEl() {
return mainContainerRef.value || store?.mescrollContent || null
}
function scrollMainTo(top) {
const el = getMainEl()
if (!el) return false
const value = Math.max(0, Number(top) || 0)
el.scrollTop = value
return Math.abs((el.scrollTop || 0) - value) < 2 || value === 0
}
let smoothScrollToken = 0
function smoothScrollMainToBottom(duration = 300) {
const el = getMainEl()
if (!el) return
const target = el.scrollHeight - el.clientHeight
const start = el.scrollTop
const delta = target - start
if (delta <= 2) return
const token = ++smoothScrollToken
const startTime = performance.now()
const step = (now) => {
if (token !== smoothScrollToken) return
const t = Math.min(1, (now - startTime) / duration)
const eased = 1 - Math.pow(1 - t, 3)
el.scrollTop = start + delta * eased
if (t < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
}
const savedScrollTop = ref(0)
const shouldRestoreScroll = ref(false)
const isRestoringScroll = ref(false)
function readPersistedScrollTop() {
if (memoryScrollTop > 0) return memoryScrollTop
if (savedScrollTop.value > 0) return savedScrollTop.value
try {
const raw = sessionStorage.getItem(CHAT_SCROLL_KEY)
if (raw == null || raw === '') return null
const top = Number(raw)
return Number.isFinite(top) ? top : null
} catch (e) {
return null
}
}
function persistScrollTop(top, { allowZero = false } = {}) {
const value = Math.max(0, Number(top) || 0)
// display:none 后常被读成 0,禁止用 0 覆盖已记录的有效位置
if (!allowZero && value <= 0 && (memoryScrollTop > 0 || savedScrollTop.value > 0)) {
return memoryScrollTop || savedScrollTop.value
}
memoryScrollTop = value
savedScrollTop.value = value
try {
sessionStorage.setItem(CHAT_SCROLL_KEY, String(value))
} catch (e) {}
return value
}
function saveScrollPosition(markRestore = true) {
const el = getMainEl()
// 优先用离开前仍可见时的 scrollTop;读到 0 时保留 memory 中的旧值
if (el) persistScrollTop(el.scrollTop)
if (markRestore) shouldRestoreScroll.value = true
}
function restoreScrollPosition(forceTop) {
const top = forceTop != null ? forceTop : (readPersistedScrollTop() ?? 0)
console.log('[chat-scroll] restore to', top, {
memoryScrollTop,
saved: savedScrollTop.value,
el: !!getMainEl(),
})
shouldRestoreScroll.value = true
isRestoringScroll.value = true
const apply = () => {
const el = getMainEl()
if (!el) return false
// 内容高度不够时先撑不住,稍后重试
if (top > 0 && el.scrollHeight <= el.clientHeight + 2) return false
el.scrollTop = top
return true
}
nextTick(() => {
apply()
requestAnimationFrame(apply)
const delays = [16, 50, 100, 200, 350, 500, 800]
delays.forEach((delay) => setTimeout(apply, delay))
setTimeout(() => {
apply()
isRestoringScroll.value = false
shouldRestoreScroll.value = false
}, 850)
})
}
function onMainScroll(event) {
if (isRestoringScroll.value) return
const top = event?.target?.scrollTop
if (top == null) return
persistScrollTop(top, { allowZero: true })
// 上滑到顶部附近时加载更多历史记录
if (top <= 60 && !nolist.value && !historyLoading.value) {
getChatHistory()
}
}
onBeforeRouteLeave(() => {
// 此时 DOM 仍可见,最适合记录真实滚动位置
saveScrollPosition(true)
})
onDeactivated(() => {
// 只标记需要恢复,不再用可能已被置 0 的 scrollTop 覆盖
shouldRestoreScroll.value = true
const el = getMainEl()
if (el && el.scrollTop > 0) persistScrollTop(el.scrollTop)
})
onActivated(() => {
const top = readPersistedScrollTop()
console.log('[chat-scroll] onActivated', { top, shouldRestoreScroll: shouldRestoreScroll.value })
if (!shouldRestoreScroll.value && (top == null || top <= 0)) return
restoreScrollPosition(top)
})
function getHeaderUrl(context, payload) {
let params = {
"source": store.source
}
return new Promise((resolve, reject) => {
Post(
"config/avatar",
params
).then(res => {
resolve(res.data);
console.log("获取头像URL==", res)
if (res.code && res.code != 0) {
// Toast(res.msg);
reject(res.msg)
return;
}
//本地环境显示图片
// if (window.location.href.indexOf('localhost') != -1) {
// res.data = res.data.replaceAll('../', `//ssp-dev.cpic.com.cn/`)
// }
store.setHeaderAvatar(JSON.parse(res.data));
resolve(res.data);
})
.catch(error => {
reject(error)
});
});
}
const getChatHistory = async (isAuto = false) => {
const params = {
page: pageNum.value,
pageSize: pageSize.value,
sessionId: sessionId.value || store?.sessionId,
userId: userId.value || store?.userId,
}
historyLoading.value = true
Post('history/chatInfo', params)
.then((response) => {
historyLoading.value = false
historyReady.value = true
if (response.code && response.code != 0) {
showToast(response.msg)
return
}
// 成功返回
if (response.data?.list?.length > 0) {
const dataList = response.data.list
pageNum.value++
// 不满一页,说明已经无更多消息
if (dataList.length < pageSize.value) {
nolist.value = true
nolistTxt.value = '没有更多数据了'
}
// 生成 VIEW_ID,大写,避免污染源数据
dataList.forEach((val) => {
val.VIEW_ID = 'msg' + val.timestamp
})
// 获取当前最顶部的 VIEW_ID(注意写在 concat 前面)
let topMsg = historylist.value[0]
// 处理隐藏的数据
if (topMsg && !topMsg.msgText) {
topMsg = historylist.value[1]
if (topMsg && !topMsg.msgText) {
topMsg = historylist.value[2]
}
}
// 设置列表数据(历史消息也走同一套字段归一,保证 msgType=01 卡片能显示)
const normalizedList = store.normalizeHistoryMessages
? store.normalizeHistoryMessages(dataList)
: dataList
historylist.value = normalizedList.concat(historylist.value)
_.map(historylist.value, (item) => {
// 历史记录不展示数据互联
if (SJHLCmdList.includes(item.cmd) && !_.isEmpty(item.VIEW_ID)) {
item.msgText = ''
}
// 历史问句去掉 & 符号
if (item.fromFlag === 1 && item.msgText && item.msgText.indexOf('&') !== -1) {
item.msgText = item.msgText.replace(/&/g, '')
}
})
// 更新未读状态
updateReadStatus(dataList)
nextTick(() => {
if (isAuto) {
// 参考 chat-main:有未读自动加载时滚到底部并提示未读
if (isLostMessage.value === 3) {
showToast('您有新消息未读,请及时查看历史消息')
}
setTimeout(() => {
scrollMainTo(99999)
}, 150)
} else if (topMsg) {
const view = document.querySelector('#' + topMsg.VIEW_ID)
console.log(topMsg.VIEW_ID + ' 节点离页面顶部的距离=' + view?.offsetTop)
setTimeout(() => {
if (view) scrollMainTo(view.offsetTop - 20)
}, 150)
} else {
// 首次手动上拉加载:停在顶部
setTimeout(() => {
scrollMainTo(0)
}, 150)
}
})
} else {
nolist.value = true
nolistTxt.value = pageNum.value === 1 ? '当前还没有您的聊天记录' : '没有更多数据了'
}
})
.catch(() => {
// 网络异常不置 nolist,用户再次上滑可重试(chat-main 靠下拉手势重置 nolist,这里等价放开)
historyLoading.value = false
historyReady.value = true
pageNum.value--
})
}
onMounted(async () => {
getHeaderUrl()
await nextTick()
if (mainContainerRef.value) {
store?.setMescrollContent?.(mainContainerRef.value)
}
const persisted = readPersistedScrollTop()
if (persisted != null && persisted > 0) {
shouldRestoreScroll.value = true
restoreScrollPosition()
} else {
// 参考 chat-main 的 mescroll down.auto:有未读消息或投诉直连渠道时,进入页面自动加载历史
const hasUnread = isLostMessage.value == '1' || isLostMessage.value == '3'
if (hasUnread || store.source === '95460000') {
getChatHistory(true)
}
}
})
watch(
chatArr,
() => {
// 从其他路由返回、正在恢复滚动位置时,不要强制滚到底部
if (isRestoringScroll.value || shouldRestoreScroll.value) return
nextTick(() => {
smoothScrollMainToBottom()
})
},
{ deep: true },
)
</script>
<template>
<div class="chat-new">
<div class="main-container" ref="mainContainerRef" @scroll.passive="onMainScroll">
<div class="history-loading" v-if="historyLoading">
<van-loading type="spinner" size="18rem" />
</div>
<div class="empty-tips opacity" v-if="nolist">{{ nolistTxt }}</div>
<div class="historicRecords" v-if="historylist.length > 0">
<chatContent
:historylist="historylist"
:isWX="isWX"
:tokenExist="tokenExist"
:isHistory="true"
:lostChatInfoNum="lostChatInfoNum"
/>
</div>
<div class="this-news-box opacity" v-if="historylist.length > 0">
<div class="this-news-tips">以下是本次消息</div>
</div>
<template v-if="historyReady">
<welcomes @ready="onIntroReady" />
<abstract @ready="onIntroReady" @businessClick="businessClick" />
<hotQuestionNew @ready="onIntroReady" />
</template>
<chatContent
:historylist="chatArr"
:isWX="isWX"
:tokenExist="tokenExist"
:lostChatInfoNum="lostChatInfoNum"
@handleAppraise="appraiseIsClose"
/>
</div>
<div class="chat-footer">
<footer-bar v-if="tokenExist" :tokenExist="tokenExist" />
</div>
<bussinessHandle :businessHandleInfo="store.businessHandleInfo" ref="businessHandleRef" />
<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>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/style/common.css';
.van-overlay.appraise-popup-box {
background: rgba(0, 0, 0, 0.5) !important;
}
.empty-tips {
font-family: PingFangSC-Regular;
font-size: 11rem;
line-height: 16rem;
color: #666666;
text-align: center;
padding: 0 0 15rem 0;
}
.chat-new {
width: 100vw;
height: 100vh;
background-color: #ebf1ff;
background-image: url(@/assets/images/chat/chat-bg.png);
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: center center;
display: flex;
flex-direction: column;
padding: 12rem;
.chat-footer {
flex-shrink: 0;
margin: 12rem -12rem -12rem;
background: #ffffff;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.main-container {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
.history-loading {
display: flex;
justify-content: center;
}
.message-con {
display: flex;
flex-direction: column;
gap: 12rem;
margin-top: 16rem;
.msg-item {
display: flex;
.bubble {
max-width: 80%;
padding: 10rem 14rem;
font-family: PingFang SC;
font-size: 14rem;
font-weight: normal;
line-height: 20rem;
letter-spacing: 0rem;
color: #17204d;
word-break: break-all;
.message-text {
white-space: normal;
:deep(p) {
margin: 0;
}
}
&.image-bubble {
// width: 130rem;
// height: 130rem;
min-width: 30rem;
min-height: 30rem;
max-width: 130rem;
max-height: 130rem;
position: relative;
background: #f5f5f5;
padding: 0;
overflow: hidden;
.message-image {
display: block;
width: 100%;
height: 100%;
min-width: 30rem;
min-height: 30rem;
border-radius: inherit;
}
.upload-loading-icon {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
background: rgba(50, 50, 51, 0.88);
border-radius: inherit;
}
.upload-failed-icon {
font-size: 22rem;
}
}
.message-image {
width: 100%;
height: 100%;
}
}
}
.from-user {
justify-content: flex-end;
.bubble {
background: linear-gradient(266deg, #025cea 0%, #3f8cf8 100%);
color: #ffffff;
border-radius: 12rem 12rem 4rem 12rem;
&.image-bubble {
border-radius: 16rem;
}
}
}
.from-bot {
justify-content: flex-start;
.bubble {
background: #fcfdfd;
box-shadow: 0rem 4rem 10rem 0rem #e7effd;
border-radius: 12rem 12rem 12rem 4rem;
&.image-bubble {
border-radius: 16rem 16rem 16rem 2rem;
}
}
}
.typing {
display: flex;
align-items: center;
gap: 5rem;
span {
width: 6rem;
height: 6rem;
border-radius: 50%;
background: #7e849f;
animation: typing-blink 1s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing-blink {
0%,
80%,
100% {
opacity: 0.2;
}
40% {
opacity: 1;
}
}
}
}
}
</style>