| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Message } from '#/views/im/home/types' |
| | | |
| | | import { computed, inject, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { useUserStore } from '#/packages/stores/src' |
| | | |
| | | import { Button, Calendar, Input, Modal, Popover, Tag } from 'ant-design-vue' |
| | | import dayjs from 'dayjs' |
| | | |
| | | import { getGroupMessageList as apiGetGroupMessageList } from '#/api/im/message/group' |
| | | import { getPrivateMessageList as apiGetPrivateMessageList } from '#/api/im/message/private' |
| | | import { useMessagePuller } from '#/views/im/home/composables/useMessagePuller' |
| | | import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer' |
| | | import { |
| | | ImContentType, |
| | | ImConversationType, |
| | | isFriendChatTip, |
| | | isGroupNotification |
| | | } from '#/views/im/utils/constants' |
| | | import { |
| | | buildFacePreviewText, |
| | | buildRecallTip, |
| | | buildRecallTipSegments, |
| | | getConversationKey |
| | | } from '#/views/im/utils/conversation' |
| | | import { getClientConversationId } from '#/views/im/utils/db' |
| | | import { |
| | | resolveFriendNotificationSegments, |
| | | resolveFriendNotificationText, |
| | | resolveGroupNotificationSegments |
| | | } from '#/views/im/utils/message' |
| | | import { |
| | | type CardMessage, |
| | | type FaceMessage, |
| | | type FileMessage, |
| | | getCardLabelInfo, |
| | | type MergeMessage, |
| | | parseMessage, |
| | | type TextMessage |
| | | } from '#/views/im/utils/message' |
| | | import { formatHistoryTime } from '#/views/im/utils/time' |
| | | import { |
| | | getMemberDisplayName, |
| | | getSenderDisplayName, |
| | | getSenderRealNickname |
| | | } from '#/views/im/utils/user' |
| | | |
| | | import { GroupMember, type GroupMemberLite } from '../../../../components/group' |
| | | import { UserAvatar } from '../../../../components/user' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useFriendStore } from '../../../../store/friendStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys' |
| | | import MessageBubble from './message-bubble.vue' |
| | | import TipSegments from './tip-segments.vue' |
| | | |
| | | defineOptions({ name: 'ImMessageHistory' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | // å岿¶æ¯è¡ä¸ç"å®ä½"æé®ï¼éç¥ç¶ç»ä»¶ MessagePanel æ»å°å¯¹åºæ¶æ¯ä½ç½® + å
³æèªå·± |
| | | locate: [messageId: number] |
| | | }>() |
| | | |
| | | const userStore = useUserStore() |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const groupStore = useGroupStore() |
| | | const friendStore = useFriendStore() |
| | | const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY) |
| | | const voicePlayer = useVoicePlayer() |
| | | const { convertPrivateMessage, convertGroupMessage } = useMessagePuller() |
| | | |
| | | const visible = ref(false) |
| | | |
| | | defineExpose({ |
| | | /** æå¼å岿¶æ¯æ½å± */ |
| | | open() { |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | const conversation = computed(() => conversationStore.activeConversation) |
| | | const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP) |
| | | const allMessages = computed<Message[]>(() => |
| | | conversation.value |
| | | ? messageStore.getMessages( |
| | | getClientConversationId(conversation.value.type, conversation.value.targetId) |
| | | ) |
| | | : [] |
| | | ) |
| | | |
| | | /** åæ¡æ¶æ¯çåé人æ¾ç¤ºåï¼æ¸²ææ¶æ conversation ä¸ä¸æèµ° WeChat ä¼å
çº§å®æ¶ç® */ |
| | | function senderDisplayNameOf(message: Message): string { |
| | | return getSenderDisplayName( |
| | | message.senderId, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | /** 群广æäºä»¶ segments çæååè§£æå¨ï¼æå½åä¼è¯ targetId èµ° getSenderDisplayName */ |
| | | function resolveGroupMemberName(message: Message): (userId: number) => string { |
| | | return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0) |
| | | } |
| | | |
| | | /** åæ¡æ¶æ¯çåé人ç宿µç§°ï¼ç» UserAvatar è²å¡ / alt ç¨ï¼æ°¸è¿æ¯ nickname 䏿ºå¤æ³¨ */ |
| | | function senderRealNicknameOf(message: Message): string { |
| | | return getSenderRealNickname( |
| | | message.senderId, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | /** åæ¡æ¤åæ¶æ¯ç tip ææ¡ï¼buildRecallTip å
鍿 conversation ä¸ä¸æå®æ¶ç® sender å */ |
| | | function recallTipOf(message: Message): string { |
| | | return buildRecallTip( |
| | | message.senderId, |
| | | message.selfSend, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | /** åæ¡æ¤åæ¶æ¯ç tip segmentsï¼sender åæ®µæå¯ç¹å» mention */ |
| | | function recallTipSegmentsOf(message: Message) { |
| | | return buildRecallTipSegments( |
| | | message.senderId, |
| | | message.selfSend, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | // ==================== æ é¢ ==================== |
| | | |
| | | /** |
| | | * å¼¹çªæ é¢ï¼åè微信 PC |
| | | * - ç§èï¼ä¸"X"çèå¤©è®°å½ |
| | | * - 群èï¼"X 群"çè天记å½(N) |
| | | */ |
| | | const title = computed(() => { |
| | | if (!conversation.value) { |
| | | return 'è天记å½' |
| | | } |
| | | const name = conversation.value.name |
| | | if (isGroup.value) { |
| | | return `"${name}"çè天记å½(${allMessages.value.length})` |
| | | } |
| | | return `ä¸"${name}"çè天记å½` |
| | | }) |
| | | |
| | | // ==================== æç´¢ + çé chip ==================== |
| | | |
| | | /** å½åæ¿æ´»çç鿡件 ââ å chip 模å¼ï¼åæ¶åª 1 个ï¼ï¼ç¹å» tab è½æ°å¼ï¼Ã æ¸
空 */ |
| | | type ActiveFilter = |
| | | | { day: string; kind: 'date'; } // YYYY-MM-DD |
| | | | { kind: 'file' } |
| | | | { kind: 'image' } |
| | | | { kind: 'member'; nickname: string; userId: number; } |
| | | | { kind: 'voice' } |
| | | |
| | | const keyword = ref('') |
| | | const activeFilter = ref<ActiveFilter | null>(null) |
| | | |
| | | /** chip ææ¡ï¼æ¥æ / 群æå å¤å¸¦å¼ï¼å
¶ä»ç´æ¥ tab å */ |
| | | const filterChipLabel = computed(() => { |
| | | if (!activeFilter.value) { |
| | | return '' |
| | | } |
| | | switch (activeFilter.value.kind) { |
| | | case 'date': { |
| | | return `æ¥æï¼${activeFilter.value.day}` |
| | | } |
| | | case 'file': { |
| | | return 'æä»¶' |
| | | } |
| | | case 'image': { |
| | | return 'å¾ç' |
| | | } |
| | | case 'member': { |
| | | return `@${activeFilter.value.nickname}` |
| | | } |
| | | case 'voice': { |
| | | return 'è¯é³' |
| | | } |
| | | default: { |
| | | return '' |
| | | } |
| | | } |
| | | }) |
| | | |
| | | /** ç¹ tab è½çéï¼å kind éå¤ç¹å» â å½ toggle å
³æï¼é¿å
è¿·æï¼ */ |
| | | function setFilter(filter: ActiveFilter) { |
| | | if ( |
| | | activeFilter.value?.kind === filter.kind && |
| | | filter.kind !== 'date' && |
| | | filter.kind !== 'member' |
| | | ) { |
| | | activeFilter.value = null |
| | | return |
| | | } |
| | | activeFilter.value = filter |
| | | } |
| | | |
| | | /** chip à å
³é / éç½® */ |
| | | function clearFilter() { |
| | | activeFilter.value = null |
| | | } |
| | | |
| | | // ==================== æ¥æ popover ==================== |
| | | |
| | | const datePopoverVisible = ref(false) |
| | | const datePickerValue = ref(dayjs()) |
| | | |
| | | /** æ¥æ popover ç¡®å®ï¼æ Date â YYYY-MM-DD è½å° activeFilterï¼å
³ popover */ |
| | | function onDateConfirm() { |
| | | if (!datePickerValue.value) { |
| | | datePopoverVisible.value = false |
| | | return |
| | | } |
| | | const day = datePickerValue.value.format('YYYY-MM-DD') |
| | | activeFilter.value = { kind: 'date', day } |
| | | datePopoverVisible.value = false |
| | | } |
| | | |
| | | // ==================== 群æå popover ==================== |
| | | |
| | | const memberPopoverVisible = ref(false) |
| | | const memberSearchKeyword = ref('') |
| | | |
| | | /** 群æå picker å表ï¼ä» groupStore æ + éé
GroupMemberLite + å
³é®åè¿æ»¤ */ |
| | | const filteredMembersForPicker = computed<GroupMemberLite[]>(() => { |
| | | if (!isGroup.value || !conversation.value) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.value.targetId) |
| | | const all = (group?.members || []).map((member) => { |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status |
| | | } |
| | | }) |
| | | const trimmedKeyword = memberSearchKeyword.value.trim() |
| | | if (!trimmedKeyword) { |
| | | return all |
| | | } |
| | | return all.filter((member) => member.showName.includes(trimmedKeyword)) |
| | | }) |
| | | |
| | | /** 群æå picker éæ©ï¼è½ activeFilter + å
³ popover + æ¸
æç´¢è¯ */ |
| | | function onMemberSelect(member: GroupMemberLite) { |
| | | activeFilter.value = { |
| | | kind: 'member', |
| | | userId: member.userId, |
| | | nickname: member.showName |
| | | } |
| | | memberPopoverVisible.value = false |
| | | memberSearchKeyword.value = '' |
| | | } |
| | | |
| | | // ==================== åè¡¨è¿æ»¤ ==================== |
| | | |
| | | /** activeFilter å½ä¸ï¼é»è®¤æ çéæ¶å
¨é¨å½ä¸ */ |
| | | function matchesActiveFilter(message: Message): boolean { |
| | | if (!activeFilter.value) { |
| | | return true |
| | | } |
| | | switch (activeFilter.value.kind) { |
| | | case 'date': { |
| | | return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day |
| | | } |
| | | case 'file': { |
| | | return message.type === ImContentType.FILE |
| | | } |
| | | case 'image': { |
| | | return message.type === ImContentType.IMAGE |
| | | } |
| | | case 'member': { |
| | | return message.senderId === activeFilter.value.userId |
| | | } |
| | | case 'voice': { |
| | | return message.type === ImContentType.VOICE |
| | | } |
| | | default: { |
| | | return true |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * å½åå表ï¼å
activeFilter è¿æ»¤ãå keyword 模ç³å½ä¸ãæå reverseï¼ææ°å¨åï¼ |
| | | * |
| | | * å
³é®åå½ä¸èµ° textSnippetOf ââ ææ¬æ¿åæãåªä½æ¿"[å¾ç]"çå ä½è¯ãæä»¶æ¿æä»¶å |
| | | */ |
| | | const currentList = computed<Message[]>(() => { |
| | | const trimmedKeyword = keyword.value.trim() |
| | | let list = allMessages.value.filter((message) => matchesActiveFilter(message)) |
| | | if (trimmedKeyword) { |
| | | list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword)) |
| | | } |
| | | return list.toReversed() |
| | | }) |
| | | |
| | | // ==================== å è½½æ´æ©æ¶æ¯ ==================== |
| | | |
| | | const HISTORY_PAGE_SIZE = 50 |
| | | const loadingMore = ref(false) |
| | | const hasMore = ref(true) |
| | | |
| | | /** |
| | | * å è½½æ´æ©æ¶æ¯ï¼æ¿å½åææ©ä¸æ¡ id ä½ maxIdï¼ä¸å«ï¼ï¼è° list æ¥å£æä¸é¡µ + convert + prepend |
| | | * |
| | | * - æªå¯¹æ¥ list æ¥å£ç type / keyword / sender è¿æ»¤åæ°ï¼åç«¯åªæ¯æ maxId + limit 游æ åé¡µï¼ |
| | | * tab çéå¨å端åï¼æ°æ®æ¥åå°æ¬å°åè¿æ»¤ï¼ |
| | | * - æ¬å°å ä½è·³è¿ï¼åç«¯æ²¡æ³æ messageId æ¥ |
| | | * - è¿åæ°é < limit è§ä¸ºå°é¡¶ |
| | | */ |
| | | async function loadEarlier() { |
| | | // éå
¥ / å°é¡¶ / æ ä¼è¯ æ©éï¼é¿å
éå¤è¯·æ±æå¨ conversation 忢é´é触å |
| | | if (loadingMore.value || !hasMore.value || !conversation.value) { |
| | | return |
| | | } |
| | | // ä»
PRIVATE / GROUP èµ°å页æ¥å£ï¼CHANNEL åå广æã没æ list æ¥å£ï¼è½å° else ä¼è¯¯è°ç§èæ¥å£ï¼receiverId ä¼ channelIdï¼ |
| | | const requestedType = conversation.value.type |
| | | if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) { |
| | | return |
| | | } |
| | | // å¿«ç
§å½åä¼è¯ä¸»é®ï¼await æé´ç¨æ·åèµ° / å
³é颿¿æ¶ä¸¢å¼ååºï¼é¿å
æ§ä¼è¯åå²è¢« prepend å°æ°ä¼è¯é æä¸²å· |
| | | const requestedKey = getConversationKey(conversation.value) |
| | | const requestedTargetId = conversation.value.targetId |
| | | const requestedIsGroup = requestedType === ImConversationType.GROUP |
| | | |
| | | loadingMore.value = true |
| | | try { |
| | | // ç® maxIdï¼ä¸å«ï¼ä½ä¸ºå端游æ ï¼ï¼åå½åä¼è¯æ¬å°ç¼åéææ©ä¸æ¡æå¡ç«¯ idï¼ |
| | | // æ¬å°ä¹è§å 使¶æ¯æ²¡ææå¡ç«¯ idï¼è¦åé¤ |
| | | // å
¨æ¯å ä½ / åè¡¨ä¸ºç©ºæ¶ reduce 䏿´æ°åå¼ï¼POSITIVE_INFINITYï¼ï¼è½¬æ undefined â åç«¯ä»ææ°æ |
| | | let earliestId = Number.POSITIVE_INFINITY |
| | | for (const message of allMessages.value) { |
| | | if (message.id && message.id > 0) { |
| | | earliestId = Math.min(earliestId, message.id) |
| | | } |
| | | } |
| | | const maxId = Number.isFinite(earliestId) ? earliestId : undefined |
| | | |
| | | // è°å端 list æ¥å£ï¼ç§è / ç¾¤èæ¥å£ç¾åä¸åï¼åæ¯è°åº¦ï¼è¿åç»æç¨ useMessagePuller |
| | | // æ´é²ç convert 彿°è½¬ææ¬å° Messageï¼ä¸ puller åä¸ä»½å段æ å°ï¼é¿å
åæ§ï¼ |
| | | let earlier: Message[] = [] |
| | | let pageLength = 0 |
| | | if (requestedIsGroup) { |
| | | const list = await apiGetGroupMessageList({ |
| | | groupId: requestedTargetId, |
| | | maxId, |
| | | limit: HISTORY_PAGE_SIZE |
| | | }) |
| | | earlier = (list || []).map((message) => convertGroupMessage(message)) |
| | | pageLength = list?.length ?? 0 |
| | | } else { |
| | | const list = await apiGetPrivateMessageList({ |
| | | receiverId: requestedTargetId, |
| | | maxId, |
| | | limit: HISTORY_PAGE_SIZE |
| | | }) |
| | | earlier = (list || []).map((message) => convertPrivateMessage(message)) |
| | | pageLength = list?.length ?? 0 |
| | | } |
| | | |
| | | // await æé´ active å¯è½è¢«å¤é¨ç½® null / æ¢ä¸»é®ï¼ç´æ¥ä¸¢å¼ååºï¼ä¸æ´æ° hasMoreï¼æ§ä¼è¯å°é¡¶ä¸ä»£è¡¨æ°ä¼è¯å°é¡¶ï¼ä¹ä¸ prepend |
| | | if (!conversation.value || getConversationKey(conversation.value) !== requestedKey) { |
| | | return |
| | | } |
| | | |
| | | // è¿åæ°é < limit è§ä¸ºå°é¡¶ ââ å
³é"å è½½æ´æ©"æé®ï¼é¿å
åç»ç¹å»ç©ºè·æ¥å£ |
| | | if (pageLength < HISTORY_PAGE_SIZE) { |
| | | hasMore.value = false |
| | | } |
| | | // åå¹¶å° messageStoreï¼prependMessageList å
é¨å»é + ååºåå¹¶ + è½ IndexedDBï¼ |
| | | // 主è天颿¿ç messages æ¯åä¸ä»½å¼ç¨ï¼èæ¶æ¯ä¹ä¼ä¸èµ·åºç°å¨ä¸»é¢æ¿éï¼ç¬¦åé¢æï¼ |
| | | messageStore.prependMessageList(requestedType, requestedTargetId, earlier) |
| | | } finally { |
| | | loadingMore.value = false |
| | | } |
| | | } |
| | | |
| | | // ==================== å¼¹çªæå¼ reset ==================== |
| | | |
| | | /** å¼¹çªæå¼æ¶æä¸æ¬¡ç chip / æç´¢ / å è½½ç¶æé½æ¸
å¹²åï¼é¿å
䏿¬¡çç¶ææ®çè¿·æ */ |
| | | function onDialogOpen() { |
| | | activeFilter.value = null |
| | | keyword.value = '' |
| | | hasMore.value = true |
| | | datePopoverVisible.value = false |
| | | memberPopoverVisible.value = false |
| | | memberSearchKeyword.value = '' |
| | | datePickerValue.value = dayjs() |
| | | // æ¬å°æ æ¶æ¯æ¶ç«å³æä¸æ¬¡ï¼maxId=undefined 仿æ°å¼å§ï¼ï¼é¿å
æ°è®¾å¤ / ç¼åæ¸
åå¼¹çªåªæ¾ç¤º"ææ æ¶æ¯" |
| | | if (allMessages.value.length === 0 && hasMore.value && conversation.value) { |
| | | void loadEarlier() |
| | | } |
| | | } |
| | | |
| | | /** æ½å±å
³éæ¶å¤ä½ + åè¯é³ */ |
| | | watch(visible, (value) => { |
| | | if (!value) { |
| | | activeFilter.value = null |
| | | keyword.value = '' |
| | | voicePlayer.stop() |
| | | } |
| | | }) |
| | | |
| | | /** |
| | | * æ½å±å¼çæ¶å¤é¨åäº active conversationï¼dialog ç title / å表 / isGroup å
¨é¨è·çæ° conversation èµ°ï¼ |
| | | * è¿éæå页æä¸å¹¶éç½®ï¼å¦åæ§ä¼è¯æ®çç loadingMore=true / hasMore=false ä¼è®©æ°ä¼è¯"å è½½æ´æ©"æé®å¤±æ |
| | | */ |
| | | watch(conversation, () => { |
| | | loadingMore.value = false |
| | | hasMore.value = true |
| | | }) |
| | | |
| | | // ==================== helper ==================== |
| | | |
| | | /** å头å urlï¼èªå·±ç¨ userStoreï¼ç¾¤éæ¥ groupStore æåï¼ç§èç¨ conversation.avatar */ |
| | | function getAvatar(message: Message): string { |
| | | if (message.selfSend) { |
| | | return userStore.userInfo?.avatar || '' |
| | | } |
| | | if (!conversation.value) { |
| | | return '' |
| | | } |
| | | if (isGroup.value) { |
| | | const group = groupStore.getGroup(conversation.value.targetId) |
| | | return group?.members?.find((member) => member.userId === message.senderId)?.avatar || '' |
| | | } |
| | | return conversation.value.avatar || '' |
| | | } |
| | | |
| | | /** å
³é®åå½ä¸ææ¬ï¼ææ¬ç±»è¿ååæãæä»¶è¿åæä»¶åï¼å©äºææä»¶åæï¼ãå
¶ä»è¿åå ä½è¯ */ |
| | | function textSnippetOf(message: Message): string { |
| | | if (isFriendChatTip(message.type)) { |
| | | return resolveFriendNotificationText(message) |
| | | } |
| | | switch (message.type) { |
| | | case ImContentType.CARD: { |
| | | const card = parseMessage<CardMessage>(message.content) |
| | | return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}` |
| | | } |
| | | case ImContentType.FACE: { |
| | | return buildFacePreviewText(parseMessage<FaceMessage>(message.content)) |
| | | } |
| | | case ImContentType.FILE: { |
| | | return parseMessage<FileMessage>(message.content)?.name ?? '[æä»¶]' |
| | | } |
| | | case ImContentType.IMAGE: { |
| | | return '[å¾ç]' |
| | | } |
| | | case ImContentType.MERGE: { |
| | | const merge = parseMessage<MergeMessage>(message.content) |
| | | return merge?.title ?? '[è天记å½]' |
| | | } |
| | | case ImContentType.RECALL: { |
| | | return recallTipOf(message) |
| | | } |
| | | case ImContentType.TEXT: { |
| | | return parseMessage<TextMessage>(message.content)?.content ?? '' |
| | | } |
| | | case ImContentType.VIDEO: { |
| | | return '[è§é¢]' |
| | | } |
| | | case ImContentType.VOICE: { |
| | | return '[è¯é³]' |
| | | } |
| | | default: { |
| | | return '' |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * å®ä½å°è天ä½ç½®ï¼emit ç» MessagePanel èµ° scrollIntoView + çæé«äº®ï¼åå
³æèªå·± |
| | | * messageId === 0ï¼æ¬å°å 使¶æ¯ï¼è·³è¿ââè¿æ²¡æ¿å°çå® idï¼DOM 䏿²¡æ³ querySelector |
| | | */ |
| | | function locateMessage(messageId: number) { |
| | | if (!messageId) { |
| | | return |
| | | } |
| | | emit('locate', messageId) |
| | | visible.value = false |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | å岿¶æ¯å¼¹çªï¼å¯¹é½å¾®ä¿¡ PCï¼ |
| | | - å±
ä¸ dialogï¼title "ä¸ãXãè天记å½" / "ãX 群ãè天记å½(N)" |
| | | - é»è®¤æ çéï¼å
¨éååºå±ç¤ºï¼ç¹å» tab è½çé â æç´¢æ¡å·¦ä¾§åº chip Ã å¯æ¸
|
| | | - æä»¶ / å¾ç / 龿¥ï¼ç´æ¥è½çéï¼æ¥æ / 群æåï¼å¼¹ popover äºæ¬¡éæ©åè½ |
| | | - "å è½½æ´æ©æ¶æ¯"ï¼å表åºé¨æé®ï¼ç¹å»è° /im/message/{private,group}/list æ¿ä¸é¡µ + prepend |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | :title="title" |
| | | width="640px" |
| | | :footer="null" |
| | | class="im-message-history__dialog" |
| | | @after-open-change="(open: boolean) => open && onDialogOpen()" |
| | | > |
| | | <div class="flex flex-col gap-3 h-[520px]"> |
| | | <!-- æç´¢åºï¼activeFilter å卿¶å·¦ä¾§åº chip Ã å¯æ¸
ï¼å¦å纯æç´¢æ¡ --> |
| | | <div |
| | | class="im-message-history__searchbar flex items-center gap-2 px-3 py-1.5 rounded-md bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <Tag |
| | | v-if="activeFilter" |
| | | closable |
| | | size="small" |
| | | class="im-message-history__chip flex-shrink-0" |
| | | @close="clearFilter" |
| | | > |
| | | {{ filterChipLabel }} |
| | | </Tag> |
| | | <Icon |
| | | v-else |
| | | icon="ant-design:search-outlined" |
| | | class="text-[var(--ant-color-text-secondary)]" |
| | | /> |
| | | <input |
| | | v-model="keyword" |
| | | type="text" |
| | | placeholder="æç´¢" |
| | | class="im-message-history__search-input flex-1 min-w-0 bg-transparent border-none outline-none text-sm text-[var(--ant-color-text)]" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- Tab è¡ï¼æä»¶ / å¾ç / è¯é³ / æ¥æ(popover) / 群æå(popover, ä»
群è)ï¼åºé¨ä¸æ¡åå²çº¿æ tab åºè·æ¶æ¯å表åå¼ï¼å¯¹é½å¾®ä¿¡è§æ --> |
| | | <div |
| | | class="flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa] border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'file' }" |
| | | @click="setFilter({ kind: 'file' })" |
| | | > |
| | | æä»¶ |
| | | </span> |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'image' }" |
| | | @click="setFilter({ kind: 'image' })" |
| | | > |
| | | å¾ç |
| | | </span> |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'voice' }" |
| | | @click="setFilter({ kind: 'voice' })" |
| | | > |
| | | è¯é³ |
| | | </span> |
| | | <!-- æ¥æï¼el-popover å
el-calendarï¼ç¡®è®¤åæè½çé --> |
| | | <Popover |
| | | v-model:open="datePopoverVisible" |
| | | trigger="click" |
| | | placement="bottom" |
| | | :overlay-style="{ width: '320px' }" |
| | | > |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'date' }" |
| | | > |
| | | æ¥æ |
| | | </span> |
| | | <template #content> |
| | | <div class="im-message-history__date-panel"> |
| | | <div class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--ant-color-text)]"> |
| | | éæ©å鿥æ |
| | | </div> |
| | | <Calendar |
| | | v-model:value="datePickerValue" |
| | | :fullscreen="false" |
| | | class="im-message-history__calendar" |
| | | /> |
| | | <div class="flex gap-2 justify-end px-2 pt-2"> |
| | | <Button size="small" @click="datePopoverVisible = false">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="onDateConfirm">ç¡®å®</Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | <!-- 群æåï¼ä»
群èï¼popover å
èªå¸¦æç´¢ + GroupMember å表 --> |
| | | <Popover |
| | | v-if="isGroup" |
| | | v-model:open="memberPopoverVisible" |
| | | trigger="click" |
| | | placement="bottom" |
| | | :overlay-style="{ width: '320px' }" |
| | | > |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'member' }" |
| | | > |
| | | 群æå |
| | | </span> |
| | | <template #content> |
| | | <div> |
| | | <Input v-model:value="memberSearchKeyword" placeholder="æç´¢ç¾¤æå" size="small"> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | <div class="max-h-[360px] overflow-y-auto mt-2"> |
| | | <GroupMember |
| | | v-for="member in filteredMembersForPicker" |
| | | :key="member.userId" |
| | | :member="member" |
| | | :height="44" |
| | | :clickable="false" |
| | | class="cursor-pointer hover:bg-[var(--ant-color-fill)]" |
| | | @click="onMemberSelect(member)" |
| | | /> |
| | | <div |
| | | v-if="filteredMembersForPicker.length === 0" |
| | | class="py-6 text-12px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | 没æå¹é
çæå |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | </div> |
| | | |
| | | <!-- æ¶æ¯å表 --> |
| | | <div class="flex-1 overflow-y-auto"> |
| | | <template v-for="message in currentList" :key="message.id || message.clientMessageId"> |
| | | <!-- 好åä¼è¯äºä»¶ï¼FRIEND_ADD / FRIEND_DELETEï¼ï¼å±
ä¸ç°è²ï¼ä¸æå¤´å / senderï¼ |
| | | è·ä¸»è天颿¿é MessageItem çæ¸²æè¯ä¹å¯¹é½ --> |
| | | <div |
| | | v-if="isFriendChatTip(message.type)" |
| | | class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <TipSegments :segments="resolveFriendNotificationSegments(message)" /> |
| | | </div> |
| | | |
| | | <!-- 群广æäºä»¶ï¼è·å¥½åäºä»¶åç°è²æ ·å¼ï¼mention 段æç¹å»å¼¹ UserInfoCard --> |
| | | <div |
| | | v-else-if="isGroupNotification(message.type)" |
| | | class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <TipSegments |
| | | :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ®éæ¶æ¯è¡ --> |
| | | <div |
| | | v-else |
| | | class="im-message-history__row flex gap-3 items-start px-1 py-3 border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <UserAvatar |
| | | :url="getAvatar(message)" |
| | | :name="senderRealNicknameOf(message)" |
| | | :size="36" |
| | | :clickable="false" |
| | | /> |
| | | <div class="flex-1 min-w-0"> |
| | | <div |
| | | class="flex justify-between items-start text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <span class="font-medium text-[var(--ant-color-text)]"> |
| | | {{ senderDisplayNameOf(message) }} |
| | | </span> |
| | | <span class="im-message-history__meta relative flex-shrink-0"> |
| | | <span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span> |
| | | <!-- å®ä½å°è天ä½ç½®ï¼absolute æµ®å¨æ¶é´ä¸æ¹ï¼è¡ hover ææ¾ç¤ºï¼ |
| | | ä¸åä¸å³ä¾§æ flex æçï¼é¿å
éèæ¶å ä½è®©"æ"åå
容ä¹é´ç空ï¼ï¼ |
| | | ä»
æçå® id çæ¶æ¯ææ¯æï¼æ¬å°å 使¶æ¯ä¸è¡ï¼ --> |
| | | <span |
| | | v-if="message.id && message.id > 0" |
| | | class="im-message-history__locate" |
| | | @click="locateMessage(message.id!)" |
| | | > |
| | | å®ä½å°è天ä½ç½® |
| | | </span> |
| | | </span> |
| | | </div> |
| | | |
| | | <div class="mt-1.5"> |
| | | <!-- æ¤åï¼åç¬èµ°ç°è² tipï¼sender åæ®µå¯ç¹å» --> |
| | | <div |
| | | v-if="message.type === ImContentType.RECALL" |
| | | class="text-sm italic text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="recallTipSegmentsOf(message)" /> |
| | | </div> |
| | | <!-- å
¶å®ç±»åèµ° MessageBubble å¤ç¨ä¸»èå¤©æ°æ³¡ --> |
| | | <MessageBubble |
| | | v-else |
| | | :type="message.type" |
| | | :content="message.content" |
| | | @open-merge="openMergeDetail?.($event)" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <div |
| | | v-if="currentList.length === 0" |
| | | class="py-10 text-center text-13px text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ keyword || activeFilter ? '没æå¹é
çæ¶æ¯' : 'ææ æ¶æ¯' }} |
| | | </div> |
| | | |
| | | <!-- å è½½æ´æ©æ¶æ¯ï¼å表åºé¨ triggerï¼reverse åææ©ç卿ä¸ï¼æé®æ¾åºé¨æ´èªç¶ï¼ï¼ |
| | | filter å½ä¸ 0 æ¡æ¶ä»ä¿ç ââ å è½½æ´æ©å¯è½å¸¦åå¹é
å
容 --> |
| | | <div |
| | | v-if="hasMore && allMessages.length > 0" |
| | | class="py-3 text-center border-t border-t-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <Button :loading="loadingMore" type="link" @click="loadEarlier"> |
| | | å è½½æ´æ©æ¶æ¯ |
| | | </Button> |
| | | </div> |
| | | <!-- "æ²¡ææ´æ©" åªå¨å·²æå¹é
项æ¶é²åºï¼é¿å
åä¸é¢"没æå¹é
çæ¶æ¯"ç©ºæææ¡éå --> |
| | | <div |
| | | v-else-if="!hasMore && currentList.length > 0" |
| | | class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | æ²¡ææ´æ©çæ¶æ¯äº |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* :deep ç©¿é el-tag åç»ä»¶ DOMï¼æç´¢åº chip ç¦æ hover é¢è²è¿æ¸¡ / Ã å¾æ 卿ï¼é¿å
å¨æç´¢åºéææå¨æ */ |
| | | .im-message-history__chip, |
| | | .im-message-history__chip :deep(.el-tag__close) { |
| | | transition: none !important; |
| | | animation: none !important; |
| | | } |
| | | |
| | | /* ãå®ä½å°è天ä½ç½®ãæé®ï¼ç¶è¡ hover ææ¾ç¤ºï¼èµ° .parent:hover .child è·¨å
ç´ ç¶æèå¨ */ |
| | | .im-message-history__locate { |
| | | position: absolute; |
| | | top: 100%; |
| | | right: 0; |
| | | margin-top: 4px; |
| | | display: none; |
| | | white-space: nowrap; |
| | | color: #1989fa; |
| | | cursor: pointer; |
| | | transition: color 0.15s; |
| | | } |
| | | .im-message-history__row:hover .im-message-history__locate { |
| | | display: block; |
| | | } |
| | | .im-message-history__locate:hover { |
| | | color: #146fc7; |
| | | } |
| | | |
| | | /* ::after 伪å
ç´ ç»æ¿æ´»ä¸å线ï¼tab é»è®¤èè²è¡å
æåï¼æ¿æ´»æå ä¸å线 */ |
| | | .im-message-history__tab { |
| | | position: relative; |
| | | padding: 4px 2px; |
| | | color: #1989fa; |
| | | transition: color 0.15s; |
| | | } |
| | | .im-message-history__tab:hover { |
| | | color: #2f81d4; |
| | | } |
| | | .im-message-history__tab--active::after { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | height: 2px; |
| | | background: #1989fa; |
| | | border-radius: 1px; |
| | | } |
| | | |
| | | /* :deep ç©¿é antd Calendarï¼fullscreen=false å¡ç模å¼ï¼å DOMï¼åä¸åå¡è¿ 320 popover */ |
| | | .im-message-history__calendar :deep(.ant-picker-calendar-header) { |
| | | padding: 4px 8px; |
| | | } |
| | | .im-message-history__calendar :deep(.ant-picker-content) { |
| | | font-size: 12px; |
| | | } |
| | | .im-message-history__calendar :deep(.ant-picker-cell) { |
| | | padding: 1px 0; |
| | | } |
| | | .im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) { |
| | | height: 28px; |
| | | margin: 0 2px; |
| | | padding: 2px 4px 0; |
| | | } |
| | | </style> |