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.
747 lines
22 KiB
747 lines
22 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'
|
|
import MeScroll from 'mescroll.js'
|
|
import 'mescroll.js/mescroll.min.css'
|
|
|
|
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 webSocketChannel = '95460000' // 投诉直连渠道号
|
|
// mescroll 实例(非响应式)
|
|
let mescroll = 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 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
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
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)
|
|
});
|
|
});
|
|
}
|
|
function initMescroll() {
|
|
let isLostMessageFlag =
|
|
isLostMessage.value && (isLostMessage.value == '1' || isLostMessage.value == '3') ? true : false
|
|
|
|
// 投诉直连渠道首次进入需要刷新
|
|
const isTouSuZhiLian = store.source === webSocketChannel
|
|
isLostMessageFlag = isTouSuZhiLian ? true : isLostMessageFlag
|
|
|
|
// 创建 MeScroll 对象(下拉加载历史记录,参考 chat-main)
|
|
mescroll = new MeScroll(mainContainerRef.value, {
|
|
down: {
|
|
isBounce: false,
|
|
auto: isLostMessageFlag,
|
|
callback: getChatHistory,
|
|
htmlContent: '<p class="downwarp-progress"></p><p class="downwarp-tip">没有数据了</p>',
|
|
hardwareClass: 'mescroll-hardware',
|
|
warpClass: 'mescroll-downwarp', // 下拉刷新的布局容器样式
|
|
offset: 70, // 触发刷新的距离,默认80
|
|
mustToTop: false,
|
|
autoShowLoading: true, // 自动执行下拉回调时是否显示下拉刷新进度
|
|
textInOffset: '下拉加载历史记录', // 下拉的距离在offset范围内的提示文本
|
|
textOutOffset: '释放更新', // 下拉的距离大于offset范围的提示文本
|
|
textLoading: '加载中 ...', // 加载中的提示文本
|
|
onMoving: upOnMoving,
|
|
},
|
|
up: {
|
|
use: false,
|
|
},
|
|
})
|
|
}
|
|
|
|
function upOnMoving() {
|
|
nolist.value = false
|
|
}
|
|
|
|
// 下拉回调 拉取历史记录
|
|
function getChatHistory() {
|
|
const params = {
|
|
page: pageNum.value,
|
|
pageSize: pageSize.value,
|
|
sessionId: sessionId.value || store?.sessionId,
|
|
userId: userId.value || store?.userId,
|
|
}
|
|
|
|
setTimeout(() => {
|
|
Post('history/chatInfo', params)
|
|
.then((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 && 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(注意写在 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 (pageNum.value <= 2) {
|
|
if (isLostMessage.value === 3) {
|
|
showToast('您有新消息未读,请及时查看历史消息')
|
|
}
|
|
setTimeout(() => {
|
|
mescroll && mescroll.scrollTo(99999, 0)
|
|
}, 150)
|
|
} else if (topMsg) {
|
|
// 保持顶部消息的位置
|
|
const view = document.querySelector('#' + topMsg.VIEW_ID)
|
|
console.log(topMsg.VIEW_ID + ' 节点离页面顶部的距离=' + view?.offsetTop)
|
|
|
|
setTimeout(() => {
|
|
if (view) mescroll.scrollTo(view.offsetTop - 20, 0)
|
|
}, 150)
|
|
}
|
|
})
|
|
} else {
|
|
mescroll && mescroll.endErr()
|
|
nolist.value = true
|
|
nolistTxt.value = pageNum.value === 1 ? '当前还没有您的聊天记录' : '没有更多数据了'
|
|
}
|
|
})
|
|
.catch(() => {
|
|
pageNum.value--
|
|
mescroll && mescroll.endErr()
|
|
nolist.value = true
|
|
})
|
|
}, 600)
|
|
}
|
|
onMounted(async () => {
|
|
getHeaderUrl()
|
|
await nextTick()
|
|
if (mainContainerRef.value) {
|
|
store?.setMescrollContent?.(mainContainerRef.value)
|
|
}
|
|
// 下拉加载历史记录初始化(有未读/投诉直连渠道时 down.auto 自动拉第一页,参考 chat-main)
|
|
initMescroll()
|
|
const persisted = readPersistedScrollTop()
|
|
if (persisted != null && persisted > 0) {
|
|
shouldRestoreScroll.value = true
|
|
restoreScrollPosition()
|
|
}
|
|
})
|
|
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="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;
|
|
|
|
.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>
|
|
|