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" 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' }" :style="{ left: adjustedPosition.x + 'px', top: adjustedPosition.y + 'px' }"
> >
<div <template v-for="(item, index) in contextMenu.items" :key="item.key">
v-for="item in contextMenu.items" <!-- divided 项上方插一条分割线首项跳过避免空白 bg+h-[1px] 而非 borderUnoCSS 不带 border-style preflight -->
:key="item.key" <div
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)]" v-if="item.divided && index > 0"
:class="{ '!text-[var(--el-text-color-disabled)] cursor-not-allowed hover:!bg-transparent': item.disabled }" class="my-1 mx-2 h-[1px] bg-[var(--el-border-color-lighter)]"
@click.stop="handleSelect(item)" ></div>
> <div
{{ item.name }} class="px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
</div> :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>
</div> </div>
</teleport> </teleport>
@ -43,12 +54,14 @@ const contextMenu = computed(() => uiStore.contextMenu)
* 计算菜单实际渲染坐标靠近视口右 / 下边缘时回弹避免菜单被裁剪 * 计算菜单实际渲染坐标靠近视口右 / 下边缘时回弹避免菜单被裁剪
* *
* itemHeight / menuWidth 是和模板里 px-4 py-2 + text-13px / min-w-30 配套的实际尺寸 * 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 * menuHeight 额外加 8 是外层 py-1 的上下 padding 之和4px × 2
*/ */
const adjustedPosition = computed(() => { const adjustedPosition = computed(() => {
const items = contextMenu.value.items const items = contextMenu.value.items
const itemHeight = 34 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 const menuWidth = 120
let x = contextMenu.value.position.x let x = contextMenu.value.position.x
let y = contextMenu.value.position.y 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)]"> <span class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]">
{{ conversation.name }} {{ conversation.name }}
</span> </span>
<!-- TODO @AI不要有动效 -->
<el-tag <el-tag
v-if="isGroup" v-if="isGroup"
type="primary" type="primary"
@ -57,15 +56,14 @@
> >
{{ conversation.lastContent }} {{ conversation.lastContent }}
</span> </span>
<!-- 置顶 & 免打扰图标 --> <!-- 免打扰图标 -->
<el-icon <Icon
v-if="conversation.muted" 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="消息免打扰" title="消息免打扰"
> />
<!-- TODO @AI消息免打扰后是个 / 铃铛 -->
<Bell />
</el-icon>
</div> </div>
</div> </div>
</div> </div>
@ -73,9 +71,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus' 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 { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore' import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
@ -103,8 +103,7 @@ const isActive = computed(
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP) const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
// + + /** 群聊 + 有发送者昵称 + 最后一条是普通消息 时,显示发送者前缀 */
// TODO @AI
const showSendName = computed(() => { const showSendName = computed(() => {
if (!isGroup.value) { if (!isGroup.value) {
return false return false
@ -119,8 +118,7 @@ const showSendName = computed(() => {
return isNormalMessage(last.type) return isNormalMessage(last.type)
}) })
// "@ " / "@ " /** 会话列表 "@ 我" / "@ 全体成员" 红字提示 */
// TODO @AI
const atText = computed(() => { const atText = computed(() => {
if (props.conversation.atMe) { if (props.conversation.atMe) {
return '[有人@我]' return '[有人@我]'
@ -131,83 +129,91 @@ const atText = computed(() => {
return '' return ''
}) })
/** 点击切会话 */
function handleClick() { function handleClick() {
conversationStore.setActiveConversation(props.conversation) 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) { function handleContextMenu(e: MouseEvent) {
uiStore.openContextMenu( uiStore.openContextMenu(
{ x: e.clientX, y: e.clientY }, { x: e.clientX, y: e.clientY },
// TODO @AITOP/MUTED 线
[ [
{ key: 'TOP', name: props.conversation.top ? '取消置顶' : '置顶' }, { key: 'TOP', name: props.conversation.top ? '取消置顶' : '置顶' },
// TODO @AI { key: 'MUTED', name: props.conversation.muted ? '允许消息通知' : '消息免打扰' },
{ key: 'MUTED', name: props.conversation.muted ? '新消息提醒' : '消息免打扰' }, { key: 'DELETE', name: '删除', divided: true, danger: true }
{ key: 'DELETE', name: '删除会话' }
], ],
async (item) => { (item) => {
// TODO @AIhandleXXX key
if (item.key === 'TOP') { if (item.key === 'TOP') {
conversationStore.setTop( handleTop()
props.conversation.type,
props.conversation.targetId,
!props.conversation.top
)
} else if (item.key === 'MUTED') { } else if (item.key === 'MUTED') {
// TODO /im/group/update muted groupStore.setMuted friendStore.setMuted /im/friend/update handleMuted()
// 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)
}
} else if (item.key === 'DELETE') { } else if (item.key === 'DELETE') {
try { void handleDelete()
await ElMessageBox.confirm(
`确定删除与「${props.conversation.name}」的会话吗?`,
'删除会话',
{ type: 'warning' }
)
conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
} catch {
//
}
} }
} }
) )
} }
// TODO @AI format date /** 会话列表时间:当天显示 HH:mm否则显示 MM-DD微信风格 */
function formatTime(timestamp: number): string { function formatTime(timestamp: number): string {
if (!timestamp) { if (!timestamp) {
return '' return ''
} }
const date = new Date(timestamp) return isSameDay(timestamp, Date.now())
const now = new Date() ? dayjs(timestamp).format('HH:mm')
const pad = (n: number) => n.toString().padStart(2, '0') : dayjs(timestamp).format('MM-DD')
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())}`
} }
</script> </script>
<style scoped> <style scoped>
/* el-tag 内部尺寸走 CSS 变量UnoCSS 的高度/内边距会被 el-tag 自身的样式覆盖,用 :deep 微调 */ /* el-tag 内部尺寸走 CSS 变量UnoCSS 的高度/内边距会被 el-tag 自身的样式覆盖,用 :deep 微调 */
/* transition:none 是为了消掉 el-tag 切会话时 active 底色变化的渐变(看起来像闪烁) */
.conversation-item__tag { .conversation-item__tag {
flex-shrink: 0; flex-shrink: 0;
height: 18px; height: 18px;
padding: 0 4px; padding: 0 4px;
line-height: 16px; line-height: 16px;
transition: none !important;
} }
/* el-icon 的全局 color:var(--color) 在暗色模式下会渲染成白色,这里用 :deep + !important 锁定 */ /* el-icon 的全局 color:var(--color) 在暗色模式下会渲染成白色,这里用 :deep + !important 锁定 */

View File

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

View File

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