diff --git a/package.json b/package.json index 6dd2b056..c5984095 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@alova/scene-vue": "^1.0.4", "@element-plus/icons-vue": "^2.1.0", + "@imengyu/vue3-context-menu": "^1.2.10", "alova": "^2.3.0", "dayjs": "^1.11.7", "dompurify": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baf1a6bc..ce86b788 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,8 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false dependencies: '@alova/scene-vue': @@ -7,6 +11,9 @@ dependencies: '@element-plus/icons-vue': specifier: ^2.1.0 version: registry.npmmirror.com/@element-plus/icons-vue@2.1.0(vue@3.2.47) + '@imengyu/vue3-context-menu': + specifier: ^1.2.10 + version: registry.npmmirror.com/@imengyu/vue3-context-menu@1.2.10 alova: specifier: ^2.3.0 version: registry.npmmirror.com/alova@2.3.0 @@ -1166,6 +1173,12 @@ packages: - supports-color dev: true + registry.npmmirror.com/@imengyu/vue3-context-menu@1.2.10: + resolution: {integrity: sha512-L+qIoqcewQ8SPk2PMvsaMD9HWxFouij0JAqYwn19Fz7giqz0rLpWMf9aJRpQ3ysOySEKPdilg8dLYYB96QmGHA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@imengyu/vue3-context-menu/-/vue3-context-menu-1.2.10.tgz} + name: '@imengyu/vue3-context-menu' + version: 1.2.10 + dev: false + registry.npmmirror.com/@jridgewell/gen-mapping@0.3.3: resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz} name: '@jridgewell/gen-mapping' diff --git a/src/assets/operate-icons/to_top.svg b/src/assets/operate-icons/to_top.svg new file mode 100644 index 00000000..78645853 --- /dev/null +++ b/src/assets/operate-icons/to_top.svg @@ -0,0 +1 @@ + diff --git a/src/components.d.ts b/src/components.d.ts index 990a61fa..a611c034 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -19,8 +19,10 @@ declare module '@vue/runtime-core' { ElPopover: typeof import('element-plus/es')['ElPopover'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] IconCommunity: typeof import('./components/icons/IconCommunity.vue')['default'] + IconDislike: typeof import('./components/icons/iconDislike.vue')['default'] IconDocumentation: typeof import('./components/icons/IconDocumentation.vue')['default'] IconEcosystem: typeof import('./components/icons/IconEcosystem.vue')['default'] + IconLike: typeof import('./components/icons/iconLike.vue')['default'] IconSupport: typeof import('./components/icons/IconSupport.vue')['default'] IconTooling: typeof import('./components/icons/IconTooling.vue')['default'] IEpArrowDownBold: typeof import('~icons/ep/arrow-down-bold')['default'] @@ -33,6 +35,7 @@ declare module '@vue/runtime-core' { IEpLock: typeof import('~icons/ep/lock')['default'] IEpMale: typeof import('~icons/ep/male')['default'] IEpSuccessFilled: typeof import('~icons/ep/success-filled')['default'] + LikeButton: typeof import('./components/LikeButton/index.vue')['default'] LoginBox: typeof import('./components/LoginBox/index.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/components/VirtualList/index.tsx b/src/components/VirtualList/index.tsx index e5c97397..0add0b96 100644 --- a/src/components/VirtualList/index.tsx +++ b/src/components/VirtualList/index.tsx @@ -178,12 +178,12 @@ export default defineComponent({ * @param index 索引值 * @description 如果索引值大于等于数据长度说明到底了则滚动到底部 */ - const scrollToIndex = (index: number) => { + const scrollToIndex = (index: number, smooth?: boolean) => { if (index >= props.data.length - 1) { scrollToBottom() } else { const offset = virtual.getOffset(index) - scrollToOffset(offset) + scrollToOffset(offset, smooth) } } @@ -191,9 +191,10 @@ export default defineComponent({ * 滚动到指定偏移量 * @param offset 滚动条偏移量 */ - const scrollToOffset = (offset: number) => { + const scrollToOffset = (offset: number, smooth = false) => { if (rootRef.value) { - rootRef.value.scrollTop = offset + // rootRef.value.scrollTop = offset + rootRef.value.scroll({ left: 0, top: offset, behavior: smooth ? 'smooth' : 'auto' }) } } @@ -236,13 +237,13 @@ export default defineComponent({ } // 滚动到底部 - const scrollToBottom = () => { + const scrollToBottom = (smooth?: boolean) => { if (shepherd.value) { const offset = shepherd.value.offsetTop - scrollToOffset(offset) + scrollToOffset(offset, smooth) setTimeout(() => { if (getOffset() + getClientSize() < getScrollSize()) { - scrollToBottom() + scrollToBottom(smooth) } }, 3) } diff --git a/src/components/icons/iconDislike.vue b/src/components/icons/iconDislike.vue new file mode 100644 index 00000000..4f4e1519 --- /dev/null +++ b/src/components/icons/iconDislike.vue @@ -0,0 +1,9 @@ + diff --git a/src/components/icons/iconLike.vue b/src/components/icons/iconLike.vue new file mode 100644 index 00000000..41fd66a3 --- /dev/null +++ b/src/components/icons/iconLike.vue @@ -0,0 +1,11 @@ + diff --git a/src/hooks/useLikeToggle.ts b/src/hooks/useLikeToggle.ts new file mode 100644 index 00000000..10f5656c --- /dev/null +++ b/src/hooks/useLikeToggle.ts @@ -0,0 +1,67 @@ +import { computed } from 'vue' +import apis from '@/services/apis' +import { ActType, MarkType, IsYet } from '@/services/types' +import type { MessageItemType } from '@/services/types' + +/** + * 统一点赞倒赞操作Hook + * @param message 消息 + * @description 引入该Hook后,可直接使用isLike(是否已点赞)、isDisLike(是否已倒赞)、onLike(点赞方法)、onDisLike(倒赞方法) + */ +export const useLikeToggle = (message: MessageItemType['message']) => { + const isLike = computed(() => message.messageMark.userLike === IsYet.Yes) + const isDisLike = computed(() => message.messageMark.userDislike === IsYet.Yes) + const likeCount = computed(() => message.messageMark.likeCount) + const dislikeCount = computed(() => message.messageMark.dislikeCount) + + /** + * 点赞\取消点赞 + * @description 根据是否已经点赞控制更新点赞状态 + */ + const onLike = async () => { + const actType = isLike.value ? ActType.Cancel : ActType.Confirm + await apis.markMsg({ actType, markType: MarkType.Like, msgId: message.id }).send() + + // 根据actType类型去更新本地点赞状态-点赞数 + const { likeCount } = message.messageMark + const isConfirm = actType === ActType.Confirm + message.messageMark.userLike = isConfirm ? IsYet.Yes : IsYet.No + message.messageMark.likeCount = isConfirm ? likeCount + 1 : likeCount - 1 + // 互斥操作 + if (isDisLike.value) { + message.messageMark.userDislike = IsYet.No + message.messageMark.dislikeCount = dislikeCount.value - 1 + } + } + + /** + * 倒赞\取消倒赞 + * @description 根据是否已经倒赞控制更新倒赞状态 + */ + const onDisLike = async () => { + const actType = isDisLike.value ? ActType.Cancel : ActType.Confirm + await apis.markMsg({ actType, markType: MarkType.DisLike, msgId: message.id }).send() + + // 根据actType类型去更新本地倒赞状态-倒赞数 + const { dislikeCount } = message.messageMark + const isConfirm = actType === ActType.Confirm + message.messageMark.userDislike = isConfirm ? IsYet.Yes : IsYet.No + message.messageMark.dislikeCount = isConfirm ? dislikeCount + 1 : dislikeCount - 1 + // 互斥操作 + if (isLike.value) { + message.messageMark.userLike = IsYet.No + message.messageMark.likeCount = likeCount.value - 1 + } + } + + return { + isLike, + isDisLike, + likeCount, + dislikeCount, + onLike, + onDisLike, + } +} + +export default useLikeToggle diff --git a/src/main.ts b/src/main.ts index b96be10f..f08fd7a0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { createApp } from 'vue' import dayjs from 'dayjs' import 'dayjs/locale/zh-cn' import weekday from 'dayjs/plugin/weekday' +import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css' // import 'element-plus/dist/index.css' import { createPinia } from 'pinia' diff --git a/src/services/types.ts b/src/services/types.ts index 97bcc285..98980923 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -70,6 +70,77 @@ export enum MessageType { Danger, } +export type MessageReplyType = { + /** + * 是否可消息跳转 0否 1是 + */ + canCallback: number + /** + * 是否可消息跳转 0否 1是 + */ + content: string + /** + * 跳转间隔的消息条数 + */ + gapCount: number + /** + * 消息id + */ + id: number + /** + * 用户名称 + */ + username: string +} + +export type MessageItemContentType = { + /** + * 消息内容 + */ + content: string + /** + * 消息id + */ + id: number + /** + * 消息标记 + */ + messageMark: { + /** + * 点赞数 + */ + likeCount: number + /** + * 该用户是否已经点赞 0否 1是 + */ + userLike: IsYet + /** + * 点赞数 + */ + dislikeCount: number + /** + * 到赞数 + */ + userDislike: IsYet + } + /** + * 父消息,如果没有父消息,返回的是null + */ + reply: MessageReplyType | null + /** + * 消息发送时间 + */ + sendTime: number + /** + * 消息类型 1正常文本 2.爆赞 (点赞超过10)3.危险发言(举报超5) + */ + type: MessageType + /** + * 消息中的链接 + */ + urlTitleMap: Record +} + export type MessageItemType = { /** * 发送者信息 @@ -108,74 +179,8 @@ export type MessageItemType = { /** * 消息详情 */ - message: { - /** - * 消息内容 - */ - content: string - /** - * 消息id - */ - id: number - /** - * 消息标记 - */ - messageMark: { - /** - * 点赞数 - */ - likeCount: number - /** - * 该用户是否已经点赞 0否 1是 - */ - userLike: IsYet - /** - * 点赞数 - */ - dislikeCount: number - /** - * 到赞数 - */ - userDislike: IsYet - } - /** - * 父消息,如果没有父消息,返回的是null - */ - reply: { - /** - * 是否可消息跳转 0否 1是 - */ - canCallback: number - /** - * 是否可消息跳转 0否 1是 - */ - content: string - /** - * 跳转间隔的消息条数 - */ - gapCount: number - /** - * 消息id - */ - id: number - /** - * 用户名称 - */ - username: string - } | null - /** - * 消息发送时间 - */ - sendTime: number - /** - * 消息类型 1正常文本 2.爆赞 (点赞超过10)3.危险发言(举报超5) - */ - type: MessageType - /** - * 消息中的链接 - */ - urlTitleMap: Record - } + message: MessageItemContentType + // 是否显示时间,有值才显示 timeBlock?: string } @@ -260,3 +265,26 @@ export type BadgeType = { // 是否佩戴 0否 1是 wearing: IsYet } + +export type MarkItemType = { + /** + * 操作用户 + */ + uid: number + /** + * 消息id + */ + msgId: number + /** + * 操作类型 1点赞 2举报 + */ + markType: MarkType + /** + * 数量 + */ + markCount: number + /** + * 动作类型 1确认 2取消 + */ + actType: ActType +} diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 387d6234..de6c04ba 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1,7 +1,8 @@ import { ref, reactive } from 'vue' import { defineStore } from 'pinia' import apis from '@/services/apis' -import type { MessageItemType } from '@/services/types' +import type { MessageItemType, MarkItemType } from '@/services/types' +import { MarkType } from '@/services/types' import { computedTimeBlock } from '@/utils/computedTime' import shakeTitle from '@/utils/shakeTitle' @@ -15,6 +16,18 @@ export const useChatStore = defineStore('chat', () => { const isLoading = ref(false) // 是否正在加载 const isStartCount = ref(false) // 是否开始计数 const cursor = ref() + const flashMsgId = ref(null) // 记录当前回复的消息id 用于当前消息的闪烁 + let timer: any = null // 消息闪烁计时器 + + // 开始闪烁 + const startFlash = (replyId: number) => { + flashMsgId.value = replyId + // 以最后一次为准,清除上一次的定时器 + clearTimeout(timer) + timer = setTimeout(() => { + flashMsgId.value = null + }, 2000) + } // 新消息计数 const newMsgCount = ref(0) @@ -22,9 +35,9 @@ export const useChatStore = defineStore('chat', () => { // 当前消息回复 const currentMsgReply = reactive>({}) - const getMsgList = async () => { + const getMsgList = async (size = pageSize) => { isLoading.value = true - const data = await apis.getMsgList({ params: { pageSize, cursor: cursor.value, roomId: 1 } }).send() + const data = await apis.getMsgList({ params: { pageSize: size, cursor: cursor.value, roomId: 1 } }).send() if (!data) return chatMessageList.value = [...computedTimeBlock(data.list), ...chatMessageList.value] cursor.value = data.cursor @@ -62,19 +75,44 @@ export const useChatStore = defineStore('chat', () => { chatMessageList.value = chatMessageList.value.filter((item) => item.fromUser.uid !== uid) } - const loadMore = async () => { + const loadMore = async (size?: number) => { if (isLast.value && isLoading.value) return - await getMsgList() + await getMsgList(size) } const clearNewMsgCount = () => { newMsgCount.value = 0 } + // 查找消息在列表里面的索引,倒过来的索引 + const getMsgIndex = (msgId: number) => { + if (!msgId || isNaN(Number(msgId))) return -1 + return chatMessageList.value.findIndex((item) => item.message.id === msgId) + } + + // 更新点赞、举报数 + const updateMarkCount = (markList: MarkItemType[]) => { + // 循环更新点赞数 + markList.forEach((mark: MarkItemType) => { + const { msgId, markType, markCount } = mark + + const msgItem = chatMessageList.value.find((item) => item.message.id === msgId) + if (msgItem) { + if (markType === MarkType.Like) { + msgItem.message.messageMark.likeCount = markCount + } else if (markType === MarkType.DisLike) { + msgItem.message.messageMark.dislikeCount = markCount + } + } + }) + } + return { + getMsgIndex, chatMessageList, pushMsg, clearNewMsgCount, + updateMarkCount, chatListToBottomAction, newMsgCount, isLoading, @@ -83,5 +121,7 @@ export const useChatStore = defineStore('chat', () => { loadMore, currentMsgReply, filterUser, + startFlash, + flashMsgId, } }) diff --git a/src/utils/copy.ts b/src/utils/copy.ts new file mode 100644 index 00000000..f15a5472 --- /dev/null +++ b/src/utils/copy.ts @@ -0,0 +1,17 @@ +export function copyToClip(text: string) { + return new Promise((resolve, reject) => { + try { + const input: HTMLTextAreaElement = document.createElement('textarea') + input.setAttribute('readonly', 'readonly') + input.value = text + input.style.zIndex = '-1' + document.body.appendChild(input) + input.select() + if (document.execCommand('copy')) document.execCommand('copy') + document.body.removeChild(input) + resolve(text) + } catch (error) { + reject(error) + } + }) +} diff --git a/src/utils/websocket.ts b/src/utils/websocket.ts index 9167e419..b11e8747 100644 --- a/src/utils/websocket.ts +++ b/src/utils/websocket.ts @@ -4,7 +4,7 @@ import { useChatStore } from '@/stores/chat' import { useGroupStore } from '@/stores/group' import { WsResponseMessageType, WsRequestMsgType } from './wsType' import type { LoginSuccessResType, LoginInitResType, WsReqMsgContentType, OnStatusChangeType } from './wsType' -import type { MessageItemType } from '@/services/types' +import type { MessageItemType, MarkItemType } from '@/services/types' import { OnlineStatus } from '@/services/types' import { worker } from './initWorker' import shakeTitle from '@/utils/shakeTitle' @@ -177,6 +177,12 @@ class WS { groupStore.filterUser(data.uid) break } + // 点赞、倒赞消息通知 + case WsResponseMessageType.WSMsgMarkItem: { + const data = params.data as { markList: MarkItemType[] } + chatStore.updateMarkCount(data.markList) + break + } default: { console.log('接收到未处理类型的消息:', params) break diff --git a/src/utils/wsType.ts b/src/utils/wsType.ts index ca8aa2a4..350d8678 100644 --- a/src/utils/wsType.ts +++ b/src/utils/wsType.ts @@ -30,6 +30,10 @@ export enum WsResponseMessageType { * 7.禁用的用户 */ InValidUser, + /** + * 8.点赞、倒赞更新通知 + */ + WSMsgMarkItem, } /** diff --git a/src/views/Home/components/ChatBox/index.vue b/src/views/Home/components/ChatBox/index.vue index 7f1c2bcf..b666b9c5 100644 --- a/src/views/Home/components/ChatBox/index.vue +++ b/src/views/Home/components/ChatBox/index.vue @@ -140,7 +140,12 @@ const insertText = (emoji: string) => {
    -
  • +
  • {{ emoji }}
diff --git a/src/views/Home/components/ChatList/MsgItem/index.vue b/src/views/Home/components/ChatList/MsgItem/index.vue index 24403db8..c3d73156 100644 --- a/src/views/Home/components/ChatList/MsgItem/index.vue +++ b/src/views/Home/components/ChatList/MsgItem/index.vue @@ -1,13 +1,16 @@