feat(im): 优化整体 message 包结构

im
YunaiV 2026-04-28 09:29:40 +08:00
parent 122b1ba748
commit 29a03ef03d
10 changed files with 164 additions and 116 deletions

View File

@ -1,7 +1,7 @@
<template> <template>
<!-- <!--
群成员单行 群成员单行
跨子域复用@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ChatGroupSide) 跨子域复用@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ConversationGroupSide)
--> -->
<div <div
class="relative flex items-center px-[5px] box-border whitespace-nowrap" class="relative flex items-center px-[5px] box-border whitespace-nowrap"
@ -27,9 +27,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import UserAvatar from '../../../components/UserAvatar.vue' import UserAvatar from './UserAvatar.vue'
defineOptions({ name: 'ImChatGroupMember' }) defineOptions({ name: 'ImGroupMember' })
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts */ /** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts */
export interface GroupMemberLite { export interface GroupMemberLite {

View File

@ -48,7 +48,7 @@
</ResizableAside> </ResizableAside>
<!-- 右侧聊天面板 --> <!-- 右侧聊天面板 -->
<ChatPanel /> <MessagePanel />
<!-- 添加朋友 / 发起群聊弹窗 --> <!-- 添加朋友 / 发起群聊弹窗 -->
<AddFriendDialog v-model="addFriendVisible" @added="handleFriendAdded" /> <AddFriendDialog v-model="addFriendVisible" @added="handleFriendAdded" />
@ -73,7 +73,7 @@ import type { Friend } from '../../types'
import type { FriendLite } from '../friend/components/FriendItem.vue' import type { FriendLite } from '../friend/components/FriendItem.vue'
import ResizableAside from '../../components/ResizableAside.vue' import ResizableAside from '../../components/ResizableAside.vue'
import ConversationItem from './components/conversation/ConversationItem.vue' import ConversationItem from './components/conversation/ConversationItem.vue'
import ChatPanel from './components/ChatPanel.vue' import MessagePanel from './components/message/MessagePanel.vue'
import AddFriendDialog from '../friend/components/AddFriendDialog.vue' import AddFriendDialog from '../friend/components/AddFriendDialog.vue'
import CreateGroupDialog from '../group/components/CreateGroupDialog.vue' import CreateGroupDialog from '../group/components/CreateGroupDialog.vue'
@ -115,7 +115,7 @@ async function handleFriendAdded() {
await friendStore.loadFriends(true) await friendStore.loadFriends(true)
} }
/** 建群成功后刷新群列表,并直接打开新群会话(自动选中并渲染到右侧 ChatPanel */ /** 建群成功后刷新群列表,并直接打开新群会话(自动选中并渲染到右侧 MessagePanel */
async function handleGroupCreated(groupId: number) { async function handleGroupCreated(groupId: number) {
await groupStore.loadGroups(true) await groupStore.loadGroups(true)
const group = groupStore.getGroup(groupId) const group = groupStore.getGroup(groupId)

View File

@ -1,20 +1,13 @@
<template> <template>
<!-- TODO @AI不够对齐微信如果让你改时需要提醒我给你图片 --> <!-- TODO @AI不够对齐微信如果让你改时需要提醒我给你图片 -->
<!-- TODO @AI新建一个 components/side --> <!-- 聊天面板右侧信息抽屉群成员宫格 + 群信息表单 + 退群 / 移除等动作 -->
<!--
聊天面板右侧信息抽屉
- 抽屉形态 v-model 控制由父组件 ChatPanel 管理开关
- 群成员宫格 + 邀请 / 移除按钮仅群主 + 群信息表单 + 退群按钮
- page 引用 pages/group/components/ 下的 Dialog 组件
- TODO TODO @AI群模块后端 API 对接后替换 saveGroup / quit / remove
-->
<el-drawer <el-drawer
v-model="visible" v-model="visible"
:with-header="false" :with-header="false"
direction="rtl" direction="rtl"
size="360px" size="360px"
append-to-body append-to-body
modal-class="im-chat-group-side__modal" modal-class="im-conversation-group-side__modal"
> >
<div class="flex flex-col h-full p-2.5"> <div class="flex flex-col h-full p-2.5">
<!-- 群成员区 --> <!-- 群成员区 -->
@ -39,7 +32,7 @@
@click="inviteVisible = true" @click="inviteVisible = true"
> >
<div <div
class="im-chat-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]" class="im-conversation-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]"
> >
<Icon icon="ant-design:plus-outlined" /> <Icon icon="ant-design:plus-outlined" />
</div> </div>
@ -54,7 +47,7 @@
@click="removeVisible = true" @click="removeVisible = true"
> >
<div <div
class="im-chat-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]" class="im-conversation-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]"
> >
<Icon icon="ant-design:minus-outlined" /> <Icon icon="ant-design:minus-outlined" />
</div> </div>
@ -120,21 +113,26 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { CommonStatusEnum } from '@/utils/constants' import { CommonStatusEnum } from '@/utils/constants'
import GroupMemberGrid from '../../group/components/GroupMemberGrid.vue' import { updateGroup } from '@/api/im/group'
import AddGroupMemberDialog from '../../group/components/AddGroupMemberDialog.vue' import { quitGroup, removeGroupMember, updateGroupMember } from '@/api/im/group/member'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { ImConversationType } from '../../../../../utils/constants'
import GroupMemberGrid from '../../../group/components/GroupMemberGrid.vue'
import AddGroupMemberDialog from '../../../group/components/AddGroupMemberDialog.vue'
import GroupMemberSelector, { import GroupMemberSelector, {
type GroupMemberFlag type GroupMemberFlag
} from '../../group/components/GroupMemberSelector.vue' } from '../../../group/components/GroupMemberSelector.vue'
import type { GroupLite } from '../../group/components/GroupItem.vue' import type { GroupLite } from '../../../group/components/GroupItem.vue'
import type { GroupMemberLite } from './ChatGroupMember.vue' import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
import type { FriendLite } from '../../friend/components/FriendItem.vue' import type { FriendLite } from '../../../friend/components/FriendItem.vue'
defineOptions({ name: 'ImChatGroupSide' }) defineOptions({ name: 'ImConversationGroupSide' })
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -156,6 +154,9 @@ const emit = defineEmits<{
}>() }>()
const userStore = useUserStore() const userStore = useUserStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const message = useMessage()
const visible = computed({ const visible = computed({
get: () => props.modelValue, get: () => props.modelValue,
@ -167,11 +168,6 @@ const editing = ref(false)
const inviteVisible = ref(false) const inviteVisible = ref(false)
const removeVisible = ref(false) const removeVisible = ref(false)
/**
* 群信息表单本地副本
* 不能直接 v-model propvue/no-mutating-props用本地 reactive 承接
* 保存时通过 emit / API 把变更回写给父组件
*/
const formData = reactive({ const formData = reactive({
name: '', name: '',
notice: '', notice: '',
@ -189,9 +185,7 @@ watch(
) )
const myId = computed(() => Number(userStore.getUser?.id) || 0) const myId = computed(() => Number(userStore.getUser?.id) || 0)
const isOwner = computed(() => props.group != null && props.group.ownerId === myId.value) const isOwner = computed(() => props.group != null && props.group.ownerId === myId.value)
const ownerName = computed(() => { const ownerName = computed(() => {
if (!props.group) { if (!props.group) {
return '' return ''
@ -199,7 +193,6 @@ const ownerName = computed(() => {
const owner = props.members.find((m) => m.userId === props.group!.ownerId) const owner = props.members.find((m) => m.userId === props.group!.ownerId)
return owner?.showNickName || '-' return owner?.showNickName || '-'
}) })
const filteredMembers = computed(() => const filteredMembers = computed(() =>
props.members.filter( props.members.filter(
(member) => (member) =>
@ -208,36 +201,92 @@ const filteredMembers = computed(() =>
) )
) )
// TODO /im/group/modify /**
* 保存群信息
* - 群主群名 / 群公告 /im/group/update自己在群里的昵称 /im/group-member/update
* - 普通成员仅自己的群昵称群名 / 公告 disabled但兜底防表单被绕开
*/
async function handleSave() { async function handleSave() {
ElMessage.info('群信息保存接口待接入,当前为占位实现') if (!props.group) {
editing.value = false
}
// TODO /im/group/quit
async function handleQuit() {
try {
await ElMessageBox.confirm('退出群聊后将不再接受群里的消息,确认退出吗?', '确认退出', {
type: 'warning'
})
ElMessage.info('退出群聊接口待接入,当前为占位实现')
} catch {
//
}
}
// TODO /im/group/member/remove
function handleRemoveComplete(members: GroupMemberFlag[]) {
if (members.length === 0) {
return return
} }
ElMessage.info(`移除成员接口待接入,选择了 ${members.length} 位成员`) const groupId = props.group.id
try {
// 1. / disabled
if (isOwner.value) {
await updateGroup({
id: groupId,
name: formData.name,
notice: formData.notice
})
}
// 2. displayUserName
await updateGroupMember({
groupId,
displayUserName: formData.remarkNickName
})
// 3. emit reload MessagePanel reloadGroupData +
message.success('保存成功')
editing.value = false
emit('reload')
} catch (error) {
console.error('[IM ConversationGroupSide] 保存群信息失败', { groupId }, error)
message.error('保存失败')
}
}
/** 退出群聊(普通成员入口;群主退出走"解散群"是另一条路径,这里不处理) */
async function handleQuit() {
if (!props.group) {
return
}
// 1. ElMessageBox reject return
try {
await message.confirm('退出群聊后将不再接收群里的消息,确认退出吗?', '确认退出')
} catch {
return
}
const groupId = props.group.id
try {
// 2. /im/group/quit
await quitGroup(groupId)
// 3. + store /
conversationStore.removeConversation(ImConversationType.GROUP, groupId)
groupStore.removeGroup(groupId)
// 4.
message.success('已退出群聊')
visible.value = false
} catch (error) {
console.error('[IM ConversationGroupSide] 退出群聊失败', { groupId }, error)
message.error('退出群聊失败')
}
}
/** 移除群成员(仅群主入口)*/
async function handleRemoveComplete(members: GroupMemberFlag[]) {
if (!props.group || members.length === 0) {
return
}
const groupId = props.group.id
try {
// 1. userId N
await removeGroupMember({
groupId,
memberUserIds: members.map((member) => member.userId)
})
// 2. emit reload UI
message.success(`已移除 ${members.length} 位成员`)
emit('reload')
} catch (error) {
console.error('[IM ConversationGroupSide] 移除群成员失败', { groupId }, error)
message.error('移除成员失败')
}
} }
</script> </script>
<style scoped> <style scoped>
/* el-icon 的全局 color 在暗色模式下会被主题盖过;:deep(svg) 锁 fill 到当前色 */ /* el-icon 的全局 color 在暗色模式下会被主题盖过;:deep(svg) 锁 fill 到当前色 */
.im-chat-group-side__tool-btn :deep(svg) { .im-conversation-group-side__tool-btn :deep(svg) {
fill: currentColor !important; fill: currentColor !important;
} }
</style> </style>

View File

@ -1,9 +1,8 @@
<template> <template>
<!-- TODO @AI不够对齐微信如果让你改时需要提醒我给你图片 --> <!-- TODO @AI不够对齐微信如果让你改时需要提醒我给你图片 -->
<!-- TODO @AI新建一个 components/side -->
<!-- <!--
私聊侧边抽屉 私聊侧边抽屉
- 抽屉形态 v-model 控制由父组件 ChatPanel 管理开关 - 抽屉形态 v-model 控制由父组件 MessagePanel 管理开关
- 顶部好友头像 + 昵称 - 顶部好友头像 + 昵称
- 操作消息免打扰 / 置顶聊天 - 操作消息免打扰 / 置顶聊天
- 与会话列表右键菜单同语义免打扰联动 friendStore.setMuted - 与会话列表右键菜单同语义免打扰联动 friendStore.setMuted
@ -26,7 +25,7 @@
</div> </div>
</div> </div>
<el-divider class="im-chat-private-side__divider" /> <el-divider class="im-conversation-private-side__divider" />
<!-- 操作项 --> <!-- 操作项 -->
<div class="flex flex-col gap-3.5 text-sm"> <div class="flex flex-col gap-3.5 text-sm">
@ -46,13 +45,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useConversationStore } from '../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../store/friendStore' import { useFriendStore } from '../../../../store/friendStore'
import { ImConversationType } from '../../../../utils/constants' import { ImConversationType } from '../../../../../utils/constants'
import type { Conversation, Friend } from '../../../types' import type { Conversation, Friend } from '../../../../types'
import UserAvatar from '../../../components/UserAvatar.vue' import UserAvatar from '../../../../components/UserAvatar.vue'
defineOptions({ name: 'ImChatPrivateSide' }) defineOptions({ name: 'ImConversationPrivateSide' })
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -98,7 +97,7 @@ function onTopChange(value: boolean | string | number) {
<style scoped> <style scoped>
/* el-divider 默认 margin 较大,压成 8px和群聊抽屉视觉对齐 */ /* el-divider 默认 margin 较大,压成 8px和群聊抽屉视觉对齐 */
.im-chat-private-side__divider { .im-conversation-private-side__divider {
margin: 8px 0; margin: 8px 0;
} }
</style> </style>

View File

@ -41,7 +41,7 @@
</div> </div>
<!-- 真成员行 --> <!-- 真成员行 -->
<ChatGroupMember <GroupMember
v-for="(member, idx) in memberItems" v-for="(member, idx) in memberItems"
:key="member.userId" :key="member.userId"
:member="member" :member="member"
@ -65,7 +65,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { CommonStatusEnum } from '@/utils/constants' import { CommonStatusEnum } from '@/utils/constants'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants' import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue' import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
defineOptions({ name: 'ImMentionPicker' }) defineOptions({ name: 'ImMentionPicker' })

View File

@ -135,7 +135,7 @@ import {
import EmojiPicker from './EmojiPicker.vue' import EmojiPicker from './EmojiPicker.vue'
import MentionPicker from './MentionPicker.vue' import MentionPicker from './MentionPicker.vue'
import VoiceRecorder from './VoiceRecorder.vue' import VoiceRecorder from './VoiceRecorder.vue'
import type { GroupMemberLite } from '../ChatGroupMember.vue' import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
defineOptions({ name: 'ImMessageInput' }) defineOptions({ name: 'ImMessageInput' })
@ -416,7 +416,7 @@ const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP () => conversationStore.activeConversation?.type === ImConversationType.GROUP
) )
/** 从 groupStore 读当前激活群的成员(切会话时由 ChatPanel 预拉) */ /** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */
const groupMembers = computed<GroupMemberLite[]>(() => { const groupMembers = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) { if (!conversation || conversation.type !== ImConversationType.GROUP) {

View File

@ -94,7 +94,7 @@
</div> </div>
</div> </div>
</el-popover> </el-popover>
<!-- 群成员仅群聊popover 内自带搜索 + ChatGroupMember 列表 --> <!-- 群成员仅群聊popover 内自带搜索 + GroupMember 列表 -->
<el-popover <el-popover
v-if="isGroup" v-if="isGroup"
v-model:visible="memberPopoverVisible" v-model:visible="memberPopoverVisible"
@ -117,7 +117,7 @@
</template> </template>
</el-input> </el-input>
<div class="max-h-[360px] overflow-y-auto mt-2"> <div class="max-h-[360px] overflow-y-auto mt-2">
<ChatGroupMember <GroupMember
v-for="member in filteredMembersForPicker" v-for="member in filteredMembersForPicker"
:key="member.userId" :key="member.userId"
:member="member" :member="member"
@ -321,7 +321,7 @@ import {
} from '../../../../../utils/message' } from '../../../../../utils/message'
import type { Message } from '../../../../types' import type { Message } from '../../../../types'
import UserAvatar from '../../../../components/UserAvatar.vue' import UserAvatar from '../../../../components/UserAvatar.vue'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue' import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
defineOptions({ name: 'ImMessageHistory' }) defineOptions({ name: 'ImMessageHistory' })
@ -330,7 +330,7 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
// "" ChatPanel + // "" MessagePanel +
locate: [messageId: number] locate: [messageId: number]
}>() }>()
@ -697,7 +697,7 @@ function openFile(url?: string) {
} }
/** /**
* 定位到聊天位置emit ChatPanel scrollIntoView + 短暂高亮再关掉自己 * 定位到聊天位置emit MessagePanel scrollIntoView + 短暂高亮再关掉自己
* messageId === 0本地占位消息跳过还没拿到真实 idDOM 上没法 querySelector * messageId === 0本地占位消息跳过还没拿到真实 idDOM 上没法 querySelector
*/ */
function locateMessage(messageId: number) { function locateMessage(messageId: number) {

View File

@ -240,7 +240,7 @@ import { useMessageSender } from '../../../../composables/useMessageSender'
import type { Message } from '../../../../types' import type { Message } from '../../../../types'
import MessageReadStatus from './MessageReadStatus.vue' import MessageReadStatus from './MessageReadStatus.vue'
import UserAvatar from '../../../../components/UserAvatar.vue' import UserAvatar from '../../../../components/UserAvatar.vue'
import type { GroupMemberLite } from '../ChatGroupMember.vue' import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
defineOptions({ name: 'ImMessageItem' }) defineOptions({ name: 'ImMessageItem' })

View File

@ -14,7 +14,7 @@
<Icon <Icon
icon="ant-design:profile-outlined" icon="ant-design:profile-outlined"
:size="20" :size="20"
class="chat-panel__header-icon cursor-pointer" class="message-panel__header-icon cursor-pointer"
@click="historyVisible = true" @click="historyVisible = true"
/> />
</el-tooltip> </el-tooltip>
@ -24,7 +24,7 @@
<Icon <Icon
icon="ant-design:more-outlined" icon="ant-design:more-outlined"
:size="20" :size="20"
class="chat-panel__header-icon cursor-pointer" class="message-panel__header-icon cursor-pointer"
@click="toggleSide" @click="toggleSide"
/> />
</el-tooltip> </el-tooltip>
@ -49,16 +49,16 @@
v-for="msg in messages" v-for="msg in messages"
:key="msg.id || msg.clientMessageId" :key="msg.id || msg.clientMessageId"
:data-message-id="msg.id || ''" :data-message-id="msg.id || ''"
class="chat-panel__message-anchor" class="message-panel__message-anchor"
> >
<MessageItem :message="msg" /> <MessageItem :message="msg" />
</div> </div>
<!-- 回到底部浮动按钮滚动不在底部时显示 --> <!-- 回到底部浮动按钮滚动不在底部时显示 -->
<transition name="chat-panel__jump-fade"> <transition name="message-panel__jump-fade">
<div <div
v-if="showJumpToBottom" v-if="showJumpToBottom"
class="chat-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--el-bg-color-overlay)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]" class="message-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--el-bg-color-overlay)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]"
@click="scrollToBottom(true)" @click="scrollToBottom(true)"
> >
<Icon icon="ant-design:down-outlined" :size="14" /> <Icon icon="ant-design:down-outlined" :size="14" />
@ -78,7 +78,7 @@
<MessageInput :key="messageInputKey" /> <MessageInput :key="messageInputKey" />
<!-- 右侧信息抽屉群聊 / 私聊各自一份 --> <!-- 右侧信息抽屉群聊 / 私聊各自一份 -->
<ChatGroupSide <ConversationGroupSide
v-if="isGroup" v-if="isGroup"
v-model="sideVisible" v-model="sideVisible"
:group="groupInfo" :group="groupInfo"
@ -86,7 +86,7 @@
:friends="groupFriends" :friends="groupFriends"
@reload="reloadGroupData" @reload="reloadGroupData"
/> />
<ChatPrivateSide <ConversationPrivateSide
v-else v-else
v-model="sideVisible" v-model="sideVisible"
:conversation="conversationStore.activeConversation" :conversation="conversationStore.activeConversation"
@ -109,21 +109,21 @@
import { ref, watch, nextTick, computed } from 'vue' import { ref, watch, nextTick, computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import { useConversationStore } from '../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../store/friendStore' import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
import { ImConversationType } from '../../../../utils/constants' import { ImConversationType } from '../../../../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants' import { CommonStatusEnum } from '@/utils/constants'
import MessageItem from './message/MessageItem.vue' import MessageItem from './MessageItem.vue'
import MessageInput from './input/MessageInput.vue' import MessageInput from '../input/MessageInput.vue'
import MessageHistory from './message/MessageHistory.vue' import MessageHistory from './MessageHistory.vue'
import ChatGroupSide from './ChatGroupSide.vue' import ConversationGroupSide from '../conversation/ConversationGroupSide.vue'
import ChatPrivateSide from './ChatPrivateSide.vue' import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue'
import type { GroupLite } from '../../group/components/GroupItem.vue' import type { GroupLite } from '../../../group/components/GroupItem.vue'
import type { GroupMemberLite } from './ChatGroupMember.vue' import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
import type { FriendLite } from '../../friend/components/FriendItem.vue' import type { FriendLite } from '../../../friend/components/FriendItem.vue'
defineOptions({ name: 'ImChatPanel' }) defineOptions({ name: 'ImMessagePanel' })
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
@ -212,13 +212,13 @@ const groupFriends = computed<FriendLite[]>(() =>
*/ */
function ensureGroupData(groupId: number) { function ensureGroupData(groupId: number) {
groupStore.loadGroupInfo(groupId).catch((error) => { groupStore.loadGroupInfo(groupId).catch((error) => {
console.warn('[IM ChatPanel] loadGroupInfo 失败', { groupId }, error) console.warn('[IM MessagePanel] loadGroupInfo 失败', { groupId }, error)
}) })
groupStore.loadGroupMembers(groupId).catch((error) => { groupStore.loadGroupMembers(groupId).catch((error) => {
console.warn('[IM ChatPanel] loadGroupMembers 失败', { groupId }, error) console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error)
}) })
friendStore.loadFriends().catch((error) => { friendStore.loadFriends().catch((error) => {
console.warn('[IM ChatPanel] loadFriends 失败', { groupId }, error) console.warn('[IM MessagePanel] loadFriends 失败', { groupId }, error)
}) })
} }
@ -238,7 +238,7 @@ function reloadGroupData() {
const historyVisible = ref(false) const historyVisible = ref(false)
/** /**
* 信息抽屉的开关 ChatPanel 本地 UI 状态 * 信息抽屉的开关 MessagePanel 本地 UI 状态
* *
* 群聊 / 私聊共用一个 ref模板里 v-if-else 决定挂哪个抽屉同一时刻只有一个组件 * 群聊 / 私聊共用一个 ref模板里 v-if-else 决定挂哪个抽屉同一时刻只有一个组件
* DOM 所以一个布尔够用早先拆成 sideVisible + privateSideVisible 是冗余 * DOM 所以一个布尔够用早先拆成 sideVisible + privateSideVisible 是冗余
@ -324,9 +324,9 @@ async function handleLocate(messageId: number) {
return return
} }
target.scrollIntoView({ behavior: 'smooth', block: 'center' }) target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('chat-panel__message-anchor--highlight') target.classList.add('message-panel__message-anchor--highlight')
setTimeout(() => { setTimeout(() => {
target.classList.remove('chat-panel__message-anchor--highlight') target.classList.remove('message-panel__message-anchor--highlight')
}, 1600) }, 1600)
} }
@ -379,20 +379,20 @@ watch(
<style scoped> <style scoped>
/* el-icon .el-icon{color:var(--color,inherit)} UnoCSS :deep + !important /* el-icon .el-icon{color:var(--color,inherit)} UnoCSS :deep + !important
颜色直接引用 Element Plus 主题变量暗色模式自动切到更亮的灰 */ 颜色直接引用 Element Plus 主题变量暗色模式自动切到更亮的灰 */
.chat-panel__header-icon, .message-panel__header-icon,
.chat-panel__header-icon :deep(svg) { .message-panel__header-icon :deep(svg) {
color: var(--el-text-color-regular) !important; color: var(--el-text-color-regular) !important;
fill: currentColor !important; fill: currentColor !important;
transition: color 0.15s; transition: color 0.15s;
} }
.chat-panel__header-icon:hover, .message-panel__header-icon:hover,
.chat-panel__header-icon:hover :deep(svg) { .message-panel__header-icon:hover :deep(svg) {
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
} }
/* sticky + translate fit-content transform -50% /* sticky + translate fit-content transform -50%
UnoCSS 表达 transform+transition value 不太方便这里用最小的 scoped CSS 承接 */ UnoCSS 表达 transform+transition value 不太方便这里用最小的 scoped CSS 承接 */
.chat-panel__jump-bottom { .message-panel__jump-bottom {
transform: translateX(-50%); transform: translateX(-50%);
transition: transition:
opacity 0.2s, opacity 0.2s,
@ -400,23 +400,23 @@ watch(
} }
/* MessageHistory "定位" 跳过来时短暂高亮1.6s 后由 JS 移除 class配合 transition 缓出黄底 */ /* MessageHistory "定位" 跳过来时短暂高亮1.6s 后由 JS 移除 class配合 transition 缓出黄底 */
.chat-panel__message-anchor { .message-panel__message-anchor {
transition: background-color 0.6s ease; transition: background-color 0.6s ease;
} }
.chat-panel__message-anchor--highlight { .message-panel__message-anchor--highlight {
background-color: var(--el-color-warning-light-9); background-color: var(--el-color-warning-light-9);
} }
/* 回到底部按钮的 Vue transition 钩子类名 */ /* 回到底部按钮的 Vue transition 钩子类名 */
.chat-panel__jump-fade-enter-active, .message-panel__jump-fade-enter-active,
.chat-panel__jump-fade-leave-active { .message-panel__jump-fade-leave-active {
transition: transition:
opacity 0.2s, opacity 0.2s,
transform 0.2s; transform 0.2s;
} }
.chat-panel__jump-fade-enter-from, .message-panel__jump-fade-enter-from,
.chat-panel__jump-fade-leave-to { .message-panel__jump-fade-leave-to {
opacity: 0; opacity: 0;
transform: translate(-50%, 20px); transform: translate(-50%, 20px);
} }

View File

@ -25,7 +25,7 @@
<el-tab-pane :label="`已读(${readMembers.length})`" name="read"> <el-tab-pane :label="`已读(${readMembers.length})`" name="read">
<PagedScroller :items="readMembers" :page-size="20" class="h-75"> <PagedScroller :items="readMembers" :page-size="20" class="h-75">
<template #default="{ item }"> <template #default="{ item }">
<ChatGroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" /> <GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
</template> </template>
</PagedScroller> </PagedScroller>
<div <div
@ -38,7 +38,7 @@
<el-tab-pane :label="`未读(${unreadMembers.length})`" name="unread"> <el-tab-pane :label="`未读(${unreadMembers.length})`" name="unread">
<PagedScroller :items="unreadMembers" :page-size="20" class="h-75"> <PagedScroller :items="unreadMembers" :page-size="20" class="h-75">
<template #default="{ item }"> <template #default="{ item }">
<ChatGroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" /> <GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
</template> </template>
</PagedScroller> </PagedScroller>
<div <div
@ -60,14 +60,14 @@ import { CommonStatusEnum } from '@/utils/constants'
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants' import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
import type { Message } from '../../../../types' import type { Message } from '../../../../types'
import { useConversationStore } from '../../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue' import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
import PagedScroller from '../../../../components/PagedScroller.vue' import PagedScroller from '../../../../components/PagedScroller.vue'
defineOptions({ name: 'ImMessageReadStatus' }) defineOptions({ name: 'ImMessageReadStatus' })
const props = defineProps<{ const props = defineProps<{
message: Message message: Message
// ChatPanel.groupMembers // MessagePanel.groupMembers
groupMembers: GroupMemberLite[] groupMembers: GroupMemberLite[]
// loadReadUsers /im/message/group/get-read-users // loadReadUsers /im/message/group/get-read-users
groupId: number groupId: number