@@ -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 @@
+
-
-
-
+
-
-
-
-
-
-
-
-
-
- 最近聊天
-
-
-
-
-
+
+
+
-
+ {{ mergePreview.title }}
+
+
+
+ 聊天记录
+
+
+
+
+
+
+
+ 共 {{ state.messages.length }} 条消息
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- {{ conversation.name }}
-
-
-
- {{ keyword ? '没有满足条件的会话' : '暂无会话' }}
-
-
-
-
-
-
-
- {{ sendTitle }}
-
-
-
-
-
-
-
- {{ conversation.name }}
-
-
-
-
- 从左侧选择联系人或群聊
-
-
-
-
-
-
-
-
- {{ mergePreview.title }}
-
-
+ 取消
+
- {{ line }}
-
-
-
- 聊天记录
+ {{ confirmButtonText }}
+
-
-
-
-
-
- 共 {{ state.messages.length }} 条消息
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 取消
-
- {{ confirmButtonText }}
-
-
-
-
+
+
@@ -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 @@
-
+
发起群聊
-
+
添加朋友
@@ -90,12 +90,8 @@
-
-
+
+
@@ -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 偏好。 */