✨ feat(im): 对齐微信:免打扰会话改小红点、消息 Tab 二次点击滚动到下一未读、移除工具栏 hover tooltip
parent
c6b6e723e0
commit
b63492199a
|
|
@ -3,7 +3,9 @@
|
||||||
ToolBar:IM 左侧工具栏
|
ToolBar:IM 左侧工具栏
|
||||||
布局:顶部头像 → 中间三 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 })
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue