feat(im): 支持历史消息的加载

im
YunaiV 2026-04-28 01:08:45 +08:00
parent e9be6ef8b3
commit 9c5b11e551
5 changed files with 796 additions and 151 deletions

View File

@ -42,7 +42,7 @@
class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10"
@click="goProfile"
>
<el-icon :size="22"><Setting /></el-icon>
<Icon icon="ant-design:setting-outlined" :size="22" />
</div>
</el-tooltip>
</div>
@ -52,8 +52,6 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Setting } from '@element-plus/icons-vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../store/conversationStore'

View File

@ -257,5 +257,5 @@ export const useMessagePuller = () => {
}
)
return { pullOnce }
return { pullOnce, convertPrivateMessage, convertGroupMessage }
}

View File

@ -1,21 +1,24 @@
<template>
<!--
群成员单行对应 boxim chat/ChatGroupMember.vue
群成员单行
跨子域复用@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ChatGroupSide)
-->
<div
class="im-chat-group-member"
:class="{ 'is-active': active }"
class="relative flex items-center px-[5px] box-border whitespace-nowrap"
:class="{ 'bg-[#e1eaf7] dark:bg-[var(--el-color-primary-light-9)]': active }"
:style="{ height: height + 'px' }"
>
<UserAvatar
:size="avatarSize"
:name="member.showNickName"
:url="member.headImage"
:url="member.showImage"
:clickable="clickable"
:id="member.userId"
/>
<div class="im-chat-group-member__name" :style="{ lineHeight: height + 'px' }">
<div
class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--el-text-color-regular)]"
:style="{ lineHeight: height + 'px' }"
>
{{ member.showNickName }}
</div>
</div>
@ -30,25 +33,20 @@ defineOptions({ name: 'ImChatGroupMember' })
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts */
export interface GroupMemberLite {
/** 用户 id特殊值 -1 表示「全体成员」 */
userId: number | string
/** 展示昵称:优先群备注,再群昵称,再用户昵称 */
showNickName: string
/** 头像 URL */
headImage?: string
/** 是否已退群 */
quit?: boolean
userId: number // IM_AT_ALL_USER_ID@
showNickName: string //
showImage?: string
// GroupMember.status退
// CommonStatusEnum.DISABLE producer quit
status?: number
}
const props = withDefaults(
defineProps<{
member: GroupMemberLite
/** 行高px影响头像大小 */
height?: number
/** 选中态(@候选键盘高亮等) */
active?: boolean
/** 头像点击是否弹 UserInfoCard@候选场景通常禁用(避免嵌套交互) */
clickable?: boolean
height?: number // px
active?: boolean // @
clickable?: boolean // UserInfoCard@
}>(),
{
height: 50,
@ -59,29 +57,3 @@ const props = withDefaults(
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
</script>
<style scoped>
.im-chat-group-member {
position: relative;
display: flex;
align-items: center;
padding: 0 5px;
box-sizing: border-box;
white-space: nowrap;
}
.im-chat-group-member.is-active {
background-color: #e1eaf7;
}
.im-chat-group-member__name {
flex: 1;
height: 100%;
padding-left: 10px;
overflow: hidden;
font-size: 14px;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@ -1,162 +1,801 @@
<template>
<!--
历史消息抽屉对应 boxim chat/ChatHistory.vue
- 从输入框工具栏触发展示当前会话的全部历史消息
- 简化实现基于本地缓存的 activeChat.messages 全量展示 + 关键词搜索
- 未来可接入后端按时间段 / 发送人查询并用 PagedScroller 做增量加载
历史消息弹窗对齐微信 PC
- 居中 dialogtitle "与「X」聊天记录" / "「X 群」聊天记录(N)"
- 默认无筛选全量倒序展示点击 tab 落筛选 搜索框左侧出 chip × 可清
- 文件 / 图片 / 链接直接落筛选日期 / 群成员 popover 二次选择再落
- "加载更早消息"列表底部按钮点击调 /im/message/{private,group}/list 拿一页 + prepend
-->
<el-drawer
<el-dialog
v-model="visible"
title="历史消息"
direction="rtl"
size="420px"
:title="title"
width="640px"
append-to-body
class="im-message-history__dialog"
@open="onDialogOpen"
>
<div class="im-message-history">
<el-input
v-model="keyword"
placeholder="搜索消息内容"
clearable
class="im-message-history__search"
/>
<div class="im-message-history__count">
{{ filtered.length }} {{ keyword ? '(过滤后)' : '' }}
<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(--el-fill-color-light)]"
>
<el-tag
v-if="activeFilter"
closable
size="small"
round
class="im-message-history__chip flex-shrink-0"
@close="clearFilter"
>
{{ filterChipLabel }}
</el-tag>
<el-icon v-else class="text-[var(--el-text-color-secondary)]"><Search /></el-icon>
<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(--el-text-color-primary)]"
/>
</div>
<div class="im-message-history__list">
<div
v-for="msg in filtered"
:key="msg.id || msg.tmpId"
class="im-message-history__item"
<!-- Tab 文件 / 图片 / 语音 / 日期(popover) / 群成员(popover, 仅群聊)
底部一条分割线把 tab 区跟消息列表分开对齐微信观感border scoped CSS 走主题变量 -->
<div
class="im-message-history__tabs flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa]"
>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'file' }"
@click="setFilter({ kind: 'file' })"
>
<div class="im-message-history__meta">
<span class="im-message-history__sender">{{ msg.selfSend ? '我' : (msg.sendNickName || '对方') }}</span>
<span class="im-message-history__time">{{ formatTime(msg.sendTime) }}</span>
文件
</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确认后才落筛选 -->
<el-popover
v-model:visible="datePopoverVisible"
trigger="click"
placement="bottom"
:width="320"
>
<template #reference>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'date' }"
>
日期
</span>
</template>
<div class="im-message-history__date-panel">
<div class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--el-text-color-primary)]">
选择发送日期
</div>
<el-calendar v-model="datePickerValue" class="im-message-history__calendar" />
<div class="flex gap-2 justify-end px-2 pt-2">
<el-button size="small" @click="datePopoverVisible = false">取消</el-button>
<el-button size="small" type="primary" @click="onDateConfirm"></el-button>
</div>
</div>
<div class="im-message-history__content">{{ renderContent(msg) }}</div>
</el-popover>
<!-- 群成员仅群聊popover 内自带搜索 + ChatGroupMember 列表 -->
<el-popover
v-if="isGroup"
v-model:visible="memberPopoverVisible"
trigger="click"
placement="bottom"
:width="320"
>
<template #reference>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'member' }"
>
群成员
</span>
</template>
<div>
<el-input v-model="memberSearchKeyword" placeholder="搜索群成员" size="small">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="max-h-[360px] overflow-y-auto mt-2">
<ChatGroupMember
v-for="member in filteredMembersForPicker"
:key="member.userId"
:member="member"
:height="44"
:clickable="false"
class="cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="onMemberSelect(member)"
/>
<div
v-if="filteredMembersForPicker.length === 0"
class="py-6 text-12px text-center text-[var(--el-text-color-disabled)]"
>
没有匹配的成员
</div>
</div>
</div>
</el-popover>
</div>
<!-- 消息列表 -->
<div class="flex-1 overflow-y-auto">
<template
v-for="message in currentList"
:key="message.id || message.clientMessageId"
>
<!-- TIP_TEXT 系统提示"你们已成为好友"居中灰色不挂头像 / sender
跟主聊天面板里 MessageItem 的渲染语义对齐 -->
<div
v-if="message.type === ImMessageType.TIP_TEXT"
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
>
{{ resolveTipText(message.content) }}
</div>
<!-- 普通消息行 -->
<div
v-else
class="im-message-history__row flex gap-3 items-start px-1 py-3 border-b border-[var(--el-border-color-lighter)]"
>
<UserAvatar
:url="getAvatar(message)"
:name="
message.selfSend
? userStore.getUser?.nickname
: message.senderNickName || '对方'
"
:size="36"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div
class="flex justify-between items-start text-12px text-[var(--el-text-color-secondary)]"
>
<span class="font-medium text-[var(--el-text-color-regular)]">
{{
message.selfSend
? userStore.getUser?.nickname || ''
: message.senderNickName || ''
}}
</span>
<span class="im-message-history__meta relative flex-shrink-0">
<span class="block text-right">{{ formatTime(message.sendTime) }}</span>
<!-- 定位到聊天位置absolute 浮在时间下方 hover 才显示
不参与右侧栏 flex 排版避免隐藏时占位让"我"和内容之间留空
仅有真实 id 的消息才支持本地占位消息 id=0 不行 -->
<span
v-if="message.id > 0"
class="im-message-history__locate"
@click="locateMessage(message.id)"
>
定位到聊天位置
</span>
</span>
</div>
<div class="mt-1.5">
<!-- 文本 -->
<div
v-if="message.type === ImMessageType.TEXT"
class="text-sm leading-normal break-all text-[var(--el-text-color-primary)]"
>
{{ parseMessage<TextMessage>(message.content)?.content ?? '' }}
</div>
<!-- 图片el-image 缩略 + 点击预览 -->
<el-image
v-else-if="message.type === ImMessageType.IMAGE && imageOf(message)"
class="max-w-[160px] max-h-[120px] rounded cursor-zoom-in"
:src="imageOf(message)?.thumbnailUrl || imageOf(message)?.url"
:preview-src-list="[imageOf(message)?.url || '']"
:preview-teleported="true"
fit="cover"
/>
<!-- 文件彩色文件类型图标 + + 大小点击新窗口打开 -->
<div
v-else-if="message.type === ImMessageType.FILE && fileOf(message)"
class="inline-flex gap-2 items-center max-w-[300px] px-3 py-2 border rounded cursor-pointer transition-colors hover:border-[#409eff]"
@click="openFile(fileOf(message)?.url)"
>
<Icon
:icon="getFileIcon(fileOf(message)?.name || '').icon"
:color="getFileIcon(fileOf(message)?.name || '').color"
:size="32"
class="flex-shrink-0"
/>
<div class="flex-1 min-w-0">
<div
class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ fileOf(message)?.name }}
</div>
<div class="mt-0.5 text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(fileOf(message)?.size || 0) }}
</div>
</div>
</div>
<!-- 语音 -->
<div
v-else-if="message.type === ImMessageType.VOICE"
class="text-sm text-[var(--el-text-color-secondary)]"
>
[语音 {{ formatSeconds(audioOf(message)?.duration || 0) }}]
</div>
<!-- 视频 -->
<div
v-else-if="message.type === ImMessageType.VIDEO"
class="text-sm text-[var(--el-text-color-secondary)]"
>
[视频]
</div>
<!-- 撤回 -->
<div
v-else-if="message.type === ImMessageType.RECALL"
class="text-sm italic text-[var(--el-text-color-secondary)]"
>
{{ buildRecallTip(message.senderNickName || '', !!message.selfSend) }}
</div>
<!-- 兜底 -->
<div v-else class="text-sm italic text-[var(--el-text-color-secondary)]">
[不支持的消息类型]
</div>
</div>
</div>
</div>
</template>
<div v-if="currentList.length === 0" class="im-message-history__empty">
{{ keyword || activeFilter ? '没有匹配的消息' : '暂无消息' }}
</div>
<div v-if="filtered.length === 0" class="im-message-history__empty">
{{ keyword ? '没有匹配的消息' : '暂无历史消息' }}
<!-- 加载更早消息列表底部 triggerreverse 后最早的在最下按钮放底部更自然
filter 命中 0 条时仍保留 加载更早可能带回匹配内容 -->
<div
v-if="hasMore && allMessages.length > 0"
class="py-3 text-center border-t border-[var(--el-border-color-lighter)]"
>
<el-button :loading="loadingMore" link type="primary" @click="loadEarlier">
加载更早消息
</el-button>
</div>
<!-- "没有更早" 只在已有匹配项时露出避免和上面"没有匹配的消息"空态文案重叠 -->
<div
v-else-if="!hasMore && currentList.length > 0"
class="py-3 text-12px text-center text-[var(--el-text-color-disabled)]"
>
没有更早的消息了
</div>
</div>
</div>
</el-drawer>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
import { useChatStore } from '../../../../store/chatStore'
import { ImMessageType } from '../../../../../utils/constants'
import { parseTextContent, buildRecallTip } from '../../../../../utils'
import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime'
import { getPrivateMessageList as apiGetPrivateMessageList } from '@/api/im/message/private'
import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessagePuller } from '../../../../composables/useMessagePuller'
import { ImConversationType, ImMessageType } from '../../../../../utils/constants'
import {
parseMessage,
buildRecallTip,
resolveTipText,
type TextMessage,
type ImageMessage,
type FileMessage,
type AudioMessage
} from '../../../../../utils/message'
import type { Message } from '../../../../types'
import UserAvatar from '../../../../components/UserAvatar.vue'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
defineOptions({ name: 'ImMessageHistory' })
const props = defineProps<{
/** v-model 控制抽屉显隐 */
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
// "" ChatPanel +
locate: [messageId: number]
}>()
const chatStore = useChatStore()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
set: (value) => emit('update:modelValue', value)
})
const conversation = computed(() => conversationStore.activeConversation)
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
const allMessages = computed<Message[]>(() => conversation.value?.messages || [])
// ==================== ====================
/**
* 弹窗标题参考微信 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 =
| { kind: 'file' }
| { kind: 'image' }
| { kind: 'voice' }
| { kind: 'date'; day: string } // YYYY-MM-DD
| { kind: 'member'; userId: number; nickname: string }
const keyword = ref('')
const activeFilter = ref<ActiveFilter | null>(null)
const filtered = computed(() => {
const all = chatStore.activeChat?.messages || []
const kw = keyword.value.trim()
if (!kw) return [...all].reverse()
return all.filter((m) => renderContent(m).includes(kw)).reverse()
/** chip 文案:日期 / 群成员 多带值,其他直接 tab 名 */
const filterChipLabel = computed(() => {
if (!activeFilter.value) {
return ''
}
switch (activeFilter.value.kind) {
case 'file':
return '文件'
case 'image':
return '图片'
case 'voice':
return '语音'
case 'date':
return `日期:${activeFilter.value.day}`
case 'member':
return `@${activeFilter.value.nickname}`
default:
return ''
}
})
function renderContent(msg: { type: number; content: string; sendNickName?: string; selfSend?: boolean }): string {
switch (msg.type) {
/** 点 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<Date>(new Date())
/** 日期 popover 确定:把 Date → YYYY-MM-DD 落到 activeFilter关 popover */
function onDateConfirm() {
if (!datePickerValue.value) {
datePopoverVisible.value = false
return
}
const day = dayjs(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) => ({
userId: member.userId,
showNickName: member.displayUserName || member.nickname,
showImage: member.avatar,
status: member.status
}))
const trimmedKeyword = memberSearchKeyword.value.trim()
if (!trimmedKeyword) {
return all
}
return all.filter((member) => member.showNickName.includes(trimmedKeyword))
})
/** 群成员 picker 选择:落 activeFilter + 关 popover + 清搜索词 */
function onMemberSelect(member: GroupMemberLite) {
activeFilter.value = {
kind: 'member',
userId: member.userId,
nickname: member.showNickName
}
memberPopoverVisible.value = false
memberSearchKeyword.value = ''
}
// ==================== ====================
/** activeFilter 命中:默认无筛选时全部命中 */
function matchesActiveFilter(message: Message): boolean {
if (!activeFilter.value) {
return true
}
switch (activeFilter.value.kind) {
case 'file':
return message.type === ImMessageType.FILE
case 'image':
return message.type === ImMessageType.IMAGE
case 'voice':
return message.type === ImMessageType.VOICE
case 'date':
return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day
case 'member':
return message.senderId === activeFilter.value.userId
default:
return true
}
}
/**
* 当前列表先剔除 TIP_TIME每行已有绝对时间时间分隔线无意义
* activeFilter 过滤 keyword 模糊命中最后 reverse最新在前
*
* 关键字命中走 textSnippetOf 文本拿原文媒体拿"[图片]"等占位词文件拿文件名
*/
const currentList = computed<Message[]>(() => {
const trimmedKeyword = keyword.value.trim()
let list = allMessages.value
.filter((message) => message.type !== ImMessageType.TIP_TIME)
.filter(matchesActiveFilter)
if (trimmedKeyword) {
list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword))
}
return list.slice().reverse()
})
// ==================== ====================
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 筛选在前端做数据来回到本地后过滤
* - id=0本地占位跳过后端没法按 messageId
* - 返回数量 < limit 视为到顶
*/
async function loadEarlier() {
// 1. / / 退 conversation
if (loadingMore.value || !hasMore.value || !conversation.value) {
return
}
loadingMore.value = true
try {
// 2. maxId id
// id=0 id
// / reduce POSITIVE_INFINITY undefined
const earliestId = allMessages.value
.filter((message) => message.id > 0)
.reduce((min, message) => Math.min(min, message.id), Number.POSITIVE_INFINITY)
const maxId = Number.isFinite(earliestId) ? earliestId : undefined
// 3. list / useMessagePuller
// convert Message沿 senderNickName
let earlier: Message[] = []
if (isGroup.value) {
const list = await apiGetGroupMessageList({
groupId: conversation.value.targetId,
maxId,
limit: HISTORY_PAGE_SIZE
})
earlier = (list || []).map(convertGroupMessage)
// < limit ""
if (!list || list.length < HISTORY_PAGE_SIZE) {
hasMore.value = false
}
} else {
const list = await apiGetPrivateMessageList({
receiverId: conversation.value.targetId,
maxId,
limit: HISTORY_PAGE_SIZE
})
earlier = (list || []).map(convertPrivateMessage)
if (!list || list.length < HISTORY_PAGE_SIZE) {
hasMore.value = false
}
}
// 4. conversationStoreprependMessages + + IndexedDB
// messages
conversationStore.prependMessages(
conversation.value.type,
conversation.value.targetId,
earlier
)
} catch (error) {
console.error('[IM] 加载更早历史消息失败', error)
ElMessage.error('加载历史消息失败')
} 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 = new Date()
}
/** v-model 关闭时也复位(兼容父组件 props 直接置 false 的路径dialog @open 不一定再触发) */
watch(
() => props.modelValue,
(value) => {
if (!value) {
activeFilter.value = null
keyword.value = ''
}
}
)
// ==================== helper ====================
/** 取头像 url自己用 userStore群里查 groupStore 成员,私聊用 conversation.avatar */
function getAvatar(message: Message): string {
if (message.selfSend) {
return userStore.getUser?.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 || ''
}
/** 媒体 payload helper模板里多次用避免重复 parseMessage */
function imageOf(message: Message): ImageMessage | null {
return parseMessage<ImageMessage>(message.content)
}
function fileOf(message: Message): FileMessage | null {
return parseMessage<FileMessage>(message.content)
}
function audioOf(message: Message): AudioMessage | null {
return parseMessage<AudioMessage>(message.content)
}
/** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string {
switch (message.type) {
case ImMessageType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? ''
case ImMessageType.TIP_TEXT:
return parseTextContent(msg.content)
return resolveTipText(message.content)
case ImMessageType.IMAGE:
return '[图片]'
case ImMessageType.FILE:
return '[文件]'
return parseMessage<FileMessage>(message.content)?.name ?? '[文件]'
case ImMessageType.VOICE:
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.RECALL:
return buildRecallTip(msg.sendNickName || '', !!msg.selfSend)
return buildRecallTip(message.senderNickName || '', !!message.selfSend)
default:
return '[不支持的消息类型]'
return ''
}
}
function formatTime(ts: number): string {
if (!ts) return ''
const d = new Date(ts)
const pad = (n: number) => n.toString().padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
/**
* 文件类型图标 + 配色按扩展名分发 MessageItem 同款逻辑
*
* TODO @AIMessageItem 也有一份完全相同的实现下次顺手抽到 utils/message.ts 里去重
*/
function getFileIcon(name: string): { icon: string; color: string } {
const ext = name.split('.').pop()?.toLowerCase() || ''
if (ext === 'pdf') {
return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }
}
if (['doc', 'docx'].includes(ext)) {
return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }
}
if (['xls', 'xlsx'].includes(ext)) {
return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }
}
if (['ppt', 'pptx'].includes(ext)) {
return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }
}
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }
}
if (['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext)) {
return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }
}
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext)) {
return { icon: 'ant-design:audio-filled', color: '#9c27b0' }
}
if (['txt', 'md', 'log', 'json', 'xml'].includes(ext)) {
return { icon: 'ant-design:file-text-filled', color: '#909399' }
}
return { icon: 'ant-design:file-filled', color: '#909399' }
}
/** 文件点击 → 新窗口打开下载 */
function openFile(url?: string) {
if (!url) {
return
}
window.open(url, '_blank')
}
/**
* 定位到聊天位置emit ChatPanel scrollIntoView + 短暂高亮再关掉自己
* messageId === 0本地占位消息跳过还没拿到真实 idDOM 上没法 querySelector
*/
function locateMessage(messageId: number) {
if (!messageId) {
return
}
emit('locate', messageId)
visible.value = false
}
/**
* 时间格式参考微信 历史消息列表里展示绝对日期跟会话列表的相对时间不一样
* - 跨年YYYY年M月D日 HH:mm
* - 同年M月D日 HH:mm
*/
function formatTime(timestamp: number): string {
if (!timestamp) {
return ''
}
const target = dayjs(timestamp)
return target.year() === dayjs().year()
? target.format('M月D日 HH:mm')
: target.format('YYYY年M月D日 HH:mm')
}
</script>
<style scoped>
.im-message-history {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.im-message-history__search {
flex-shrink: 0;
}
.im-message-history__count {
flex-shrink: 0;
font-size: 12px;
color: #909399;
}
.im-message-history__list {
flex: 1;
overflow-y: auto;
}
.im-message-history__item {
padding: 10px 0;
border-bottom: 1px solid #f0f2f5;
}
.im-message-history__meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
}
.im-message-history__sender {
font-weight: 500;
color: #606266;
}
.im-message-history__content {
margin-top: 4px;
font-size: 14px;
color: #303133;
line-height: 1.5;
word-break: break-all;
}
/* 空态文案 */
.im-message-history__empty {
padding: 40px 0;
font-size: 13px;
color: #c0c4cc;
text-align: center;
font-size: 13px;
color: var(--el-text-color-disabled);
}
/* 搜索区 chip禁掉 el-tag 默认的 hover 颜色过渡 / × 图标动效,避免在搜索区里有抖动感 */
.im-message-history__chip,
.im-message-history__chip :deep(.el-tag__close) {
transition: none !important;
animation: none !important;
}
/* ""
- absolute 定位浮在时间下方不参与右侧栏 flex 排版隐藏时不占位 "我"和内容之间不留空隙
- 默认 display:none hover block颜色对齐 tab 同款蓝按钮自身 hover 再深一档 */
.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;
}
/* tab 线 tab UnoCSS border-[var(--*)]
直接用 scoped CSS 引主题变量更稳 */
.im-message-history__tabs {
border-bottom: 1px solid var(--el-border-color-lighter);
}
/* 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;
}
/* el-calendar 默认偏大,压一压让它能塞进 320 popover */
.im-message-history__calendar :deep(.el-calendar) {
--el-calendar-cell-width: 36px;
}
.im-message-history__calendar :deep(.el-calendar__header) {
padding: 4px 8px;
}
.im-message-history__calendar :deep(.el-calendar-table) {
font-size: 12px;
}
.im-message-history__calendar :deep(.el-calendar-day) {
height: 36px;
padding: 4px;
}
</style>

View File

@ -560,6 +560,42 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversations(conversation)
},
/**
* "更早的历史消息" +
*
* MessageHistory "加载更早" /im/message/{private,group}/list +
* useMessagePuller convert
*
* lastContent / lastSendTime / unreadCount"最新一条"
* conversation
*/
prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
if (earlierMessages.length === 0) {
return
}
const conversation = this.getConversation(conversationType, targetId)
if (!conversation) {
return
}
// 1. 去重:拿当前会话已有消息的 id 集合,把入参里 id 撞上的过滤掉
// (后端返回的"更早消息"可能跟本地缓存有重叠,比如增量 pull 拉到过同一段)
// id=0 是本地占位消息,不参与去重判定(也不会被 prepend下面 filter 一并卡掉)
const existingIds = new Set(
conversation.messages.map((message) => message.id).filter((id) => id > 0)
)
// 2. 过滤后按 id 升序list 接口虽然按 id desc 返回,前端要展示成"早 → 晚"
// 所以 prepend 之前先 sort asc让 fresh 数组本身的相对顺序符合时间线
const fresh = earlierMessages
.filter((message) => message.id > 0 && !existingIds.has(message.id))
.sort((a, b) => a.id - b.id)
if (fresh.length === 0) {
return
}
// 3. 拼接 + 落盘fresh 在前、原 messages 在后;持久化让下次冷启动不用再调接口
conversation.messages = [...fresh, ...conversation.messages]
this.saveConversations(conversation)
},
/**
* "删除"
* id id 0 clientMessageId