feat(im): 优化 ConversationItem.vue,对齐微信交互

im
YunaiV 2026-04-27 08:42:39 +08:00
parent 115e0482db
commit e85f8edcaa
4 changed files with 93 additions and 71 deletions

View File

@ -15,15 +15,26 @@
class="fixed min-w-30 py-1 bg-[var(--el-bg-color-overlay)] rounded-md shadow-lg"
:style="{ left: adjustedPosition.x + 'px', top: adjustedPosition.y + 'px' }"
>
<div
v-for="item in contextMenu.items"
:key="item.key"
class="px-4 py-2 text-13px text-center cursor-pointer transition-colors text-[var(--el-text-color-primary)] hover:bg-[var(--el-fill-color)]"
:class="{ '!text-[var(--el-text-color-disabled)] cursor-not-allowed hover:!bg-transparent': item.disabled }"
@click.stop="handleSelect(item)"
>
{{ item.name }}
</div>
<template v-for="(item, index) in contextMenu.items" :key="item.key">
<!-- divided 项上方插一条分割线首项跳过避免空白 bg+h-[1px] 而非 borderUnoCSS 不带 border-style preflight -->
<div
v-if="item.divided && index > 0"
class="my-1 mx-2 h-[1px] bg-[var(--el-border-color-lighter)]"
></div>
<div
class="px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="[
item.disabled
? '!text-[var(--el-text-color-disabled)] cursor-not-allowed hover:!bg-transparent'
: item.danger
? 'text-[#f56c6c]'
: 'text-[var(--el-text-color-primary)]'
]"
@click.stop="handleSelect(item)"
>
{{ item.name }}
</div>
</template>
</div>
</div>
</teleport>
@ -43,12 +54,14 @@ const contextMenu = computed(() => uiStore.contextMenu)
* 计算菜单实际渲染坐标靠近视口右 / 下边缘时回弹避免菜单被裁剪
*
* itemHeight / menuWidth 是和模板里 px-4 py-2 + text-13px / min-w-30 配套的实际尺寸
* dividerHeight = 9pxmy-1 上下各 4 + 1px border仅非首项的 divided 计入
* menuHeight 额外加 8 是外层 py-1 的上下 padding 之和4px × 2
*/
const adjustedPosition = computed(() => {
const items = contextMenu.value.items
const itemHeight = 34
const menuHeight = items.length * itemHeight + 8
const dividerCount = items.filter((it, i) => it.divided && i > 0).length
const menuHeight = items.length * itemHeight + dividerCount * 9 + 8
const menuWidth = 120
let x = contextMenu.value.position.x
let y = contextMenu.value.position.y

View File

@ -27,7 +27,6 @@
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]">
{{ conversation.name }}
</span>
<!-- TODO @AI不要有动效 -->
<el-tag
v-if="isGroup"
type="primary"
@ -57,15 +56,14 @@
>
{{ conversation.lastContent }}
</span>
<!-- 置顶 & 免打扰图标 -->
<el-icon
<!-- 免打扰图标 -->
<Icon
v-if="conversation.muted"
class="conversation-item__muted flex-shrink-0 ml-1 text-14px text-[var(--el-text-color-disabled)]"
icon="mdi:bell-off-outline"
:size="14"
class="conversation-item__muted flex-shrink-0 ml-1 text-[var(--el-text-color-disabled)]"
title="消息免打扰"
>
<!-- TODO @AI消息免打扰后是个 / 铃铛 -->
<Bell />
</el-icon>
/>
</div>
</div>
</div>
@ -73,9 +71,11 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import dayjs from 'dayjs'
import Icon from '@/components/Icon/src/Icon.vue'
import { isSameDay } from '@/utils/formatTime'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
@ -103,8 +103,7 @@ const isActive = computed(
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
// + +
// TODO @AI
/** 群聊 + 有发送者昵称 + 最后一条是普通消息 时,显示发送者前缀 */
const showSendName = computed(() => {
if (!isGroup.value) {
return false
@ -119,8 +118,7 @@ const showSendName = computed(() => {
return isNormalMessage(last.type)
})
// "@ " / "@ "
// TODO @AI
/** 会话列表 "@ 我" / "@ 全体成员" 红字提示 */
const atText = computed(() => {
if (props.conversation.atMe) {
return '[有人@我]'
@ -131,83 +129,91 @@ const atText = computed(() => {
return ''
})
/** 点击切会话 */
function handleClick() {
conversationStore.setActiveConversation(props.conversation)
}
// / /
// TODO @AI
/** 切换置顶 */
function handleTop() {
conversationStore.setTop(
props.conversation.type,
props.conversation.targetId,
!props.conversation.top
)
}
/**
* 切换免打扰会话级 muted 立刻同步friend / group 侧在后台异步推后端 + 落本地
*
* await friend / group setMutedUI 已经通过 conversationStore.setMuted 完成视觉切换
* 后台 /im/friend/update / /im/group-member/update void fire-and-forget
*/
function handleMuted() {
const next = !props.conversation.muted
conversationStore.setMuted(props.conversation.type, props.conversation.targetId, next)
if (props.conversation.type === ImConversationType.PRIVATE) {
void friendStore.setMuted(props.conversation.targetId, next)
} else {
void groupStore.setMuted(props.conversation.targetId, next)
}
}
/** 删除会话:二次确认后软删(用户取消走 catch 静默) */
async function handleDelete() {
try {
await ElMessageBox.confirm(
`确定删除与「${props.conversation.name}」的会话吗?`,
'删除会话',
{ type: 'warning' }
)
conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
} catch {
//
}
}
/** 右键菜单:置顶 / 免打扰 / 删除 */
function handleContextMenu(e: MouseEvent) {
uiStore.openContextMenu(
{ x: e.clientX, y: e.clientY },
// TODO @AITOP/MUTED 线
[
{ key: 'TOP', name: props.conversation.top ? '取消置顶' : '置顶' },
// TODO @AI
{ key: 'MUTED', name: props.conversation.muted ? '新消息提醒' : '消息免打扰' },
{ key: 'DELETE', name: '删除会话' }
{ key: 'MUTED', name: props.conversation.muted ? '允许消息通知' : '消息免打扰' },
{ key: 'DELETE', name: '删除', divided: true, danger: true }
],
async (item) => {
// TODO @AIhandleXXX key
(item) => {
if (item.key === 'TOP') {
conversationStore.setTop(
props.conversation.type,
props.conversation.targetId,
!props.conversation.top
)
handleTop()
} else if (item.key === 'MUTED') {
// TODO /im/group/update muted groupStore.setMuted friendStore.setMuted /im/friend/update
// friendStore / groupStore
const next = !props.conversation.muted
conversationStore.setMuted(props.conversation.type, props.conversation.targetId, next)
// TODO @AI await
if (props.conversation.type === ImConversationType.PRIVATE) {
friendStore.setMuted(props.conversation.targetId, next)
} else {
groupStore.setMuted(props.conversation.targetId, next)
}
handleMuted()
} else if (item.key === 'DELETE') {
try {
await ElMessageBox.confirm(
`确定删除与「${props.conversation.name}」的会话吗?`,
'删除会话',
{ type: 'warning' }
)
conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
} catch {
//
}
void handleDelete()
}
}
)
}
// TODO @AI format date
/** 会话列表时间:当天显示 HH:mm否则显示 MM-DD微信风格 */
function formatTime(timestamp: number): string {
if (!timestamp) {
return ''
}
const date = new Date(timestamp)
const now = new Date()
const pad = (n: number) => n.toString().padStart(2, '0')
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isToday) {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
}
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
return isSameDay(timestamp, Date.now())
? dayjs(timestamp).format('HH:mm')
: dayjs(timestamp).format('MM-DD')
}
</script>
<style scoped>
/* el-tag 内部尺寸走 CSS 变量UnoCSS 的高度/内边距会被 el-tag 自身的样式覆盖,用 :deep 微调 */
/* transition:none 是为了消掉 el-tag 切会话时 active 底色变化的渐变(看起来像闪烁) */
.conversation-item__tag {
flex-shrink: 0;
height: 18px;
padding: 0 4px;
line-height: 16px;
transition: none !important;
}
/* el-icon 的全局 color:var(--color) 在暗色模式下会渲染成白色,这里用 :deep + !important 锁定 */

View File

@ -8,6 +8,7 @@ import {
} from '@/api/im/group'
import {
getGroupMemberList as apiGetGroupMemberList,
updateGroupMember as apiUpdateGroupMember,
type ImGroupMemberRespVO
} from '@/api/im/group/member'
import { useConversationStore } from './conversationStore'
@ -121,9 +122,9 @@ export const useGroupStore = defineStore('imGroupStore', {
conversationStore.removeGroupConversation(id)
},
/** 切换免打扰(仅本地状态;后端 /im/group/update 接入 muted 字段后再补) */
setMuted(id: number, muted: boolean) {
// 在本地 group 上直接打 muted 标记conversationStore 的会话级 muted 由 ConversationItem 单独 setMuted 写
/** 切换免打扰:调 /im/group-member/update 推后端,再把当前用户在该群的 muted 标记落到本地 */
async setMuted(id: number, muted: boolean) {
await apiUpdateGroupMember({ groupId: id, muted })
const group = this.getGroup(id)
if (group) {
group.muted = muted

View File

@ -40,6 +40,8 @@ export const useImUiStore = defineStore('imUiStore', () => {
key: string
name: string
disabled?: boolean
divided?: boolean // 是否在该项上方显示分割线(用于把"删除"等危险操作与上面的常规项隔开)
danger?: boolean // 是否走危险操作样式(红色文字)
}
const contextMenu = reactive({