✨ feat(im): 支持历史消息的加载
parent
e9be6ef8b3
commit
9c5b11e551
|
|
@ -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"
|
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"
|
@click="goProfile"
|
||||||
>
|
>
|
||||||
<el-icon :size="22"><Setting /></el-icon>
|
<Icon icon="ant-design:setting-outlined" :size="22" />
|
||||||
</div>
|
</div>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -52,8 +52,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Setting } from '@element-plus/icons-vue'
|
|
||||||
|
|
||||||
import Icon from '@/components/Icon/src/Icon.vue'
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { useConversationStore } from '../store/conversationStore'
|
import { useConversationStore } from '../store/conversationStore'
|
||||||
|
|
|
||||||
|
|
@ -257,5 +257,5 @@ export const useMessagePuller = () => {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return { pullOnce }
|
return { pullOnce, convertPrivateMessage, convertGroupMessage }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
群成员单行(对应 boxim chat/ChatGroupMember.vue)
|
群成员单行
|
||||||
跨子域复用:@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ChatGroupSide)
|
跨子域复用:@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ChatGroupSide)
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="im-chat-group-member"
|
class="relative flex items-center px-[5px] box-border whitespace-nowrap"
|
||||||
:class="{ 'is-active': active }"
|
:class="{ 'bg-[#e1eaf7] dark:bg-[var(--el-color-primary-light-9)]': active }"
|
||||||
:style="{ height: height + 'px' }"
|
:style="{ height: height + 'px' }"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
:size="avatarSize"
|
:size="avatarSize"
|
||||||
:name="member.showNickName"
|
:name="member.showNickName"
|
||||||
:url="member.headImage"
|
:url="member.showImage"
|
||||||
:clickable="clickable"
|
:clickable="clickable"
|
||||||
:id="member.userId"
|
: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 }}
|
{{ member.showNickName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,25 +33,20 @@ defineOptions({ name: 'ImChatGroupMember' })
|
||||||
|
|
||||||
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts) */
|
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts) */
|
||||||
export interface GroupMemberLite {
|
export interface GroupMemberLite {
|
||||||
/** 用户 id,特殊值 -1 表示「全体成员」 */
|
userId: number // 用户编号;特殊值见 IM_AT_ALL_USER_ID(@ 全体成员)
|
||||||
userId: number | string
|
showNickName: string // 展示昵称:优先群备注,再群昵称,再用户昵称
|
||||||
/** 展示昵称:优先群备注,再群昵称,再用户昵称 */
|
showImage?: string
|
||||||
showNickName: string
|
// 群成员状态:直接透传 GroupMember.status;消费方(过滤已退群成员等)按
|
||||||
/** 头像 URL */
|
// CommonStatusEnum.DISABLE 自行判断,不在 producer 端预翻译成 quit
|
||||||
headImage?: string
|
status?: number
|
||||||
/** 是否已退群 */
|
|
||||||
quit?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
member: GroupMemberLite
|
member: GroupMemberLite
|
||||||
/** 行高(px),影响头像大小 */
|
height?: number // 行高(px),影响头像大小
|
||||||
height?: number
|
active?: boolean // 选中态(@候选键盘高亮等)
|
||||||
/** 选中态(@候选键盘高亮等) */
|
clickable?: boolean // 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互)
|
||||||
active?: boolean
|
|
||||||
/** 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互) */
|
|
||||||
clickable?: boolean
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
height: 50,
|
height: 50,
|
||||||
|
|
@ -59,29 +57,3 @@ const props = withDefaults(
|
||||||
|
|
||||||
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
|
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
|
||||||
</script>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,801 @@
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
历史消息抽屉(对应 boxim chat/ChatHistory.vue)
|
历史消息弹窗(对齐微信 PC)
|
||||||
- 从输入框工具栏触发,展示当前会话的全部历史消息
|
- 居中 dialog,title "与「X」聊天记录" / "「X 群」聊天记录(N)"
|
||||||
- 简化实现:基于本地缓存的 activeChat.messages 全量展示 + 关键词搜索
|
- 默认无筛选,全量倒序展示;点击 tab 落筛选 → 搜索框左侧出 chip × 可清
|
||||||
- 未来可接入后端「按时间段 / 发送人」查询,并用 PagedScroller 做增量加载
|
- 文件 / 图片 / 链接:直接落筛选;日期 / 群成员:弹 popover 二次选择再落
|
||||||
|
- "加载更早消息":列表底部按钮,点击调 /im/message/{private,group}/list 拿一页 + prepend
|
||||||
-->
|
-->
|
||||||
<el-drawer
|
<el-dialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
title="历史消息"
|
:title="title"
|
||||||
direction="rtl"
|
width="640px"
|
||||||
size="420px"
|
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="im-message-history__dialog"
|
||||||
|
@open="onDialogOpen"
|
||||||
>
|
>
|
||||||
<div class="im-message-history">
|
<div class="flex flex-col gap-3 h-[520px]">
|
||||||
<el-input
|
<!-- 搜索区:activeFilter 存在时左侧出 chip × 可清,否则纯搜索框 -->
|
||||||
v-model="keyword"
|
<div
|
||||||
placeholder="搜索消息内容"
|
class="im-message-history__searchbar flex items-center gap-2 px-3 py-1.5 rounded-md bg-[var(--el-fill-color-light)]"
|
||||||
clearable
|
>
|
||||||
class="im-message-history__search"
|
<el-tag
|
||||||
/>
|
v-if="activeFilter"
|
||||||
|
closable
|
||||||
<div class="im-message-history__count">
|
size="small"
|
||||||
共 {{ filtered.length }} 条{{ keyword ? '(过滤后)' : '' }}
|
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>
|
||||||
|
|
||||||
<div class="im-message-history__list">
|
<!-- Tab 行:文件 / 图片 / 语音 / 日期(popover) / 群成员(popover, 仅群聊)
|
||||||
<div
|
底部一条分割线把 tab 区跟消息列表分开,对齐微信观感(border 走 scoped CSS 走主题变量) -->
|
||||||
v-for="msg in filtered"
|
<div
|
||||||
:key="msg.id || msg.tmpId"
|
class="im-message-history__tabs flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa]"
|
||||||
class="im-message-history__item"
|
>
|
||||||
|
<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>
|
||||||
<span class="im-message-history__time">{{ formatTime(msg.sendTime) }}</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>
|
||||||
<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>
|
||||||
<div v-if="filtered.length === 0" class="im-message-history__empty">
|
|
||||||
{{ keyword ? '没有匹配的消息' : '暂无历史消息' }}
|
<!-- 加载更早消息:列表底部 trigger(reverse 后最早的在最下,按钮放底部更自然);
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { ImMessageType } from '../../../../../utils/constants'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { parseTextContent, buildRecallTip } from '../../../../../utils'
|
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' })
|
defineOptions({ name: 'ImMessageHistory' })
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** v-model 控制抽屉显隐 */
|
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'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({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
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 keyword = ref('')
|
||||||
|
const activeFilter = ref<ActiveFilter | null>(null)
|
||||||
|
|
||||||
const filtered = computed(() => {
|
/** chip 文案:日期 / 群成员 多带值,其他直接 tab 名 */
|
||||||
const all = chatStore.activeChat?.messages || []
|
const filterChipLabel = computed(() => {
|
||||||
const kw = keyword.value.trim()
|
if (!activeFilter.value) {
|
||||||
if (!kw) return [...all].reverse()
|
return ''
|
||||||
return all.filter((m) => renderContent(m).includes(kw)).reverse()
|
}
|
||||||
|
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 {
|
/** 点 tab 落筛选;同 kind 重复点击 → 当 toggle 关掉(避免迷惑) */
|
||||||
switch (msg.type) {
|
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. 合并到 conversationStore:prependMessages 内部去重 + 升序合并 + 落 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:
|
case ImMessageType.TEXT:
|
||||||
|
return parseMessage<TextMessage>(message.content)?.content ?? ''
|
||||||
case ImMessageType.TIP_TEXT:
|
case ImMessageType.TIP_TEXT:
|
||||||
return parseTextContent(msg.content)
|
return resolveTipText(message.content)
|
||||||
case ImMessageType.IMAGE:
|
case ImMessageType.IMAGE:
|
||||||
return '[图片]'
|
return '[图片]'
|
||||||
case ImMessageType.FILE:
|
case ImMessageType.FILE:
|
||||||
return '[文件]'
|
return parseMessage<FileMessage>(message.content)?.name ?? '[文件]'
|
||||||
case ImMessageType.VOICE:
|
case ImMessageType.VOICE:
|
||||||
return '[语音]'
|
return '[语音]'
|
||||||
case ImMessageType.VIDEO:
|
case ImMessageType.VIDEO:
|
||||||
return '[视频]'
|
return '[视频]'
|
||||||
case ImMessageType.RECALL:
|
case ImMessageType.RECALL:
|
||||||
return buildRecallTip(msg.sendNickName || '', !!msg.selfSend)
|
return buildRecallTip(message.senderNickName || '', !!message.selfSend)
|
||||||
default:
|
default:
|
||||||
return '[不支持的消息类型]'
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
/**
|
||||||
if (!ts) return ''
|
* 文件类型图标 + 配色(按扩展名分发,与 MessageItem 同款逻辑)
|
||||||
const d = new Date(ts)
|
*
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
* TODO @AI:MessageItem 也有一份完全相同的实现,下次顺手抽到 utils/message.ts 里去重
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
*/
|
||||||
|
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(本地占位消息)跳过——还没拿到真实 id,DOM 上没法 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.im-message-history__empty {
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
font-size: 13px;
|
|
||||||
color: #c0c4cc;
|
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,42 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
this.saveConversations(conversation)
|
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 匹配
|
* 按 id 优先匹配;若 id 为 0(本地发送中),则按 clientMessageId 匹配
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue