feat(im): 对齐微信:免打扰会话改小红点、消息 Tab 二次点击滚动到下一未读、移除工具栏 hover tooltip

im
YunaiV 2026-05-20 23:57:18 +08:00
parent c6b6e723e0
commit b63492199a
4 changed files with 113 additions and 41 deletions

View File

@ -3,7 +3,9 @@
ToolBarIM 左侧工具栏 ToolBarIM 左侧工具栏
布局顶部头像 中间三 Tab消息/好友/群聊 底部设置 布局顶部头像 中间三 Tab消息/好友/群聊 底部设置
--> -->
<div class="flex flex-col items-center w-14 pt-4 pb-3 gap-2 flex-shrink-0 bg-[#2b2b2b]"> <div
class="flex flex-col items-center w-14 pt-4 pb-3 gap-2 flex-shrink-0 select-none bg-[#2b2b2b]"
>
<!-- 顶部用户头像点击跳个人中心 UserAvatar 统一首字 / 哈希配色规则 --> <!-- 顶部用户头像点击跳个人中心 UserAvatar 统一首字 / 哈希配色规则 -->
<div class="mb-2 cursor-pointer" @click="goProfile"> <div class="mb-2 cursor-pointer" @click="goProfile">
<UserAvatar <UserAvatar
@ -16,43 +18,41 @@
<!-- 中间三 Tab --> <!-- 中间三 Tab -->
<div class="flex flex-col items-center gap-2 flex-1 w-full"> <div class="flex flex-col items-center gap-2 flex-1 w-full">
<el-tooltip v-for="item in tabs" :key="item.name" :content="item.label" placement="right"> <div
<div v-for="item in tabs"
class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10" :key="item.name"
:class="{ 'bg-white/15 text-white': isActive(item.name) }" class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10"
@click="goTab(item.name)" :class="{ 'bg-white/15 text-white': isActive(item.name) }"
@click="goTab(item.name)"
>
<el-badge
v-if="item.name === 'ImHomeConversation' && totalUnread > 0"
:value="totalUnread"
:max="99"
class="tool-bar__badge"
> >
<el-badge <Icon :icon="item.icon" :size="22" />
v-if="item.name === 'ImHomeConversation' && totalUnread > 0" </el-badge>
:value="totalUnread" <el-badge
:max="99" v-else-if="item.name === 'ImHomeContact' && unhandledRequestCount > 0"
class="tool-bar__badge" :value="unhandledRequestCount"
> :max="99"
<Icon :icon="item.icon" :size="22" /> class="tool-bar__badge"
</el-badge> >
<el-badge <Icon :icon="item.icon" :size="22" />
v-else-if="item.name === 'ImHomeContact' && unhandledRequestCount > 0" </el-badge>
:value="unhandledRequestCount" <Icon v-else :icon="item.icon" :size="22" />
:max="99" </div>
class="tool-bar__badge"
>
<Icon :icon="item.icon" :size="22" />
</el-badge>
<Icon v-else :icon="item.icon" :size="22" />
</div>
</el-tooltip>
</div> </div>
<!-- 底部设置按钮点击跳个人中心 --> <!-- 底部设置按钮点击跳个人中心 -->
<div class="flex flex-col items-center gap-2 w-full"> <div class="flex flex-col items-center gap-2 w-full">
<el-tooltip content="设置" placement="right"> <div
<div class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10"
class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10" @click="goProfile"
@click="goProfile" >
> <Icon icon="ant-design:setting-outlined" :size="22" />
<Icon icon="ant-design:setting-outlined" :size="22" /> </div>
</div>
</el-tooltip>
</div> </div>
</div> </div>
</template> </template>
@ -64,6 +64,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../store/conversationStore' import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore' import { useFriendStore } from '../store/friendStore'
import { useImUiStore } from '../store/uiStore'
import UserAvatar from './user/UserAvatar.vue' import UserAvatar from './user/UserAvatar.vue'
defineOptions({ name: 'ImToolBar' }) defineOptions({ name: 'ImToolBar' })
@ -73,21 +74,25 @@ const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const uiStore = useImUiStore()
const totalUnread = computed(() => conversationStore.getTotalUnread) // Tab const totalUnread = computed(() => conversationStore.getTotalUnread) // Tab
const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount) // Tab = const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount) // Tab =
const tabs = [ const tabs = [
{ name: 'ImHomeConversation', label: '消息', icon: 'ep:chat-round' }, { name: 'ImHomeConversation', icon: 'ep:chat-round' },
{ name: 'ImHomeContact', label: '通讯录', icon: 'mingcute:contacts-line' } { name: 'ImHomeContact', icon: 'mingcute:contacts-line' }
] // 两个主 Tab用路由 name 而非 path避免前缀 / 嵌套调整后失效 ] // 两个主 Tab用路由 name 而非 path避免前缀 / 嵌套调整后失效
/** 当前路由是否命中 Tab直接比对 route.name */ /** 当前路由是否命中 Tab直接比对 route.name */
const isActive = (name: string) => route.name === name const isActive = (name: string) => route.name === name
/** 切换 Tab当前 Tab 已选中时跳过,避免无意义的导航 */ /** 切换 Tab当前已选中时,消息 Tab 触发"滚动到下一个未读"(对齐微信 PC其它 Tab 无动作 */
const goTab = (name: string) => { const goTab = (name: string) => {
if (route.name === name) { if (route.name === name) {
if (name === 'ImHomeConversation') {
uiStore.requestNextUnreadJump()
}
return return
} }
router.push({ name }) router.push({ name })

View File

@ -2,10 +2,11 @@
<div <div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]" class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--el-color-primary-light-8)]': isActive }" :class="{ '!bg-[#d9ecff] dark:!bg-[var(--el-color-primary-light-8)]': isActive }"
:data-conversation-key="`${conversation.type}-${conversation.targetId}`"
@click="handleClick" @click="handleClick"
@contextmenu.prevent="handleContextMenu" @contextmenu.prevent="handleContextMenu"
> >
<!-- 头像 + 未读徽标免打扰会话不显示徽标 --> <!-- 头像 + 未读提示普通会话显示红色数字徽标免打扰会话仅显示纯小红点对齐微信不暴露具体条数 -->
<div class="relative"> <div class="relative">
<GroupAvatar <GroupAvatar
v-if="isGroup" v-if="isGroup"
@ -21,12 +22,18 @@
:size="40" :size="40"
:clickable="false" :clickable="false"
/> />
<!-- 数字徽标非免打扰且有未读时显示具体条数 -->
<span <span
v-show="!conversation.silent && conversation.unreadCount > 0" v-show="!conversation.silent && conversation.unreadCount > 0"
class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1.5 text-11px leading-[18px] text-white text-center bg-[#f56c6c] border border-solid border-white dark:border-[var(--el-bg-color)] rounded-full box-border whitespace-nowrap" class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1.5 text-11px leading-[18px] text-white text-center bg-[#f56c6c] border border-solid border-white dark:border-[var(--el-bg-color)] rounded-full box-border whitespace-nowrap"
> >
{{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }} {{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}
</span> </span>
<!-- 小红点免打扰且有未读时仅提示存在新消息切回非免打扰后用上面的数字徽标暴露累计未读 -->
<span
v-show="conversation.silent && conversation.unreadCount > 0"
class="absolute -top-1 -right-1 w-2.5 h-2.5 bg-[#f56c6c] border border-solid border-white dark:border-[var(--el-bg-color)] rounded-full box-border"
></span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">

View File

@ -96,10 +96,11 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, nextTick, ref, watch } 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 { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore'
import { StorageKeys } from '../../../utils/storage' import { StorageKeys } from '../../../utils/storage'
import { ImConversationType } from '../../../utils/constants' import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation' import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
@ -114,6 +115,7 @@ defineOptions({ name: 'ImMessagePage' })
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const uiStore = useImUiStore()
// ==================== ==================== // ==================== ====================
@ -171,7 +173,7 @@ const renderedPinnedConversations = computed(() =>
pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible
) )
/** 与会话项右上角红点的可见条件保持一致:免打扰不亮,无未读不亮 */ /** 置顶折叠时是否上浮到折叠头之上:仅以数字徽标为准;免打扰即便有未读也只展示小红点,不参与上浮 */
function hasUnreadBadge(conversation: Conversation): boolean { function hasUnreadBadge(conversation: Conversation): boolean {
return !conversation.silent && (conversation.unreadCount || 0) > 0 return !conversation.silent && (conversation.unreadCount || 0) > 0
} }
@ -220,4 +222,48 @@ function handleGroupCreated(groupId: number) {
{ silent: !!group.silent } { silent: !!group.silent }
) )
} }
// ==================== ====================
// Tab uiStore.nextUnreadJumpNonce watch
// lastJumpedConversationKey 穿
/** 上次命中的未读会话 key为空时从未读列表头开始 */
let lastJumpedConversationKey: string | null = null
/** 滚动到下一个未读会话(含免打扰);目标被搜索框过滤或藏在置顶折叠区时先解除拦截再滚 */
async function jumpToNextUnread() {
//
const unreadList = sortedConversations.value.filter((c) => (c.unreadCount || 0) > 0)
if (unreadList.length === 0) {
return
}
// refIndex=-1
const refIndex = lastJumpedConversationKey
? unreadList.findIndex((c) => getConversationKey(c) === lastJumpedConversationKey)
: -1
const target = unreadList[(refIndex + 1) % unreadList.length]
const key = getConversationKey(target)
lastJumpedConversationKey = key
// keyword
if (keyword.value && !filteredConversations.value.some((c) => getConversationKey(c) === key)) {
keyword.value = ''
}
// localStorage
const inFoldable = pinnedGroups.value.foldable.some((c) => getConversationKey(c) === key)
if (inFoldable && !pinnedExpanded.value) {
pinnedExpanded.value = true
}
// keyword / pinnedExpanded DOM
await nextTick()
const el = document.querySelector(`[data-conversation-key="${key}"]`) as HTMLElement | null
if (!el) {
return
}
// block: 'start' "="
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread)
</script> </script>

View File

@ -1,5 +1,5 @@
import { defineStore, acceptHMRUpdate } from 'pinia' import { defineStore, acceptHMRUpdate } from 'pinia'
import { reactive } from 'vue' import { reactive, ref } from 'vue'
import { ImFriendAddSource } from '../../utils/constants' import { ImFriendAddSource } from '../../utils/constants'
import type { GroupLite, User } from '../types' import type { GroupLite, User } from '../types'
@ -116,6 +116,17 @@ export const useImUiStore = defineStore('imUiStore', () => {
contextMenu.onSelect = null contextMenu.onSelect = null
} }
// ==================== 消息 Tab 跳转下一未读 ====================
// 在 ImHomeConversation 页面再次点击工具栏「消息」时触发;
// 通过递增 nonce 让 conversation/index.vue 的 watch 感知后执行滚动 + 高亮
const nextUnreadJumpNonce = ref(0)
/** 请求滚动到下一个未读会话(含免打扰) */
function requestNextUnreadJump() {
nextUnreadJumpNonce.value++
}
return { return {
userInfoCard, userInfoCard,
openUserInfoCard, openUserInfoCard,
@ -128,7 +139,10 @@ export const useImUiStore = defineStore('imUiStore', () => {
contextMenu, contextMenu,
openContextMenu, openContextMenu,
closeContextMenu closeContextMenu,
nextUnreadJumpNonce,
requestNextUnreadJump
} }
}) })