From 312df4c73d11e181507abc5281ee4f99d3ba0ef7 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 8 May 2026 14:06:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor(im):=20=E6=8A=BD=E8=B1=A1=20IM=20?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E7=B1=BB=E5=BC=B9=E7=AA=97=E4=B8=BA=20Picker?= =?UTF-8?q?Panel=20=E4=BD=93=E7=B3=BB=EF=BC=8C=E5=AF=B9=E9=BD=90=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=20PC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆「业务壳 + 纯 PickerPanel」两层;13 个 dialog 统一 ref + open(opts) 接口 - 新增 FriendPickerPanel / ConversationPickerPanel / GroupMemberPickerPanel - 抽 useFriendBuckets / useSelectedItems composable + buildDefaultGroupName / picker-dialog.scss mixin - conversationStore 加 recentForwardConversationKeys 系列 action(持久化到 IDB) - 三态语义固化:hide > locked > disabled - 圆形勾选用微信绿;主按钮跟随项目主题色;最近转发横向头像 + 移除模式 - 删 GroupMemberSelector(由 GroupMemberPickerPanel 替代)/ FriendLite.deleted 死字段 - 配套:project_duibiao/im/dialog-picker-{contract,wechat-compare}.md --- .../components/friend/FriendAddDialog.vue | 58 +-- .../components/group/GroupAdminSetDialog.vue | 121 +++++ .../components/group/GroupCreateDialog.vue | 263 +++------- .../components/group/GroupMemberAddDialog.vue | 255 +++------ .../home/components/group/GroupMemberGrid.vue | 2 +- .../group/GroupMemberRemoveDialog.vue | 103 ++++ .../components/group/GroupMemberSelector.vue | 179 ------- .../group/GroupOwnerTransferDialog.vue | 123 +++++ .../group/GroupRequestListDialog.vue | 45 +- .../picker/ConversationPickerPanel.vue | 359 +++++++++++++ .../components/picker/FriendPickerPanel.vue | 276 ++++++++++ .../picker/GroupMemberPickerPanel.vue | 254 +++++++++ .../home/components/picker/picker-dialog.scss | 13 + .../components/user/RecommendCardDialog.vue | 492 +++++++++--------- .../im/home/components/user/UserInfo.vue | 33 +- .../im/home/composables/useFriendBuckets.ts | 98 ++++ .../im/home/composables/useSelectedItems.ts | 75 +++ .../im/home/pages/contact/FriendList.vue | 86 +-- src/views/im/home/pages/contact/index.vue | 15 +- .../conversation/ConversationGroupSide.vue | 308 +++++------ .../conversation/ConversationPrivateSide.vue | 28 +- .../message/GroupRequestPending.vue | 12 +- .../components/message/MessageHistory.vue | 31 +- .../components/message/MessagePanel.vue | 26 +- .../message/forward/MessageForwardDialog.vue | 364 +++++-------- .../im/home/pages/conversation/index.vue | 42 +- src/views/im/home/store/conversationStore.ts | 65 ++- src/views/im/home/store/friendStore.ts | 13 +- src/views/im/home/types/index.ts | 3 +- src/views/im/utils/constants.ts | 3 + src/views/im/utils/group.ts | 14 + src/views/im/utils/storage.ts | 4 + 32 files changed, 2295 insertions(+), 1468 deletions(-) create mode 100644 src/views/im/home/components/group/GroupAdminSetDialog.vue create mode 100644 src/views/im/home/components/group/GroupMemberRemoveDialog.vue delete mode 100644 src/views/im/home/components/group/GroupMemberSelector.vue create mode 100644 src/views/im/home/components/group/GroupOwnerTransferDialog.vue create mode 100644 src/views/im/home/components/picker/ConversationPickerPanel.vue create mode 100644 src/views/im/home/components/picker/FriendPickerPanel.vue create mode 100644 src/views/im/home/components/picker/GroupMemberPickerPanel.vue create mode 100644 src/views/im/home/components/picker/picker-dialog.scss create mode 100644 src/views/im/home/composables/useFriendBuckets.ts create mode 100644 src/views/im/home/composables/useSelectedItems.ts create mode 100644 src/views/im/utils/group.ts diff --git a/src/views/im/home/components/friend/FriendAddDialog.vue b/src/views/im/home/components/friend/FriendAddDialog.vue index b837a6db1..314fd996e 100644 --- a/src/views/im/home/components/friend/FriendAddDialog.vue +++ b/src/views/im/home/components/friend/FriendAddDialog.vue @@ -127,7 +127,7 @@ + + diff --git a/src/views/im/home/components/group/GroupCreateDialog.vue b/src/views/im/home/components/group/GroupCreateDialog.vue index d44cebf77..1e281e615 100644 --- a/src/views/im/home/components/group/GroupCreateDialog.vue +++ b/src/views/im/home/components/group/GroupCreateDialog.vue @@ -1,87 +1,24 @@ + + + + diff --git a/src/views/im/home/components/group/GroupMemberSelector.vue b/src/views/im/home/components/group/GroupMemberSelector.vue deleted file mode 100644 index 4ea1e4a63..000000000 --- a/src/views/im/home/components/group/GroupMemberSelector.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - diff --git a/src/views/im/home/components/group/GroupOwnerTransferDialog.vue b/src/views/im/home/components/group/GroupOwnerTransferDialog.vue new file mode 100644 index 000000000..abb23d7ae --- /dev/null +++ b/src/views/im/home/components/group/GroupOwnerTransferDialog.vue @@ -0,0 +1,123 @@ + + + + + + diff --git a/src/views/im/home/components/group/GroupRequestListDialog.vue b/src/views/im/home/components/group/GroupRequestListDialog.vue index b58b13158..94fa2423a 100644 --- a/src/views/im/home/components/group/GroupRequestListDialog.vue +++ b/src/views/im/home/components/group/GroupRequestListDialog.vue @@ -160,29 +160,28 @@ import UserAvatar from '../user/UserAvatar.vue' defineOptions({ name: 'ImGroupRequestListDialog' }) -const props = defineProps<{ - modelValue: boolean - groupId?: number -}>() -const emit = defineEmits<{ - 'update:modelValue': [value: boolean] -}>() - -const visible = computed({ - get: () => props.modelValue, - set: (v) => emit('update:modelValue', v) -}) - const message = useMessage() const groupRequestStore = useGroupRequestStore() +const visible = ref(false) +/** 当前展示的群编号;undefined 时走全局未处理列表(store.unhandledList) */ +const groupId = ref() const loading = ref(false) const groupList = ref([]) const actingId = ref(null) +defineExpose({ + /** 打开进群申请弹窗:reset → 灌参 → visible=true;不传 groupId 走全局未处理列表 */ + open(opts?: { groupId?: number }) { + groupId.value = opts?.groupId + actingId.value = null + visible.value = true + } +}) + /** 数据源:单群模式用 fetch 回来的 groupList;全局模式直接读 store.unhandledList,处理后 store 自动 reactive 同步 */ const list = computed(() => - props.groupId ? groupList.value : groupRequestStore.unhandledList + groupId.value ? groupList.value : groupRequestStore.unhandledList ) /** 顶部卡片:最新一条;空数组时为 null */ @@ -192,11 +191,11 @@ const histories = computed(() => list.value.slice(1)) /** 打开 dialog 时拉数据:单群拉 API;全局直接读 store;关闭时清掉单群缓存 */ watch( - () => [visible.value, props.groupId] as const, - ([open, groupId]) => { - if (open && groupId) { - void fetchList(groupId) - } else if (!open) { + [visible, groupId], + ([isVisible, currentGroupId]) => { + if (isVisible && currentGroupId) { + void fetchList(currentGroupId) + } else if (!isVisible) { groupList.value = [] } }, @@ -211,9 +210,9 @@ watch( */ watch( () => - props.groupId && visible.value + groupId.value && visible.value ? groupRequestStore.unhandledList - .filter((request) => request.groupId === props.groupId) + .filter((request) => request.groupId === groupId.value) .map((request) => `${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`) .join(',') : null, @@ -224,8 +223,8 @@ watch( if (actingId.value !== null) { return } - if (props.groupId) { - void fetchList(props.groupId) + if (groupId.value) { + void fetchList(groupId.value) } } ) diff --git a/src/views/im/home/components/picker/ConversationPickerPanel.vue b/src/views/im/home/components/picker/ConversationPickerPanel.vue new file mode 100644 index 000000000..2c50d23ca --- /dev/null +++ b/src/views/im/home/components/picker/ConversationPickerPanel.vue @@ -0,0 +1,359 @@ + + + + + diff --git a/src/views/im/home/components/picker/FriendPickerPanel.vue b/src/views/im/home/components/picker/FriendPickerPanel.vue new file mode 100644 index 000000000..928e8e055 --- /dev/null +++ b/src/views/im/home/components/picker/FriendPickerPanel.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/src/views/im/home/components/picker/GroupMemberPickerPanel.vue b/src/views/im/home/components/picker/GroupMemberPickerPanel.vue new file mode 100644 index 000000000..ef6d02974 --- /dev/null +++ b/src/views/im/home/components/picker/GroupMemberPickerPanel.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/src/views/im/home/components/picker/picker-dialog.scss b/src/views/im/home/components/picker/picker-dialog.scss new file mode 100644 index 000000000..7f9943a8e --- /dev/null +++ b/src/views/im/home/components/picker/picker-dialog.scss @@ -0,0 +1,13 @@ +// IM 选择类弹窗的公共样式 mixin +// 每个业务壳在自己的 diff --git a/src/views/im/home/components/user/UserInfo.vue b/src/views/im/home/components/user/UserInfo.vue index 8e21618fb..8213bbfd1 100644 --- a/src/views/im/home/components/user/UserInfo.vue +++ b/src/views/im/home/components/user/UserInfo.vue @@ -171,15 +171,10 @@ - + - + @@ -348,19 +343,21 @@ function handleComingSoon(featureName: string) { // ==================== 添加好友 / 删除好友 ==================== -// 加好友弹窗显隐 + 预填用户(点「加为好友」时把 props.user 传给 FriendAddDialog 跳过搜索) -const addFriendVisible = ref(false) -const presetUserForAdd = ref(null) +/** 加好友弹窗 ref:handleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发 */ +const friendAddDialogRef = ref>() +/** 推荐名片弹窗 ref:handleRecommend 调用 open({ target }) 打开 */ +const recommendDialogRef = ref>() /** 把他推荐给朋友:弹 RecommendCardDialog 选目标会话 */ -const recommendVisible = ref(false) // 推荐名片弹窗显隐:「把他推荐给朋友」入口控制 -/** 推荐名片源对象:用户名片(targetType = PRIVATE),从 full 派生 */ -const recommendTarget = computed(() => toUserCardTarget(full.value)) function handleRecommend() { if (!props.user?.id) { return } - recommendVisible.value = true + const target = toUserCardTarget(full.value) + if (!target) { + return + } + recommendDialogRef.value?.open({ target }) } /** 加为好友:弹 FriendAddDialog(带预填用户),让用户填申请理由 + 备注后再发申请 */ @@ -368,7 +365,7 @@ function handleAddFriend() { if (!props.user?.id) { return } - presetUserForAdd.value = { + const presetUser: UserVO = { id: props.user.id, nickname: props.user.nickname, avatar: props.user.avatar, @@ -376,7 +373,11 @@ function handleAddFriend() { deptId: props.user.deptId, deptName: props.user.deptName } as UserVO - addFriendVisible.value = true + friendAddDialogRef.value?.open({ + presetUser, + addSource: props.addSource, + addSourceExtra: props.addSourceExtra + }) } /** 加入黑名单:confirm → friendStore.blockFriend;后端 FRIEND_BLOCK 推到时由 dispatcher 同步多端 */ diff --git a/src/views/im/home/composables/useFriendBuckets.ts b/src/views/im/home/composables/useFriendBuckets.ts new file mode 100644 index 000000000..2f592e941 --- /dev/null +++ b/src/views/im/home/composables/useFriendBuckets.ts @@ -0,0 +1,98 @@ +import { computed, type ComputedRef, type Ref } from 'vue' + +import type { FriendLite } from '../types' + +/** 字母分桶结果:letter 取 'A'-'Z' 或兜底 '#';list 桶内按拼音 / 名字自然序 */ +export interface FriendBucket { + letter: string + list: FriendLite[] +} + +/** 取分桶 / 排序键:备注拼音优先 → 昵称拼音 → 名字本身(兜底英文 / 数字) */ +function getSortKey(friend: FriendLite): string { + return ( + friend.displayNamePinyin || + friend.nicknamePinyin || + (friend.displayName || friend.nickname || '').toLowerCase() + ) +} + +/** 取分桶字母:拼音首字母大写,非字母(纯符号 / 数字 / 中文)兜底 '#' */ +function getBucketLetter(friend: FriendLite): string { + const first = getSortKey(friend).charAt(0) + return /^[a-zA-Z]$/.test(first) ? first.toUpperCase() : '#' +} + +/** 拼音首字母拼接:「lao zhang」→ 'lz',支持「输 lz 搜老张」 */ +function pinyinInitials(pinyin?: string): string { + if (!pinyin) { + return '' + } + return pinyin + .split(' ') + .map((word) => word.charAt(0)) + .join('') +} + +/** + * 好友列表的搜索 + 字母分桶 + * + * - 搜索匹配:备注 / 昵称 / 全拼(去空格)/ 首字母任一命中 + * - 分桶规则:A-Z + '#' 兜底,桶内按 getSortKey 自然序 + * + * 通讯录页 FriendList 与选择类弹窗 FriendPickerPanel 共用,避免规则各自实现走偏 + */ +export function useFriendBuckets( + friends: Ref | ComputedRef, + keyword: Ref +): { + filtered: ComputedRef + buckets: ComputedRef +} { + const filtered = computed(() => { + const keywordLower = keyword.value.trim().toLowerCase() + if (!keywordLower) { + return friends.value + } + return friends.value.filter((friend) => { + const nicknamePinyin = friend.nicknamePinyin || '' + const displayNamePinyin = friend.displayNamePinyin || '' + // 全拼搜索去掉空格,让「laozhang」也能命中「lao zhang」 + return ( + (friend.nickname || '').toLowerCase().includes(keywordLower) || + (friend.displayName || '').toLowerCase().includes(keywordLower) || + nicknamePinyin.replace(/\s/g, '').includes(keywordLower) || + displayNamePinyin.replace(/\s/g, '').includes(keywordLower) || + pinyinInitials(nicknamePinyin).includes(keywordLower) || + pinyinInitials(displayNamePinyin).includes(keywordLower) + ) + }) + }) + + const buckets = computed(() => { + const map = new Map() + for (const friend of filtered.value) { + const letter = getBucketLetter(friend) + if (!map.has(letter)) { + map.set(letter, []) + } + map.get(letter)!.push(friend) + } + const letters = Array.from(map.keys()).sort((a, b) => { + // '#' 永远排末尾,A-Z 走 localeCompare + if (a === '#') { + return 1 + } + if (b === '#') { + return -1 + } + return a.localeCompare(b) + }) + return letters.map((letter) => ({ + letter, + list: map.get(letter)!.sort((a, b) => getSortKey(a).localeCompare(getSortKey(b))) + })) + }) + + return { filtered, buckets } +} diff --git a/src/views/im/home/composables/useSelectedItems.ts b/src/views/im/home/composables/useSelectedItems.ts new file mode 100644 index 000000000..f9ef1c77a --- /dev/null +++ b/src/views/im/home/composables/useSelectedItems.ts @@ -0,0 +1,75 @@ +import { computed, type ComputedRef, type Ref } from 'vue' + +/** + * 三态选择面板的「已选数 + 已选项列表」派生 + * + * - 三态优先级(与 dialog-picker-contract 对齐):hide > locked > disabled + * - hide:永远剔除,不计数 / 不进已选列表 + * - locked:默认勾选 + 计数 + 优先排在前面;即使被外部塞进 disabledIds 也胜出 + * - disabled:仅过滤 selectedIds,不计数 / 不进已选列表 + * - 顺序:lockedIds 在前,selectedIds 紧随;按数组顺序即为「点击顺序」 + * + * FriendPickerPanel / GroupMemberPickerPanel 共用,避免两侧 25 行同构 computed 各写一份; + * Panel 内部的 isLocked / isDisabled / isSelected 等模板判定函数仍各自维护,本 composable 只承担派生量 + */ +export function useSelectedItems( + selectedIds: () => readonly number[], + lockedIds: () => readonly number[], + disabledIds: () => readonly number[], + hideIds: () => readonly number[], + byId: Ref> | ComputedRef> +): { + selectedCount: ComputedRef + selectedItems: ComputedRef +} { + const hideSet = computed(() => new Set(hideIds())) + const disabledSet = computed(() => new Set(disabledIds())) + + const selectedCount = computed(() => { + const merged = new Set() + for (const id of selectedIds()) { + if (hideSet.value.has(id) || disabledSet.value.has(id)) { + continue + } + merged.add(id) + } + // locked 仅被 hide 过滤;契约里 locked 胜过 disabled,确保锁定项始终计入 + for (const id of lockedIds()) { + if (hideSet.value.has(id)) { + continue + } + merged.add(id) + } + return merged.size + }) + + const selectedItems = computed(() => { + const seen = new Set() + const result: T[] = [] + // locked 在前;仅被 hide 过滤 + for (const id of lockedIds()) { + if (seen.has(id) || hideSet.value.has(id)) { + continue + } + const item = byId.value.get(id) + if (item) { + seen.add(id) + result.push(item) + } + } + // selectedIds 紧随;额外过滤 disabled + for (const id of selectedIds()) { + if (seen.has(id) || disabledSet.value.has(id) || hideSet.value.has(id)) { + continue + } + const item = byId.value.get(id) + if (item) { + seen.add(id) + result.push(item) + } + } + return result + }) + + return { selectedCount, selectedItems } +} diff --git a/src/views/im/home/pages/contact/FriendList.vue b/src/views/im/home/pages/contact/FriendList.vue index 6cbf9a33d..aeaa1c2a9 100644 --- a/src/views/im/home/pages/contact/FriendList.vue +++ b/src/views/im/home/pages/contact/FriendList.vue @@ -2,12 +2,13 @@
@@ -43,9 +44,10 @@ diff --git a/src/views/im/home/pages/contact/index.vue b/src/views/im/home/pages/contact/index.vue index 2cd7da0cf..18e623815 100644 --- a/src/views/im/home/pages/contact/index.vue +++ b/src/views/im/home/pages/contact/index.vue @@ -102,10 +102,9 @@ import { useConversationStore } from '../../store/conversationStore' import { useFriendStore } from '../../store/friendStore' import { useGroupStore } from '../../store/groupStore' import { getFriendDisplayName, getGroupDisplayName } from '../../../utils/user' -import type { Friend, FriendLite, FriendRequest, Group, GroupLite, User } from '../../types' +import type { FriendLite, FriendRequest, Group, GroupLite, User } from '../../types' import { ImConversationType } from '../../../utils/constants' import { StorageKeys } from '../../../utils/storage' -import { CommonStatusEnum } from '@/utils/constants' defineOptions({ name: 'ImContactPage' }) @@ -137,17 +136,7 @@ const currentRequest = computed(() => { const friendRequests = computed(() => friendStore.friendRequests) /** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */ -const friends = computed(() => - friendStore.getActiveFriends.map((friend: Friend) => ({ - id: friend.friendUserId, - nickname: friend.nickname, - nicknamePinyin: friend.nicknamePinyin, - avatar: friend.avatar, - displayName: friend.displayName, - displayNamePinyin: friend.displayNamePinyin, - deleted: friend.status === CommonStatusEnum.DISABLE - })) -) +const friends = computed(() => friendStore.getActiveFriendsLite) const groups = computed(() => groupStore.groups.map((group: Group) => ({ diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index 6be42eecd..3db006b19 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -36,7 +36,7 @@
@@ -49,7 +49,7 @@ v-if="isOwnerOrAdmin" class="im-conversation-group-side__tile-wrap" title="移出群成员" - @click="removeVisible = true" + @click="handleOpenRemove" >
@@ -257,7 +257,7 @@
分享群名片 - 进群申请
群管理员
群主管理权转让 - - + + - - + + - + - + @@ -423,29 +395,22 @@ import { useUserStore } from '@/store/modules/user' import { CommonStatusEnum } from '@/utils/constants' import { updateGroup, - addGroupAdmin, - removeGroupAdmin, - transferGroupOwner, muteAll, dissolveGroup } from '@/api/im/group' -import { quitGroup, removeGroupMember, updateGroupMember } from '@/api/im/group/member' +import { quitGroup, updateGroupMember } from '@/api/im/group/member' import { useConversationStore } from '../../../../store/conversationStore' import { useGroupStore } from '../../../../store/groupStore' -import { - GROUP_ADMIN_MAX_COUNT, - ImConversationType, - ImGroupMemberRole -} from '@/views/im/utils/constants' +import { ImConversationType, ImGroupMemberRole } from '@/views/im/utils/constants' import GroupMemberGrid from '../../../../components/group/GroupMemberGrid.vue' import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDialog.vue' -import GroupMemberSelector, { - type GroupMemberFlag -} from '../../../../components/group/GroupMemberSelector.vue' +import GroupMemberRemoveDialog from '../../../../components/group/GroupMemberRemoveDialog.vue' +import GroupAdminSetDialog from '../../../../components/group/GroupAdminSetDialog.vue' +import GroupOwnerTransferDialog from '../../../../components/group/GroupOwnerTransferDialog.vue' import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue' import RecommendCardDialog from '../../../../components/user/RecommendCardDialog.vue' import { toGroupCardTarget } from '@/views/im/utils/message' -import type { Conversation, FriendLite, GroupLite } from '../../../../types' +import type { Conversation, GroupLite } from '../../../../types' import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' defineOptions({ name: 'ImConversationGroupSide' }) @@ -458,12 +423,10 @@ const props = withDefaults( group?: GroupLite & { notice?: string; remarkNickName?: string; groupRemark?: string } // 当前群信息(可空:无激活群会话时) conversation?: Conversation | null // 当前会话:用于读 / 切免打扰、置顶状态 members?: GroupMemberLite[] - friends?: FriendLite[] }>(), { modelValue: false, - members: () => [], - friends: () => [] + members: () => [] } ) @@ -483,56 +446,21 @@ const visible = computed({ set: (v) => emit('update:modelValue', v) }) -const searchText = ref('') -const inviteVisible = ref(false) -const removeVisible = ref(false) -const adminVisible = ref(false) -const transferOwnerVisible = ref(false) -const requestListVisible = ref(false) -/** 分享群名片弹窗显隐:「分享群名片」入口控制 */ -const recommendCardVisible = ref(false) -/** 群名片源对象:targetType = GROUP,含成员数快照 */ -const recommendCardTarget = computed(() => toGroupCardTarget(props.group)) -const showAllMembers = ref(false) -const namePopoverVisible = ref(false) -const noticePopoverVisible = ref(false) -const remarkPopoverVisible = ref(false) -const groupRemarkPopoverVisible = ref(false) -const editName = ref('') -const editNotice = ref('') -const editRemark = ref('') -const editGroupRemark = ref('') - -// ==================== 状态同步 watch ==================== - -// 抽屉关闭时重置临时态:搜索 / 折叠展开 / 还在打开的 popover 都清掉 -watch(visible, (v) => { - if (!v) { - searchText.value = '' - showAllMembers.value = false - namePopoverVisible.value = false - noticePopoverVisible.value = false - remarkPopoverVisible.value = false - groupRemarkPopoverVisible.value = false - } -}) - -// popover 弹出时把当前值灌进编辑态,避免上次未保存的脏值 -watch(namePopoverVisible, (v) => { - if (v) editName.value = props.group?.name || '' -}) -watch(noticePopoverVisible, (v) => { - if (v) editNotice.value = props.group?.notice || '' -}) -watch(remarkPopoverVisible, (v) => { - if (v) editRemark.value = props.group?.remarkNickName || '' -}) -watch(groupRemarkPopoverVisible, (v) => { - if (v) editGroupRemark.value = props.group?.groupRemark || '' -}) - // ==================== 角色 / 成员展示 ==================== +const searchText = ref('') +const showAllMembers = ref(false) + +/** 邀请好友入群弹窗 ref:handleOpenInvite 调 open({ groupId }) 打开 */ +const inviteDialogRef = ref>() +/** 打开邀请好友入群弹窗 */ +function handleOpenInvite() { + if (!props.group?.id) { + return + } + inviteDialogRef.value?.open({ groupId: props.group.id }) +} + const myId = computed(() => Number(userStore.getUser?.id) || 0) const isOwner = computed(() => props.group != null && props.group.ownerId === myId.value) /** 当前用户在群里的角色(来自 props.members 的 me 行);用于判定是否可移出他人 */ @@ -568,8 +496,49 @@ const displayMembers = computed(() => : visibleMembers.value ) +// 抽屉关闭时清掉成员区临时态(搜索关键字、查看更多展开) +watch(visible, (v) => { + if (!v) { + searchText.value = '' + showAllMembers.value = false + } +}) + // ==================== 群信息编辑 ==================== +const namePopoverVisible = ref(false) +const noticePopoverVisible = ref(false) +const remarkPopoverVisible = ref(false) +const groupRemarkPopoverVisible = ref(false) +const editName = ref('') +const editNotice = ref('') +const editRemark = ref('') +const editGroupRemark = ref('') + +// popover 弹出时把当前值灌进编辑态,避免上次未保存的脏值 +watch(namePopoverVisible, (v) => { + if (v) editName.value = props.group?.name || '' +}) +watch(noticePopoverVisible, (v) => { + if (v) editNotice.value = props.group?.notice || '' +}) +watch(remarkPopoverVisible, (v) => { + if (v) editRemark.value = props.group?.remarkNickName || '' +}) +watch(groupRemarkPopoverVisible, (v) => { + if (v) editGroupRemark.value = props.group?.groupRemark || '' +}) + +// 抽屉关闭时清掉所有 popover,避免下次打开仍弹着 +watch(visible, (v) => { + if (!v) { + namePopoverVisible.value = false + noticePopoverVisible.value = false + remarkPopoverVisible.value = false + groupRemarkPopoverVisible.value = false + } +}) + /** 群主:保存群名(走 /im/group/update) */ async function saveName() { if (!props.group) { @@ -685,6 +654,33 @@ async function onMuteAllChange(value: boolean | string | number) { } } +// ==================== 进群审批 ==================== + +/** 进群申请列表弹窗 ref:handleOpenRequestList 调 open({ groupId }) 触发 */ +const requestListDialogRef = ref>() + +/** 打开当前群的进群申请列表 */ +function handleOpenRequestList() { + if (!props.group?.id) { + return + } + requestListDialogRef.value?.open({ groupId: props.group.id }) +} + +// ==================== 分享群名片 ==================== + +/** 分享群名片弹窗 ref:handleShareGroupCard 调用 open({ target }) 打开 */ +const recommendCardDialogRef = ref>() + +/** 分享群名片:把当前群作为名片消息推荐给其他会话 */ +function handleShareGroupCard() { + const target = toGroupCardTarget(props.group) + if (!target) { + return + } + recommendCardDialogRef.value?.open({ target }) +} + // ==================== 退出群聊 ==================== /** 退出群聊(普通成员入口;群主退出走"解散群"是另一条路径,这里不处理) */ @@ -731,12 +727,22 @@ async function handleDissolve() { // ==================== 群主操作 ==================== // 移除群成员(群主 / 管理员可见)+ 设置群管理员(仅群主)+ 群主管理权转让(仅群主) +/** 移除群成员弹窗 ref */ +const removeDialogRef = ref>() +/** 设置群管理员弹窗 ref */ +const adminSetDialogRef = ref>() +/** 转让群主弹窗 ref */ +const ownerTransferDialogRef = ref>() + // ---------- 移除群成员 ---------- -/** 移除群成员的 hideIds:始终隐藏群主;管理员视角额外隐藏其它管理员(管理员不能移出管理员) */ -const removeHideIds = computed(() => { +/** 打开移除群成员弹窗:始终隐藏群主;管理员视角额外隐藏其它管理员(管理员不能移出管理员) */ +function handleOpenRemove() { + if (!props.group?.id) { + return + } const hideIds: number[] = [] - if (props.group?.ownerId) { + if (props.group.ownerId) { hideIds.push(props.group.ownerId) } if (myRole.value === ImGroupMemberRole.ADMIN) { @@ -744,90 +750,48 @@ const removeHideIds = computed(() => { .filter((m) => m.role === ImGroupMemberRole.ADMIN) .forEach((m) => hideIds.push(m.userId)) } - return hideIds -}) - -/** 移除群成员入口(群主可移出任意非群主,管理员只能移出普通成员;具体由后端校验) */ -async function handleRemoveComplete(members: GroupMemberFlag[]) { - if (!props.group || members.length === 0) { - return - } - // 一次性批量踢人:把选中成员 userId 数组传给后端,比循环调 N 次接口省往返 - await removeGroupMember({ + removeDialogRef.value?.open({ groupId: props.group.id, - memberUserIds: members.map((member) => member.userId) + members: props.members, + hideIds }) - message.success(`已移除 ${members.length} 位成员`) - emit('reload') } // ---------- 设置群管理员 ---------- -/** 当前管理员的 userId 列表,作为 Selector 默认勾选;过滤已退群成员,避免 maxSize 名额被隐藏成员占用导致无法新增管理员 */ -const adminCheckedIds = computed(() => - props.members +/** 打开设置群管理员弹窗:当前管理员默认勾选;群主从候选里隐藏 */ +function handleOpenAdminSet() { + if (!props.group?.id) { + return + } + // 过滤已退群成员,避免 maxSize 名额被隐藏成员占用导致无法新增管理员 + const currentAdminIds = props.members .filter( (member) => member.role === ImGroupMemberRole.ADMIN && member.status !== CommonStatusEnum.DISABLE ) .map((member) => member.userId) -) - -/** 设置管理员时隐藏:群主(不能设为管理员) */ -const adminHideIds = computed(() => (props.group?.ownerId ? [props.group.ownerId] : [])) - -/** 群管理员变更:跟当前管理员列表 diff,新增 → addGroupAdmin,撤销 → removeGroupAdmin */ -async function handleAdminUpdate(selected: GroupMemberFlag[]) { - if (!props.group) { - return - } - // 跟当前管理员列表做差集:分别拿到要新增 / 撤销的 userId - const previousIds = adminCheckedIds.value - const previousIdSet = new Set(previousIds) - const selectedIds = selected.map((member) => member.userId) - const selectedIdSet = new Set(selectedIds) - const addedIds = selectedIds.filter((id) => !previousIdSet.has(id)) - const removedIds = previousIds.filter((id) => !selectedIdSet.has(id)) - if (addedIds.length === 0 && removedIds.length === 0) { - return - } - if (addedIds.length > 0) { - await addGroupAdmin({ groupId: props.group.id, userIds: addedIds }) - } - if (removedIds.length > 0) { - await removeGroupAdmin({ groupId: props.group.id, userIds: removedIds }) - } - message.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`) - emit('reload') + const hideIds = props.group.ownerId ? [props.group.ownerId] : [] + adminSetDialogRef.value?.open({ + groupId: props.group.id, + members: props.members, + currentAdminIds, + hideIds + }) } // ---------- 群主管理权转让 ---------- -/** 转让群主时隐藏当前用户(不能转让给自己) */ -const transferOwnerHideIds = computed(() => [myId.value]) - -async function handleTransferOwnerComplete(selected: GroupMemberFlag[]) { - if (!props.group || selected.length === 0) { +/** 打开转让群主弹窗:当前用户从候选里隐藏(不能转给自己) */ +function handleOpenTransferOwner() { + if (!props.group?.id) { return } - const newOwner = selected[0] - // 二次确认:转让后旧群主降为普通成员 - try { - await message.confirm( - `确定将群主转让给 ${newOwner.showName}?转让后你将变为普通成员,无法撤销。`, - '确认转让群主' - ) - } catch { - return - } - // 转让群主 - await transferGroupOwner({ + ownerTransferDialogRef.value?.open({ groupId: props.group.id, - newOwnerUserId: newOwner.userId + members: props.members, + hideIds: [myId.value] }) - // 提示结果 + 刷新数据 - message.success('群主转让成功') - emit('reload') } diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue index 0315729c4..045f83ffc 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue @@ -33,7 +33,7 @@
@@ -119,12 +119,7 @@
- + @@ -140,7 +135,7 @@ import { useFriendStore } from '@/views/im/home/store/friendStore' import { useGroupStore } from '@/views/im/home/store/groupStore' import { getFriendDisplayName } from '@/views/im/utils/user' import { ImConversationType } from '@/views/im/utils/constants' -import type { Conversation, Friend, FriendLite } from '../../../../types' +import type { Conversation, Friend } from '../../../../types' defineOptions({ name: 'ImConversationPrivateSide' }) @@ -149,11 +144,9 @@ const props = withDefaults( modelValue?: boolean // 抽屉开关(v-model) conversation?: Conversation | null // 当前会话(取置顶 / 免打扰态) friend?: Friend // 对方好友信息(取头像 / 昵称) - friends?: FriendLite[] // 全量好友("+创建群"时给 GroupCreateDialog 选人) }>(), { - modelValue: false, - friends: () => [] + modelValue: false } ) @@ -175,14 +168,17 @@ const message = useMessage() /** tile 标签 / 后续聊天界面用的展示名:备注优先 */ const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : '')) -/** GroupCreateDialog 锁定 id:把对方默认勾上且不可取消,对应微信"基于私聊发起群聊" */ -const lockedIds = computed(() => - props.friend ? [props.friend.friendUserId] : [] -) +/** 发起群聊弹窗 ref:handleOpenCreateGroup 调 open({ lockedIds }) 锁定对方 */ +const createGroupDialogRef = ref>() + +/** 打开发起群聊弹窗:把对方默认勾上且不可取消,对应微信"基于私聊发起群聊" */ +function handleOpenCreateGroup() { + const lockedIds = props.friend ? [props.friend.friendUserId] : [] + createGroupDialogRef.value?.open({ lockedIds }) +} const displayNamePopoverVisible = ref(false) const editDisplayName = ref('') -const createGroupVisible = ref(false) // popover 弹出时把当前备注灌进编辑态,避免上次未保存的脏值 watch(displayNamePopoverVisible, (open) => { diff --git a/src/views/im/home/pages/conversation/components/message/GroupRequestPending.vue b/src/views/im/home/pages/conversation/components/message/GroupRequestPending.vue index b79433c89..0909ffe67 100644 --- a/src/views/im/home/pages/conversation/components/message/GroupRequestPending.vue +++ b/src/views/im/home/pages/conversation/components/message/GroupRequestPending.vue @@ -6,7 +6,7 @@ - 点击横幅打开 GroupRequestListDialog(含历史已处理记录),不再就地展开 -->
-
+
- +
@@ -45,7 +45,13 @@ const userStore = useUserStore() const groupStore = useGroupStore() const groupRequestStore = useGroupRequestStore() -const dialogVisible = ref(false) +/** 申请列表弹窗 ref:handleOpen 调 open({ groupId }) 触发 */ +const requestListDialogRef = ref>() + +/** 打开当前群的进群申请列表 */ +function handleOpen() { + requestListDialogRef.value?.open({ groupId: props.groupId }) +} /** 当前群(含 ownerUserId / members) */ const group = computed(() => groupStore.getGroup(props.groupId)) diff --git a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue index b95944564..df12b092e 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue @@ -287,11 +287,7 @@ import TipSegments from './TipSegments.vue' defineOptions({ name: 'ImMessageHistory' }) -const props = defineProps<{ - modelValue: boolean -}>() const emit = defineEmits<{ - 'update:modelValue': [value: boolean] // 历史消息行上的"定位"按钮:通知父组件 MessagePanel 滚到对应消息位置 + 关掉自己 locate: [messageId: number] }>() @@ -304,9 +300,13 @@ const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY) const voicePlayer = useVoicePlayer() const { convertPrivateMessage, convertGroupMessage } = useMessagePuller() -const visible = computed({ - get: () => props.modelValue, - set: (value) => emit('update:modelValue', value) +const visible = ref(false) + +defineExpose({ + /** 打开历史消息抽屉 */ + open() { + visible.value = true + } }) const conversation = computed(() => conversationStore.activeConversation) @@ -588,17 +588,14 @@ function onDialogOpen() { datePickerValue.value = new Date() } -/** v-model 关闭时复位 + 停语音(兼容父组件 props 直接置 false 的路径,dialog @open 不一定再触发) */ -watch( - () => props.modelValue, - (value) => { - if (!value) { - activeFilter.value = null - keyword.value = '' - voicePlayer.stop() - } +/** 抽屉关闭时复位 + 停语音 */ +watch(visible, (value) => { + if (!value) { + activeFilter.value = null + keyword.value = '' + voicePlayer.stop() } -) +}) // ==================== helper ==================== diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 2962ad580..68bae80e4 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -33,7 +33,7 @@ icon="ep:chat-dot-round" :size="20" class="message-panel__header-icon cursor-pointer" - @click="historyVisible = true" + @click="historyDialogRef?.open()" /> @@ -145,21 +145,19 @@ :group="groupInfo" :conversation="conversationStore.activeConversation" :members="groupMembers" - :friends="friends" @reload="reloadGroupData" - @open-history="historyVisible = true" + @open-history="historyDialogRef?.open()" /> - + @@ -188,7 +186,6 @@ import { useImUiStore } from '../../../../store/uiStore' import { getMemberDisplayName } from '@/views/im/utils/user' import { useGroupStore } from '../../../../store/groupStore' import { ImConversationType } from '@/views/im/utils/constants' -import { CommonStatusEnum } from '@/utils/constants' import MessageItem from './MessageItem.vue' import MessageInput from '../input/MessageInput.vue' import MessageMultiSelectBar from '../input/MessageMultiSelectBar.vue' @@ -202,7 +199,7 @@ import ConversationGroupSide from '../conversation/ConversationGroupSide.vue' import GroupPinnedMessage from './GroupPinnedMessage.vue' import GroupRequestPending from './GroupRequestPending.vue' import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue' -import type { FriendLite, GroupLite } from '../../../../types' +import type { GroupLite } from '../../../../types' import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' import GroupMuteMemberDialog from '../../../../components/group/GroupMuteMemberDialog.vue' @@ -354,16 +351,6 @@ const groupMembers = computed(() => { }) }) -/** 好友列表:群侧用于"邀请入群",私聊侧用于"+创建群",统一从 friendStore 映射成 FriendLite 窄接口 */ -const friends = computed(() => - friendStore.getActiveFriends.map((friend) => ({ - id: friend.friendUserId, - nickname: friend.nickname, - avatar: friend.avatar, - deleted: friend.status === CommonStatusEnum.DISABLE - })) -) - /** 切换到群会话时同步群信息 + 成员;各自 fire-and-forget + catch,任何一项失败不牵连其它 */ async function ensureGroupData(groupId: number) { // 远程异步拉群信息(群名 / 公告 / 群主等元数据) @@ -392,7 +379,8 @@ function reloadGroupData() { groupStore.fetchGroupMembers(conversation.targetId, true) } -const historyVisible = ref(false) +/** 历史消息抽屉 ref:「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发 */ +const historyDialogRef = ref>() const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref const muteMemberDialogRef = ref>() diff --git a/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue b/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue index fc7a2b97e..d550593e6 100644 --- a/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue +++ b/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue @@ -1,195 +1,105 @@ +
@@ -199,7 +109,7 @@ import { computed, reactive, ref } from 'vue' import Icon from '@/components/Icon/src/Icon.vue' import { useMessage } from '@/hooks/web/useMessage' -import UserAvatar from '@/views/im/home/components/user/UserAvatar.vue' +import ConversationPickerPanel from '@/views/im/home/components/picker/ConversationPickerPanel.vue' import FacePicker from '../../input/FacePicker.vue' import { useConversationStore } from '@/views/im/home/store/conversationStore' import { useMessageSender } from '@/views/im/home/composables/useMessageSender' @@ -210,11 +120,7 @@ import { MERGE_FORWARD_PREVIEW_LINES, type ImForwardModeValue } from '@/views/im/utils/constants' -import { - filterConversationsByKeyword, - getConversationKey, - summarizeMessageContent -} from '@/views/im/utils/conversation' +import { getConversationKey, summarizeMessageContent } from '@/views/im/utils/conversation' import { buildMergeMessagePayload, removeQuotePayload, @@ -224,7 +130,6 @@ import type { Conversation, Message } from '@/views/im/home/types' defineOptions({ name: 'ImMessageForwardDialog' }) -/** 父级 ref 调 open(opts) 触发;不再走 v-model + props */ const message = useMessage() const conversationStore = useConversationStore() const { sendRaw, send } = useMessageSender() @@ -236,23 +141,14 @@ const state = reactive({ sourceConversation: null as Conversation | null }) const visible = ref(false) - -const keyword = ref('') +const selectedKeys = ref([]) const leaveMessage = ref('') const sending = ref(false) -const selectedKeys = ref([]) -const selectedSet = computed(() => new Set(selectedKeys.value)) - /** emoji picker 显隐:右侧笑脸按钮切换 */ const emojiVisible = ref(false) -/** 选中 emoji:拼到留言末尾;FacePicker 自身 emit('update:visible', false) 关闭面板 */ -function handleEmojiSelect(emoji: string) { - leaveMessage.value = `${leaveMessage.value}${emoji}` -} - defineExpose({ - /** 打开转发弹窗 */ + /** 打开转发弹窗:reset → 灌参 → visible=true */ open(opts: { mode: ImForwardModeValue messages: Message[] @@ -261,6 +157,9 @@ defineExpose({ state.mode = opts.mode state.messages = opts.messages state.sourceConversation = opts.sourceConversation + selectedKeys.value = [] + leaveMessage.value = '' + emojiVisible.value = false visible.value = true } }) @@ -268,48 +167,19 @@ defineExpose({ /** 弹窗标题:按 mode 区分「逐条转发 / 合并转发」 */ const dialogTitle = computed(() => (state.mode === ImForwardMode.MERGE ? '合并转发' : '逐条转发')) -/** 右栏标题:选中多个时改「分别发送给」与底部按钮文案保持一致 */ -const sendTitle = computed(() => (selectedKeys.value.length > 1 ? '分别发送给' : '发送给')) - /** 确认按钮文案:单选「发送」、多选「分别发送(n)」 */ const confirmButtonText = computed(() => selectedKeys.value.length > 1 ? `分别发送(${selectedKeys.value.length})` : '发送' ) -/** 弹窗打开时复位 */ -function resetForm() { - keyword.value = '' - leaveMessage.value = '' - selectedKeys.value = [] - emojiVisible.value = false -} - -/** 候选会话:转发回原会话也允许(与微信一致:可以"转发给当前对话") */ +/** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致) */ const candidateConversations = computed( () => conversationStore.getSortedConversations ) -const shownConversations = computed(() => - filterConversationsByKeyword(candidateConversations.value, keyword.value) -) - -const selectedConversations = computed(() => { - const keys = selectedSet.value - return candidateConversations.value.filter((c) => keys.has(getConversationKey(c))) -}) - -function isSelected(conversation: Conversation): boolean { - return selectedSet.value.has(getConversationKey(conversation)) -} - -function handleToggle(conversation: Conversation) { - const key = getConversationKey(conversation) - const index = selectedKeys.value.indexOf(key) - if (index >= 0) { - selectedKeys.value.splice(index, 1) - } else { - selectedKeys.value.push(key) - } +/** 选中 emoji:拼到留言末尾;FacePicker 自身负责关闭面板 */ +function handleEmojiSelect(emoji: string) { + leaveMessage.value = `${leaveMessage.value}${emoji}` } /** 合并 payload + 序列化 content;merge 模式下一次构造,预览 / 发送共用 */ @@ -337,7 +207,7 @@ const mergePreview = computed(() => { return { title: payload.title, lines } }) -/** 单条 / 逐条模式预览:取前 N 条 */ +/** 逐条模式预览:取前 N 条摘要 */ const singlePreviewLines = computed(() => state.messages.slice(0, MERGE_FORWARD_PREVIEW_LINES).map((m) => summarizeMessageContent(m)) ) @@ -383,7 +253,15 @@ async function handleSend() { message.warning('没有可转发的消息') return } - const targets = selectedConversations.value + // 反查已选 conversation 对象(按 selectedKeys 数组顺序,即点击顺序) + const candidates = candidateConversations.value + const byKey = new Map(candidates.map((c) => [getConversationKey(c), c])) + const targets = selectedKeys.value + .map((key) => byKey.get(key)) + .filter((c): c is Conversation => c != null) + if (targets.length === 0) { + return + } const leaveText = leaveMessage.value.trim() sending.value = true try { @@ -397,6 +275,8 @@ async function handleSend() { }) const results = await Promise.all(tasks) const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || '未命名会话') + // 命中的目标统一推到最近转发列表(部分失败也推:用户的"意图"已表达) + conversationStore.pushRecentForwardConversationKeys(targets.map((c) => getConversationKey(c))) if (failedNames.length === 0) { message.success('已转发') } else if (failedNames.length === targets.length) { @@ -414,23 +294,11 @@ async function handleSend() { } - + diff --git a/src/views/im/home/pages/conversation/index.vue b/src/views/im/home/pages/conversation/index.vue index ff52aca16..7a281c8c6 100644 --- a/src/views/im/home/pages/conversation/index.vue +++ b/src/views/im/home/pages/conversation/index.vue @@ -18,11 +18,11 @@ @@ -103,13 +99,11 @@ 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 { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation' -import { CommonStatusEnum } from '@/utils/constants' -import type { Conversation, Friend, FriendLite } from '../../types' +import type { Conversation } from '../../types' import ResizableAside from '../../components/ResizableAside.vue' import ConversationItem from './components/conversation/ConversationItem.vue' import MessagePanel from './components/message/MessagePanel.vue' @@ -119,12 +113,11 @@ 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) @@ -197,17 +190,20 @@ const showPinnedSection = computed( () => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD ) +// ==================== 添加朋友 ==================== + +/** 添加朋友弹窗 ref:右上角 +-下拉「添加朋友」入口调 open() 触发 */ +const friendAddDialogRef = ref>() + // ==================== 建群相关 ==================== -/** GroupCreateDialog 需要全量好友列表来勾选成员,结构与通讯录里好友/群分组保持一致 */ -const friends = computed(() => - friendStore.getActiveFriends.map((friend: Friend) => ({ - id: friend.friendUserId, - nickname: friend.nickname, - avatar: friend.avatar, - deleted: friend.status === CommonStatusEnum.DISABLE - })) -) +/** 发起群聊弹窗 ref:handleOpenCreateGroup 调 open() 打开 */ +const createGroupDialogRef = ref>() + +/** 打开发起群聊弹窗:无锁定项的全局入口 */ +function handleOpenCreateGroup() { + createGroupDialogRef.value?.open() +} /** 处理建群成功 */ function handleGroupCreated(groupId: number) { diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 6c3ae19bd..9f6f04ef1 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -7,6 +7,7 @@ import { ImMessageType, ImMessageStatus, IM_AT_ALL_USER_ID, + RECENT_FORWARD_MAX, isGroupNotification, isMediaMessageType, isNormalMessage @@ -124,7 +125,8 @@ export const useConversationStore = defineStore('imConversationStore', { activeConversation: null as Conversation | null, // 当前激活的会话 privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标 groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标 - loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储 + loading: false, // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储 + recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表(按推送顺序倒序,最大 RECENT_FORWARD_MAX 个) }), getters: { @@ -179,9 +181,15 @@ export const useConversationStore = defineStore('imConversationStore', { return } try { - const meta = await imStorage.getItem( - StorageKeys.conversationMeta(userId) - ) + // 顺手把最近转发列表也恢复出来;和 meta 并发读 + const [meta, recent] = await Promise.all([ + imStorage.getItem(StorageKeys.conversationMeta(userId)), + imStorage.getItem(StorageKeys.recentForwardConversationKeys(userId)) + ]) + // 缺数据时显式赋空,避免切账号后沿用上一个用户的内存列表 + this.recentForwardConversationKeys = Array.isArray(recent) + ? recent.slice(0, RECENT_FORWARD_MAX) + : [] if (!meta) { return } @@ -801,6 +809,55 @@ export const useConversationStore = defineStore('imConversationStore', { this.saveConversations(this.activeConversation) }, + // ==================== 最近转发 ==================== + + /** + * 推送一批会话 key 到最近转发列表:去重 + 推到队首 + 截断 RECENT_FORWARD_MAX + * + * 调用点:RecommendCardDialog / MessageForwardDialog 提交后(含部分成功)把目标 keys 推进来 + */ + pushRecentForwardConversationKeys(keys: string[]) { + if (!keys || keys.length === 0) { + return + } + const merged = [...keys, ...this.recentForwardConversationKeys] + this.recentForwardConversationKeys = Array.from(new Set(merged)).slice( + 0, + RECENT_FORWARD_MAX + ) + this.persistRecentForwardConversationKeys() + }, + + /** + * 从最近转发列表移除单条会话 key + * + * 调用点:ConversationPickerPanel「最近转发」段进入移除模式时点击 × 触发 + */ + removeRecentForwardConversationKey(key: string) { + const index = this.recentForwardConversationKeys.indexOf(key) + if (index < 0) { + return + } + this.recentForwardConversationKeys.splice(index, 1) + this.persistRecentForwardConversationKeys() + }, + + /** 把当前最近转发会话 key 列表落到 IDB;fire-and-forget,按 userId 分桶 */ + persistRecentForwardConversationKeys() { + const userId = getCurrentUserId() + if (!userId) { + return + } + void imStorage + .setItem( + StorageKeys.recentForwardConversationKeys(userId), + toRaw(this.recentForwardConversationKeys) + ) + .catch((e) => console.warn('[IM] 最近转发列表持久化失败', e)) + }, + + // ==================== 其它 ==================== + /** 更新 privateMessageMaxId / groupMessageMaxId 游标 */ updateMaxId(conversationType: number, messageId?: number) { if (!messageId) { diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index 75624d23d..8d7497893 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -28,7 +28,7 @@ import { } from '../../utils/constants' import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage' import { getFriendDisplayName } from '../../utils/user' -import type { Friend, FriendRequest } from '../types' +import type { Friend, FriendLite, FriendRequest } from '../types' /** 当前正在进行的好友列表拉取;多 dispatcher 同时触发时复用同一 Promise,避免雪崩重拉 */ let pendingFetchFriends: Promise | null = null @@ -87,6 +87,17 @@ export const useFriendStore = defineStore('imFriendStore', { getActiveFriends: (state): Friend[] => { return state.friends.filter((friend) => friend.status !== CommonStatusEnum.DISABLE) }, + /** 当前生效好友的 Lite 视图(PickerPanel / 选人弹窗共用,自带拼音字段供分桶 / 搜索) */ + getActiveFriendsLite(): FriendLite[] { + return this.getActiveFriends.map((friend: Friend) => ({ + id: friend.friendUserId, + nickname: friend.nickname, + nicknamePinyin: friend.nicknamePinyin, + avatar: friend.avatar, + displayName: friend.displayName, + displayNamePinyin: friend.displayNamePinyin + })) + }, /** 判断对方是否是当前用户的有效好友(存在 + 非 DISABLE) */ isFriend() { return (friendUserId: number): boolean => { diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index b6819fdf9..2866cd240 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -206,7 +206,7 @@ export interface User { /** * 好友列表行:从 Friend 派生的展示快照 * - id 用 friendUserId(与列表 click / 选中比对一致),不是 Friend.id(关系记录主键) - * - deleted 派生自 Friend.status === DISABLE(软删保留),调用方按场景过滤 + * - 软删(status === DISABLE)由上游 friendStore.getActiveFriends / getActiveFriendsLite 统一过滤掉 */ export interface FriendLite { id: number @@ -215,7 +215,6 @@ export interface FriendLite { avatar?: string displayName?: string displayNamePinyin?: string // 备注拼音(优先于 nicknamePinyin 参与分桶) - deleted?: boolean } /** diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 1a6c6bc33..120373724 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -237,6 +237,9 @@ export const IM_AT_ALL_NICKNAME = '所有人' /** 合并转发气泡内预览的最大行数(对齐微信「聊天记录」气泡) */ export const MERGE_FORWARD_PREVIEW_LINES = 3 +/** 最近转发会话 key 列表的最大保留数量(对齐微信 PC 横向头像区可见容量) */ +export const RECENT_FORWARD_MAX = 12 + /** 转发模式:SINGLE 逐条原样转 / MERGE 打包成 MergeMessage */ export const ImForwardMode = { SINGLE: 'single', diff --git a/src/views/im/utils/group.ts b/src/views/im/utils/group.ts new file mode 100644 index 000000000..6d7f47b38 --- /dev/null +++ b/src/views/im/utils/group.ts @@ -0,0 +1,14 @@ +import type { FriendLite } from '../home/types' + +/** 默认群名生成:所选好友前 4 个名字拼接,超过补「等 N 人」;为空兜底「群聊」 */ +export function buildDefaultGroupName(members: FriendLite[]): string { + if (members.length === 0) { + return '群聊' + } + const names = members.slice(0, 4).map((m) => m.displayName || m.nickname || '') + const head = names.filter(Boolean).join('、') + if (members.length > 4) { + return `${head}等${members.length}人` + } + return head || '群聊' +} diff --git a/src/views/im/utils/storage.ts b/src/views/im/utils/storage.ts index 83f7f4d77..3307737fc 100644 --- a/src/views/im/utils/storage.ts +++ b/src/views/im/utils/storage.ts @@ -60,6 +60,10 @@ export const StorageKeys = { groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`, + /** 最近转发会话 key 列表(按 userId 分桶);ConversationPickerPanel 左栏顶部头像区使用 */ + recentForwardConversationKeys: (userId: number | string) => + `recentForwardConversationKeys:${userId}`, + /** 侧边栏宽度(localStorage);三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */ asideWidth: 'im:aside', /** 会话列表置顶折叠展开态(localStorage);轻量 UI 偏好。 */