✨ feat(im): 优化好友列表的管理
parent
01fff53aaf
commit
fd1ba30bdb
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue