feat(im): 优化好友列表的管理

im
YunaiV 2026-04-30 21:09:03 +08:00
parent 01fff53aaf
commit fd1ba30bdb
6 changed files with 164 additions and 51 deletions

View File

@ -33,11 +33,50 @@
<!-- 会话列表主体 -->
<div class="flex-1 overflow-y-auto">
<!-- 置顶会话数量 < 阈值或搜索中直接铺开否则进入分组模式独立浅底 + 可选折叠头 -->
<template v-if="!showPinnedSection">
<ConversationItem
v-for="conversation in pinnedConversations"
:key="`${conversation.type}-${conversation.targetId}`"
:conversation="conversation"
/>
</template>
<div v-else class="bg-[var(--el-fill-color-light)]">
<ConversationItem
v-for="conversation in renderedPinnedConversations"
:key="`${conversation.type}-${conversation.targetId}`"
:conversation="conversation"
/>
<!-- 折叠头放在置顶区底部对齐 WeChat mac展开 / 折叠态共用仅在还有"可折叠"内容或当前已展开时出现 -->
<div
v-if="foldablePinnedConversations.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
? '折叠置顶聊天'
: `${foldablePinnedConversations.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 filteredConversations"
v-for="conversation in normalConversations"
:key="`${conversation.type}-${conversation.targetId}`"
:conversation="conversation"
/>
<div
v-if="filteredConversations.length === 0"
class="flex items-center justify-center py-10 text-sm text-[var(--el-text-color-secondary)]"
@ -69,7 +108,7 @@ import { useGroupStore } from '../../store/groupStore'
import { StorageKeys } from '../../../utils/storage'
import { ImConversationType } from '../../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants'
import type { Friend, FriendLite } from '../../types'
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'
@ -99,6 +138,67 @@ const filteredConversations = computed(() => {
)
})
// ==================== ====================
/** 置顶超过该数量时显示折叠入口;以下数量直接铺开(避免单条置顶就出折叠头视觉太重) */
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))
/** 置顶 + 无未读 / 免打扰且非激活:折叠时藏在折叠头之下,决定折叠头计数 + 是否要显示折叠头 */
const foldablePinnedConversations = computed(() =>
pinnedConversations.value.filter((c) => !isActiveConversation(c) && !hasUnreadBadge(c))
)
/**
* 折叠时只渲未读 + 当前激活穿透折叠展开时渲全部置顶
*
* 展开后不沿用visible 在前 + foldable 在后的分组会让点击折叠区某条跨组上跳
* 上一条激活的会从 visible 掉到 foldable视觉上像"互换位置" lastSendTime 自然顺序铺最稳
*/
const renderedPinnedConversations = computed(() => {
if (pinnedExpanded.value) {
return pinnedConversations.value
}
return pinnedConversations.value.filter((c) => isActiveConversation(c) || hasUnreadBadge(c))
})
/** 与会话项右上角红点的可见条件保持一致:免打扰不亮,无未读不亮 */
function hasUnreadBadge(conversation: Conversation): boolean {
return !conversation.muted && (conversation.unreadCount || 0) > 0
}
/** 是否为当前激活会话 */
function isActiveConversation(conversation: Conversation): boolean {
const active = conversationStore.activeConversation
return !!active && active.type === conversation.type && active.targetId === conversation.targetId
}
/**
* 是否进入"分组模式"独立浅底 + 可能有折叠头搜索时不分组用户在找人别再让折叠挡住
* 置顶数 < 阈值也不分组避免单条置顶就出折叠头视觉太重
*/
const showPinnedSection = computed(
() => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD
)
// ==================== ====================
/** GroupCreateDialog 需要全量好友列表来勾选成员,结构与通讯录里好友/群分组保持一致 */
const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend: Friend) => ({
@ -116,7 +216,6 @@ function handleGroupCreated(groupId: number) {
if (!group) {
return
}
//
conversationStore.openConversation(
groupId,
ImConversationType.GROUP,

View File

@ -8,25 +8,39 @@
:inline="true"
label-width="80px"
>
<!-- TODO @AI使用 userselectv2 组件 -->
<el-form-item label="用户编号" prop="userId">
<el-input
<!-- TODO DONE @AI使用 userselectv2 组件v2 待建先用 simple-list + filterable 下拉 -->
<el-form-item label="用户" prop="userId">
<el-select
v-model="queryParams.userId"
placeholder="请输入用户编号"
placeholder="请选择用户"
clearable
@keyup.enter="handleQuery"
filterable
class="!w-200px"
/>
>
<el-option
v-for="user in userOptions"
:key="user.id"
:label="`${user.nickname} (${user.id})`"
:value="user.id"
/>
</el-select>
</el-form-item>
<!-- TODO @AI使用 userselectv2 组件 -->
<el-form-item label="好友编号" prop="friendUserId">
<el-input
<!-- TODO DONE @AI使用 userselectv2 组件v2 待建先用 simple-list + filterable 下拉 -->
<el-form-item label="好友" prop="friendUserId">
<el-select
v-model="queryParams.friendUserId"
placeholder="请输入好友用户编号"
placeholder="请选择好友"
clearable
@keyup.enter="handleQuery"
filterable
class="!w-200px"
/>
>
<el-option
v-for="user in userOptions"
:key="user.id"
:label="`${user.nickname} (${user.id})`"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="好友状态" prop="status">
<el-select
@ -80,15 +94,15 @@
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" width="100" />
<!-- TODO @AI宽度调整下 -->
<el-table-column label="用户" align="center" min-width="160">
<!-- TODO DONE @AI宽度调整下 -->
<el-table-column label="用户" align="center" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.userNickname || '-' }}</span>
<span class="text-gray-400 ml-5px">({{ row.userId }})</span>
</template>
</el-table-column>
<!-- TODO @AI宽度调整下 -->
<el-table-column label="好友" align="center" min-width="160">
<!-- TODO DONE @AI宽度调整下 -->
<el-table-column label="好友" align="center" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.friendNickname || '-' }}</span>
<span class="text-gray-400 ml-5px">({{ row.friendUserId }})</span>
@ -134,12 +148,14 @@
import { dateFormatter } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
import * as ManagerFriendApi from '@/api/im/manager/friend'
import * as UserApi from '@/api/system/user'
defineOptions({ name: 'ImFriend' })
const loading = ref(true) //
const total = ref(0) //
const list = ref<ManagerFriendApi.ImManagerFriendVO[]>([]) //
const userOptions = ref<UserApi.UserVO[]>([]) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
@ -176,7 +192,9 @@ const resetQuery = () => {
}
/** 初始化 */
onMounted(() => {
getList()
onMounted(async () => {
// userId / friendUserId
userOptions.value = await UserApi.getSimpleUserList()
await getList()
})
</script>

View File

@ -172,7 +172,7 @@
import { dateFormatter, formatDate } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ManagerGroupMessageApi from '@/api/im/manager/message/group'
import { getContentPreview, formatJson } from '../utils'
import { getContentPreview, formatJson } from '@/views/im/utils/message'
defineOptions({ name: 'ImGroupMessage' })

View File

@ -159,7 +159,7 @@
import { dateFormatter, formatDate } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ManagerPrivateMessageApi from '@/api/im/manager/message/private'
import { getContentPreview, formatJson } from '../utils'
import { getContentPreview, formatJson } from '@/views/im/utils/message'
defineOptions({ name: 'ImPrivateMessage' })

View File

@ -1,28 +0,0 @@
// TODO @AI应该放到 utils 里
// IM 消息管理共享工具:消息内容预览 / JSON 美化
//
// 消息类型 / 消息状态都走字典:
// DICT_TYPE.IM_MESSAGE_TYPE - 对应后端 ImMessageTypeEnum
// DICT_TYPE.IM_MESSAGE_STATUS - 对应后端 ImMessageStatusEnum
/** 消息内容JSON取首层 content 字段做列表预览,解析失败时回退原文 */
export const getContentPreview = (content?: string): string => {
if (!content) return ''
try {
const parsed = JSON.parse(content)
if (typeof parsed === 'object' && parsed.content) return String(parsed.content)
return content
} catch {
return content
}
}
/** 详情弹窗里把 content JSON 美化成 2 缩进 */
export const formatJson = (content?: string): string => {
if (!content) return ''
try {
return JSON.stringify(JSON.parse(content), null, 2)
} catch {
return content
}
}

View File

@ -151,3 +151,27 @@ export const playAudioTip = () => {
console.debug('[IM] playAudioTip 失败', e)
}
}
// ==================== 管理后台展示工具 ====================
/** 消息内容JSON取首层 content 字段做列表预览,解析失败时回退原文 */
export const getContentPreview = (content?: string): string => {
if (!content) return ''
try {
const parsed = JSON.parse(content)
if (typeof parsed === 'object' && parsed.content) return String(parsed.content)
return content
} catch {
return content
}
}
/** 详情弹窗里把 content JSON 美化成 2 缩进 */
export const formatJson = (content?: string): string => {
if (!content) return ''
try {
return JSON.stringify(JSON.parse(content), null, 2)
} catch {
return content
}
}