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"> <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 <ConversationItem
v-for="conversation in filteredConversations" v-for="conversation in normalConversations"
:key="`${conversation.type}-${conversation.targetId}`" :key="`${conversation.type}-${conversation.targetId}`"
:conversation="conversation" :conversation="conversation"
/> />
<div <div
v-if="filteredConversations.length === 0" v-if="filteredConversations.length === 0"
class="flex items-center justify-center py-10 text-sm text-[var(--el-text-color-secondary)]" 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 { StorageKeys } from '../../../utils/storage'
import { ImConversationType } from '../../../utils/constants' import { ImConversationType } from '../../../utils/constants'
import { CommonStatusEnum } 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 ResizableAside from '../../components/ResizableAside.vue'
import ConversationItem from './components/conversation/ConversationItem.vue' import ConversationItem from './components/conversation/ConversationItem.vue'
import MessagePanel from './components/message/MessagePanel.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 需要全量好友列表来勾选成员,结构与通讯录里好友/群分组保持一致 */ /** GroupCreateDialog 需要全量好友列表来勾选成员,结构与通讯录里好友/群分组保持一致 */
const friends = computed<FriendLite[]>(() => const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend: Friend) => ({ friendStore.getActiveFriends.map((friend: Friend) => ({
@ -116,7 +216,6 @@ function handleGroupCreated(groupId: number) {
if (!group) { if (!group) {
return return
} }
//
conversationStore.openConversation( conversationStore.openConversation(
groupId, groupId,
ImConversationType.GROUP, ImConversationType.GROUP,

View File

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

View File

@ -172,7 +172,7 @@
import { dateFormatter, formatDate } from '@/utils/formatTime' import { dateFormatter, formatDate } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ManagerGroupMessageApi from '@/api/im/manager/message/group' 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' }) defineOptions({ name: 'ImGroupMessage' })

View File

@ -159,7 +159,7 @@
import { dateFormatter, formatDate } from '@/utils/formatTime' import { dateFormatter, formatDate } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ManagerPrivateMessageApi from '@/api/im/manager/message/private' 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' }) 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) 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
}
}