From 2e0fc25be6ed9a4f354263a6c715ec29dc5d0d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B1=9F=E8=BE=B0?= Date: Fri, 2 Jun 2023 15:55:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E5=88=97=E8=A1=A8=E3=80=81=E7=BB=93=E6=9E=84=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/VirtualList/index.tsx | 260 +++++++++++++++++ src/components/VirtualList/item.tsx | 60 ++++ src/components/VirtualList/virtual.ts | 254 +++++++++++++++++ src/stores/chat.ts | 36 +-- src/utils/computedTime.ts | 22 +- src/views/Home/components/ChatBox/index.vue | 4 +- .../components/ChatList/MsgItem/index.vue | 135 +++++++++ .../components/ChatList/MsgItem/styles.scss | 137 +++++++++ .../components/ChatList/MsgOption/index.vue | 75 +++++ .../components/ChatList/MsgOption/styles.scss | 47 ++++ src/views/Home/components/ChatList/index.vue | 246 ++++------------ src/views/Home/components/ChatList/item.vue | 18 ++ .../Home/components/ChatList/styles.scss | 263 +++--------------- 13 files changed, 1121 insertions(+), 436 deletions(-) create mode 100644 src/components/VirtualList/index.tsx create mode 100644 src/components/VirtualList/item.tsx create mode 100644 src/components/VirtualList/virtual.ts create mode 100644 src/views/Home/components/ChatList/MsgItem/index.vue create mode 100644 src/views/Home/components/ChatList/MsgItem/styles.scss create mode 100644 src/views/Home/components/ChatList/MsgOption/index.vue create mode 100644 src/views/Home/components/ChatList/MsgOption/styles.scss create mode 100644 src/views/Home/components/ChatList/item.vue diff --git a/src/components/VirtualList/index.tsx b/src/components/VirtualList/index.tsx new file mode 100644 index 00000000..a06d6806 --- /dev/null +++ b/src/components/VirtualList/index.tsx @@ -0,0 +1,260 @@ +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), + ) + + 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 + return data.map((dataSource: any) => + typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey], + ) + } + const onRangeChanged = (newRange: any) => { + range.value = newRange + } + 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() + } + + const scrollToIndex = (index: number) => { + if (index >= props.data.length - 1) { + scrollToBottom() + } else { + const offset = virtual.getOffset(index) + scrollToOffset(offset) + } + } + + const scrollToOffset = (offset: number) => { + if (rootRef.value) { + rootRef.value.scrollTop = offset + } + } + + 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) { + 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..8abc01b7 --- /dev/null +++ b/src/components/VirtualList/item.tsx @@ -0,0 +1,60 @@ +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) + let resizeObserver: ResizeObserver | null = null + + 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 + + return ( +
+ {/* @ts-ignore */} + +
+ ) + } + }, +}) diff --git a/src/components/VirtualList/virtual.ts b/src/components/VirtualList/virtual.ts new file mode 100644 index 00000000..e5a2b2a4 --- /dev/null +++ b/src/components/VirtualList/virtual.ts @@ -0,0 +1,254 @@ +// @ts-nocheck +export default class Virtual { + sizes: Map = new Map(); + offset: number; + + constructor(param, callUpdate) { + this.init(param, 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) + } + + 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 + } + } + + saveSize(id, size) { + this.sizes.set(id, size) + + 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') { + 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()) { + start = start - 2 + } else if (this.isBehind()) { + start = start + 2 + } + + start = Math.max(start, 0) + + this.updateRange(this.range.start, this.getEndByStart(start)) + } + + handleSlotSizeChange() { + this.handleDataSourcesChange() + } + + 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() + } + } + + 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)) + } + + handleBehind() { + const overs = this.getScrollOvers() + if (overs < this.range.start + this.param.buffer) { + return + } + + this.checkRange(overs, this.getEndByStart(overs)) + } + + 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 + } + + 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) + } + } + + 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 @@ + + + + +