✨ feat(im): 优化好友列表的管理
parent
01fff53aaf
commit
fd1ba30bdb
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 管理后台展示工具 ====================
|
||||
|
||||
/** 消息内容(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