diff --git a/src/components/VirtualList/index.tsx b/src/components/VirtualList/index.tsx new file mode 100644 index 00000000..b03c7467 --- /dev/null +++ b/src/components/VirtualList/index.tsx @@ -0,0 +1,297 @@ +import { + defineComponent, + onActivated, + onBeforeMount, + onMounted, + onUnmounted, + ref, + watch +} from 'vue' +import Virtual from './virtual' +import Item from './item' + +interface Range { + start: number + end: number + padFront: number + padBehind: number +} + +interface DataSource { + [key: string]: any; +} + +export default defineComponent({ + name: 'VirtualList', + props: { + // 数据 + data: { + type: Array, + required: true, + default: () => [], + }, + // 唯一标识键值 + dataKey: { + type: [String, Function], + required: true, + }, + // 数据项组件 + item: { + type: [Object, Function], + required: true, + }, + // 可视区域内保留的数据项个数 + keeps: { + type: Number, + default: 30, + }, + size: { + type: Number, + default: 50, + }, + // 起始索引-用来指定默认从哪里开始渲染 + start: { + type: Number, + default: 0, + }, + // 偏移量 + offset: { + type: Number, + default: 0, + }, + // 顶部触发阈值 + topThreshold: { + type: Number, + default: 0, + }, + // 底部触发阈值 + bottomThreshold: { + type: Number, + default: 0, + }, + }, + setup(props, { emit, expose }) { + const range = ref(null) + const rootRef = ref() + const shepherd = ref(null) + let virtual: Virtual + + // 监听数据数组长度变化 更新数据 + watch( + () => props.data.length, + () => { + virtual.updateParam('uniqueIds', getUniqueIdFromDataSources()) + virtual.handleDataSourcesChange() + }, + ) + watch( + () => props.keeps, + (newValue) => { + virtual.updateParam('keeps', newValue) + virtual.handleSlotSizeChange() + }, + ) + watch( + () => props.start, + (newValue) => { + scrollToIndex(newValue) + }, + ) + watch( + () => props.offset, + (newValue) => scrollToOffset(newValue), + ) + + // 根据id获取数据项大小 + const getSize = (id: string) => { + return virtual.sizes.get(id) + } + // 获取滚动条偏移量 + const getOffset = () => { + return rootRef.value ? Math.ceil(rootRef.value.scrollTop) : 0 + } + // 获取可视区域大小 + const getClientSize = () => { + const key = 'clientHeight' + return rootRef.value ? Math.ceil(rootRef.value[key]) : 0 + } + // 获取滚动条总高度 + const getScrollSize = () => { + const key = 'scrollHeight' + return rootRef.value ? Math.ceil(rootRef.value[key]) : 0 + } + + // 统一处理向外暴露事件 + const emitEvent = (offset:number, clientSize:number, scrollSize :number) => { + emit('scroll', {offset, clientSize, scrollSize}) + + if (virtual.isFront() && !!props.data.length && offset - props.topThreshold <= 0) { + emit('totop') + } else if (virtual.isBehind() && offset + clientSize + props.bottomThreshold >= scrollSize) { + emit('tobottom') + } + } + const onScroll = () => { + const offset = getOffset() + const clientSize = getClientSize() + const scrollSize = getScrollSize() + if (offset < 0 || offset + clientSize > scrollSize + 1 || !scrollSize) { + return + } + + virtual.handleScroll(offset) + emitEvent(offset, clientSize, scrollSize) + } + + // 获取数据源中的唯一标识 + const getUniqueIdFromDataSources = () => { + const { dataKey, data = [] } = props + // 如果dataKey是函数 则调用传入的函数执行获取唯一标识 + return data.map((dataSource: any) => + typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey], + ) + } + const onRangeChanged = (newRange: any) => { + range.value = newRange + } + /** + * 初始化一个virtual实例 + * @description 详细参数见virtual.ts + */ + const installVirtual = () => { + virtual = new Virtual( + { + slotHeaderSize: 0, + slotFooterSize: 0, + keeps: props.keeps, + estimateSize: props.size, + buffer: Math.round(props.keeps / 3), + uniqueIds: getUniqueIdFromDataSources(), + }, + onRangeChanged, + ) + + range.value = virtual.getRange() + } + + /** + * 滚动到指定索引 + * @param index 索引值 + * @description 如果索引值大于等于数据长度说明到底了则滚动到底部 + */ + const scrollToIndex = (index: number) => { + if (index >= props.data.length - 1) { + scrollToBottom() + } else { + const offset = virtual.getOffset(index) + scrollToOffset(offset) + } + } + + /** + * 滚动到指定偏移量 + * @param offset 滚动条偏移量 + */ + const scrollToOffset = (offset: number) => { + if (rootRef.value) { + rootRef.value.scrollTop = offset + } + } + + /** + * 渲染插槽列表-(重点函数) + * @returns {VNode[]} 插槽列表 + */ + const getRenderSlots = () => { + const slots = [] + const { start, end } = range.value! // 解构获取范围的起始、结束索引 + const { data, dataKey, item } = props + for (let index = start; index <= end; index++) { + const dataSource = data[index] as DataSource // 获取当前索引的数据项 + if (dataSource) { + // 取这个项里面的唯一标识拿来做key + const uniqueKey = typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey] + // 如果唯一标识是字符串或者数字则渲染 + if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') { + slots.push( + , + ) + } + } + } + return slots + } + + // 数据项大小改变时触发 + const onItemResized = (id: string, size: number) => { + virtual.saveSize(id, size) + emit('resized', id, size) + } + + // 滚动到底部 + const scrollToBottom = () => { + if (shepherd.value) { + const offset = shepherd.value.offsetTop + scrollToOffset(offset) + setTimeout(() => { + if (getOffset() + getClientSize() < getScrollSize()) { + scrollToBottom() + } + }, 3) + } + } + + const getSizes = () => { + return virtual.sizes.size + } + + onBeforeMount(() => { + installVirtual() + }) + + onActivated(() => { + scrollToOffset(virtual.offset) + }) + + onMounted(() => { + if (props.start) { + scrollToIndex(props.start) + } else if (props.offset) { + scrollToOffset(props.offset) + } + }) + + onUnmounted(() => { + virtual.destroy() + }) + + expose({ + scrollToBottom, + getSizes, + getSize, + getOffset, + getScrollSize, + getClientSize, + scrollToOffset, + scrollToIndex, + }) + + return () => { + const { padFront, padBehind } = range.value! + return ( +
+
+ {getRenderSlots()} +
+
+
+ ) + } + }, +}) diff --git a/src/components/VirtualList/item.tsx b/src/components/VirtualList/item.tsx new file mode 100644 index 00000000..dd2e923e --- /dev/null +++ b/src/components/VirtualList/item.tsx @@ -0,0 +1,79 @@ +/** + * 这个文件负责渲染出传入自定义组件项 + * 主要作用和逻辑: + * 1. 监听元素尺寸变化 - (耗性能可以考虑在高度固定的情况下不监听) + * 2. 通知父组件当前项的高度-(这样做的目的是解决聊天信息这样不固定高度项的) + * 3. 渲染出传入自定义组件项 + */ +import { defineComponent, onMounted, onUnmounted, onUpdated, ref } from 'vue' +export default defineComponent({ + name: 'VirtualListItem', + props: { + // 下标 + index: { + type: Number, + }, + // 数据源 + source: { + type: Object, + }, + // 数据项组件 + component: { + type: [Object, Function], + }, + // 唯一标识键值 + uniqueKey: { + type: [String, Number], + }, + }, + emits: ['itemResize'], + setup(props, { emit }) { + const rootRef = ref(null) // 根节点 + // ResizeObserver实例 参考文档:https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver + let resizeObserver: ResizeObserver | null = null + + /** + * 尺寸变化事件 + * @description: 通知父组件当前项的高度 + */ + const dispatchSizeChange = () => { + const { uniqueKey } = props + const size = rootRef.value ? rootRef.value.offsetHeight : 0 // 当前项的高度 + emit('itemResize', uniqueKey, size) + } + + onMounted(() => { + if (typeof ResizeObserver !== 'undefined') { + // 监听元素尺寸变化 + resizeObserver = new ResizeObserver(() => { + dispatchSizeChange() + }) + // 创建观察者实例并传入回调函数 + rootRef.value && resizeObserver.observe(rootRef.value as HTMLElement) + } + }) + + onUpdated(() => { + dispatchSizeChange() + }) + + // 组件卸载时关闭观察者监听实例 + onUnmounted(() => { + if (resizeObserver) { + resizeObserver.disconnect() + resizeObserver = null + } + }) + + return () => { + const { component: Comp, index, source, uniqueKey } = props + // 渲染出传入自定义组件项-这里的Comp就是传入的自定义组件 (ts-ignore避免类型警告) + return ( +
+ {/* @ts-ignore */} + +
+ ) + } + }, +}) diff --git a/src/components/VirtualList/virtual.ts b/src/components/VirtualList/virtual.ts new file mode 100644 index 00000000..bfc306a6 --- /dev/null +++ b/src/components/VirtualList/virtual.ts @@ -0,0 +1,318 @@ +/** + * @file 虚拟滚动核心逻辑文件 - (构造函数) + * @description 主要用于虚拟滚动计算和管理 + * @param {Object} param 定制参数 + * @param {Function} callUpdate 回调 + */ +// @ts-nocheck +export default class Virtual { + sizes: Map = new Map(); // 存储元素的尺寸的Map对象 + offset: number; + + constructor(param, callUpdate) { + this.init(param, callUpdate) + } + + /** + * 初始化 + * @param {object} param - 参数对象 + * @param {function} callUpdate - 更新回调函数 + */ + init(param, callUpdate) { + this.param = param + this.callUpdate = callUpdate + + this.sizes = new Map() + this.firstRangeTotalSize = 0 + this.firstRangeAverageSize = 0 + this.lastCalcIndex = 0 + this.fixedSizeValue = 0 + this.calcType = 'INIT' + + this.offset = 0 + this.direction = '' + + this.range = Object.create(null) + if (param) { + this.checkRange(0, param.keeps - 1) + } + } + + // 销毁整个虚拟列表实例 + destroy() { + this.init(null, null) + } + + /** + * 获取当前范围 + * @returns {object} - 当前范围对象 + */ + getRange() { + const range = Object.create(null) + range.start = this.range.start + range.end = this.range.end + range.padFront = this.range.padFront + range.padBehind = this.range.padBehind + return range + } + + isBehind() { + return this.direction === 'BEHIND' + } + + isFront() { + return this.direction === 'FRONT' + } + + getOffset(start) { + return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize + } + + // 更新参数 + updateParam(key, value) { + if (this.param && key in this.param) { + if (key === 'uniqueIds') { + this.sizes.forEach((v, key) => { + if (!value.includes(key)) { + this.sizes.delete(key) + } + }) + } + this.param[key] = value + } + } + + /** + * 保存元素的尺寸 + * @param {string} id - 唯一标识 + * @param {number} size - 尺寸 + * @description 该方法用于保存数据项的尺寸信息。将指定id和尺寸保存到`sizes`映射中,以便后续使用 + */ + saveSize(id, size) { + this.sizes.set(id, size) + + // 如果当前计算类型为初始化(INIT) 将尺寸值设为固定尺寸值,并将计算类型设置为固定 + if (this.calcType === 'INIT') { + this.fixedSizeValue = size + this.calcType = 'FIXED' + // 如果当前计算类型固定并且固定尺寸值不等于新的尺寸 + } else if (this.calcType === 'FIXED' && this.fixedSizeValue !== size) { + this.calcType = 'DYNAMIC' + delete this.fixedSizeValue // 删除固定尺寸值 + } + + if (this.calcType !== 'FIXED' && typeof this.firstRangeTotalSize !== 'undefined') { + // 如果sizes映射的大小小于参数keeps和uniqueIds长度的最小值 这么做的目的是为了计算平均尺寸 + if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) { + // 第一个范围的总尺 + this.firstRangeTotalSize = [...this.sizes.values()].reduce((acc, val) => acc + val, 0) + // 第一个范围的平均尺寸 + this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size) + } else { + delete this.firstRangeTotalSize + } + } + } + + /** + * 数据变化处理 + */ + handleDataSourcesChange() { + let start = this.range.start + + if (this.isFront()) { + // 将起始位置向前调整2个单位 + start = start - 2 + } else if (this.isBehind()) { + // 将起始位置向后调整2个单位 + start = start + 2 + } + + start = Math.max(start, 0) // 确保起始位置不小于0 + + this.updateRange(this.range.start, this.getEndByStart(start)) + } + + handleSlotSizeChange() { + this.handleDataSourcesChange() + } + + /** + * 滚动处理 + * @param offset 偏移量 + * @description 根据滚动的偏移量判断滚动的方向是向前还是向后 + */ + handleScroll(offset) { + this.direction = offset < this.offset ? 'FRONT' : 'BEHIND' + this.offset = offset + + if (!this.param) { + return + } + + if (this.direction === 'FRONT') { + this.handleFront() + } else if (this.direction === 'BEHIND') { + this.handleBehind() + } + } + + /** + * 向前滚动处理 + * @returns {number} 滚动的位置 + */ + handleFront() { + const overs = this.getScrollOvers() + if (overs > this.range.start) { + return + } + + const start = Math.max(overs - this.param.buffer, 0) + this.checkRange(start, this.getEndByStart(start)) + } + + /** + * 向后滚动处理 + * @returns {number} 滚动的位置 + */ + handleBehind() { + const overs = this.getScrollOvers() + if (overs < this.range.start + this.param.buffer) { + return + } + + this.checkRange(overs, this.getEndByStart(overs)) + } + + /** + * 获取滚动的位置 + * @returns {number} 滚动的位置 + */ + getScrollOvers() { + const offset = this.offset - this.param.slotHeaderSize + if (offset <= 0) { + return 0 + } + + if (this.isFixedType()) { + return Math.floor(offset / this.fixedSizeValue) + } + + let low = 0 + let middle = 0 + let middleOffset = 0 + let high = this.param.uniqueIds.length + + while (low <= high) { + middle = low + Math.floor((high - low) / 2) + middleOffset = this.getIndexOffset(middle) + + if (middleOffset === offset) { + return middle + } else if (middleOffset < offset) { + low = middle + 1 + } else if (middleOffset > offset) { + high = middle - 1 + } + } + + return low > 0 ? --low : 0 + } + + /** + * 获取指定索引的偏移量 + * @param {number} givenIndex - 给定的索引 + * @returns {number} - 偏移量 + */ + getIndexOffset(givenIndex) { + if (!givenIndex) { + return 0 + } + + let offset = 0 + let indexSize = 0 + for (let index = 0; index < givenIndex; index++) { + indexSize = this.sizes.get(this.param.uniqueIds[index]) + offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize()) + } + + this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1) + this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex()) + + return offset + } + + isFixedType() { + return this.calcType === 'FIXED' + } + + getLastIndex() { + return this.param.uniqueIds.length - 1 + } + + checkRange(start, end) { + const keeps = this.param.keeps + const total = this.param.uniqueIds.length + + if (total <= keeps) { + start = 0 + end = this.getLastIndex() + } else if (end - start < keeps - 1) { + start = end - keeps + 1 + } + + if (this.range.start !== start) { + this.updateRange(start, end) + } + } + + // 更新范围 + updateRange(start, end) { + this.range.start = start + this.range.end = end + this.range.padFront = this.getPadFront() + this.range.padBehind = this.getPadBehind() + this.callUpdate(this.getRange()) + } + + getEndByStart(start) { + const theoryEnd = start + this.param.keeps - 1 + const truelyEnd = Math.min(theoryEnd, this.getLastIndex()) + return truelyEnd + } + + // 获取前方的预填充大小 + getPadFront() { + if (this.isFixedType()) { + return this.fixedSizeValue * this.range.start + } else { + return this.getIndexOffset(this.range.start) + } + } + + /** + * 获取后方的预填充大小 + * @returns {number} - 填充大小 + */ + getPadBehind() { + const end = this.range.end; // 当前显示范围的结束索引 + const lastIndex = this.getLastIndex(); // 最后一个索引 + + // 如果是固定尺寸类型就直接返回固定尺寸的填充大小 + if (this.isFixedType()) { + return (lastIndex - end) * this.fixedSizeValue; + } + + // 如果上一次计算的索引等于最后一个索引,则返回当前索引的偏移量减去结束索引的偏移量 + if (this.lastCalcIndex === lastIndex) { + return this.getIndexOffset(lastIndex) - this.getIndexOffset(end); + } else { + // 否则返回预估尺寸乘以剩余元素个数的填充尺寸 + return (lastIndex - end) * this.getEstimateSize(); + } + } + + getEstimateSize() { + return this.isFixedType() ? this.fixedSizeValue : this.firstRangeAverageSize || this.param.estimateSize + } +} diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 93ab325e..5c1853a3 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1,4 +1,4 @@ -import { ref, reactive, watch } from 'vue' +import { ref, reactive } from 'vue' import { defineStore } from 'pinia' import apis from '@/services/apis' import type { MessageItemType } from '@/services/types' @@ -8,18 +8,14 @@ import shakeTitle from '@/utils/shakeTitle' export const pageSize = 20 export const useChatStore = defineStore('chat', () => { - // 消息列表滚动到底部事件 - const chatListToBottomAction = ref<() => void>() - // 消息列表 + const chatListToBottomAction = ref<() => void>() // 外部提供消息列表滚动到底部事件 const chatMessageList = ref([]) - const isLast = ref(false) - const loading = ref(true) + const isLast = ref(false) // 是否到底了 + const isLoading = ref(false) // 是否正在加载 + const isStartCount = ref(false) // 是否开始计数 const cursor = ref() - // 离最新消息是否滚动超过一屏 - const isScrollAboveOneScreen = ref(false) - // 新消息计数 const newMsgCount = ref(0) @@ -27,12 +23,13 @@ export const useChatStore = defineStore('chat', () => { const currentMsgReply = reactive>({}) const getMsgList = async () => { + isLoading.value = true const data = await apis.getMsgList({ params: { pageSize, cursor: cursor.value, roomId: 1 } }).send() if (!data) return chatMessageList.value = [...computedTimeBlock(data.list), ...chatMessageList.value] cursor.value = data.cursor isLast.value = data.isLast - loading.value = false + isLoading.value = false } // 默认执行一次 @@ -48,11 +45,10 @@ export const useChatStore = defineStore('chat', () => { shakeTitle.start() } - if (isScrollAboveOneScreen.value) { + if (isStartCount.value) { newMsgCount.value++ return } - // 聊天列表滚动到底部 setTimeout(() => { // 如果超过一屏了,不自动滚动到最新消息。 @@ -67,24 +63,22 @@ export const useChatStore = defineStore('chat', () => { } const loadMore = async () => { - if (isLast.value) return + if (isLast.value && isLoading.value) return await getMsgList() } - // 如果滚动超过一屏了,来了新消息要计数标识,滚动到一屏内的时候,清空计数 - watch(isScrollAboveOneScreen, (val) => { - if (!val) { - newMsgCount.value = 0 - } - }) + const clearNewMsgCount = () => { + newMsgCount.value = 0 + } return { chatMessageList, pushMsg, + clearNewMsgCount, chatListToBottomAction, - isScrollAboveOneScreen, newMsgCount, - loading, + isLoading, + isStartCount, isLast, loadMore, currentMsgReply, diff --git a/src/utils/computedTime.ts b/src/utils/computedTime.ts index ef5689a6..34a04d3e 100644 --- a/src/utils/computedTime.ts +++ b/src/utils/computedTime.ts @@ -1,5 +1,7 @@ -import type { MessageItemType } from '@/services/types' import dayjs from 'dayjs' +import type { Dayjs } from 'dayjs' +import type { MessageItemType } from '@/services/types' + // 5 分钟 5 * 60 * 1000; const intervalTime = 300000 @@ -52,3 +54,21 @@ export const computedTimeBlock = (list: MessageItemType[], needFirst = true) => } return temp } + +/** + * 消息时间戳格式化 + * @param timestamp 时间戳 + * @returns 格式化后的时间字符串 + */ +export const formatTimestamp = (timestamp: number): string => { + const now: Dayjs = dayjs() + const date: Dayjs = dayjs(timestamp) + + if (now.isSame(date, 'day')) { + return date.format('HH:mm') + } else if (now.diff(date, 'year') >= 1) { + return date.format('YYYY年MM月DD日 HH:mm') + } else { + return date.format('MM月DD日 HH:mm') + } +} diff --git a/src/views/Home/components/ChatBox/index.vue b/src/views/Home/components/ChatBox/index.vue index 27c6e111..7f1c2bcf 100644 --- a/src/views/Home/components/ChatBox/index.vue +++ b/src/views/Home/components/ChatBox/index.vue @@ -1,5 +1,5 @@ + + + + diff --git a/src/views/Home/components/ChatList/MsgItem/styles.scss b/src/views/Home/components/ChatList/MsgItem/styles.scss new file mode 100644 index 00000000..f6e2f452 --- /dev/null +++ b/src/views/Home/components/ChatList/MsgItem/styles.scss @@ -0,0 +1,137 @@ +.chat-item { + display: flex; + position: relative; + width: 100%; + padding-bottom: 20px; + + &-avatar { + width: 40px; + height: 40px; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + user-select: none; + -webkit-user-drag: none; + cursor: pointer; + } + } + + &-box { + flex: 1; + padding:0 12px; + } + + &-user-info { + display: flex; + align-items: center; + font-size: 12px; + color: #999; + margin-bottom: 8px; + column-gap: 4px; + white-space: nowrap; + + .user-name:hover { + color: var(--color-primary); + cursor: pointer; + } + + .user-badge { + width: 18px; + height: 18px; + user-select: none; + cursor: pointer; + } + + .send-time { + display: none; + user-select: none; + } + } + + &-content { + position: relative; + width: fit-content; + min-height: 1em; + padding: 8px 12px; + border-radius: 5px 20px 20px 20px; + background-color: #383c4b; + color: #fff; + word-break: break-word; + white-space: pre-line; + } + + &-option { + position: absolute; + // display: none; + top: -20px; + right: 0; + z-index: 999; + } + + &-reply { + display: -webkit-inline-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + margin-top: 4px; + padding: 3px 12px; + border-radius: 8px; + color: var(--font-light); + background-color: #424656; + } +} + +.right { + flex-direction: row-reverse; + + .chat-item-user-info { + justify-content: flex-end; + + .send-time { + order: 0; + } + + .user-badge { + order: 1; + } + + .user-name { + order: 2; + } + + .user-ip { + order: 3; + } + } + .chat-item-content { + margin-left: auto; + border-radius: 20px 5px 20px 20px; + } +} + +.chat-item:hover { + .send-time { + display: inline-block; + } + .chat-item-option { + display: inline-flex; + } +} + +.send-time-block { + display: inline-block; + width: 100%; + font-size: 12px; + margin-bottom: 12px; + color: var(--font-light); + text-align: center; + user-select: none; +} + +.is-me .chat-item-content { + background-color: var(--color-primary); +} \ No newline at end of file diff --git a/src/views/Home/components/ChatList/MsgOption/index.vue b/src/views/Home/components/ChatList/MsgOption/index.vue new file mode 100644 index 00000000..6cd7fe70 --- /dev/null +++ b/src/views/Home/components/ChatList/MsgOption/index.vue @@ -0,0 +1,75 @@ + + + + +