feat(im): 增加 ChatPanel.vue 组件

im
YunaiV 2026-04-28 01:15:04 +08:00
parent 4c8898b6f5
commit 9fc10b304c
3 changed files with 475 additions and 149 deletions

View File

@ -1,10 +1,12 @@
<template> <template>
<!-- TODO @AI不够对齐微信如果让你改时需要提醒我给你图片 -->
<!-- TODO @AI新建一个 components/side -->
<!-- <!--
聊天面板右侧信息抽屉对应 boxim chat/ChatGroupSide.vue 聊天面板右侧信息抽屉
- 抽屉形态uiStore.chatGroupSide.show=true 时滑入 - 抽屉形态v-model 控制由父组件 ChatPanel 管理开关
- 群成员宫格 + 邀请 / 移除按钮仅群主 + 群信息表单 + 退群按钮 - 群成员宫格 + 邀请 / 移除按钮仅群主 + 群信息表单 + 退群按钮
- page 引用 pages/group/components/ 下的 Dialog 组件 - page 引用 pages/group/components/ 下的 Dialog 组件
- TODO 群模块后端 API 对接后替换 saveGroup / quit / remove - TODO TODO @AI群模块后端 API 对接后替换 saveGroup / quit / remove
--> -->
<el-drawer <el-drawer
v-model="visible" v-model="visible"
@ -14,66 +16,70 @@
append-to-body append-to-body
modal-class="im-chat-group-side__modal" modal-class="im-chat-group-side__modal"
> >
<div class="im-chat-group-side"> <div class="flex flex-col h-full p-2.5">
<!-- 群成员区 --> <!-- 群成员区 -->
<div v-if="group" class="im-chat-group-side__block"> <div v-if="group" class="mb-2.5">
<el-input <el-input
v-model="searchText" v-model="searchText"
placeholder="搜索群成员" placeholder="搜索群成员"
clearable clearable
size="small" size="small"
class="im-chat-group-side__search" class="mb-2.5"
> >
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
</template> </template>
</el-input> </el-input>
<div class="im-chat-group-side__members"> <div class="flex flex-wrap gap-1">
<!-- 邀请按钮 --> <!-- 邀请按钮 -->
<div class="im-chat-group-side__tool" title="邀请好友入群" @click="inviteVisible = true"> <div
<div class="im-chat-group-side__tool-btn"> class="flex flex-col items-center w-[54px]"
title="邀请好友入群"
@click="inviteVisible = true"
>
<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]"
>
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</div> </div>
<div class="im-chat-group-side__tool-text">邀请</div> <div class="mt-1 text-12px text-[var(--el-text-color-regular)]">邀请</div>
</div> </div>
<!-- 移除按钮仅群主 --> <!-- 移除按钮仅群主 -->
<div <div
v-if="isOwner" v-if="isOwner"
class="im-chat-group-side__tool" class="flex flex-col items-center w-[54px]"
title="移出成员" title="移出成员"
@click="removeVisible = true" @click="removeVisible = true"
> >
<div class="im-chat-group-side__tool-btn"> <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]"
>
<el-icon><Minus /></el-icon> <el-icon><Minus /></el-icon>
</div> </div>
<div class="im-chat-group-side__tool-text">移除</div> <div class="mt-1 text-12px text-[var(--el-text-color-regular)]">移除</div>
</div> </div>
<!-- 成员宫格 --> <!-- 成员宫格抽屉里点头像弹 UserInfoCard -->
<GroupMemberGrid <GroupMemberGrid v-for="m in filteredMembers" :key="m.userId" :member="m" clickable />
v-for="m in filteredMembers"
:key="m.userId"
:member="m"
/>
</div> </div>
</div> </div>
<el-divider /> <el-divider />
<!-- 群信息表单 --> <!-- 群信息表单 -->
<div v-if="group" class="im-chat-group-side__form"> <div v-if="group" class="flex-1 overflow-y-auto">
<el-form label-position="top" size="small"> <el-form label-position="top" size="small">
<el-form-item label="群聊名称"> <el-form-item label="群聊名称">
<el-input v-model="group.name" :disabled="!editing" maxlength="20" /> <el-input v-model="formData.name" :disabled="!editing" maxlength="20" />
</el-form-item> </el-form-item>
<el-form-item label="群主"> <el-form-item label="群主">
<el-input :model-value="ownerName" disabled /> <el-input :model-value="ownerName" disabled />
</el-form-item> </el-form-item>
<el-form-item label="群公告"> <el-form-item label="群公告">
<el-input <el-input
v-model="group.notice" v-model="formData.notice"
:disabled="!editing" :disabled="!editing"
type="textarea" type="textarea"
maxlength="1024" maxlength="1024"
@ -81,11 +87,11 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="我在本群的昵称"> <el-form-item label="我在本群的昵称">
<el-input v-model="group.remarkNickName" :disabled="!editing" maxlength="20" /> <el-input v-model="formData.remarkNickName" :disabled="!editing" maxlength="20" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="im-chat-group-side__actions"> <div class="flex gap-2 justify-center mt-3">
<el-button v-if="editing" type="success" @click="handleSave"></el-button> <el-button v-if="editing" type="success" @click="handleSave"></el-button>
<el-button v-else type="primary" @click="editing = true">编辑</el-button> <el-button v-else type="primary" @click="editing = true">编辑</el-button>
<el-button v-if="!isOwner" type="danger" @click="handleQuit">退</el-button> <el-button v-if="!isOwner" type="danger" @click="handleQuit">退</el-button>
@ -113,12 +119,12 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Minus } from '@element-plus/icons-vue' import { Search, Plus, Minus } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useImUiStore } from '../../../store/uiStore' import { CommonStatusEnum } from '@/utils/constants'
import GroupMemberGrid from '../../group/components/GroupMemberGrid.vue' import GroupMemberGrid from '../../group/components/GroupMemberGrid.vue'
import AddGroupMemberDialog from '../../group/components/AddGroupMemberDialog.vue' import AddGroupMemberDialog from '../../group/components/AddGroupMemberDialog.vue'
import GroupMemberSelector, { import GroupMemberSelector, {
@ -132,28 +138,28 @@ defineOptions({ name: 'ImChatGroupSide' })
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
/** 当前群信息(可空:无激活群会话时) */ modelValue?: boolean // v-model
group?: GroupLite & { notice?: string; remarkNickName?: string } group?: GroupLite & { notice?: string; remarkNickName?: string } //
members?: GroupMemberLite[] members?: GroupMemberLite[]
friends?: FriendLite[] friends?: FriendLite[]
}>(), }>(),
{ {
modelValue: false,
members: () => [], members: () => [],
friends: () => [] friends: () => []
} }
) )
defineEmits<{ const emit = defineEmits<{
/** 邀请 / 移除 / 修改群资料后,父组件重新拉群数据 */ 'update:modelValue': [value: boolean]
reload: [] reload: [] // 邀请 / 移除 / 修改群资料后父组件重新拉群数据
}>() }>()
const uiStore = useImUiStore()
const userStore = useUserStore() const userStore = useUserStore()
const visible = computed({ const visible = computed({
get: () => uiStore.chatGroupSide.show, get: () => props.modelValue,
set: (v) => uiStore.toggleChatGroupSide(v) set: (v) => emit('update:modelValue', v)
}) })
const searchText = ref('') const searchText = ref('')
@ -161,23 +167,44 @@ const editing = ref(false)
const inviteVisible = ref(false) const inviteVisible = ref(false)
const removeVisible = ref(false) const removeVisible = ref(false)
const myId = computed(() => userStore.getUser?.id?.toString() || '') /**
* 群信息表单本地副本
* 不能直接 v-model propvue/no-mutating-props用本地 reactive 承接
* 保存时通过 emit / API 把变更回写给父组件
*/
const formData = reactive({
name: '',
notice: '',
remarkNickName: ''
})
const isOwner = computed( watch(
() => props.group != null && String(props.group.ownerId) === myId.value () => props.group,
(g) => {
formData.name = g?.name || ''
formData.notice = g?.notice || ''
formData.remarkNickName = g?.remarkNickName || ''
},
{ immediate: true, deep: true }
) )
const myId = computed(() => Number(userStore.getUser?.id) || 0)
const isOwner = computed(() => props.group != null && props.group.ownerId === myId.value)
const ownerName = computed(() => { const ownerName = computed(() => {
if (!props.group) return '' if (!props.group) {
const owner = props.members.find( return ''
(m) => String(m.userId) === String(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(
(m) => !m.quit && (m.showNickName || '').includes(searchText.value) (member) =>
member.status !== CommonStatusEnum.DISABLE &&
(member.showNickName || '').includes(searchText.value)
) )
) )
@ -190,11 +217,9 @@ async function handleSave() {
// TODO /im/group/quit // TODO /im/group/quit
async function handleQuit() { async function handleQuit() {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm('退出群聊后将不再接受群里的消息,确认退出吗?', '确认退出', {
'退出群聊后将不再接受群里的消息,确认退出吗?', type: 'warning'
'确认退出', })
{ type: 'warning' }
)
ElMessage.info('退出群聊接口待接入,当前为占位实现') ElMessage.info('退出群聊接口待接入,当前为占位实现')
} catch { } catch {
// //
@ -203,73 +228,16 @@ async function handleQuit() {
// TODO /im/group/member/remove // TODO /im/group/member/remove
function handleRemoveComplete(members: GroupMemberFlag[]) { function handleRemoveComplete(members: GroupMemberFlag[]) {
if (members.length === 0) return if (members.length === 0) {
return
}
ElMessage.info(`移除成员接口待接入,选择了 ${members.length} 位成员`) ElMessage.info(`移除成员接口待接入,选择了 ${members.length} 位成员`)
} }
</script> </script>
<style scoped> <style scoped>
.im-chat-group-side { /* el-icon 的全局 color 在暗色模式下会被主题盖过;:deep(svg) 锁 fill 到当前色 */
display: flex; .im-chat-group-side__tool-btn :deep(svg) {
flex-direction: column; fill: currentColor !important;
height: 100%;
padding: 10px;
}
.im-chat-group-side__block {
margin-bottom: 10px;
}
.im-chat-group-side__search {
margin-bottom: 10px;
}
.im-chat-group-side__members {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.im-chat-group-side__tool {
display: flex;
flex-direction: column;
align-items: center;
width: 54px;
}
.im-chat-group-side__tool-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
font-size: 18px;
color: #606266;
cursor: pointer;
border: 1px dashed #dcdfe6;
border-radius: 4px;
}
.im-chat-group-side__tool-btn:hover {
color: #409eff;
border-color: #409eff;
}
.im-chat-group-side__tool-text {
margin-top: 4px;
font-size: 12px;
color: #606266;
}
.im-chat-group-side__form {
flex: 1;
overflow-y: auto;
}
.im-chat-group-side__actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
} }
</style> </style>

View File

@ -1,42 +1,406 @@
<template> <template>
<div class="chat-panel"> <div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
<template v-if="chatStore.activeChat"> <template v-if="conversationStore.activeConversation">
<ChatHeader /> <!-- 顶部会话名 + 右侧功能图标 -->
<MessageList /> <div
<InputBox /> class="flex items-center justify-between h-14 px-5 bg-[var(--el-fill-color-light)] border-b border-[var(--el-border-color-light)]"
>
<span class="text-base font-medium text-[var(--el-text-color-primary)]">
{{ conversationStore.activeConversation?.name || '' }}
</span>
<div class="flex gap-3 items-center">
<!-- 聊天历史从输入区底部工具栏挪到顶部右上角对齐微信 PC点击弹窗承接历史消息 -->
<el-tooltip content="聊天历史" placement="bottom">
<el-icon
class="chat-panel__header-icon text-[20px] cursor-pointer"
@click="historyVisible = true"
>
<Tickets />
</el-icon>
</el-tooltip>
<!-- TODO @AI无论是群聊还是单聊都是 *** 三个点 -->
<!-- 私聊聊天信息抽屉免打扰 / 置顶 -->
<el-tooltip v-if="!isGroup" content="聊天信息" placement="bottom">
<el-icon
class="chat-panel__header-icon text-[20px] cursor-pointer"
@click="togglePrivateSide"
>
<MoreFilled />
</el-icon>
</el-tooltip>
<!-- 群聊群聊信息抽屉 -->
<el-tooltip v-if="isGroup" content="群聊信息" placement="bottom">
<el-icon class="chat-panel__header-icon text-[20px] cursor-pointer" @click="toggleSide">
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</div>
<!-- 中间消息列表 -->
<div
ref="listRef"
class="relative flex-1 py-2 overflow-y-auto bg-[var(--el-bg-color-page)]"
@scroll="handleScroll"
>
<div
v-if="messages.length === 0"
class="flex items-center justify-center h-full text-sm text-[var(--el-text-color-secondary)]"
>
暂无消息
</div>
<!-- data-message-id MessageHistory "定位到聊天位置" 父级通过 querySelector
找到这层 wrapperscrollIntoView + 加高亮 classid=0 的本地占位消息跳过 -->
<div
v-for="msg in messages"
:key="msg.id || msg.clientMessageId"
:data-message-id="msg.id || ''"
class="chat-panel__message-anchor"
>
<MessageItem :message="msg" />
</div>
<!-- 回到底部浮动按钮滚动不在底部时显示 -->
<transition name="chat-panel__jump-fade">
<div
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]"
@click="scrollToBottom(true)"
>
<el-icon class="text-sm"><ArrowDown /></el-icon>
<span v-if="newMessageCount > 0" class="font-medium">
{{ newMessageCount > 99 ? '99+' : newMessageCount }} 条新消息
</span>
<span v-else></span>
</div>
</transition>
</div>
<!-- 底部输入框
:key 绑会话标识切换 A B 时强制重建组件 editor / mention range / pendingAtUserIds
全部清零避免上一会话的草稿和 @ 被发到新会话 -->
<!-- TODO @AI切换时之前的要被保留 -->
<!-- TODO @AI切换时用户如果有输入信息需要把 lastContent 变成输入信息 -->
<MessageInput :key="messageInputKey" />
<!-- 右侧信息抽屉群聊 / 私聊各自一份 -->
<ChatGroupSide
v-if="isGroup"
v-model="sideVisible"
:group="groupInfo"
:members="groupMembers"
:friends="groupFriends"
@reload="reloadGroupData"
/>
<ChatPrivateSide
v-else
v-model="privateSideVisible"
:conversation="conversationStore.activeConversation"
:friend="privateFriend"
/>
<!-- 历史消息抽屉 -->
<MessageHistory v-model="historyVisible" @locate="handleLocate" />
</template> </template>
<div v-else class="chat-panel__empty"> <div
v-else
class="flex items-center justify-center h-full text-sm text-[var(--el-text-color-secondary)]"
>
<span>选择一个会话开始聊天</span> <span>选择一个会话开始聊天</span>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useChatStore } from '../store/chatStore' import { ref, watch, nextTick, computed } from 'vue'
import ChatHeader from './ChatHeader.vue' import { InfoFilled, MoreFilled, ArrowDown, Tickets } from '@element-plus/icons-vue'
import MessageList from './MessageList.vue'
import InputBox from './InputBox.vue' import { useConversationStore } from '../../../store/conversationStore'
import { useFriendStore } from '../../../store/friendStore'
import { useGroupStore } from '../../../store/groupStore'
import { ImConversationType } from '../../../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants'
import MessageItem from './message/MessageItem.vue'
import MessageInput from './input/MessageInput.vue'
import MessageHistory from './message/MessageHistory.vue'
import ChatGroupSide from './ChatGroupSide.vue'
import ChatPrivateSide from './ChatPrivateSide.vue'
import type { GroupLite } from '../../group/components/GroupItem.vue'
import type { GroupMemberLite } from './ChatGroupMember.vue'
import type { FriendLite } from '../../friend/components/FriendItem.vue'
defineOptions({ name: 'ImChatPanel' }) defineOptions({ name: 'ImChatPanel' })
const chatStore = useChatStore() const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const listRef = ref<HTMLElement>()
const messages = computed(() => conversationStore.getActiveMessages)
const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
/**
* MessageInput :key 切群时强制 unmount + remount editor / mention range /
* 上一会话草稿全部归零 fallback 'none' 避开 activeConversation 短暂为 null 的窗口
*/
const messageInputKey = computed(() => {
const conv = conversationStore.activeConversation
return conv ? `${conv.type}-${conv.targetId}` : 'none'
})
/** "是否停留在底部"的阈值:距离底部 < 80px 视为底部 */
const BOTTOM_THRESHOLD = 80
/** 当前是否已不在底部(显示"回到底部"按钮) */
const showJumpToBottom = ref(false)
/** 不在底部期间累计的新消息数 */
const newMessageCount = ref(0)
// groupStore activeConversation
const groupInfo = computed<
(GroupLite & { notice?: string; remarkNickName?: string; ownerId?: number }) | undefined
>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return undefined
}
// TODO @AIgroup
const g = groupStore.getGroup(conversation.targetId)
return {
id: conversation.targetId,
name: g?.name || conversation.name,
showGroupName: g?.name || conversation.name,
showImage: g?.avatar || conversation.avatar,
notice: g?.notice,
ownerId: g?.ownerUserId,
memberCount: g?.memberCount
}
})
// groupStore map GroupMemberLite @-mention /
const groupMembers = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
}
const group = groupStore.getGroup(conversation.targetId)
return (group?.members || []).map((member) => ({
userId: member.userId,
showNickName: member.displayUserName || member.nickname,
showImage: member.avatar,
status: member.status
}))
})
/** 好友列表(用于邀请对话框) */
const groupFriends = computed<FriendLite[]>(() =>
// TODO @AIfriend
friendStore.getActiveFriends.map((f) => ({
id: f.friendUserId,
nickname: f.nickname,
avatar: f.avatar,
deleted: f.status === CommonStatusEnum.DISABLE
}))
)
// TODO @AI
// group / members /
//
// fire-and-forget + catch Promise.all
//
function ensureGroupData(groupId: number) {
// TODO @AI groupid
groupStore.loadGroupInfo(groupId).catch((e) => {
console.warn('[IM ChatPanel] loadGroupInfo 失败', e)
})
groupStore.loadGroupMembers(groupId).catch((e) => {
console.warn('[IM ChatPanel] loadGroupMembers 失败', e)
})
friendStore.loadFriends().catch((e) => {
console.warn('[IM ChatPanel] loadFriends 失败', e)
})
}
// TODO @AI
function reloadGroupData() {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return
}
groupStore.loadGroupInfo(conversation.targetId)
groupStore.loadGroupMembers(conversation.targetId, true)
}
const historyVisible = ref(false)
// TODO @AIsideVisible sideVisible + privateSideVisible
/** 群聊抽屉的开关:纯 ChatPanel 本地 UI 状态 */
const sideVisible = ref(false) // ChatPanel UI
/** 私聊抽屉的开关:纯 ChatPanel 本地 UI 状态 */
const privateSideVisible = ref(false)
// TODO @AI
function toggleSide() {
sideVisible.value = !sideVisible.value
}
// TODO @AI
function togglePrivateSide() {
privateSideVisible.value = !privateSideVisible.value
}
/** 当前私聊对应的好友(抽屉头部展示用) */
const privateFriend = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
return undefined
}
return friendStore.getFriend(conversation.targetId)
})
/** 计算距离底部的像素 */
function distanceFromBottom(): number {
const el = listRef.value
if (!el) {
return 0
}
return el.scrollHeight - el.scrollTop - el.clientHeight
}
// TODO @AI
function handleScroll() {
const dist = distanceFromBottom()
const atBottom = dist <= BOTTOM_THRESHOLD
showJumpToBottom.value = !atBottom
if (atBottom) {
newMessageCount.value = 0
}
}
// TODO @AI
function scrollToBottom(smooth = false) {
nextTick(() => {
if (!listRef.value) {
return
}
listRef.value.scrollTo({
top: listRef.value.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
newMessageCount.value = 0
showJumpToBottom.value = false
})
}
/**
* 定位到聊天位置MessageHistory 行上"定位"按钮触发
*
* 1. 先关掉历史弹窗避免 scroll 时遮挡 + dialog 关闭后让聊天面板拿回焦点
* 2. nextTick 等弹窗 leave 动画 / 列表渲染稳定后再查 DOM
* 3. data-message-id wrapperscrollIntoView({ block: center }) 让消息落到视口中部
* 4. --highlight class 短暂高亮提示用户"就是这条"
*/
async function handleLocate(messageId: number) {
if (!messageId) {
return
}
await nextTick()
if (!listRef.value) {
return
}
const target = listRef.value.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`)
if (!target) {
return
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('chat-panel__message-anchor--highlight')
setTimeout(() => {
target.classList.remove('chat-panel__message-anchor--highlight')
}, 1600)
}
/**
* 消息变化时
* - 如果当前在底部自动跟进滚动
* - 否则累计 newMessageCount
*/
watch(
() => messages.value.length,
(newLen, oldLen) => {
const delta = (newLen || 0) - (oldLen || 0)
if (delta <= 0) {
return
}
// TODO @AI
const dist = distanceFromBottom()
if (dist <= BOTTOM_THRESHOLD) {
scrollToBottom()
} else {
newMessageCount.value += delta
showJumpToBottom.value = true
}
}
)
//
watch(
() => conversationStore.activeConversation?.targetId,
(targetId) => {
// TODO @AI
newMessageCount.value = 0
showJumpToBottom.value = false
scrollToBottom()
if (targetId && conversationStore.activeConversation?.type === ImConversationType.GROUP) {
ensureGroupData(targetId)
}
},
{ immediate: true }
)
</script> </script>
<style scoped> <style scoped>
.chat-panel { /* el-icon .el-icon{color:var(--color,inherit)} UnoCSS :deep + !important
display: flex; 颜色直接引用 Element Plus 主题变量暗色模式自动切到更亮的灰 */
flex-direction: column; .chat-panel__header-icon,
flex: 1; .chat-panel__header-icon :deep(svg) {
min-width: 0; color: var(--el-text-color-regular) !important;
background-color: #f5f5f5; fill: currentColor !important;
transition: color 0.15s;
}
.chat-panel__header-icon:hover,
.chat-panel__header-icon:hover :deep(svg) {
color: var(--el-color-primary) !important;
} }
.chat-panel__empty { /* sticky + translate fit-content transform -50%
display: flex; UnoCSS 表达 transform+transition value 不太方便这里用最小的 scoped CSS 承接 */
align-items: center; .chat-panel__jump-bottom {
justify-content: center; transform: translateX(-50%);
height: 100%; transition:
color: #909399; opacity 0.2s,
font-size: 14px; transform 0.2s;
}
/* MessageHistory "定位" 跳过来时短暂高亮1.6s 后由 JS 移除 class配合 transition 缓出黄底 */
.chat-panel__message-anchor {
transition: background-color 0.6s ease;
}
.chat-panel__message-anchor--highlight {
background-color: var(--el-color-warning-light-9);
}
/* 回到底部按钮的 Vue transition 钩子类名 */
.chat-panel__jump-fade-enter-active,
.chat-panel__jump-fade-leave-active {
transition:
opacity 0.2s,
transform 0.2s;
}
.chat-panel__jump-fade-enter-from,
.chat-panel__jump-fade-leave-to {
opacity: 0;
transform: translate(-50%, 20px);
} }
</style> </style>

View File

@ -1,4 +1,6 @@
<template> <template>
<!-- TODO @AI不够对齐微信如果让你改时需要提醒我给你图片 -->
<!-- TODO @AI新建一个 components/side -->
<!-- <!--
私聊侧边抽屉 私聊侧边抽屉
- 抽屉形态 v-model 控制由父组件 ChatPanel 管理开关 - 抽屉形态 v-model 控制由父组件 ChatPanel 管理开关
@ -6,13 +8,7 @@
- 操作消息免打扰 / 置顶聊天 - 操作消息免打扰 / 置顶聊天
- 与会话列表右键菜单同语义免打扰联动 friendStore.setMuted - 与会话列表右键菜单同语义免打扰联动 friendStore.setMuted
--> -->
<el-drawer <el-drawer v-model="visible" :with-header="false" direction="rtl" size="320px" append-to-body>
v-model="visible"
:with-header="false"
direction="rtl"
size="320px"
append-to-body
>
<div class="flex flex-col h-full p-2.5"> <div class="flex flex-col h-full p-2.5">
<!-- 头像 + 昵称 --> <!-- 头像 + 昵称 -->
<div v-if="friend" class="flex flex-col gap-1.5 items-start"> <div v-if="friend" class="flex flex-col gap-1.5 items-start">
@ -21,10 +17,11 @@
:url="friend.avatar" :url="friend.avatar"
:name="friend.nickname" :name="friend.nickname"
:size="56" :size="56"
radius="10%"
:clickable="false" :clickable="false"
/> />
<div class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)] max-w-full"> <div
class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)] max-w-full"
>
{{ friend.nickname }} {{ friend.nickname }}
</div> </div>
</div> </div>
@ -59,12 +56,9 @@ defineOptions({ name: 'ImChatPrivateSide' })
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
/** 抽屉开关v-model */ modelValue?: boolean // v-model
modelValue?: boolean conversation?: Conversation | null // 当前会话(取置顶 / 免打扰态
/** 当前会话(取置顶 / 免打扰态) */ friend?: Friend // 对方好友信息(取头像 / 昵称
conversation?: Conversation | null
/** 对方好友信息(取头像 / 昵称) */
friend?: Friend
}>(), }>(),
{ {
modelValue: false modelValue: false