admin-vue3/src/views/im/home/pages/conversation/index.vue

234 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<!-- 消息 Tab左侧会话列表 + 右侧聊天面板 -->
<div class="flex flex-1 min-w-0 h-full">
<!-- 左侧会话列表可拖拽宽度 -->
<ResizableAside :default-width="260" :storage-key="StorageKeys.asideWidth">
<!-- 顶部搜索框 + "+" 号下拉对齐微信 PC发起群聊 / 添加朋友 -->
<div
class="flex flex-shrink-0 gap-2 items-center px-4 py-2 border-b border-[var(--el-border-color-lighter)]"
>
<el-input v-model="keyword" placeholder="搜索" clearable class="flex-1">
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
<el-dropdown trigger="click" placement="bottom">
<el-button size="small" circle>
<Icon icon="ant-design:plus-outlined" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="createGroupVisible = true">
<Icon icon="ant-design:message-outlined" :size="16" />
<span>发起群聊</span>
</el-dropdown-item>
<el-dropdown-item @click="addFriendVisible = true">
<Icon icon="ant-design:user-add-outlined" :size="16" />
<span>添加朋友</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 会话列表主体 -->
<div class="flex-1 overflow-y-auto">
<!-- 置顶会话:数量 < 阈值或搜索中直接铺开;否则进入分组模式(独立浅底 + 可选折叠头) -->
<template v-if="!showPinnedSection">
<ConversationItem
v-for="conversation in pinnedConversations"
:key="getConversationKey(conversation)"
:conversation="conversation"
/>
</template>
<div v-else class="bg-[var(--el-fill-color-light)]">
<ConversationItem
v-for="conversation in renderedPinnedConversations"
:key="getConversationKey(conversation)"
:conversation="conversation"
/>
<!-- 折叠头:放在置顶区底部对齐 WeChat mac展开 / 折叠态共用,仅在还有"可折叠"内容、或当前已展开时出现 -->
<div
v-if="pinnedGroups.foldable.length > 0 || pinnedExpanded"
class="flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors text-13px text-[var(--el-text-color-regular)] border-y border-[var(--el-border-color-lighter)] hover:bg-[var(--el-fill-color)]"
@click="togglePinnedExpanded"
>
<span class="flex items-center gap-1.5">
<Icon icon="ant-design:menu-outlined" :size="14" />
{{
pinnedExpanded
? '折叠置顶聊天'
: `${pinnedGroups.foldable.length} 个置顶聊天`
}}
</span>
<Icon
:icon="pinnedExpanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
:size="11"
class="text-[var(--el-text-color-placeholder)]"
/>
</div>
</div>
<!-- 普通会话 -->
<ConversationItem
v-for="conversation in normalConversations"
:key="getConversationKey(conversation)"
:conversation="conversation"
/>
<div
v-if="filteredConversations.length === 0"
class="flex items-center justify-center py-10 text-sm text-[var(--el-text-color-secondary)]"
>
{{ keyword ? '没有满足条件的会话' : '暂无会话' }}
</div>
</div>
</ResizableAside>
<!-- 右侧聊天面板 -->
<MessagePanel />
<!-- 添加朋友 / 发起群聊弹窗 -->
<FriendAddDialog v-model="addFriendVisible" />
<GroupCreateDialog
v-model="createGroupVisible"
:friends="friends"
@created="handleGroupCreated"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { StorageKeys } from '../../../utils/storage'
import { ImConversationType } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { CommonStatusEnum } from '@/utils/constants'
import type { Conversation, Friend, FriendLite } from '../../types'
import ResizableAside from '../../components/ResizableAside.vue'
import ConversationItem from './components/conversation/ConversationItem.vue'
import MessagePanel from './components/message/MessagePanel.vue'
import FriendAddDialog from '../../components/friend/FriendAddDialog.vue'
import GroupCreateDialog from '../../components/group/GroupCreateDialog.vue'
defineOptions({ name: 'ImMessagePage' })
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const keyword = ref('')
const addFriendVisible = ref(false)
const createGroupVisible = ref(false)
const sortedConversations = computed(() => conversationStore.getSortedConversations)
/** 顶部搜索框过滤会话:只按 name 模糊匹配,避免命中 lastContent 等次要字段干扰 */
const filteredConversations = computed(() => {
const keywordLower = keyword.value.trim().toLowerCase()
if (!keywordLower) {
return sortedConversations.value
}
return sortedConversations.value.filter((c) =>
(c.name || '').toLowerCase().includes(keywordLower)
)
})
// ==================== 置顶相关 ====================
/** 置顶超过该数量时显示折叠入口;以下数量直接铺开(避免单条置顶就出折叠头视觉太重) */
const PINNED_FOLD_THRESHOLD = 3
/** 置顶折叠展开态localStorage 持久化,刷新后保留用户上次的选择,对齐微信 */
const pinnedExpanded = ref(
localStorage.getItem(StorageKeys.conversationPinnedExpanded) === 'true'
)
/** toggle + 写盘 */
function togglePinnedExpanded() {
pinnedExpanded.value = !pinnedExpanded.value
localStorage.setItem(StorageKeys.conversationPinnedExpanded, String(pinnedExpanded.value))
}
/** 置顶会话:单独切片,给折叠头计数 + 折叠区渲染用 */
const pinnedConversations = computed(() => filteredConversations.value.filter((c) => c.top))
/** 非置顶会话:折叠态下始终铺开在折叠头之下 */
const normalConversations = computed(() => filteredConversations.value.filter((c) => !c.top))
/**
* 置顶分两堆visible折叠头之上 = 未读 + 当前激活)/ foldable折叠头之下一次 partition 完成
*
* 当前激活会话也"钉"在 visible避免点开未读置顶 → 立刻被读 → 列表一闪重排回折叠的体验
*/
const pinnedGroups = computed(() => {
const visible: Conversation[] = []
const foldable: Conversation[] = []
for (const conversation of pinnedConversations.value) {
if (isActiveConversation(conversation) || hasUnreadBadge(conversation)) {
visible.push(conversation)
} else {
foldable.push(conversation)
}
}
return { visible, foldable }
})
/** 折叠时只渲 visible未读 / 激活穿透);展开时渲全部 —— 展开后不分组,避免点击折叠区跨组上跳 */
const renderedPinnedConversations = computed(() =>
pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible
)
/** 与会话项右上角红点的可见条件保持一致:免打扰不亮,无未读不亮 */
function hasUnreadBadge(conversation: Conversation): boolean {
return !conversation.muted && (conversation.unreadCount || 0) > 0
}
/** 是否为当前激活会话 */
function isActiveConversation(conversation: Conversation): boolean {
const active = conversationStore.activeConversation
return !!active && getConversationKey(active) === getConversationKey(conversation)
}
/**
* 是否进入"分组模式"(独立浅底 + 可能有折叠头):搜索时不分组(用户在找人,别再让折叠挡住);
* 置顶数 < 阈值也不分组,避免单条置顶就出折叠头视觉太重
*/
const showPinnedSection = computed(
() => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD
)
// ==================== 建群相关 ====================
/** GroupCreateDialog 需要全量好友列表来勾选成员,结构与通讯录里好友/群分组保持一致 */
const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend: Friend) => ({
id: friend.friendUserId,
nickname: friend.nickname,
avatar: friend.avatar,
deleted: friend.status === CommonStatusEnum.DISABLE
}))
)
/** 处理建群成功 */
function handleGroupCreated(groupId: number) {
// GroupCreateDialog 已经 upsertGroup 把新群写进 store这里只 get + 打开会话
const group = groupStore.getGroup(groupId)
if (!group) {
return
}
conversationStore.openConversation(
groupId,
ImConversationType.GROUP,
group.name,
group.avatar || '',
{ muted: !!group.muted }
)
}
</script>