✨ feat(im): 优化 ConversationItem.vue,对齐微信交互
parent
115e0482db
commit
e85f8edcaa
|
|
@ -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] 而非 border,UnoCSS 不带 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 = 9px(my-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
|
||||||
|
|
|
||||||
|
|
@ -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 的 setMuted:UI 已经通过 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 @AI:TOP/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 @AI:是不是抽成小方法。handleXXX;下面的每个 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 锁定 */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue